上一篇博文我們提到了圖的最短路徑問題:兩個頂點間的最短路徑該如何尋找?其實這個問題不應該叫“最短”路徑問題,而應該叫“最便宜”路徑問題,因為有時候我們會為圖中的邊賦權(weight),也叫權重,相當於經過一條邊的“代價”,一般為正數。比如下圖(邊旁的數字即該邊的權重)
如果單純考慮一條路徑上邊的條數,那么從v0到v6的最短路徑應該是:v0-v3-v6。但是如果考慮邊的權重,從v0到v6的“最便宜”路徑應該是:v0-v1-v4-v6,其總權重為3(路徑中所有邊的權重之和),而如果走v0-v3-v6的路徑,總權重將是11。
邊有權重的圖我們稱之為賦權圖,反之稱為無權圖,賦權圖顯然可以比無權圖應用於更多場合,比如用賦權圖來表示城市間公路,權重越大路況越差,或者權重越大,過路費用越高等等。
其實不考慮權重的最短路徑問題就是所有邊的權重都是1的“最便宜”路徑問題,比如將上圖的所有邊去掉權重后的無權圖也可以這樣表示:
方便起見,我們就將“最便宜”路徑稱為最短路徑。
接下來讓我們先從簡單的無權情況開始,看看如何找兩個頂點間的最短路徑。不過到了這一步,一件有意思的事情需要說明一下,那就是:找X到Y的最短路徑,比找X到所有頂點的最短路徑更慢(有權無權都是如此)。
出現這個情況的原因我們可以簡單的分析一波:找X到Y的最短路徑,最直接的做法就是令程序從X出發沿着可行的邊不斷的走,直到走到Y處為止,但是當走到Y處時,沒人能保證剛剛走的那條路就是最短的,除非你走遍了整個圖的頂點,換句話說,你要確定走到Y處且走的路徑是最短的,你就得走遍所有頂點,而且在這個過程中你必須不斷記錄各個路徑的長度,不然當你發現到一個頂點有多條路徑時怎么比較它們呢?所以,你要找X到Y的最短路徑,你就得找出X到所有頂點的最短路徑。
當然,也存在專門尋找點對點最短路徑的思路,但是目前來說,單獨找X到Y的最短路徑不會比找X到所有頂點的最短路徑更快,所以我們接下來探討的問題其實都是:單源最短路徑問題。即給定一個起點(源),求出其與所有頂點的最短路徑。有了到所有頂點的最短路徑,我們自然也就有了到給定頂點Y的最短路徑。
對無權圖進行單源最短路徑尋找的思路,就是我們上面所說的“最直接的做法”。為了更方便講解,我們假定若存在邊(A,B),則B是被A“指向”的頂點。那么對無權圖進行單源最短路徑尋找就是這樣的:
首先,我們將起點的路徑長設為0,其他頂點路徑長設為負數(也可以是其他不可能的值,圖例中用?表示),下例以v1作為起點
接着我們將起點所指向的頂點的路徑長設為1,可以肯定的是,只有被路徑長為0的起點所指向的頂點的路徑長為1,本例中即v3和v4:
接下來,我們將路徑長為1的頂點(v3和v4)所指向的頂點的路徑長設為2,同樣可以肯定,只有被路徑長為1的頂點所指向的頂點的路徑長為2。不過此時會遇到一個問題:v3是v4所指向的頂點,但v3的路徑長顯然不應該被設為2。所以我們需要對已知路徑長的頂點設一個“已知”標記,已知的頂點不再更改其路徑長,具體做法在給出代碼時將寫明。本例中,路徑長要被設為2的頂點是v2、v5、v6:
然后我們繼續這樣的步驟,將路徑長為2的頂點所指向的頂點的路徑長設為3。不過本例中路徑長為2的頂點所指向的頂點中已經沒有未知頂點了,所以算法結束。
上述步驟隨着圖的規模變大而變多,但不難發現其規律就是:將路徑長為i的頂點所指向的未知頂點的路徑長設為i+1,i從0開始,結束條件即:當前路徑長為i的頂點沒有指向其它頂點,或所指向的頂點均為已知。
需要注意的是結束條件的說法,我們並沒有要求所有頂點都變為已知,因為確定某頂點為起點后,是有可能存在某個頂點無法由起點出發然后到達的,比如我們的例子中的v0,不存在從v1到v0的路徑。
接下來要做的事情就是用代碼實現我們所說的思路,此時我們需要注意是我們並不想在圖上直接動手腳,因為圖可能還有他用,並且直接在圖上動手腳也不方便,因為圖中頂點可能並沒有用於表示是否已知的域和用於表示從起點到自身的最短路徑長的域。
所以我們的做法是將最短路徑的計算結果存於一個線性表中,其結構如下:
其中“一行”為線性表中的一個元素,每一行的四個單元格就是一個元素中的四個域:頂點、是否已知、與起點最短路徑長、最短路徑中自身的前一個頂點。
那么之前計算最短路徑的過程用這個表來表示的話,就是下面這樣:
當我們想知道從起點到頂點Y的最短路徑時,我們只需要找到Y頂點,查看其distance域即可知道,而想知道整條路徑是怎么走的,我們也只要追溯Y的preV直到起點即可知道。下面是輸出起點到給定終點的最短路徑的一個例子:
//路徑表中的元素定義,我們假設頂點vx即數字x,所以元素沒有vertex域 struct pathNode { bool known; int distance; size_t preV; } //路徑表 struct pathNode pathTable[numVertex];
void printPath(size_t end,struct node* pathTable) { size_t preV=pathTable[end].preV; if(pathTable[preV].distance!=0) printPath(preV,pathTable); else printf("%d",preV); printf("->"); printf("%d",end); }
下面是上述無權最短路徑思路的一個簡單偽代碼實現:
//無權最短路徑計算,圖存於鄰接表graph,結果存入pathTable,起點即start void unweightedPath(Node* graph,struct pathNode* pathTable,size_t start) { pathTable[start].known=true; pathTable[start].distance=0; //若pathTable[x].distance為0,則其preV是無用的,我們不予理睬 //初始化pathTable中的其他元素 //curDis即當前距離,我們要做的是令distance==curDis的頂點所指的未知頂點的distance=curDis+1 for(int curDis=0;curDis<numVertex;++curDis) { for(int i=0;i<numVertex;++i) { if(!pathTable[i].known&&pathTable[i].distance==curDis) { pathTable[i].known=true; //遍歷pathTable[i]所指向的頂點X { if(!pathTable[X].known) { pathTable[X].preV=i; pathTable[X].distance=curDis+1; } } } } } }
與上一篇博文的拓撲排序一樣,上面的最短路徑算法還有改進空間。當我們尋找符合distance==curDis條件的頂點時,我們用的是直接遍歷數組的方法,這使得我們的算法時間復雜度達到了O(nv2)(nv為頂點個數),所以我們要改進的就是“尋找符合條件的頂點”的過程。我們可以創建一個隊列來存儲“需要處理的頂點”,該隊列初始時只有起點,當我們修改了某個未知頂點的distance后,我們就將該頂點入隊,而當我們令curDis遞增后再次尋找distance==curDis的頂點時,我們只需要令隊列元素出隊即可獲取到想要的頂點。這個過程口述難以表達清楚,下面是應該足夠清晰了的偽代碼:
//無權最短路徑計算,圖存於鄰接表graph,結果存入pathTable,起點即start void unweightedPath(Node* graph,struct pathNode* pathTable,size_t start) { //初始化pathTable //創建隊列pendingQueue //將起點start入隊 size_t curVertex;while(!empty(pendingQueue)) { curVertex=Dequeue(pendingQueue); pathTable[curVertex].known=true; //遍歷curVertex指向的頂點X { if(!pathTable[X].known) { pathTable[X].distance=pathTable[curVertex].distance+1; pathTable[X].preV=curVertex; Enqueue(X,pendingQueue); } } } }
這樣一來,我們就將無權最短路徑算法的時間復雜度由O(nv2)降低到了O(nv+ne)(ne即邊的條數)。此外,上述算法對於無向有圈圖也是一樣生效的,原因就不贅述了,道理是簡單的。
接下來的問題是如何對有權圖進行單源最短路徑的尋找。有權圖的最短路徑顯然比無權圖要難找,原因在於我們不能套用無權算法的思路,直接令已知頂點所指未知頂點的distance=curDis+weight(weight即兩頂點間路徑的權重,此處簡寫),以下圖為例:
若我們令v0作為起點,然后令v0所指的未知頂點的distance=v0.distance+weight,那么v3的distance就會變成5,可是實際上v3的distance應改為2。
解決的思路是:我們羅列出所有已知頂點指向的所有未知頂點,看這些未知頂點中誰的distance被修改后會是最小的,最小的那個我們就修改其distance,並認為它已知。
以上圖為例,我們一步步走一遍來加深一下理解:
首先是正常的初始化(我們將邊的權重也標識出來),假設起點為v0:
接着我們羅列出所有已知頂點(只有v0)指向的所有未知頂點:v1、v2、v3。然后發現若修改它們的distance,則v1.distance=v0.distance+1=1,v2.distance=v0.distance+3=3,v3.distance=v0.distance+5=5。顯然v1被修改后的distance是未知頂點中最小的,所以我們只修改v1的distance,並將v1設為已知,v2、v3不動:
接着我們繼續羅列出所有已知頂點(v0、v1)指向的所有未知頂點:v2、v3、v4。然后發現若修改它們的distance,則v2.distance=v0.distance+3=3,v4.distance=v1.distance+1=2,v3.distance=v1.distance+1=2(雖然v0也指向v3,但是通過v0到v3的路徑長大於從v1到v3,所以v3的distance取其小者),其中v3和v4的新distance並列最小,我們任選其一比如v4,然后只修改v4的distance,並將v4設為已知,其它不動:
繼續,我們羅列出所有已知頂點(v0、v1、v4)指向的所有未知頂點:v2、v3、v6,發現若修改,則v2.distance=3,v3.distance=2,v6.distance=3,所以我們只修改v3的distance,並將v3設為已知:
繼續,我們羅列出所有已知頂點(v0、v1、v3、v4)指向的所有未知頂點:v2、v5、v6,發現若修改,則v2.distance=3,v5.distance=10,v6.distance=3,我們在v2和v6中任選一個如v2,只修改v2.distance,並將v2設為已知:
繼續,我們羅列出所有已知頂點指向的所有未知頂點:v5、v6,發現若修改,則v5.distance=5,v6.distance=3,所以我們只修改v6:
最后,羅列出的未知頂點只有v5,若修改,其distance=5,我們將其修改並設為已知,算法結束:
其實上述算法的核心部分就是:
1.找到所有已知頂點
2.將所有已知頂點指向的所有未知頂點羅列出來
3.計算這些未知頂點的最小distance,然后再確定其中新distance最小的頂點X
4.只修改X的distance,並將X設為已知
5.回到第二步,若所有已知頂點都沒有指向未知頂點,則結束
而這個算法就是Dijkstra算法的雛形。
Dijkstra算法核心部分簡化的說法就是:找到所有可確定distance的未知頂點中新distance最小的那個,修改它並將它設為已知。
用偽代碼描述就是這樣:
//有權最短路徑計算,圖存於鄰接表graph,結果存入pathTable,起點即start void weightedPath(Node* graph,struct pathNode* pathTable,size_t start) { //初始化pathNode數組 size_t curV; while(true) { //找到可確定distance的未知頂點中新distance最小的那個,存入curV,若沒有則跳出循環 //令pathNode[curV].distance和pathNode[curV].prev修改為正確的值 pathNode[curV].known=true; } }
可以確定的是,Dijkstra算法也可以應用於無權圖,只要給無權圖中的每條邊加個值為1的權重即可。並且如果你將無權算法與Dijkstra算法進行對比,就會發現那個無權算法其實就是Dijkstra算法的“特例”,在無權算法中,我們之所以不需要去找“distance最小的未知頂點”,是因為我們可以肯定已知頂點所指向的未知頂點就是“distance最小的未知頂點”。
不用想都知道,Dijkstra算法中的循環中的兩行偽代碼其實意味着大量的操作:找到可以確定distance的未知頂點,計算它們的distance,比較出最小的那個,修改……
顯然,Dijkstra算法的核心部分是可以改進的,改進的思路與無權算法也很相像,即“加快尋找符合條件的頂點的過程”。其中一種改進方式是計算出未知頂點的新distance后,將{未知頂點,新distance}對插入到以distance為關鍵字的優先隊列中,而不是直接拋棄非最小distance的那些未知頂點(這是一個很大的浪費)。這樣在下一次尋找“distance最小的未知頂點”時,我們可以通過優先隊列的出隊來獲得,從而避免了遍歷整個數組來尋找目標的情況。這個想法要細化實現的話,還有不少坑要避開,不過我寫到這兒時深感表達之困難與疲憊,所以這篇博文就此打住,如果博文中有什么不對之處,可以在評論區指出,謝謝~
附:如果有權圖中存在權重為負值的情況,則計算單源最短路徑將會更加困難,不過可以確定的是,如果有權圖中存在圈與負權邊,且負權邊在圈中,使得圈的路徑長為負,那么單源最短路徑的計算是無法進行的,因為你可以在這個圈中永遠走下去來使路徑長不斷“減小”。解決有負值權重邊的圖的最短路徑算法是在Dijkstra的算法上進行改進得來的,本文不予講解(或許以后會有一篇文章介紹),有興趣的可以自行搜索。