1. 程式人生 > >我對單源最短路徑的思考

我對單源最短路徑的思考

前言:

        最近一直在看《演算法導論》。演算法這塊難啃的硬骨頭,向來令我頭疼不已,尤其是圖演算法這一部分愈發覺得難啃。在冥思苦想幾日之後雖不能說豁然開朗,但也算是小有斬獲,稍加整理思緒之後便有此文。所以以下的內容呢,源於我在閱讀《演算法導論》期間對自己思考過程的總結。

        言歸正傳,今天我要說的主題是。比如說,你想知道怎樣應該怎樣從武漢去北京,那麼你可以拿起你的手機登陸Google Maps,輸入起點武漢,終點北京,Google會告訴你最佳的選擇。撇開交通工具的差異(如果是飛機,估計您老就按照兩點之間,線段最短的路徑直接飛過去了),我們把地圖上每一個城市想象成一個點,從一個城市去另一個城市的公路想象成一條線,那麼怎樣從武漢去北京的問題就變成怎樣從起點經過這麼多條不同的線路抵達終點的問題了。那麼在如此錯綜複雜的地圖上,我們應該如何尋求兩點之間的最短路線呢?

        很遺憾的是,當前還沒有單純的解決地圖上從A點去B點最短路線的問題,我們往往需要求出從A點出發到所有地方的最短線路之後,才能確定從A到B的最短路線。這是一個很令人頭疼的問題,你想如果我們從武漢去北京,犯得著思考怎樣從武漢去廣州嗎?所以說目前的單源最短路徑演算法在這個境遇上顯得十分笨拙。(其實這個問題被我嚴重化了,可以通過很簡單的剪枝避免大量重複的計算)。

思考:

        解決單源最短路徑最通用的演算法是,但是在此之前,我們還是先從說起。畢竟歷史的軌跡是如此發展的,先有Bellman-Ford演算法,後才有Dijkstra的改進演算法。這樣的順序也方便於我們思考:解決同一個問題,我們是否能做得更好。

        在正式進入出題之前,我們先給定單源最短路徑問題的形式化定義:給定一個帶權有向圖G=(V, E),對於任意邊(u, v)∈E,加權函式ω:E→R賦予邊(u, v)一個實數權值ω(u,v)。計算出從給定源點s到圖中其餘頂點的最短路徑。

        另外對資料結構還有一些補充。對於任意頂點v∈V,除了儲存頂點v至其鄰接頂點的邊之外,還儲存了從源點s到頂點v的最短路徑p=<s, v0, v1, …, vk, v>中v的前驅頂點vk和p的路徑長度,分別用π[v]和d[v]表示,即π[v]=vk,d[v]=ω(p)。對於從源點到s到頂點v的最短路徑,我們只用從頂點v遞迴尋找其前驅頂點,到s終止時即為其最短路徑。我們用δ(s, v)表示從源點s到頂點v的最短路徑長度。

 

對於這張圖,我們設定源點為s,圖中被陰影加粗的邊都是最短路徑所經過的邊,

頂點內部的數值代表了其最短路徑長度。

例如從s到z的最短路徑p=<s, t, y, x, z>,其最短路徑長度δ(s, z)=11

        Bellman-Ford演算法的基底來自於動態規劃。根據《演算法導論》中總結出是否能運用動態規劃的兩點特徵:最優子結構重疊子問題。我們可以證明最短路徑問題是可以使用動態規劃的,以下將用Cut & Paste這一技術來證明最短路徑問題滿足最優子結構。

        最短路徑的子路徑也是最短路徑: 假設p=<v1, v2, ...,vn>是從v1到vn的最短路徑,對於任意i, j,其中1 ≤ i ≤ j ≤ n,那麼在路徑p中從頂點vi到頂點vj的子路徑也是從vi到vj的最短路徑。

        我們把最短路徑p分解為v1→vi→vj→vn,考慮從vi到vj的路徑q,如果存在另外一條路徑q'的路徑長度比最短路徑p中vi→vj的路徑長度還要小,那麼我們只用將這條路徑q'替換掉原有最短路徑p中vi→vj的部分,即可構造一個更短的路徑。但這與我們假設p是從v1到vk的最短路徑的前提矛盾,所以上述結論成立。至於重疊子問題就更好理解了,如果我們要求從v1到vk的最短路徑,那麼我們也需依次求出從v1到v2, v3, ..., vk-1的最短路徑,每一次求解的過程中都會出現重疊的子問題。

        在具體講解Bellman-Ford演算法之前,我們先引入三角不等式。因為正是通過不斷的判定三角不等式是否成立,我們才能確切的求出從源點到圖中每個頂點的最短路徑。

        三角不等式:對任意邊(u, v)∈E,有δ(s, v) ≤ δ(s, u)+ ω(u, v)。

        我們很容易就能證明三角不等式成立。對於從源點s到頂點v的最短路徑p,假如存在另外一條經過頂點u到達v的路徑p‘,並且p’比原有的最短路徑p的路徑長度更短,這就與最短路徑的前提矛盾,所以不等式必然成立。我們在演算法執行過程中始終維持三角不等式成立,並且不斷修正頂點v∈V中d[v]和π[v]的值,繼而求出最短路徑,我們稱這樣的步驟為鬆弛(Relax),以下為鬆弛步驟的虛擬碼:

Relax(u, v, ω)
{
      if d[v]> d[u] + ω(u, v)
           then d[v] ← d[u] + ω(u, v)
                π[v] ← u
}


Bellman-Ford演算法:

        接下來正式是Bellman-Ford演算法了。我們判定從源點s到圖中任意頂點的最短路徑所經歷的邊數都不會超過|V|-1(其中|V|為圖中頂點的個數),否則就會出現迴路。我們從最短路徑所經歷的邊數入手,從源點s出發,自底向上構造經歷邊數依次為1,2,...,|V|-1的最短路徑,結束時就能保證從源點s到圖中任意頂點的路徑都為其最短路徑。似乎說起來並不難理解,接下來我們用數學歸納法來證明這一演算法的正確性。

對於從源點s出發的最短路徑所經歷的邊數n

基礎步驟:當n=1時,從源點s到其鄰接頂點的路徑都是邊數為1的最短路徑。這一點很顯然,對於圖中除源點外的任意頂點v,要麼與源點鄰接,其路徑長度為這條邊的權值ω(s, v);要麼不與源點鄰接,其路徑長度為∞(表示兩頂點不可相互抵達)。

歸納步驟:假設當n=k時,我們已經構造了從源點s出發,經歷邊數為1,2,...,k的最短路徑。那麼任取這樣的一條路徑p=<s, v1, v2, ...,vk>,有d[vi]=δ(s, vi),i≤k。如果從源點s到頂點vk+1的最短路徑由p'=<s,v1, v2, ..., vk, vk+1>組成,在鬆弛邊(vk, vk+1)後,d[vk+1]的路徑長度即為δ(s, vk+1)。如果不是,這就與路徑p是從s到vk的最短路徑的前提矛盾。故當n=k+1時,也滿足從源點s出發,經歷邊數為k+1的路徑也為最短路徑。

綜上所述,當已經構造完畢經歷邊數為|V|-1的最短路徑之後,我們就確立了從源點s到圖中任意頂點v∈V的最短路徑。

        或者更為通俗的講,我們判定從s到圖中每個頂點v∈V存在一條這樣的最短路徑,其路徑長度為δ(s, v) = min { p:  p為從s到v的可達路徑 },否則就是不可抵達,其路徑長度為∞。如果我們依照路徑p的順序,保證每次從s出發到v的子路徑都是一條最短路徑,那麼最終我們就能夠得到一條這樣的最短路徑。

        但是我在觀看MIT的教學視屏時發現,往往我們並不需要這樣執行|V|-1次才能確定單源最短路徑,一方面是圖中所有頂點確立的最短路徑所經歷的邊數達不到|V|-1;另一方面是在演算法執行過程中,假如執行到第i次,我們並不只是鬆弛從s出發到vi的路徑所經歷的i條邊,相反我們鬆弛了所有邊。這樣導致的結果是:也許在第i次執行時巧合的發現,我們不僅確立了從s到vi的最短路徑,同時也確立了從vi到vj的最短路徑。那麼我們就直接確立了從s到vj的最短路徑,第i次以後的過程也許就不會產生實質的影響了(事實上是額外且多餘的時間開銷)。

        如果換一種思路,假設我們事先就能確立從s到任意頂點v的最短路徑所經歷的每個頂點的順序,即對於最短路徑p=<s, v1, v2, ... vk>而言,我們知道v1,v2,..., vk確切對應的是哪一個頂點,並且按照這條路徑途經頂點的順序進行鬆弛,同樣也可以得出從源點s到圖中任意頂點的最短路徑。這就好比做一項工作,如果你事先知道按照怎麼樣的順序去做這項工作的每一部分最能省時間,並且你按照這樣的順序完成這項工作,那麼當工作做完時花費的時間也是最少的。而我們確立這樣的順序所需要做的僅僅只是一次拓撲排序罷了。這樣就能將Bellman-Ford演算法的時間複雜度O(VE)降為O(V+E)。

Dijkstra演算法:

        現在應該是我們重頭戲登場的時候了:Dijkstra演算法。作為目前最為廣泛使用的單源最短路徑演算法,Dijkstra演算法不僅執行速度高效,而且程式結構堪稱精妙。美中不足的是,Dijkstra演算法在求解時採取了貪心策略,為了能夠維持貪心選擇性質,演算法要求給定的帶權有向圖中所有邊的權值不小於0。如果圖中存在一條權值為負值的邊,Dijkstra演算法就不能保證其正確性了。但是如果我們把目光置於現實生活,我們會發現在解決類似問題的情形下幾乎不存在負數,譬如公路,街道的距離就不可能是負數。更為苛刻的講,我們很難描述一條被賦值為負權的邊的實際模型,雖然在理論上這是確切存在的。所以除去這點原因,Dijkstra演算法在實際應用中解決單源最短路徑問題時可謂遊刃有餘。

        Dijkstra演算法通過維持一個動態集合S,不斷將已經確定最短路徑的頂點新增至S,當S已經包含圖中所有頂點時演算法結束。我們在前文說過,Dijkstra演算法使用了貪心策略,因為在每次選取集合S外的頂點時,我們總是選取一個當前路徑長度最小的頂點加入S。通過不斷選取當前路徑長度最小的頂點,我們期望在演算法結束時能滿足從源點到每個頂點的路徑都為最短路徑。我們仍然使用數學歸納法證明這一事實的成立。

對於集合S中頂點的個數n

基礎步驟:當n=1時,我們在初始化時將源點的路徑長度d[s]置為0,其餘頂點的路徑長度置為∞,所以S中的唯一頂點必定是源點s。因為不存在負權邊,所以δ(s, s)=0,源點s的確已經確立了最短路徑。

歸納步驟:假設當n=k時,我們已經確立了S中k個頂點的最短路徑。那麼我們依次遍歷還不在S中的其餘頂點(即集合V-S),選取其中路徑長度最小的頂點v。

⒈ 如果頂點v的路徑長度d[v]=∞,說明集合S的所有頂點都無法抵達頂點v。又因為頂點v是集合V-S中路徑長度最小的頂點,所以集合V-S中的所有頂點的路徑長度都為∞,我們可以斷言從源點s到集合V-S中的所有頂點都不可達。對於v'∈V-S,δ(s, v')=∞,因此我們確立了頂點v的最短路徑。那麼當n=k+1時結論仍然成立。

⒉ 如果頂點v的路徑長度d[v]≠∞,說明集合S中存在一條路徑抵達頂點v,對於這條路徑p=<s, v0, ..., vk, v>,我們一分為二:假設p'=<s, v0, ..., vi>,i≤k,是在集合S中形成的一條路徑,即路徑p'經過的每個頂點都屬於集合S;那麼另外一條路徑q'=<vi, vi+1, ..., vk, v>則是在集合S-V中形成的一條路徑。因為頂點v是集合V-S中路徑長度最小的頂點,所以滿足d[v]≤d[vi+1]。又因為圖中不存在負權邊,根據路徑p',我們推匯出d[v]≥d[vi+1]。綜合兩者,我們得到:d[v]=d[vi+1],ω(q')=0,q'毫無疑問是從vi+1到v的最短路徑。所以在這個時候,無論是選擇v或是vi+1(事實上,他們很有可能是同一頂點),我們都能確立他們的最短路徑。特別的,對於頂點v,我們確立了其最短路徑,那麼當n=k+1時結論仍然成立。

綜上所述,當集合S包含圖中所有頂點時,已經確立了從源點s到任意頂點v∈V的最短路徑。

        或者更為通俗的講,為什麼每次選取當前路徑長度最小的頂點就能保證全域性最優呢?歸根結底是前提的設定:圖中不存在負權邊。對於在集合V-S中的頂點而言,選取路徑長度最小的頂點v意味著不可能還有比當前路徑長度更小的路徑到達頂點v,否則那條路徑的權值為負值。對於Dijkstra演算法的改進多半是從選取最小路徑長度的頂點入手,用二叉最小堆實現的優先順序佇列的時間複雜度為O((V+E) * lgV),倘若用斐波那契堆來實現優先佇列的時間複雜度能優化為O(VlgV+ E)。除此之外,原本的Dijkstra演算法在程式結構上是無法進一步優化的。

        我們在以上的分析過程中一直對負權迴路避而不談,是因為如果給定圖中存在一條負權迴路,我們就可以不斷在這條迴路中迴圈,得到我們期望的任意小的路徑長度。如此這般也就不存在最短路徑,其路徑長度只能用-∞表示,因此單源最短路徑問題也就沒有了意義。

後記:

事實上,以上的內容多半與《演算法導論》原書無異,甚至還存在偏差和疏漏的地方。我的想法呢,是想把自己這麼一路來的思考過程給記錄下來,理清一下思路。畢竟當你能把一件複雜的事情給講明白時,多半你自己就真弄明白了。原本我也想貼一下程式碼,具體給個實現。但是在寫的過程中發現如果這樣做,重心就偏向於講解,而非是我自己的思考了(講不講得好就另當別論,再說估計也好不到哪去)。我的目的很單純,希望能徹底掌握單源最短路徑的解法,也算是為以後的程式設計師之路奠定基礎吧。