【PAT】4. 圖

magic_jiayu發表於2020-12-23

【PAT】4. 圖

圖的儲存

鄰接矩陣:設圖G(V,E)的頂點編號為0,1,…,N-1,則如果G[i][j]為0,說明頂點i和頂點j之前不存在邊。鄰接矩陣只適用於頂點數目不太大(一般不超過1000)的題目

鄰接表:如果把同一個頂點的所有出邊放在一個列表中,那麼N個頂點就會有N個列表(沒有出邊,則對應空表),這N個列表被稱為圖G的鄰接表。用vector來實現鄰接表。

//vector<int> Adj[N]; //鄰接表只存放每條邊的終點編號而不存放邊權。
struct Node{
    int v; //邊的終點編號
    int w; //邊權
    Node(int _v, int _w) : v(_v), w(_w) {}  //建構函式
};
vector<Node> Adj[N];
Adj[1].push_back(Node(3,4)); //建構函式可以不定義臨時變數來實現加邊操作

圖的遍歷

DFS遍歷圖的基本思路就是將經過的頂點設定為已訪問,在下次遞迴碰到這個結點時就不再去處理,直到整個圖的頂點都被標記為已訪問。如果已知給定的圖是一個連通圖,則只需要一次DFS就能完成遍歷。

DFS(u){ //訪問頂點u
    vis[u] = true; //設定u已被訪問
    for(從u出發能到達的所有頂點v){  //列舉從u出發可以到達的所有頂點v
        if(vis[v] == false){    //如果v未被訪問
            DFS(v); //遞迴訪問v
        }
    }
}
DFSTrave(G){    //遍歷圖G
    for(G的所有頂點u){  //對圖G的所有頂點u
        if(vis[u] == false){    //如果u未被訪問
            DFS(u); //訪問u所在的連通塊
        }
    }
}

BFS遍歷圖的基本思想是建立一個佇列,並把從該頂點出發可以到達的未曾加入過佇列(而不是訪問)的頂點全部加入佇列,直到佇列為空

bool inq[maxn] = {false}
BFS(u){ //  遍歷u所在的連通塊
    queue q;    //定義佇列q
    將u入隊;
    inq[u] = true;  //設定u已被加入過佇列
    while(q非空){   //只要佇列非空
        取出q的隊首元素u進行訪問;
        for(從u出發可達的所有頂點v){    //列舉從u能直接到達的頂點v
            if(inq[v] == false){    //如果v未曾加入過佇列
                將v入隊;
                inq[v] = true;  //設定v已被加入過佇列
            }
        }
    }
}
BFSTrave(G){    //遍歷圖G
    for(G的所有頂點u){  //列舉G的所有頂點
        if(inq[u] == false){    //如果u未曾加入過佇列
            BFS(u); //遍歷u所在的連通塊
        }
    }
}

最短路徑

對任意給出的圖G(V,E)和起點S、終點T,如何求從S到T的最短路徑

Dijkstra

解決單源最短路問題,即給定圖G(V,E)和起點s,求從起點s到達其它頂點的最短距離

設集合S存放已被訪問的結點(即已經確定該結點的最短距離),然後執行n次下面的步驟(n為結點個數)

  1. 每次從集合V-S(即未確定最短距離)中選擇與起點s的最短距離最小的一個頂點(記為u),訪問並加入集合S(設定其距離已經被確定)
  2. 之後,令u為中介點,優化起點s與所有從u能到達的頂點v之間的最短距離

集合S的實現、起點s到達頂點 V i V_i Vi(0<=i<=n-1)的最短距離的實現

  • 集合S可以用一個bool型陣列vis[]來實現,vis[i] = true表示頂點 V i V_i Vi已經被訪問
  • 令int型陣列d[]表示起點s到達頂點 V i V_i Vi的最短距離,初始時除了起點s的d[s]賦為0,其餘頂點都賦為一個很大的數(0x3fffffff)。
//G為圖,一般設定為全域性變數;陣列d為源點到達各點的最短路徑長度,s為起點
Dijkstra(G,d[],s){
    初始化;
    for(迴圈n次){
        u = 使d[u]最小的還未被訪問的頂點的標號;
        設u已被訪問;
        for(從u出發能到達的所有頂點v){
            if(v未被訪問&&以u為中介點使s到頂點v的最短距離d[v]更優){
                優化d[v];
            }
        }
    }
}
  • 臨界矩陣的時間複雜度 O ( V 2 ) O(V^2) O(V2),鄰接表的時間複雜度 O ( V 2 + E ) O(V^2+E) O(V2+E)。注意到尋找最小d[u]的過程可以不必達到O(V)的複雜度,而可以使用堆優化來降低複雜度。使用STL中的優先佇列使得鄰接表實現的演算法時間複雜度降為 O ( V L o g V + E ) O(VLogV+E) O(VLogV+E)
  • Dijkstra演算法只能應對所有邊權都是非負數的情況,如果出現負數最好使用SPFA演算法
  • 如果題目給出的是無向邊,只需要把無向邊當成兩條指向相反的有向邊即可,在輸入的時候進行處理。

求解從起點 V 1 V_1 V1到達 V i V_i Vi最短路徑:設定陣列pre[],令pre[v]表示從起點到頂點v的最短路徑上v的前一個結點,當條件成立時將u賦給pre[v]

if(v未被訪問&&以u為中介點使s到頂點v的最短距離d[v]更優){
    優化d[v];
    令v的前驅為u;
}

然後用遞迴不斷利用pre[]的資訊尋找前驅,直至到達起點後從遞迴深處開始輸出。

如果碰到有兩條即兩條以上可以達到最短距離的路徑,題目會給出一個第二標尺(第一標尺是距離),要求在所有最短路徑中選擇第二標尺最優的一條路徑

  1. 給每條邊再增加一個邊權(比如說花費),然後要求再最短路徑有多條時要求路徑上的花費之和最小
  2. 給每個點增加一個點權(例如每個城市能蒐集到的物資),然後在最短路徑有多條時要求路徑上的點權之和最大
  3. 直接問有多少條最短路徑

對這三種出題方法,都只需要增加一個陣列來存放新增的邊權或點券或最短路徑條數,然後在演算法中修改**優化d[v]**的那個步驟即可

  1. 新增邊權。以新增的邊權代表花費為例,用cost[u][v]表示u->的花費(由題目輸入),並增加一個陣列c[],令從起點s到達頂點u的最少花費為c[u],初始化時只有c[s] = 0、其餘c[u]均為INF。這樣在d[u] + G[u][v] < d[v](即可以使s到v的最短距離d[v]更優)時更新d[v]和c[v],當d[u] + G[u][v] == d[v](即最短距離相同)且c[u]+cost[u][v]<c[v](即可以使s到v的最少花費更優)時更新c[v],c[v]=c[u]+cost[u][v]
  2. 新增點權。以新增的點權代表城市中能收集到的物資為例,用weight[u]表示城市u中的物資數目(由題目輸入),並增加一個陣列w[],令從起點s到達頂點u可以收集到的最大物資為w[u],初始化時w[s]為weight[s],其餘均為0。更新方法同上
  3. 求最短路徑條數。只需要增加一個陣列num[],令從起點s到達頂點u的最短路徑條數為num[u],初始化時只有num[s]為1、其餘num[n]均為0。更新方法與上面稍有不同,在d[u] + G[u][v] < d[v](即可以使s到v的最短距離d[v]更優)時更新d[v],並讓num[v]繼承numu,而當d[u] + G[u][v] == d[v](即最短距離相同)時將num[u]加到num[v]上((num[v]+=num[u]))。
for (int v = 0; v < n; v++){                //遍歷所有頂點v
    //如果v未被訪問 && u能到達v
    if(vis[v] == false && G[u][v] != INF){
        if(d[u] + G[u][v] < d[v]){          //以u為中介點可以使d[v]更優
            d[v] = d[u] + G[u][v];
            更新第二標尺;    
        }else if(d[u] + G[u][v] == d[v] && 第二標尺可以更優){
            更新第二標尺;
        }
    }
}

DFS+Dijkstra

如果出現了一些邏輯更為複雜的計算邊權或點權的方式,此時按上面的方式只使用Dijkstra演算法就不一定能計算出正確結果(原因時不一定滿足最優子結構),或者即使能算出,邏輯也極其複雜,容易寫錯。可以用一種更通用、又模板化的方式解決此類問題——Dijkstra + DFS

先在Dijkstra演算法中記錄下所有最短路徑(只考慮距離),然後從這些最短路徑中選出一條第二標尺最優的路徑(因為在給定一一條路徑的情況下,針對這條路徑的資訊都可以通過邊權和點權很容易計算出來!)

  1. 使用Dijkstra演算法記錄所有最短路徑
    • 定義變長陣列pre,存放結點v的所有能產生最短路徑的前驅結點vector<int> pre[maxv](注:對需要查詢某個頂點u是否在頂點v前驅中的題目,可以把pre陣列設定為set<int>陣列,此時使用pre[v].count(u)來查詢比較方便),通過vector型別的pre陣列,就可以使用DFS來獲取所有最短路徑。
    • 在更新d[v]的過程中更新pre陣列
      • 如果d[u] + G[u][v] < d[v],說明以u為中介點可以使d[v]更優,此時需要令v的前驅結點為u。即清空原先的pre[v],再新增u。
      • 如果d[u] + G[u][v] == d[v],說明以u為中介點可以找到一條相同距離的路徑,因此v的前驅結點在原來的基礎上新增上u結點(不必先清空pre[v])。
  2. 遍歷所有最短路徑,找出一條使第二標尺最優的路徑
    • pre陣列會產生一條遞迴樹,遍歷這條樹,每次到達葉子結點,就會產生一條完整的最短路徑,因此每得到一條完整路徑,就可以對這條路徑計算其第二標尺的值,令其與當前第二標尺的最優值進行比較。如果更優則更新最優值,並用這條路徑覆蓋當前的最優路徑。
    • DFS遞迴函式
      • 全域性變數的第二標尺最優值optValue,記錄最優路徑的陣列path,臨時記錄DFS遍歷到葉子結點時的路徑tempPath
      • 遞迴邊界:當前訪問的結點時葉子結點(路徑的起點st)。此時對tempPath中存放的路徑求出第二標尺的值value,並與optValue比較,如果更優,則更新optValue並把tempPath覆蓋path。
      • 遞迴式(在遞迴過程中生成tempPath):在訪問當前結點v時將v加到tempPath的最後面,然後遍歷pre[v]中的所有結點並進行遞迴,等pre[v]的所有結點遍歷完畢後再把tempPath最後面的v彈出。注意葉子結點沒有辦法通過上面寫法直接加入tempPath,需要在訪問到葉子結點時臨時加入(在遞迴邊界中加入)。
      • 存放在tempPath中的路徑結點時逆序的,訪問結點需要倒著進行。
    • 如果需要同時計算最短路徑(指距離最短)的條數,既可以在Dijkstra程式碼中新增num陣列,也可以開一個全域性變數來記錄最短路徑條數,當DFS到達葉子結點時令該全域性變數加1
    • 注意頂點的下標範圍需要根據題意來考慮是n~1還是1~n,或是在某些有n+1個結點的題目裡是0~n

Bellman-Ford演算法和SPFA演算法

如果出現了負權邊,Dijkstra就會失效,而Bellman-Ford演算法可解決單源最短路徑問題,但也能處理有負邊權的情況。因為如果圖中有負環,且從源點可以到達,那麼就會影響最短路徑的求解。

Bellman-Ford演算法設定一個陣列d,用來存放從源點到各個頂點的最短距離。同時Bellman-Ford演算法返回一個bool值:如果其存在從源點可達的負環,那麼函式將返回false;否則,函式將返回true,此時陣列d中存放的值就是從源點到達各頂點的最短距離。

  1. 對圖中的邊進行V-1輪操作,每輪都遍歷圖中所有的邊:對每條邊u->v,如果以u為中介點可以使d[v]更小,即d[u] + length[u->u] < d[v]成立時,用d[u] + length[u->u]更新d[v](鬆弛操作)。時間複雜度是O(VE),n(V)是頂點個數,E是邊數。
  2. 再對所有邊進行一輪操作,判斷是否有某條邊u->v仍然滿足d[u] + length[u->u] < d[v],如果有,說明圖中有從源點可達的負環,返回false;否則,說明陣列d中的所有值都已經達到最優,返回true。
    for(i = 0; i < n - 1; i++){ //執行n-1輪操作,其中n為頂點數
        for(each edge u->v){    //每輪操作都遍歷所有邊
            if(d[u] + length[u->v] < d[v]){ //以u為中介點可以使d[v]更小
                d[v] = d[u] + length[u->v]; //鬆弛操作
            }
        }
    }
    for(each edge u->v){    //對每條邊進行判斷
        if(d[u] + length[u->v] < d[v]){ //如果仍可以被鬆弛
            return false;   //說明圖中有從源點可達的負環
        }
    }
    return true;    //陣列d中的所有值都已經達到最優
    
  • Bellman-Ford演算法需要遍歷所有邊,使用鄰接表會比較方便;如果使用鄰接矩陣,時間複雜度會上升到 O ( V 3 ) O(V^3) O(V3)
  • 如果在某一輪操作時,發現所有邊搜沒有被鬆弛,說明陣列d中的所有值都已經達到最優,不需要再繼續,提前退出即可。
  • 最短路徑的求解方法、有多重標尺時的做法均與Dijkstra演算法中介紹的相同。唯一要注意的是統計最短路徑條數的做法:BF演算法期間會多次訪問曾經訪問過的頂點,因此需要設定記錄前驅的陣列set<int> pre[maxn],且當遇到一條和已有最短路徑長度相同的路徑時,必須重新計算最短路徑長度。
    int num[maxv];  //num[]記錄最短路徑條數
    set<int> pre[maxv];
    ...
    if(d[u] + dis < d[v]){ //以u為中介點時能令d[u]變小
        ...
        num[v] = num[u];    //和Dijkstra一樣覆蓋
        pre[v].clear();
        pre[v].insert(u);
    }else if(d[u] + dis == d[v]){   //找到一條相同長度的路徑
        ...
        pre[v].insert(u);
        num[v] = 0; //重新統計num[v]
        for(set<int>::iterator it = pre[v].begin(); it != pre[v].end(); it++){
            num[v] += num[*it];
        }
    }
    

注意到,BF演算法的每輪操作都需要操作所有邊,這其中大量無意義的操作嚴重影響了演算法的效能,注意到,只有當某個頂點u的d[u]值改變時,從它出發的邊的臨界點v的d[v]值才有可能被改變。由此可以優化:建立一個佇列,每次將隊首頂點u取出,然後對從u出發的所有邊u->v進行鬆弛操作,也就是判斷d[u] + length[u->u] < d[v]是否成立,如果成立,則覆蓋,於是d[v]獲得更優的值,此時如果v不在佇列中,就把v加入佇列。這樣操作直到佇列為空(說明圖中沒有從源點可達的負環),或是某個頂點的入隊次數超過V-1(說明圖中存在從源點可達的負環)

queue<int> q;
源點s入隊;
while(佇列非空){
    取出隊首元素u;
    for(u的所有鄰接邊u->v){
        if(d[u] + dis < d[v]){
            d[v] = d[u] + dis;
            if(v不在當前佇列){
                v入隊;
                if(v入隊次數大於n-1){
                    說明有可達負環,return;
                }
            }
        }
    }
}

優化後的演算法稱為SPFA(Shortest Path Faster Algorithm),期望時間複雜度是 O ( k E ) O(kE) O(kE),E是圖的邊數,k是一個常數,很多情況下k不超過2,此演算法異常高效,並且經常性地優於堆優化的Dijkstra演算法。但如果圖中有從源點可達的負環,傳統SFPA的時間複雜度就會退化成O(VE)。

  • 使用SFPA可以判斷是否存在從源點可達的負環,但如果負環從源點不可達,則需要新增一個輔助頂點C,並新增一條從源點到達C的有向邊以及V-1條從C到達除源點外各頂點的有向邊才能判斷負環是否存在。
  • SPFA十分靈活,其內部的寫法可以根據具體場景的不同進行調整,示例程式碼中的佇列可以替換成優先佇列以加快速度;或者替換成雙端佇列,使用SLF優化和LLL優化
  • 示例程式碼是SPFA的BFS版本,如果將佇列替換成棧,則可以實現DFS版本的SPFA,對判環有奇效。

Floyd演算法(弗洛伊德演算法)

用來解決全源最短路問題,即對給定的圖G(V,E),求任意兩點u,v之間的最短路徑長度,時間複雜度是 O ( n 3 ) O(n^3) O(n3),頂點數n限制在200內,因此適合用鄰接矩陣來實現Floyd演算法。

演算法基於這樣一個事實:如果存在頂點k,使得以k作為中介點時頂點i和頂點j的當前最短距離縮短,則使用頂點k作為頂點i和頂點j的中介點,即當dis[i][k]+dis[k][j]<dis[i][j]時,令dis[i][j]=dis[i][k]+dis[k][j](其中dis[i][j]表示從頂點i到頂點j的最短距離)。

列舉頂點k∈[1,n]
    以頂點k作為中介點,列舉所有頂點對i和j(i∈[1,n], j∈[1,n])
        如果dis[i][k] + dis[k][j] < dis[i][j]成立
            賦值dis[i][j] = dis[i][k] + dis[k][j]

需要注意的是,不能把最外層的k迴圈放到內層(即產生i->j->k的三種迴圈),會導致最後結果出錯,因為如果當較後訪問的dis[u][v]有了優化之後,前面訪問的dis[i][j]會因為已經被訪問而無法獲得進一步優化(這裡i、j先於u、v進行訪問)

最小生成樹

最小生成樹(Minimum Spanning Tree, MST)是在一個給定的無向圖G(V,E)中求一棵樹,使得這棵樹擁有圖G中的所有頂點,且所有邊都是來自圖G中的邊,並且滿足整棵樹的邊權之和最小。

  • 最小生成樹是樹,其邊數等於頂點數減1,且樹內一定不會有環
  • 對給定的圖G(V,E),其最小生成樹可以不唯一,但其邊權之和一定是唯一的

prim演算法

Dijkstra演算法和prim演算法實際上是相同的思路,只不過是陣列d[]的含義不同罷了。

prim演算法的基本思想是對圖G(V,E)設定集合S來存放已被訪問的頂點,然後執行n此下面的兩個步驟(n為頂點個數)

  1. 每次從集合V-S中選擇與集合S最近的一個頂點(記為u),訪問u並將其加入集合S,同時把這條離集合S最近的邊加入最小生成樹中
  2. 令頂點u作為集合S與集合V-S連線的介面,優化從u能到達的未訪問頂點v與集合S的最短距離

和Dijkstra演算法一樣,時間複雜度為 O ( V 2 ) O(V^2) O(V2),其實鄰接表實現的prim演算法可以通過堆優化使時間複雜度降為O(VlogV+E)。另外 O ( V 2 ) O(V^2) O(V2)的時間複雜度也說明,儘量在圖的頂點數目少而邊數較多的情況下(即稠密圖)使用prim演算法。

kruskal演算法

採用了“邊貪心”的策略,基本思想為:在初始狀態時隱去圖中的所有邊,這樣圖中每個頂點都自成一個連通塊。之後執行下面的步驟:

  1. 對所有邊按邊權從小到大進行排序

  2. 按邊權從小到大測試所有邊,如果當前測試邊所連線的兩個頂點不在同一個連通塊中,則把這條測試邊加入當前最小生成樹中;否則,將邊捨棄。

  3. 執行步驟2,直到最小生成樹中的邊數等於總頂點數減1或是測試完所有邊時結束。而當結束時如果最小生成樹的邊數小於總頂點數減1,說明該圖不連通。

    struct edge{
        int u, v;//邊的兩個端點編號
        int cost;//邊權
    }E[maxe];//最多有maxe條邊
    
    bool cmp(edge a, edge b){//讓陣列E按邊權從大到小排序
        return a.cost < b.cost;
    }
    
    int kruskal(){
        令最小生成樹的邊權之和為ans、最小生成樹的當前邊數Num_Edge;
        將所有邊按邊權從小到大排序;
        for(從小到大列舉所有邊){
            if(當前測試邊的兩個端點在不同的連通塊中){
                將該測試邊加入最小生成樹中;
                ans += 測試邊的邊權;
                最小生成樹的當前邊數Num_Edge加1;
                當邊數Num_Edge等於頂點數減1時結束迴圈;
            }
        }
        return ans;
    }
    

有兩個問題

  1. 如何判斷測試邊的兩個端點是否在不同的連通塊中
  2. 如何將測試邊加入最小生成樹中

把每個連通塊當作一個集合,問題轉換為判斷兩個端點是否在同一個集合中——並查集,通過並查集的查詢操作來解決第一個問題;把測試邊的兩個端點所在集合合併,就能達到將邊加入最小生成樹的效果。

時間複雜度是 O ( E l o g E ) O(ElogE) O(ElogE),其中E為圖的邊數。kruskal適合頂點數較多,邊數較少的情況(稀疏圖)。

拓撲排序

拓撲排序時將有向無環圖G的所有頂點排成一個線性序列,使得對圖G中的任意兩個頂點u、v,如果存在邊u->v,那麼在序列中u一定在v前面。這個序列又叫拓撲序列。

原理:如果某一門課沒有先導課程或是所有先導課程都已經學習完畢,那麼這門課就可以學習了。如果有多門這樣的課,那它們的學習順序任意。對應到圖中的求解方法:

  1. 定義一個佇列q,並把所有入度為0的結點加入佇列。
  2. 取隊首結點,輸出。然後刪除所有從它出發的邊,並令這些邊到達的頂點的入度減1,如果某個頂點的入度減為0,則將其加入佇列
  3. 反覆進行步驟2操作,直到佇列為空。如果佇列為空時入過隊的結點數目恰好為N,說明拓撲排序成功,圖G為有向無環圖;否則,拓撲排序失敗,圖G中有環。

用鄰接表實現拓撲排序,額外建立一個陣列inDegree[maxv]來記錄結點的入度,並在程式一開始讀入圖時記錄好每個結點的入度。

拓撲排序很重要的應用是判斷一個給定的圖是否是有向無環圖。如果返回true,說明是有向無環圖。

注意,如果要求有多個入度為0的頂點,選擇編號最小的頂點,那麼把queue改成priority_queue,並保持隊首元素(堆頂元素)是優先佇列中最小的元素即可(用set也可以)。

關鍵路徑

  • 頂點活動(Activity On Vertex, AOV)網是指用頂點表示活動,而用邊集表示活動間優先關係的有向圖。
  • 邊活動(Activity On Edge, AOE)網是指用帶權的邊集表示活動,而用頂點表示事件的有向圖,其中邊權表示完成活動需要的時間

求解有向無環圖(DAG)最長路徑(關鍵路徑)的方法:先求點,再夾邊。

  • 由於關鍵活動是那些不允許拖延的活動,因此這些活動的最早開始時間必須等於最遲開始時間。因此可以設定陣列e和l,其中e[r]l[r]分別表示活動 a r a_r ar的最早開始時間和最遲開始時間。於是,求出這兩個陣列後,就可以通過判斷e[r]==l[r]是否成立來確定活動r是否是關鍵活動。
  • 求解陣列e和l:事件(頂點) V i V_i Vi經過活動(邊) a r a_r ar之後到達事件 V j V_j Vj,事件的最早發生時間可以理解成舊活動的最早結束時間,事件的最遲發生時間可以理解成新活動的最遲開始事件。設定陣列ve和vl,其中ve[i]vl[i]分別表示事件i的最早發生時間和最遲發生時間,然後就可以將求解e[r]l[r]轉換成求解這兩個新的陣列。
    • 對活動(邊) a r a_r ar來說,只要在事件(頂點) V i V_i Vi最早發生時馬上開始,就可以使得活動 a r a_r ar的開始時間最早,因此e[r]=ve[i]
    • 如果l[r]是活動 a r a_r ar的最遲發生時間,那麼l[r]+length[r]就是事件 V j V_j Vj的最遲發生時間(length[r]表示活動 a r a_r ar的邊權)。因此l[r] = vl[j] - length[r]

步驟總結:

  1. 按拓撲排序和逆拓排序分別計算各頂點(時間)的最早發生時間和最遲發生時間
  2. 用上面的結果計算各邊(活動)的最早發生時間和最遲發生時間
  3. e[i->j]=l[1->j]的活動即為關鍵活動