轉載:https://blog.csdn.net/robinvista/article/details/61421034 css
前言
最短路徑問題(Shortest Path Problem)是一類很是重要的問題,它出如今不少領域,例如車輛導航、路由選擇、機器人運動規劃、物流等。Dijkstra 算法是一種解決最短路徑問題的經典算法,同時也是計算機科學中最有名的算法之一。其方法簡潔,但蘊藏的思想卻很深入。經過學習 Dijkstra 算法,既能夠掌握分析、解決問題的方法,也能夠做爲進一步學習其它搜索算法的基礎。用一句時髦的話說,Dijkstra 算法——你值得擁有。 然而,對於缺乏必定基礎的初學者,要完全理解 Dijkstra 算法有些困難。筆者發現大多數講述 Dijkstra 算法的書籍或博客每每不求甚解,只知照本宣科地描述算法的流程,而忽視了算法的由來和內在的邏輯。這樣的文章是寫給機器看的,而不是給人看的。其後果是,初學者讀完後仍然似懂非懂,知其然而不知其因此然。並且初學者在編程實現時又會遇到很多麻煩,讓他們舉步維艱。本文的目的是幫助初學者儘快入門,爲此在表達上力求通俗易懂。文中出現的程序都提供了源代碼(Mathematica),方便初學者體驗程序的運行過程,並對其解剖研究。 node
1. 最短路徑問題
最短路徑問題的研究範圍很大,咱們只討論最簡單的狀況,即:告訴你一個起點和一個目標點,找到從起點出發到達目標點的最短路徑,這又稱爲單源單目標最短路徑問題。通常來講,找到一條鏈接起點和目標點的路徑並不太難,可是想找到最短 的路徑可就沒那麼容易了。 在解決這個問題以前,咱們首先須要對它用數學語言進行描述。現實世界老是存在各類約束,好比汽車應該沿着道路行駛、電流必須在電纜上傳輸、上網產生的數據包只能在路由器之間的網線傳遞。若是不存在約束,那麼最短問題就沒有研究價值了——只須要在起點和目標點之間畫直線就好了。爲了表示現實中的各類約束,同時也爲了便於用數學方法進行處理,一般選擇數學中的「圖」(graph)進行描述(研究「圖」的數學學科稱爲「圖論」,圖 1(a) 展現了一個「圖」的例子)。「圖」由兩種東西組成:節點(vertex)和 邊(edge)。圖 1(a) 中的圓點表示節點,黑色線段表示邊。咱們通常用小寫字母表示節點,例如節點
a
a
、節點
b
b
。每條邊的兩端是兩個節點。由於每條邊都惟一對應本身的兩個節點,因此能夠用兩個節點表示一條邊。咱們用 (
a
a
,
b
b
) 表示節點
a
a
與節點
b
b
之間的那條邊。咱們將「圖」中全部的節點放在一塊兒,組成一個集合,記爲
V
V
;全部的邊也放在一塊兒,記爲
E
E
。什麼是路徑(path)呢?一條路徑由若干條首尾相接的邊組成。咱們也能夠用一系列相鄰的節點表示路徑。什麼是相鄰的節點呢?若是兩個節點在同一條邊的兩端它們就是相鄰的,也能夠稱爲「鄰居」。一個節點能夠有好多個鄰居,並且咱們假設每一個節點至少有一個鄰居。既然咱們關心路徑的長短,就須要有距離的概念。咱們定義每條邊都對應一個數值,那就是它的長度。咱們用
l ( a , b )
l
(
a
,
b
)
表示
( a , b )
(
a
,
b
)
邊的長度。咱們只考慮長度爲非負數的狀況(即
l ( a , b ) ≥ 0
l
(
a
,
b
)
≥
0
),由於Dijkstra 算法不適用於負數邊長的狀況。今後之後,咱們進入這個「圖」的世界,裏面除了節點和邊(和它的長度)之外別的什麼都沒有。你可能會以爲這個小世界太簡單、太無聊了。別急,隨着咱們逐步探索這個小世界,它的豐富多彩將會讓你大吃一驚。 既然尋找最短路徑是件很難的事,咱們最好先從簡單的狀況入手。考慮如圖 1(a) 所示的例子,這個「圖」的節點排列成一個規則網格,全部邊的長度都相等,假設長度都是1吧。圖中也標出了起點(紅色點)和目標點 (綠色點),你能找到它們之間的最短路徑嗎? web
答案揭曉,最短路徑就是圖 1(b) 中的黃色線段(爲了突出它,我特地畫得比普通的邊粗一些)。由於兩點間直線段最短,起點和目標點之間恰好存在這樣組成直線段的邊。你可能以爲這太簡單了,甚至有智商被侮辱的感受。事實偏偏相反,這個例子不是太膚淺了,而是太深入了。咱們能夠從中找到一條規律,這條規律過重要了,以致於我不得不將它單獨放在一段:
規律
0
0
:一條直線段上任意兩點之間的那部分線段仍然是直線段。
數學家們喜歡乾的一件事就是推廣——將特殊推廣到通常,將簡單推廣到複雜。好比牛頓的第一個數學發現就是將二項展開式的指數從正整數推廣到負數和分數。咱們也來試着將前面這條規律推廣一下,因而就獲得了下一條規律:
規律
1
1
:一條最短路徑上任意兩個節點之間的那部分路徑仍然是它們的最短路徑。
根據咱們的平常經驗,規律1彷佛是對的,可是咱們要從邏輯上證實它的正確性。若是對的不容易發現破綻,那咱們就反其道而行之,從錯的開始推導。咱們假設規律 1 是錯的,也就是說:最短路徑上存在兩個節點,它們之間的那部分路徑不是最短路徑。圖 2(a) 展現的例子就是這種狀況,在起點
s
s
和目標點
t
t
之間的黃色曲線是它們的最短路徑。在這條最短路徑上,有兩個節點
a
a
和
b
b
,
a b
a
b
之間的最短路徑(藍色曲線)比黃色曲線上的那部分更短。從圖中能夠看到,節點
a
a
和
b
b
將黃色曲線分紅了三段,即
s
s
-
a
a
段、
a
a
-
b
b
段和
b
b
-
t
t
段。三段長度之和就是
s
s
到
t
t
的最短路徑的長度,咱們用
L m i n
L
m
i
n
表示。若是咱們用藍色曲線替換掉黃色曲線上的
a
a
-
b
b
段,如圖 2(b) 左側所示,那麼這個從新組合獲得的新路徑長度顯然小於
L m i n
L
m
i
n
。新的路徑比
s
s
和
t
t
之間的最短路徑(黃色曲線)還短,這顯然是矛盾的,也就說明規律1是正確的。 想一想看,咱們能不能將規律1換種說法:一條最短路徑上的任意兩個節點之間的最短路徑仍然在這條路徑上。看起來好像差很少,但實際上是不嚴謹的,由於咱們並不知道最短路徑是否是惟一的。若是任意兩個節點之間的最短路徑都只有一條,那麼這樣說就是對的。可是在有些狀況下,兩個節點之間的最短路徑可能會有不止一條 (它們的長度都是最短的,但通過的節點不一樣)。因此咱們仍是應該採用規律1的說法。
2. 搜索算法
2.1 鬆弛 (relax)
啊哈!這個小世界開始有意思起來了,咱們發現了其中的一條規律。但別高興的太早,咱們怎麼利用這條規律呢?若是我給你一條路徑,你能夠用規律 1 來驗證它究竟是不是最短的。若是你能在這條路徑上找到兩個節點,在它們之間有更短的路徑,那你能夠自信地說我給你的路徑確定不是最短的。注意:規律 1 的重點是「最短路徑上」。非最短路徑上也可能包含最短的子路徑;而兩個最短路徑拼接到一塊兒獲得的路徑未必是最短的。規律 1 沒有告訴咱們怎麼計算最短路徑。咱們試試把規律 1 反過來是什麼,這樣就獲得了另外一條規律: 規律
2
2
:若是一條路徑上的任意兩個節點之間的最短路徑仍然在這條路徑上,那麼這條路徑就是最短路徑。 咱們一樣不知道規律 2 是否是成立。但經驗告訴咱們,它頗有多是對的。咱們也須要從邏輯上檢驗規律 2 的正確性。這裏咱們能夠投機取巧,既然規律 2 適用於路徑上的任意兩個節點,咱們不妨選擇這條路徑的起點和目標點。由於起點和目標點間的最短路徑與這條路徑重合,顯然這條路徑就是最短路徑。因此規律 2 是正確的。太棒了,由於咱們在小世界中又發現了一個新規律。與規律 1 不一樣的是,規律 2 的提供了一種操做 —— 把一條不是最短的路徑變成最短路徑的操做: 1. 隨便選擇一條鏈接起點和目標點的路徑(不必定最短)。 2. 在這條路徑上任意選擇兩個節點,搜索它們之間的最短路徑。 3. 若是找到的最短路徑不在原路徑上,就用最短路徑替換掉原來路徑的那部分。 4. 重複第2步和第3步,直到這條路徑的長度再也不改變。 咱們一樣不知道規律 2 是否是成立。但經驗告訴咱們,它頗有多是對的。咱們也須要從邏輯上檢驗規律 2 的正確性。這裏咱們能夠投機取巧,既然規律 2 適用於路徑上的任意兩個節點,咱們不妨選擇這條路徑的起點和目標點。由於起點和目標點間的最短路徑與這條路徑重合,顯然這條路徑就是最短路徑。因此規律 2 是正確的。太棒了,由於咱們在小世界中又發現了一個新規律。與規律 1 不一樣的是,規律 2 的提供了一種操做——把一條不是最短的路徑變成最短路徑的操做: 算法
爲了幫助你們理解上面幾步的含義,下面我用一個簡單的例子來解釋。假如你因爲工做調動,來到了一個新的城市。這個城市的道路構成了一個交通網,如圖 3(a) 所示,其中紅色點表示你的住所,綠色點表示你的公司。上班第一天,你想找一條開車最快到公司的路。但是你對這個城市的道路不熟悉,因此你只能勉強找一條能到公司的路,如圖 3(b) 中所示的粉色路徑(好吧,我認可看起來實在是不怎麼好)。
隨着時間的流逝,你對這個城市的交通愈來愈熟悉,附近每條道路的走向和長度逐漸進入你的記憶。雖然你對整個交通網仍然不是很是瞭解,但對某幾段路和它周邊道路的印象仍是很清楚的,這是由於你走的次數太多了,有時也會走錯或者去其它地方,並由此發現了更多的道路。你逐漸發現你最開始找到的那條路並非最好的。在某條路附近存在更短的路(圖4(a) 中虛線內的部分)。因而,你開始超近道,如圖 4(b) 所示。之前你走的是通過
( a , b )
(
a
,
b
)
邊和
( b , c )
(
b
,
c
)
邊表示的路,如今你知道超近道直接走
( a , c )
(
a
,
c
)
邊更快。每超一次近道,路徑就會短一些,直到你最終找到最短的路徑。 依照上面幾步操做咱們最終總能找到最短路徑。可這是一個好方法嗎?看起來彷佛不太好。首先咱們並不知道運行多少步才能找到短路徑。假如你迷路了,向別人問路。那人給你指了一個方向卻沒告訴你還有多遠,你會不會內心沒底。其次是第 2 步,很明顯第 2 步自己就是一個最短路徑問題,它如何求解咱們仍是不知道。 雖然上述方法缺乏實用價值,但至少它的方向是對的,咱們能夠從中受到啓發。這個方法能夠形象的比做被抻長的橡皮筋恢復的過程。若是將路徑視爲橡皮筋,那麼路徑的長度就對應橡皮筋中儲存的彈性勢能。最短路徑就是天然狀態下(不受外力)的橡皮筋,它不會再縮短了。開始隨意肯定的路徑至關於被抻長的橡皮筋,而之後每一次超近道均可以當作橡皮筋在自身彈力做用下縮短恢復的過程。咱們稱這一過程爲「鬆弛」(relax),意思就是鬆開抻長的橡皮筋,讓它縮短從而釋放掉多餘的彈性勢能,如圖 5 所示。
鬆弛現象很容易理解,可是鬆弛發生的前提條件是什麼呢?讓咱們將注意力集中到路徑中的某一條邊,如圖 6(a) 所示。假設一條路徑從起點
s
s
節點出發,依次通過
a
a
節點和
b
b
節點(可是
( a , b )
(
a
,
b
)
邊不在這條路徑上)。再假設
s
s
節點與
a
a
節點間的路徑長度爲 2,那麼咱們就認爲
a
a
節點的能量是 2。
a
a
節點與
b
b
節點間的路徑長度爲 6,咱們認爲
b
b
節點的能量是 2 + 6 = 8。邊
( a , b )
(
a
,
b
)
的長度是 3,路徑若是通過
( a , b )
(
a
,
b
)
邊,
b
b
節點的能量就是 2 + 3 = 5。
b
b
節點的能量降低了(5 < 8)。因此,一條邊鬆弛發生的條件是要可以下降邊上節點的能量。反過來想,若是路徑通過
( a , b )
(
a
,
b
)
邊,沒有下降
b
b
節點的能量,那麼說明
a
a
,
b
b
間的路徑長度小於或等於
( a , b )
(
a
,
b
)
邊的長度,此時不該該鬆弛。
之後咱們正式稱呼一個節點的能量(或距離)爲它的值,一個節點
a
a
的值表示爲
d ( a )
d
(
a
)
。一個節點的值定義爲鏈接起點和這個節點的路徑的長度。若是鏈接起點和這個節點的路徑不止一條,咱們只選擇最短的那條路徑。顯然,若是咱們找到了起點和這個節點之間的最短路徑,那麼節點的值就是最小的了,它不會再變小了。反之,若是節點的值是最小的,那麼咱們就知道了最短路徑的長度了。 鬆弛的過程很簡單,用程序實現也不復雜。爲了便於理解,我把鬆弛程序用僞代碼寫出來,如 Algorithm 1 所示。Relax 函數負責實現鬆弛,它的輸入是兩個相鄰的節點
a
a
和
b
b
。注意Relax(
a
a
,
b
b
) 的輸入是區分順序的。
d ( b ) ← d ( a ) + l ( a , b )
d
(
b
)
←
d
(
a
)
+
l
(
a
,
b
)
表示對
( a , b )
(
a
,
b
)
邊鬆弛,也就是令
b
b
節點的值等於
d ( a ) + l ( a , b )
d
(
a
)
+
l
(
a
,
b
)
(更準確的說,是對
b
b
節點鬆弛,由於
a
a
節點的值沒變)。結束賦值後還沒完,咱們還要記錄下是誰讓
b
b
節點的值下降。咱們讓
a
a
節點做爲
b
b
節點的「母親節點」。
b
b
節點能夠有不少鄰居,可是隻能有一個「母親」。而
b
b
節點是那種「有奶即是娘」的節點:誰讓它的值下降,它就認誰作娘。咱們用
p ( b )
p
(
b
)
表示
b
b
節點的「母節點」。
終於找到了一件稍微順手點的工具了,咱們應該如何使用它呢?回想圖 3(b) 的例子,若是咱們對初始路徑上每兩個相鄰節點之間的邊進行鬆弛,就獲得如圖 6(c) 所示的新路徑。讓人欣慰的是,新路徑確實更短了,可是好像還遠遠不是最短的路徑。這是爲何呢?注意在鬆弛時,咱們只考慮了一部分邊,也就是兩端節點都在初始路徑上的邊,由於只有這些節點的值是已知的,這樣咱們才能判斷鬆弛條件是否知足。但是若是初始路徑並不經過最短路徑的節點,那麼再怎麼鬆弛也不會獲得最短路徑。
2.2 全部邊依次鬆弛
咱們知道了只鬆弛一部分邊達不到理想的效果,緣由就是初始路徑不必定與最短路徑有同樣的節點。固然,咱們不知道最短路徑通過哪些的節點。可否擴大範圍,對全部的邊都鬆弛呢?固然能夠。只是除了起點以外,咱們對其它全部節點的值都不清楚,這意味着咱們沒法判斷鬆弛的條件。不過,咱們能夠認爲起點的值是0,由於起點到起點的路徑最短就是0,不會有比0更短的路徑了。這時咱們再也不須要先尋找一個初始路徑了。咱們能夠將其它全部節點的值都認爲是無窮大,也就是說沒有路徑到達它們。每應用一次鬆弛,它們的值都會改變一點。咱們能夠編程實現這個過程,如 Algorithm 2 所示。 編程
下面咱們詳細解釋 Algorithm 2 的每一步。
1. 首先算法進行初始化,也就是咱們剛剛討論過的,設置節點的值和母節點。因爲計算機沒辦法表示無窮大,把初始值設置成一個很大的數就行 (好比 1000,實際上只要大於全部可能路徑的最大值就能夠)。 2. 第一個 for 循環執行
n
n
次,這裏
n
n
是人爲指定的,
n
n
應該是多少咱們也不知道。不過不要緊,咱們會經過幾回試驗肯定它,剛開始不妨先讓
n = 1
n
=
1
。 3. 第二個 for 循環負責掃描邊,它從「圖」的全部邊的集合
E
E
中依次取出一條邊 (用
( a , b )
(
a
,
b
)
表示),直到全部的邊都被取過。這個循環會執行
m
m
次 (
m
m
是「圖」中邊的個數)。 4. 第三步的 if 語句用於判斷是否須要鬆弛,若是
d ( b ) ≠ d ( a ) + l ( a , b )
d
(
b
)
≠
d
(
a
)
+
l
(
a
,
b
)
或者
d ( a ) > d ( b ) + l ( b , a )
d
(
a
)
>
d
(
b
)
+
l
(
b
,
a
)
,則知足鬆弛的條件,就調用 Relax 函數進行鬆弛。咱們只考慮無方向限制的邊,即路徑既能夠由
a
a
到
b
b
,也能夠由
b
b
到
a
a
,因此這裏要判斷兩次。(對於有方向的邊則更簡單,只需判斷一次便可)。
咱們用該程序求解圖 3(a) 所示的例子,看看能獲得什麼結果(代碼可見文件 Example 1.nb)。這個程序只改變節點的值和母節點。但是節點值只是一堆數字,爲了更直觀地展現結果,我將每一個節點的值用等比例高的小球表示,如圖 7(a) 所示。值越大,小球的位置越高、顏色越暖(偏紅色),反之越小就越愛、顏色越冷(偏藍色)。從圖中能夠看出,第一次掃描後起點附近的節點值變化較大,可是遠處的節點值仍爲初始設定的值,並無怎麼變化。咱們增長
n
n
看看會有什麼影響。
n = 2
n
=
2
時的結果如圖7(b) 所示,更多的節點值發生變化了。當
n = 3
n
=
3
時幾乎全部節點值都改變了。咱們繼續增長
n
n
會怎麼樣?
n
n
應該取多少才合適呢?通過一番試探,咱們發現
n > 7
n
>
7
後節點的值再也不變化了,以下動畫圖。 這說明全部節點的值都穩定到了一個固定值,同時也意味着穩定後的值不存在知足鬆弛條件的邊了。由於若是存在的話, 必定有節點的值會減小(這又是因爲鬆弛條件的標準是嚴格小於
<
<
,而不是小於等於
≤
≤
)。因此,對於這個例子(圖 3(a)),
n
n
應該取 7。
鬆弛完後,咱們怎麼找到通往目標節點的路徑呢?答案是,咱們在鬆弛邊的時候,相應節點的母節點同時也就肯定了。咱們能夠從目標節點開始,先找到它的母節點,這個母節點也有本身的母節點,咱們能夠一直向回追溯(backtrack),直到其中一個母節點是起點爲止。起點到目標點的路徑就由這一系列相鄰母節點定義的邊組成。這一過程如 Algorithm 3 所示。
將目標點帶入回溯函數,獲得的路徑如圖8(a) 所示,它看上去確實比以前的短多了。可是咱們心中有個大問號——這樣獲得的路徑是最短的嗎?或者更準確地問:當「圖」中的全部邊都不能再鬆弛時,全部節點的值都是最小的嗎? 咱們證實一下:假設某個節點
v
v
與起點
s
s
之間的最短路徑是
v → v 1 → v 2 → v 3 → ⋯ → v k → s
v
→
v
1
→
v
2
→
v
3
→
⋯
→
v
k
→
s
。既然這條路徑上的每條邊都不知足鬆弛條件,那就有
d ( v ) ≤ d ( v 1 ) + l ( v , v 1 ) d ( v 1 ) ≤ d ( v 2 ) + l ( v 1 , v 2 ) d ( v 2 ) ≤ d ( v 3 ) + l ( v 2 , v 3 ) ⋮ d ( v k ) ≤ d ( s ) + l ( v k , s )
d
(
v
)
≤
d
(
v
1
)
+
l
(
v
,
v
1
)
d
(
v
1
)
≤
d
(
v
2
)
+
l
(
v
1
,
v
2
)
d
(
v
2
)
≤
d
(
v
3
)
+
l
(
v
2
,
v
3
)
⋮
d
(
v
k
)
≤
d
(
s
)
+
l
(
v
k
,
s
)
將後一個不等式依次帶入到前一個當中,最後就能獲得
d ( v ) ≤ d ( s ) + l ( v , v 1 ) + l ( v 1 , v 2 ) + l ( v 2 , v 3 ) + ⋯ + l ( v k , s ) v 到 s 的 最 短 路 徑 的 長 度
d
(
v
)
≤
d
(
s
)
+
l
(
v
,
v
1
)
+
l
(
v
1
,
v
2
)
+
l
(
v
2
,
v
3
)
+
⋯
+
l
(
v
k
,
s
)
⏟
v
到
s
的最短路徑的長度
前面咱們已經規定了
d ( s ) = 0
d
(
s
)
=
0
,因此上面不等式的右邊恰好是
v
v
與
s
s
之間的最短路徑的長度。
d ( v )
d
(
v
)
不可能比最短路徑的長度還小(不然就不叫最短路徑了),因此只能等於最短路徑的長度。哈哈,結論是大快人心的——全部節點的值都是最小的,並且從全部節點出發進行回溯獲得的路徑都是鏈接起點的最短路徑,如圖 8(b) 所示,我用不一樣顏色和寬度的線將起點到其它節點的最短路徑畫出來了(這裏稱「起點」爲「終點」彷佛更合適,由於它看起來像個盆地,周圍的水流都匯聚到它這裏了)。值得注意的是,全部節點的最短路徑組成一個樹形結構,這好像是對規律1的迴應。
咱們不只獲得了起點到目標點的最短路徑,還順便把起點到全部節點的最短路徑都找出來了。問題解決了,到了說再見的時候了嗎?若是你對這個計算結果還滿意的話,那麼確實能夠結束了。但若是你是個完美主義者,這個方法還值得進一步雕琢。在大型的「圖」中,例若有
6000
6000
個節點,
12000
12000
條邊的網格圖,
n
n
至少要取
75
75
,程序要作
12000 × 75 × 2 = 1800000
12000
×
75
×
2
=
1800000
次鬆弛條件判斷,這就致使程序很是緩慢。這個方法應該還有改進的空間。你也許會問:爲何是對全部邊鬆弛,而不是對全部節點鬆弛呢?其實,兩者是同樣的,因爲咱們是從邊的縮短聯想到橡皮筋鬆弛的,因此選擇從邊的角度講解更天然。固然,從節點的角度進入是同樣,不管結果仍是計算效率。
2.3 標記法 (Labeling method)
上一節採用的方法稱爲「全部邊依次鬆弛方法」。我爲何要強調其中的「依次」呢?由於程序是按照邊定義的順序(也就是在集合
E
E
中出現的順序)挨個判斷是否須要鬆弛。但是,邊「真正」被鬆弛的順序是怎樣的呢?咱們回到圖 7,從圖中能夠看到節點值的改變是從起點附近開始並逐步向外擴展。(咱們知道節點值的改變意味着發生鬆弛)兩者順序的不一樣致使程序中有不少條件判斷是不知足的(邊並無被鬆弛),這就影響了程序的效率。更好的選擇邊的順序能減小沒必要要的判斷,從而可以改善過程的運行效率。咱們在對節點的值初始化時,將除起點之外的節點都設爲
∞
∞
,惟獨將起點的值設爲 0。想象一下,若是將起點的值也設爲
∞
∞
會有什麼後果。後果很簡單,那就是全部邊的鬆弛條件都不知足,所以全部節點的值都會保持在
∞
∞
上不變,顯然程序沒法找到最短路徑。將起點值拉低(從
∞
∞
降到
0
0
),便使起點的鄰居知足鬆弛條件,因此這些節點的值會下降,而這些值發生變化的節點又會使它們的鄰居知足鬆弛條件並使值下降,進而引發連鎖反應。 因此咱們須要特別注意值發生變化的那些節點,只有它們的鄰居纔會鬆弛。爲了利用這一信息,咱們將節點分爲兩類:值發生變化的節點和值沒變化的節點。爲了區分這兩種節點,咱們給每一個節點一個標籤(label)。給那些值發生變化的節點發一個 changed 標籤,而給那些值沒變化的節點發一個 unchanged 標籤。下面咱們給出「全部邊依次鬆弛」方法的改進,這就是「標記法」(labeling method)。按照命名的規則,名字應該體現事物的本質特徵,這裏咱們使用「標記」,緣由就在於這是它區別於前輩的主要特色。標記法的僞代碼如 Algorithm 4 所示。與它的前輩不一樣的是,咱們再也不須要人工試探如何選擇循環次數
n
n
了。下面咱們解釋代碼的含義: 1. 首先一樣是初始化,此次咱們多了一步 —– 定義
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表,它存儲了全部攜帶changed 標籤的節點。初始時,咱們只將 changed 標籤發給起點(認爲起點的值從
∞
∞
變爲
0
0
),而其它節點手裏都拿着 unchanged 標籤。 2. while 循環依次從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中取出一個節點,就將它記爲
a
a
吧。注意這裏「取出」是選擇的意思,被取出的節點實際仍然在列表中,而「踢出」纔是真正從列表中把它刪掉。 3. for 循環依次取出
a
a
的鄰居,記爲
b
b
。這裏
n e i g h b o r s ( a )
n
e
i
g
h
b
o
r
s
(
a
)
表示
a
a
的全部鄰居組成的列表。 4. if 判斷語句咱們已經見過了,它仍然負責判斷鬆弛條件。不過此次咱們判斷
a
a
全部的鄰居。若是有鄰居知足鬆弛條件,那麼除了調用 Relax 函數外,還要把這個鄰居添加進
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表。爲何要添加鄰居呢?由於鄰居被鬆弛了,因此它的值改變了,咱們應該把它的標籤換成 changed。 5. for 循環結束後,
a
a
的全部鄰居都被掃描了一遍 (也就是判斷了一遍),知足鬆弛條件的獲得了鬆弛。此時,咱們要將
a
a
從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表裏踢出去(
a
a
的標籤換成了 unchanged),由於
a
a
已經暫時完成了本身的使命:鬆弛本身的鄰居。除非a 的值改變了,不然它不能再一次鬆弛它的鄰居了 (鬆弛一遍後就不知足鬆弛條件了)。即使咱們將
a
a
留在
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中,它也沒什麼用了。
a
a
還會不會回到
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中呢?有這個可能,這時
a
a
的值必定是被本身的鄰居改變了。 markdown
「對全部邊依次鬆弛」的方法只會傻傻地挨個掃描(判斷)每條邊,若是知足鬆弛條件就執行鬆弛。標記法則聰明的多,在掃描邊時,它會挑剔地選擇那些最有可能發生鬆弛的邊,而後再去決定是否是真的須要鬆弛,而最有可能發生鬆弛的邊就是有節點值變化的邊。因此標記法的掃描次數要少得多。在有
6000
6000
個節點,
12000
12000
條邊的大型網格圖中,標記法平均只須要作
25000
25000
次左右的鬆弛條件判斷。而兩者獲得的結果是同樣的。 咱們前進了一大步,這值得慶祝一下!不過咱們還能夠再接再礪。標記法仍給咱們預留了改進的空間:好比第 3 步中「依次取出
a
a
的鄰居」。「依次」只是指一個挨一個的取出,並沒說從誰開始,咱們也沒有規定
a
a
的鄰居是按什麼順序排列的。利用節點值的變化這一信息,咱們排除了大量無效的判斷,但還有一個信息咱們沒有用過—–節點值的大小。爲何會想到節點值的大小呢?節點值的大小對程序的運行效率能有什麼影響呢?這時,咱們的腦海裏尚未什麼概念。 下面這個例子也許能給咱們一些啓示 (代碼可見文件 Example 2.nb)。圖 9(a) 展現了一顆「樹形」圖,咱們只須要關注樹根和樹幹部分便可。這部分很是簡單,由 4 個節點組成 —— 起點
s
s
位於左下角,
s
s
節點有兩個鄰居:
a
a
節點和
b
b
節點,
( b , c )
(
b
,
c
)
邊組成樹幹部分。咱們假設
( s , b )
(
s
,
b
)
的邊長
l ( s , b ) = 5
l
(
s
,
b
)
=
5
,而其它全部邊的長度都是單位長度 1。你可能注意到了,三角形
△ s a b
△
s
a
b
的兩邊之和小於第三邊 (1 + 1 < 5)。這是由於此處「邊長」不表明傳統的距離概念。實際上,咱們沒必要老是侷限於距離,邊能夠對應任意的代價 (或者稱爲權重,但前提是它不能是負數),好比時間或能量,這樣獲得的就是時間最短或能量最小的路徑。而時間或能量等概念沒必要遵循三角形兩邊之和大於第三邊的規則。 下面咱們使用標記法求最短路徑。首先進行初始化,起點
s
s
被添加到
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中,各節點的值爲
d ( s ) = 0
d
(
s
)
=
0
,
d ( a ) = d ( b ) = d ( c ) = 1
d
(
a
)
=
d
(
b
)
=
d
(
c
)
=
1
。這時
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表只包含
s
s
一個元素,因此取出
s
s
。而後程序會依次掃描s 的全部鄰居,也就是
a
a
和
b
b
。咱們並無規定鄰居在
n e i g h b o r s ( s )
n
e
i
g
h
b
o
r
s
(
s
)
中是按照什麼順序出現的(能夠是
{ a , b }
{
a
,
b
}
,也能夠是
{ b , a }
{
b
,
a
}
),因此它們的順序不影響最終獲得的結果。可是它們的計算過程是同樣的嗎?咱們記錄下程序每一次掃描後
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中元素的個數,結果如圖 9(b) 所示。從圖中能夠看到,兩者不只須要的掃描次數不一樣,並且每次掃描產生的
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
元素個數也不一樣。選擇鄰居順序的微小差異爲何會致使計算過程的明顯差別?下面咱們詳細分析一下程序的執行過程,看看問題到底出在哪:
1. 若是是按照
{ a , b }
{
a
,
b
}
的順序 (咱們將外層的while循環每執行一次稱爲一輪掃描):
2. 若是是按照
{ b , a }
{
b
,
a
}
的順序:
若是咱們將鬆弛過程視爲節點值的傳遞(由小到大),那麼
c
c
第一次被添加時,它的值傳遞給了後續節點(也就是樹葉上的節點);當
c
c
第二次被添加時,它的值被鄰居
b
b
變小了,這個更小的值又一次傳遞給樹葉節點。其結果就是樹葉上的每一個節點都被鬆弛了兩次(一次被較大的
d ( c ) = 6
d
(
c
)
=
6
,一次被較小的
d ( c ) = 3
d
(
c
)
=
3
)。這就解釋了圖9(b)中先掃描
b
b
比先掃描
a
a
多了幾乎一倍的掃描次數。
2.4 改進的標記法 (Modified labeling method)
圖9(a)所示的例子給了咱們一個啓示,那就是在訪問鄰居時應該遵照必定的規則——應該先去敲值最小的鄰居的門。讓咱們的思惟稍微跳躍一下,既然訪問鄰居要按照最小原則,那麼從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中選擇節點是否是也應該遵循這樣的規則呢。爲了驗證這個猜測,咱們作個試驗。下面咱們對標記法作一個小小的修改,如Algorithm 5中紅色字體顯示的。咱們用改進的標記法解決圖9(a)的例子,結果代表咱們的猜想是對的。並且咱們還發現,這時即便選擇鄰居時不按照最小原則,對結果也沒有影響。其實咱們仔細思考一下就會想到一點,訪問鄰居的前後順序並不重要,它之因此會影響程序的掃描次數,是由於鄰居進入到了
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表,程序從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
中取出節點時是按照節點被添加的順序(也就是訪問鄰居的順序)。因此,從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表取節點的策略纔是影響程序效率的關鍵。咱們的結論是:從
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
列表中取節點時,先取值最小的那個(買菜先挑便宜的)。 svg
2.5 Dijkstra算法
「Everything should be made as simple as possible, but not simpler.」 —— 愛因斯坦 咱們回過頭來看看改進的標記法(Algorithm 5)。即使你是一個完美主義者,你也不得不認可,它已經至關簡潔了。短短十行代碼就能解決看似困難的最短路徑問題。愛因斯坦說過:「任何事情都應該儘可能簡單,而不是更簡單」。這句看似矛盾的話應該怎麼理解呢?我認爲,對於咱們試圖解決的最短路徑問題來講,追求「儘可能簡單」就是儘可能去除算法中多餘的東西,這樣咱們的算法才能輕裝上陣,執行效率纔會更高。從這個角度看,「簡單」是個優勢;但是物極必反,若是咱們過度追求簡單(總想着「更簡單」),把簡單(而不是算法的執行效率)當成咱們惟一的目的,那麼咱們就鑽進了牛角尖,違背了咱們的初衷 —— 設計更好更快的算法。我絲絕不懷疑你能寫出更簡單的算法,可是在追求簡單和運算效率兩者之間,請保持平衡,而這纔是最難作到的。 Dijkstra 是平衡的大師。以他的名字命名的 Dijkstra 方法在不犧牲執行效率的前提下,比咱們的改進標記法更加簡單。Dijkstra 方法 (如 Algorithm 6 所示)只須要一個列表
Q
Q
(相似於
C h a n g e d L i s t
C
h
a
n
g
e
d
L
i
s
t
,但存儲的內容不一樣)。程序的運行過程也極其簡單,在一開始,全部的節點都被放進
Q
Q
列表中。而後從
Q
Q
中取出值最小的節點(記爲
a
a
),並對它的鄰居進行判斷並鬆弛。掃描完
a
a
的全部鄰居後,
a
a
就會被從
Q
Q
中刪除。如此反覆,直到
Q
Q
爲空時算法中止。因爲只從
Q
Q
列表中拿出,從不往裏存,因此while 循環運行的次數恰好是「圖」中節點的個數。 函數
雖然 Dijkstra 方法很簡單,可是從代碼的字裏行間,咱們看不出來它爲何能找到最短路徑。下面咱們從邏輯上分析一下: 在程序運行以前,全部的節點都是未訪問節點(即
Q = V
Q
=
V
)。隨着程序的運行,未訪問節點逐漸轉變爲已訪問節點,直到最後全部節點都被訪問了,這時程序就中止了。Dijkstra 方法與改進的標記法最大的不一樣之處是,節點一旦被從列表中踢出就不再會放進去了。這說明 Dijkstra方法認爲,被踢出的節點值不會再減少了,它已經達到最小了。一旦肯定了節點的最小值,最短路徑也就肯定了(經過回溯找到)。 爲了證實 Dijkstra 方法確實能找到最短路徑,咱們只須要證實被踢出節點的值就是它的最小值。在證實以前,先定義一個概念。咱們將從起點
s
s
出發到達任意一個節點
v
v
的最短路徑的長度表示爲
δ ( s , v )
δ
(
s
,
v
)
,由於
s
s
通常是固定不變的,因此也能夠簡寫爲
δ ( v )
δ
(
v
)
。「被踢出節點的值就是它的最小值」能夠表示爲
d ( v ) = δ ( v )
d
(
v
)
=
δ
(
v
)
,這裏
v
v
表示被踢出的節點。 下面的證實採用了數學概括法,這須要兩步證實: 第一步證實命題在第 1 個節點的狀況下成立。這很容易,由於起點的值最小,因此第一個被從
Q
Q
中踢出來的節點就是起點
s
s
。因爲
d ( s ) = 0
d
(
s
)
=
0
並且
δ ( s , s ) = 0
δ
(
s
,
s
)
=
0
,因此
d ( s ) = δ ( s )
d
(
s
)
=
δ
(
s
)
,所以命題成立。 第二步證實若是命題在前
n
n
個節點成立,那麼對於前
n + 1
n
+
1
個節點也成立。也就是:前
n
n
個被踢出節點都知足
d ( u ) = δ ( u ) ; u ∈ V − Q
d
(
u
)
=
δ
(
u
)
;
u
∈
V
−
Q
(
V − Q
V
−
Q
的意思是從
V
V
中踢出
Q
Q
後剩餘的部分),須要證實第
n + 1
n
+
1
個被踢出來的節點
v
v
也知足
d ( v ) = δ ( v )
d
(
v
)
=
δ
(
v
)
。這可須要動動腦子了。 第二步的證實: 根據 Dijkstra 方法的規則,值最小的節點最早被踢出來。因此節點
v
v
在被踢出來以前必定是
Q
Q
裏值最小的。咱們猜猜看
v
v
的值會是什麼樣的。 1.
d ( v )
d
(
v
)
會是
0
0
嗎?(咱們容許邊長爲
0
0
的狀況)若是
d ( v ) = 0
d
(
v
)
=
0
,那麼它的真實最短路徑長度
δ ( s , v )
δ
(
s
,
v
)
也必定是
0
0
。節點的值
d ( v )
d
(
v
)
必定不會小於它的最小值(最短路徑的長度
δ ( s , v )
δ
(
s
,
v
)
),由於路徑的長度必定不會小於最短路徑的長度,這是不管如何也不會改變的事實。既然
d ( v ) > δ ( s , v )
d
(
v
)
>
δ
(
s
,
v
)
,
δ ( s , v )
δ
(
s
,
v
)
又不能是負數,因此只能等於
0
0
。這樣咱們就得出
d ( v ) = δ ( v )
d
(
v
)
=
δ
(
v
)
,因此命題成立。 2.
d ( v )
d
(
v
)
會是無窮大嗎?若是
d ( v ) = ∞
d
(
v
)
=
∞
,那麼
Q
Q
裏全部節點的值都是無窮大。這說明
Q
Q
裏的全部節點都不能從起點s 到達。固然它們真實的路徑長度能夠視爲無窮大,
δ ( s , v ) = ∞ = d ( v )
δ
(
s
,
v
)
=
∞
=
d
(
v
)
。因此仍是命題成立。可是本文一開始咱們就規定,任何節點都有至少一個鄰居,因此老是能從
s
s
出發到達任何節點。這與咱們的規定矛盾了,因此
d ( v ) ≠ ∞
d
(
v
)
≠
∞
。 3. 排除了以上兩種極端的狀況,惟一剩下的就是
0 < d ( v ) < ∞
0
<
d
(
v
)
<
∞
了。既然
d ( v )
d
(
v
)
有肯定的數值,那說明
s
s
和
v
v
之間確定存在至少一條路徑。咱們不關心存在多少條路徑,咱們只關心如今最短的那條(注意我並無說它是真正的最短路徑,它只是程序運行到目前爲止找到的路徑裏最短的一條)。雖然咱們不知道這條路徑通過哪些節點,但咱們能夠分紅幾種可能,從而分別討論:
▶
▸
若是這條路徑不通過
Q
Q
中的節點,那麼這條路徑就是
s
s
和
v
v
之間真正的最短路徑。你可能會懷疑,由於尚未掃描完全部的邊呢,怎麼能這麼早就下結論呢?爲了證實這一點,咱們用圖 10 進行解釋,其中的黑色節點表示被踢出來的節點,白色節點表示在
Q
Q
中的節點,虛線內的區域包含全部被踢出來的節點,曲線表示路徑(爲了保持畫面簡單,路徑上的節點被省略了),直線表示一個邊。圖 10(a) 符合咱們假設的狀況,
s
s
和
v
v
之間存在至少一條路徑,準確的說是兩條:
p 1
p
1
和
p 2
p
2
,並且這兩條路徑不通過
Q
Q
中的節點。假設
p 2
p
2
路徑更短。若是
s
s
和
v
v
之間真正的最短路徑比
p 2
p
2
更短,咱們將真正的最短路徑記爲
p 3
p
3
。咱們將注意力放到路徑的最後一條邊上 (鏈接
v
v
的邊)。根據最後一條邊上節點
c
c
的位置,路徑
p 3
p
3
能夠分爲兩種,
c
c
要麼不在
Q
Q
中,要麼在
Q
Q
中,分別如圖 10(b)、(c) 所示的例子。咱們仍是分狀況討論: 先看第一種狀況,若是
c
c
不在
Q
Q
中(圖 10(b)),說明它已經被處理完了。根據第二步最開始的假設:
d ( c ) = δ ( c )
d
(
c
)
=
δ
(
c
)
,並且
v
v
做爲
c
c
的鄰居必然被鬆弛了,鬆弛後
d ( v )
d
(
v
)
已經到達最小了。既然
d ( v )
d
(
v
)
已是最小值了,那咱們前面爲何還要選擇更長的路徑
p 2
p
2
呢?這不是矛盾的嗎?因此不能有比
p 2
p
2
更短的路徑,不然咱們應該選擇更短的路徑,怎麼會輪到
p 2
p
2
呢。 再看第二種狀況,若是
c
c
在
Q
Q
中(圖 10(c)),那麼應該有
d ( c ) < d ( v )
d
(
c
)
<
d
(
v
)
。這是由於
c
c
是
v
v
的母節點,並且邊長
l ( c , v ) > 0
l
(
c
,
v
)
>
0
(若是邊長
l ( c , v ) = 0
l
(
c
,
v
)
=
0
就找
c
c
的前一個節點,若是一直找不到就回到狀況一了)。但是由於
v
v
的值最小才被從
Q
Q
中踢出來,既然
d ( c ) < d ( v )
d
(
c
)
<
d
(
v
)
,應該踢
c
c
而不是
v
v
,這又是矛盾的。 綜上所述,這兩種狀況都不成立,因此目前找到的這條路徑就是
s
s
和
v
v
之間真正的最短路徑。
▶
▸
若是這條路徑通過
Q
Q
中的節點,證實過程與上面的第二種狀況同樣,應該有一個節點
c
c
在
Q
Q
中,因此
d ( c ) < d ( v )
d
(
c
)
<
d
(
v
)
。應該踢
c
c
而不是
v
v
。因此這條路徑不能通過
Q
Q
中的節點。
□
◻
這樣咱們就證實了,每一個被踢出去的節點
v
v
都知足
d ( v ) = δ ( s , v )
d
(
v
)
=
δ
(
s
,
v
)
。 證實 Dijkstra 方法花了咱們很多力氣。你可能會好奇——Dijkstra 究竟是怎麼想出這個方法的。下面咱們來了解一下背景。
2.6 Dijkstra和他的算法
Edsger Wybe Dijkstra 的父親是高中化學老師,母親是業餘數學家。1956 年從萊頓大學數學和理論物理專業畢業後,Dijkstra 到阿姆斯特丹大學攻讀博士,3 年後畢業。畢業論文題目是:自動計算機的通訊方式,研究內容是第一代商業計算機的彙編語言設計。Dijkstra 終生過着斯巴達式的簡樸生活,他不看電視、不看電影、也幾乎不使用手機。Dijkstra 和夫人平時喜歡彈鋼琴和聽音樂會
[ 1 ]
[
1
]
。Dijkstra 可能把大部分時間都花在思考上,他說過一些有意思的話,例如:
「The question of whether a computer can think is no more interesting than the question of whether a submarine can swim.」 工具
1956 年,Dijkstra 在阿姆斯特丹數學中心工做期間被指派了一項任務:爲演示新建造的計算機而設計一個數學問題並編寫對應的求解程序。所設計的問題要可以展現計算機的強大性能,同時越簡單越好,以便於被更多的人理解。Dijkstra 挑選了一個最短路徑問題:在荷蘭的 64 個城市之間尋找最短的運輸路線,隨後他開始思考求解方法。一天與未婚妻在咖啡館裏消遣時,Dijkstra 花了20分鐘構思出了這個問題的解決方法,Dijkstra 算法由此誕生。三年後,Dijkstra 將這一方法連同對另外一個相關問題的解法撰寫成論文,發表在學術期刊上。論文題目是 A Note on Two Problems in Connexion with Graphs 。這篇論文只有兩頁半長,裏面沒有出現一個數學公式,沒有一幅圖,甚至連一個例子也沒有。就是這樣一篇論文,迄今爲止已經被引用了超過 17000 次。(事實上,Dijkstra 並非 Dijkstra 算法的最先發現者,其思想至少在 1950 年代早期就出現了,只不過在當時只流傳於幾個小圈子裏) post
在這篇簡短的論文中,Dijkstra 介紹了最短路徑問題後,緊接着寫下了一句回味無窮的話。而後,他描述了算法運行的邏輯和實現的細節。咱們要是想知道 Dijkstra 是如何靈感迸發,從而構思出這個優雅的算法的,就只能仔細讀讀這句話了,以下:
「We use the fact that, if
R
R
is a node on the minimal path from
P
P
to
Q
Q
, knowledge of the latter implies the knowledge of the minimal path from
P
P
to
R
R
. 」
咱們藉助這樣一個事實:若是
R
R
是從
P
P
到
Q
Q
的最短路徑上的一個節點,那麼知道了後者(
P
P
到
Q
Q
的最短路徑)就等於知道了
P
P
到
R
R
的最短路徑。
這句話也許讓你以爲似曾相識。沒錯!那就是本文最開始獲得的規律1。只不過,Dijkstra 將目光放在了起點
P
P
到任意節點
R
R
上(換句話說,從前向後推)。 規律1有一個正式的名字 —— 「最優性原理」,它的正式提出者 Richard Bellman 是這樣說的:
「An optimal policy has the property that whatever the initial state and initial decision are, the remaining decisions must constitute an optimal policy with regard to the state resulting from the first decision.」
任何一個最優策略都有這樣的性質:無論初始狀態和初始決策是什麼,隨後的決策相對於初次決策以後的狀態必然構成最優策略。
Bellman 將目光放在了任意節點到目標點上 (換句話說,從後向前推)。咱們獲得的規律1只是「最優性原理」在最短路徑問題上的一個特例。最優性原理也是一類數學方法 —— 動態規劃(Dynamic Programming)的理論基礎。Bellman 早在1940年代就開始了動態規劃理論的研究,1950~1954 年一系列的論文和報告標誌着動態規劃理論的成熟。咱們無從得知 Dijkstra 是否瞭解 Bellman 的工做或者從中受到啓發,由於他在文中並無提到 Bellman 的工做,而只引用了另外一個學者 Ford 的報告。當時 Ford 和 Bellman 是同事,二人共同就任於大名鼎鼎的蘭德公司(RAND)
[ 2 ]
[
2
]
。他們早於 Dijkstra 提出了著名的 Bellman–Ford 算法,其適用於邊的權重爲負數時的最短路徑問題(也是 Dijkstra 算法不能適用的場合)。顯然 Dijkstra 意識到了「最優性原理」,可是他止步於最短路徑問題。而 Bellman 做爲一名職業數學家,他的眼光要深遠得多。 到此爲止,關於 Dijkstra 算法咱們就告一段落了。你可能會好奇,Dijkstra 算法還有改進的餘地嗎?我以爲,Dijkstra 算法已是較「原始」的算法了,它的適用範圍和性能也難以知足今天的需求了。對它的改進一直在進行中,新的算法層出不窮(雙向 Dijkstra 算法、
A ∗
A
∗
算法、快速掃描法…),咱們纔剛剛起步。可是無論怎樣,Dijkstra 算法仍然是基礎。要理解新的算法,Dijkstra 算法不可錯過,這也是爲何 Dijkstra 的論文(一篇60年前的計算機算法論文)直到今天還在被人引用。 代碼下載地址:http://pan.baidu.com/s/1dFp4bxJ [1] R A Krzysztof, 2002, Edsger Wybe Dijkstra (1930–2002): A Portrait of a Genius. Formal Aspects of Computing, 14:92–98. [2] M Sniedovich, 2006, Dijkstra’s Algorithm Revisited: the Dynamic Programming Connexion. Control and Cybernetics, 35(3):599–620.