圖論中的常見演算法分析比較和模板

YouthDance發表於2014-02-19

                                                                   圖論小結(一)


    從一開始搞ACM到現在也有幾個年頭了,而搞圖論的時間可是從一開始搞ACM開始。所以,總是對圖論有著一種獨有的感情。圖論的內容說難不難,但是確實在演算法中日常生活中可以經常遇到,且一個很有趣的演算法。這也是我當初選擇圖論的原因,但是圖論的程式碼量一般都比較的大,當初就是看到別人啪啪的一下只就敲出了幾百行的圖論程式碼,而自己當時是佩服的不得了啊。可是進入圖論後,才發現,一照進圖論十年不想敲程式碼啊。。。。

   廢話說太多了,其實圖論內容很多。今天,最要總結一下最短路和生成樹還有拓撲問題。還有一下更高深的網路流啊啥的一堆等下次再說。

  

一、最小生成樹

解決最小生成樹的問題,主要有兩個演算法。

1、Kruscal演算法

2、Prime演算法

Kruscal演算法百度百科的解釋及步驟:

   求加權連通圖的最小生成樹的演算法。kruskal演算法總共選擇n- 1條邊,所使用的貪婪準則是:從剩下的邊中選擇一條不會產生環路的具有最小耗費的邊加入已選擇的邊的集合中。注意到所選取的邊若產生環路則不可能形成一棵生成樹。kruskal演算法分e 步,其中e 是網路中邊的數目。按耗費遞增的順序來考慮這e 條邊,每次考慮一條邊。當考慮某條邊時,若將其加入到已選邊的集合中會出現環路,則將其拋棄,否則,將它選入。

   空間複雜度為O(N*N)

   時間複雜度為O(N*logN)(根據排序的演算法時間決定)

Prim演算法百度百科的步驟:

  Prim演算法用於求無向圖的最小生成樹
  設圖G =(V,E),其生成樹的頂點集合為U。
  ①、把v0放入U。
  ②、在所有u∈U,v∈V-U的邊(u,v)∈E中找一條最小權值的邊,加入生成樹。
  ③、把②找到的邊的v加入U集合。如果U集合已有n個元素,則結束,否則繼續執行②。
  其演算法的時間複雜度為O(n^2) Prim演算法實現:
 (1)集合:設定一個陣列set(i=0,1,..,n-1),初始值為 0,代表對應頂點不在集合中(注意:頂點號與下標號差1)
 (2)圖用鄰接陣表示,路徑不通用無窮大表示,在計算機中可用一個大整數代替。
     採用堆可以將複雜度降為O(mlog n),如果採用Fibonacci堆可以將複雜度降為O(n log n + m)
 
注意:prim演算法適合稠密圖,其時間複雜度為O(n^2),其時間複雜度與邊得數目無關,而kruskal演算法的時間複雜度為O(eloge)跟邊的數目有關,適合稀疏圖。

在推薦一個生成樹講的話的部落格以供大家參考學習:部落格連結


int Prim()
{
    int dis[N],ans = 0;           //生成樹當前的最小花費,最終的最小花費
    for(int i = 1;i <= n;++i){    //初始化
       dis[i] = graph[1][i];
    }
    for(int i = 2;i <= n;++i){    //N個頂點要連通只須n-1條邊
        int x = 1,m = INF;
        for(int j = 2;j <= n;++j){ //根據貪心,找出當前最短的一條邊
           if(dis[j] != -1&&dis[j] < m)
              m = dis[x = j];
        }
        ans += m;
        dis[x] = -1;                //改點已經使用過,不可能在出現更小的情況
        for(int j = 2;j <= n;++j){
          /*
              這個if就是prim演算法和Dijkstra演算法的本質不同之處
              等提到Dijkstra演算法時候,會著重提出。進行比較區分
          */
           if((dis[j]!=-1)&&(dis[j] > graph[x][j]))  //更新點(當前j點的最短距離,跟從x到j那個更小)
              dis[j] = graph[x][j];
        }
    }
    return ans;
}


二、最短路演算法

   最短路演算法最要使用的有三個Dijkstra演算法和Bellman-Ford以及Floyd演算法。而這三個演算法有的都有自己的優化程式,下面會一一講述。

Dijkstra演算法:

      這個演算法是通過為每個頂點 v 保留目前為止所找到的從s到v的最短路徑來工作的。初始時,原點 s 的路徑長度值被賦為 0 (d[s] = 0),若存在能直接到達的邊(s,m),則把d[m]設為w(s,m),同時把所有其他(s不能直接到達的)頂點的路徑長度設為無窮大,即表示我們不知道任何通向這些頂點的路徑(對於 V 中所有頂點 v  s 和上述 m  d[v] = ∞)。當演算法退出時,d[v] 中儲存的便是從 s  v 的最短路徑,或者如果路徑不存在的話是無窮大。 Dijkstra 演算法的基礎操作是邊的擴充:如果存在一條從 u  v 的邊,那麼從 s  v 的最短路徑可以通過將邊(u, v)新增到尾部來擴充一條從 s 到 v 的路徑。這條路徑的長度是 d[u] + w(u, v)。如果這個值比目前已知的 d[v] 的值要小,我們可以用新值來替代當前 d[v] 中的值。擴充邊的操作一直執行到所有的 d[v] 都代表從 s 到 v 最短路徑的花費。這個演算法經過組織因而當 d[u] 達到它最終的值的時候每條邊(u, v)都只被擴充一次。

時間複雜度我O(|V^2|)

但是可以優化到O(n*logn)優化程式可以看我以前寫的文章:文章連結


function Dijkstra(G, w, s)
    for each vertex v in V[G]                        // 初始化
        d[v] := infinity                                 // 將各點的已知最短距離先設成無窮大
        previous[v] := undefined                         // 各點的已知最短路徑上的前趨都未知
    d[s] := 0                                              // 因為出發點到出發點間不需移動任何距離,所以可以直接將s到s的最小距離設為0
    S := empty set
    Q := set of all vertices
    while Q is not an empty set                      // Dijkstra演演算法主體
        u := Extract_Min(Q)
           S.append(u)
           for each edge outgoing from u as (u,v)
                  if d[v] > d[u] + w(u,v)             // 擴充邊(u,v)。w(u,v)為從u到v的路徑長度。
                        d[v] := d[u] + w(u,v)               // 更新路徑長度到更小的那個和值。
                        previous[v] := u                    // 紀錄前趨頂點


  現在就說說剛才在Prim演算法中提到了但是沒有解釋的那行程式吧。

Prim更新:if(d[y] != -1&&d[y]>graph[x][y]) d[y] = graph[x][y];   //d[i]==-1表示改點已經更新過

Dijkstra更新:if(d[y]>d[x]+w[x][y]) d[y] = d[x]+w[x][y];

  為什麼會有這個區別呢?其實,只要你對這兩個演算法的本質理解了,你就自然會知道了。上面我們已經提到了Prim是求解最小生成樹的,其終極目標是使得一個圖連通且最終花費最小。而Dijkstra是求單源最短路的。即,給你一個起始點,叫你求出從這個起始點到各點或者給定目標點的最短距離。所以,我們在為Prim更新的時候考慮的是最終總的花費最小,而考慮Dijkstra是考慮起點到當前更新點的花費最小。

   所以,最終我得到的d[]也是有區別的。prim的d[]是表示當前連通改點最小的花費d[],而Dijkstra表示的是從起點到當前更新點的最小花費是d[].

 


圖雖然搓了點,但是卻可以很好的解釋他們兩個的區別。其實,是想畫無向圖的,就湊合著看吧。

如果,是prim的話是選擇(1,2)+(2,3);而Dijkstra的話則是更新(1,3)(假設起點為1).現在應該知道他他們的區別了吧。


Bellman-Ford演算法維基百科解釋:

   它的原理是對圖進行V-1次鬆弛操作,得到所有可能的最短路徑。其優於迪科斯徹演算法的方面是邊的權值可以為負數、實現簡單,缺點是時間複雜度過高,高達O(VE)。但演算法可以進行若干種優化,提高了效率。

      貝爾曼-福特演算法與迪科斯徹演算法類似,都以鬆弛操作為基礎,即估計的最短路徑值漸漸地被更加準確的值替代,直至得到最優解。在兩個演算法中,計算時每個邊之間的估計距離值都比真實值大,並且被新找到路徑的最小長度替代。 然而,迪科斯徹演算法以貪心法選取未被處理的具有最小權值得節點,然後對其的出邊進行鬆弛操作;而貝爾曼-福特演算法簡單地對所有邊進行鬆弛操作,共|E | − 1次,其中 |E |是圖的邊的數量。在重複地計算中,已計算得到正確的距離的邊的數量不斷增加,直到所有邊都計算得到了正確的路徑。這樣的策略使得貝爾曼-福特演算法比迪科斯徹演算法適用於更多種類的輸入。

   貝爾曼-福特演算法的最多執行O(|V|·|E|)次,|V|和|E|分別是節點和邊的數量)。


procedure BellmanFord(list vertices, list edges, vertex source)
   // 該實現讀入邊和節點的列表,並向兩個陣列(distance和predecessor)中寫入最短路徑資訊

   // 步驟1:初始化圖
   for each vertex v in vertices:
       if v is source then distance[v] := 0
       else distance[v] := infinity
       predecessor[v] := null

   // 步驟2:重複對每一條邊進行鬆弛操作
   for i from 1 to size(vertices)-1:
       for each edge (u, v) with weight w in edges:
           if distance[u] + w < distance[v]:
               distance[v] := distance[u] + w
               predecessor[v] := u

   // 步驟3:檢查負權環
   for each edge (u, v) with weight w in edges:
       if distance[u] + w < distance[v]:
           error "圖包含了負權環"

 

 改演算法同樣也有一個很常用的優化演算法SPFA演算法,就是將檢查的時候常常用FIFO來代替了迴圈檢查。

/*
    Head[]為鄰接表的表頭
    Key[] 為鄰接表的頂點
    Next[]為鄰接表的下個節點
    w[]   為該點的權重
*/
void SPFA(int s)              //s 為起點
{
    queue<int> q;
    int c[MAXV];           //判斷是否有負環
    bool inq[MAXV];        //在佇列中的標記
    for(int i = 0;i < n;++i)
      d[i] = (i==s?0:INF);
    memset(inq,0,sizeof(inq));
    q.push(s);
    while(!q.empty())
    {
        int x = q.front();
        q.pop();
        inq[x] = 0;                   //清除在佇列中的標記
        for(int e = Head[x];e != -1;e = Next[e])if(d[Key[e]] > d[x]+w[e]){
            d[Key[e]] = d[x] + w[e];
            if(!inq[Key[e]]){          //如果已經在佇列中,就不要重複加了
               inq[Key[e]] = 1; 
               c[Key[e]]++;
               if(c[Key[e]] > MaxVerter)
                  RETURN "有負環"
               q.push(Key[e]);     
            }
        }
    }
}

判斷有無負環:如果某個點進入佇列的次數超過N次則存在負環(SPFA無法處理帶負環的圖)






相關文章