圖的最短路徑算法總結


前言

本專題旨在快速了解常見的數據結構和算法。

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

圖的最短路徑算法

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

算法具體的形式包括:

  • 確定起點的最短路徑問題:即已知起始結點,求最短路徑的問題。適合使用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爬蟲相關技術文章
  • 后端開發相關技術文章

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

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM