1. 程式人生 > 實用技巧 >圖的最短路徑演算法總結

圖的最短路徑演算法總結

前言

本專題旨在快速瞭解常見的資料結構和演算法。

在需要使用到相應演算法時,能夠幫助你回憶出常用的實現方案並且知曉其優缺點和適用環境。並不涉及十分具體的實現細節描述。

圖的最短路徑演算法

最短路徑問題是圖論研究中的一個經典演算法問題,旨在尋找圖(由結點和路徑組成的)中兩結點之間的最短路徑。

演算法具體的形式包括:

  • 確定起點的最短路徑問題:即已知起始結點,求最短路徑的問題。適合使用Dijkstra演算法。
  • 確定終點的最短路徑問題:與確定起點的問題相反,該問題是已知終結結點,求最短路徑的問題。在無向圖中該問題與確定起點的問題完全等同,在有向圖中該問題等同於把所有路徑方向反轉的確定起點的問題。
  • 確定起點終點的最短路徑問題
    :即已知起點和終點,求兩結點之間的最短路徑。
  • 全域性最短路徑問題:求圖中所有的最短路徑。適合使用Floyd-Warshall演算法。

主要介紹以下幾種演算法:

  • Dijkstra最短路演算法(單源最短路)
  • Bellman–Ford演算法(解決負權邊問題)
  • SPFA演算法(Bellman-Ford演算法改進版本)
  • Floyd最短路演算法(全域性/多源最短路)

常用演算法

Dijkstra最短路演算法(單源最短路)

圖片例子和史料來自:http://blog.51cto.com/ahalei/1387799

演算法介紹:

迪科斯徹演算法使用了廣度優先搜尋解決賦權有向圖或者無向圖的單源最短路徑問題,演算法最終得到一個最短路徑樹。該演算法常用於路由演算法或者作為其他圖演算法的一個子模組。

指定一個起始點(源點)到其餘各個頂點的最短路徑,也叫做“單源最短路徑”。例如求下圖中的1號頂點到2、3、4、5、6號頂點的最短路徑。

使用二維陣列e來儲存頂點之間邊的關係,初始值如下。

我們還需要用一個一維陣列dis來儲存1號頂點到其餘各個頂點的初始路程,如下。

將此時dis陣列中的值稱為最短路的“估計值”。

既然是求1號頂點到其餘各個頂點的最短路程,那就先找一個離1號頂點最近的頂點。通過陣列dis可知當前離1號頂點最近是2號頂點。當選擇了2號頂點後,dis[2]的值就已經從“估計值”變為了“確定值”,即1號頂點到2號頂點的最短路程就是當前dis[2]值。

既然選了2號頂點,接下來再來看2號頂點有哪些出邊呢。有2->3和2->4這兩條邊。先討論通過2->3這條邊能否讓1號頂點到3號頂點的路程變短。也就是說現在來比較dis[3]和dis[2]+e[2][3]的大小。其中dis[3]表示1號頂點到3號頂點的路程。dis[2]+e[2][3]中dis[2]表示1號頂點到2號頂點的路程,e[2][3]表示2->3這條邊。所以dis[2]+e[2][3]就表示從1號頂點先到2號頂點,再通過2->3這條邊,到達3號頂點的路程。

這個過程有個專業術語叫做“鬆弛”。鬆弛完畢之後dis陣列為:


接下來,繼續在剩下的3、4、5和6號頂點中,選出離1號頂點最近的頂點4,變為確定值,以此類推。

最終dis陣列如下,這便是1號頂點到其餘各個頂點的最短路徑。

核心程式碼:

//Dijkstra演算法核心語句
    for(i=1;i<=n-1;i++)
    {
        //找到離1號頂點最近的頂點
        min=inf;
        for(j=1;j<=n;j++)
        {
            if(book[j]==0 && dis[j]<min)
            {
                min=dis[j];
                u=j;
            }
        }
        book[u]=1;
        for(v=1;v<=n;v++)
        {
            if(e[u][v]<inf)
            {
                if(dis[v]>dis[u]+e[u][v])
                    dis[v]=dis[u]+e[u][v];
            }
        }
    }

關於複雜度:

  • M:邊的數量
  • N:節點數量

通過上面的程式碼我們可以看出,我們實現的Dijkstra最短路演算法的時間複雜度是O(N^2)。其中每次找到離1號頂點最近的頂點的時間複雜度是O(N)

優化:

  • 這裡我們可以用“堆”(以後再說)來優化,使得這一部分的時間複雜度降低到O(logN)

  • 另外對於邊數M少於N^2的稀疏圖來說(我們把M遠小於N^2的圖稱為稀疏圖,而M相對較大的圖稱為稠密圖),我們可以用鄰接表來代替鄰接矩陣,使得整個時間複雜度優化到O((M+N)logN)

  • 請注意!在最壞的情況下M就是N^2,這樣的話MlogN要比N^2還要大。但是大多數情況下並不會有那麼多邊,因此(M+N)logN要比N^2小很多。

Dijkstra思想總結:

dijkstra演算法本質上算是貪心的思想,每次在剩餘節點中找到離起點最近的節點放到佇列中,並用來更新剩下的節點的距離,再將它標記上表示已經找到到它的最短路徑,以後不用更新它了。這樣做的原因是到一個節點的最短路徑必然會經過比它離起點更近的節點,而如果一個節點的當前距離值比任何剩餘節點都小,那麼當前的距離值一定是最小的。(剩餘節點的距離值只能用當前剩餘節點來更新,因為求出了最短路的節點之前已經更新過了)

dijkstra就是這樣不斷從剩餘節點中拿出一個可以確定最短路徑的節點最終求得從起點到每個節點的最短距離。

用鄰接表代替鄰接矩陣儲存

參考:http://blog.51cto.com/ahalei/1391988

總結如下:

可以發現使用鄰接表來儲存圖的時間空間複雜度是O(M),遍歷每一條邊的時間複雜度是也是O(M)。如果一個圖是稀疏圖的話,M要遠小於N^2。因此稀疏圖選用鄰接表來儲存要比鄰接矩陣來儲存要好很多。

Bellman–Ford演算法(解決負權邊問題)

思想:

bellman-ford演算法進行n-1次更新(一次更新是指用所有節點進行一次鬆弛操作)來找到到所有節點的單源最短路。

bellman-ford演算法和dijkstra其實有點相似,該演算法能夠保證每更新一次都能確定一個節點的最短路,但與dijkstra不同的是,並不知道是那個節點的最短路被確定了,只是知道比上次多確定一個,這樣進行n-1次更新後所有節點的最短路都確定了(源點的距離本來就是確定的)。

現在來說明為什麼每次更新都能多找到一個能確定最短路的節點:

1.將所有節點分為兩類:已知最短距離的節點和剩餘節點。

2.這兩類節點滿足這樣的性質:已知最短距離的節點的最短距離值都比剩餘節點的最短路值小。(這一點也和dijkstra一樣)

3.有了上面兩點說明,易知到剩餘節點的路徑一定會經過已知節點

4.而從已知節點連到剩餘節點的所有邊中的最小的那個邊,這條邊所更新後的剩餘節點就一定是確定的最短距離,從而就多找到了一個能確定最短距離的節點,不用知道它到底是哪個節點。

bellman-ford的一個優勢是可以用來判斷是否存在負環,在不存在負環的情況下,進行了n-1次所有邊的更新操作後每個節點的最短距離都確定了,再用所有邊去更新一次不會改變結果。而如果存在負環,最後再更新一次會改變結果。原因是之前是假定了起點的最短距離是確定的並且是最短的,而又負環的情況下這個假設不再成立。

Bellman-Ford 演算法描述:

  • 建立源頂點 v 到圖中所有頂點的距離的集合 distSet,為圖中的所有頂點指定一個距離值,初始均為 Infinite,源頂點距離為 0;
  • 計算最短路徑,執行 V - 1 次遍歷;
    • 對於圖中的每條邊:如果起點 u 的距離 d 加上邊的權值 w 小於終點 v 的距離 d,則更新終點 v 的距離值 d;
  • 檢測圖中是否有負權邊形成了環,遍歷圖中的所有邊,計算 u 至 v 的距離,如果對於 v 存在更小的距離,則說明存在環;

虛擬碼:

BELLMAN-FORD(G, w, s)
  INITIALIZE-SINGLE-SOURCE(G, s)
  for i  1 to |V[G]| - 1
       do for each edge (u, v)  E[G]
            do RELAX(u, v, w)
  for each edge (u, v)  E[G]
       do if d[v] > d[u] + w(u, v)
            then return FALSE
  return TRUE

SPFA(Bellman-Ford演算法改進版本)

SPFA演算法是1994年西安交通大學段凡丁提出

spfa可以看成是bellman-ford的佇列優化版本,正如在前面講到的,bellman每一輪用所有邊來進行鬆弛操作可以多確定一個點的最短路徑,但是用每次都把所有邊拿來鬆弛太浪費了,不難發現,只有那些已經確定了最短路徑的點所連出去的邊才是有效的,因為新確定的點一定要先通過已知(最短路徑的)節點。

所以我們只需要把已知節點連出去的邊用來鬆弛就行了,但是問題是我們並不知道哪些點是已知節點,不過我們可以放寬一下條件,找哪些可能是已知節點的點,也就是之前鬆弛後更新的點,已知節點必然在這些點中。
所以spfa的做法就是把每次更新了的點放到佇列中記錄下來。

虛擬碼:

ProcedureSPFA;
Begin
    initialize-single-source(G,s);
    initialize-queue(Q);
    enqueue(Q,s);
    while not empty(Q) do begin
        u:=dequeue(Q);
        for each v∈adj[u] do begin
            tmp:=d[v];
            relax(u,v);
            if(tmp<>d[v])and(not v in Q)then enqueue(Q,v);
        end;
    end;
End; 

如何看待 SPFA 演算法已死這種說法?

來自:https://www.zhihu.com/question/292283275/answer/484694411

在非負邊權的圖中,隨手卡 SPFA 已是業界常識。在負邊權的圖中,不把 SPFA 卡到最慢就設定時限是非常不負責任的行為,而卡到最慢就意味著 SPFA 和傳統 Bellman Ford 演算法的時間效率類似,而後者的實現難度遠低於前者。

Floyd最短路演算法(全域性/多源最短路)

圖片例子和史料來自:https://www.cnblogs.com/ahalei/p/3622328.html

此演算法由Robert W. Floyd(羅伯特·弗洛伊德)於1962年發表在“Communications of the ACM”上。同年Stephen Warshall(史蒂芬·沃舍爾)也獨立發表了這個演算法。Robert W.Floyd這個牛人是朵奇葩,他原本在芝加哥大學讀的文學,但是因為當時美國經濟不太景氣,找工作比較困難,無奈之下到西屋電氣公司當了一名計算機操作員,在IBM650機房值夜班,並由此開始了他的計算機生涯。此外他還和J.W.J. Williams(威廉姆斯)於1964年共同發明了著名的堆排序演算法HEAPSORT。

演算法介紹:

上圖中有4個城市8條公路,公路上的數字表示這條公路的長短。請注意這些公路是單向的。我們現在需要求任意兩個城市之間的最短路程,也就是求任意兩個點之間的最短路徑。這個問題這也被稱為“多源最短路徑”問題。

現在需要一個數據結構來儲存圖的資訊,我們仍然可以用一個4*4的矩陣(二維陣列e)來儲存。

核心程式碼:

for(k=1;k<=n;k++)
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(e[i][j]>e[i][k]+e[k][j])
                 e[i][j]=e[i][k]+e[k][j];

這段程式碼的基本思想就是:

最開始只允許經過1號頂點進行中轉,接下來只允許經過1和2號頂點進行中轉……允許經過1~n號所有頂點進行中轉,求任意兩點之間的最短路程。一旦發現比之前矩陣記憶體儲的距離短,就用它覆蓋原來儲存的距離。

用一句話概括就是:從i號頂點到j號頂點只經過前k號點的最短路程。

另外需要注意的是:Floyd-Warshall演算法不能解決帶有“負權迴路”(或者叫“負權環”)的圖,因為帶有“負權迴路”的圖沒有最短路。例如下面這個圖就不存在1號頂點到3號頂點的最短路徑。因為1->2->3->1->2->3->…->1->2->3這樣路徑中,每繞一次1->-2>3這樣的環,最短路就會減少1,永遠找不到最短路。其實如果一個圖中帶有“負權迴路”那麼這個圖則沒有最短路。

程式碼實現:

#include <stdio.h>
int main()
{
    int e[10][10],k,i,j,n,m,t1,t2,t3;
    int inf=99999999; //用inf(infinity的縮寫)儲存一個我們認為的正無窮值
    //讀入n和m,n表示頂點個數,m表示邊的條數
    scanf("%d %d",&n,&m);
    
    //初始化
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;  
              else e[i][j]=inf;

    //讀入邊
    for(i=1;i<=m;i++)
    {
        scanf("%d %d %d",&t1,&t2,&t3);
        e[t1][t2]=t3;
    }
    
    //Floyd-Warshall演算法核心語句
    for(k=1;k<=n;k++)
        for(i=1;i<=n;i++)
            for(j=1;j<=n;j++)
                if(e[i][j]>e[i][k]+e[k][j] ) 
                    e[i][j]=e[i][k]+e[k][j];
    
    //輸出最終的結果
    for(i=1;i<=n;i++)
    {
     for(j=1;j<=n;j++)
        {
            printf("%10d",e[i][j]);
        }
        printf("\n");
    }
    
    return 0;
}

總結

關於BellmanFord和SPFA再說兩句

來自:https://www.zhihu.com/question/27312074

SPFA只是BellmanFord的一種優化,其複雜度是O(kE),SPFA的提出者認為k很小,可以看作是常數,但事實上這一說法十分不嚴謹(原論文的“證明”竟然是靠程式設計驗證,甚至沒有說明程式設計驗證使用的資料是如何生成的),如其他答案所說的,在一些資料中,這個k可能會很大。而Dijkstra演算法在使用斐波那契堆優化的情況下複雜度是O(E+VlogV)。SPFA,或者說BellmanFord及其各種優化(姜碧野的國家集訓隊論文就提到了一種棧的優化)的優勢更主要體現在能夠處理負權和判斷負環吧(BellmanFord可以找到負環,但SPFA只能判斷負環是否存在)。

補充演算法

還有一些最短路演算法的優化或者引申方法,感興趣可以谷歌一下:

  • Johnson演算法
  • Bi-Direction BFS演算法
  • ...

參考

關注我

我目前是一名後端開發工程師。技術領域主要關注後端開發,資料安全,爬蟲,5G物聯網等方向。

微信:yangzd1102

Github:@qqxx6661

個人部落格:

原創部落格主要內容

  • Java知識點複習全手冊
  • Leetcode演算法題解析
  • 劍指offer演算法題解析
  • SpringCloud菜鳥入門實戰系列
  • SpringBoot菜鳥入門實戰系列
  • Python爬蟲相關技術文章
  • 後端開發相關技術文章

個人公眾號:後端技術漫談

如果文章對你有幫助,不妨收藏起來並轉發給您的朋友們~