堆數據結構:Dijkstra算法的提速

堆的最後一個也是最高級的應用是單源最短路徑問題的Dijkstra算法(第3章)的近似線性時間的實現。這個應用非常生動地體現了算法設計與數據結構設計之間的互動。

4.4.1 爲什麼要使用堆

我們在命題3.1中看到了Dijkstra算法的簡單實現需要O(mn)的運行時間,其中m表示邊的數量,n表示頂點的數量。如果只是處理中等規模的圖(有數以千計的頂點和邊),那麼這個速度已經足夠,但對於巨型的圖,還是有點力不從心。我們能不能做得更好?堆能夠實現具有令人驚訝的高速度,也就是近似線性時間的Dijkstra算法。

定理4.4(Dijkstra算法(基於堆)的運行時間) 對於有向圖G = (V,E),起始頂點s並且所有邊的長度均爲非負值,Dijkstra算法基於堆的實現的運行時間是O((m+n)log n),其中m=|E|,n=|V |。

雖然O((m+n)log n)不如線性時間的搜索算法那麼快速,但仍然是表現非常出色的運行時間,可以與更爲出色的排序算法相提並論,基本上可以被認定爲是零代價的基本算法。

讓我們回憶一下Dijkstra算法的工作方式(3.2節)。這個算法維護一個頂點子集X⊆V,其中的頂點是它已經計算過最短路徑長度的。在每次迭代中,它識別穿越邊界的邊中具有最低Dijkstra得分的邊。邊(v,w)的Dijkstra得分是指(已經計算的)從起始頂點到v的最短路徑長度len(v)加上這條邊的長度

。換句話說,主循環的每次迭代對所有跨越邊界的邊進行一次最小值計算。Dijkstra算法的簡單實現使用窮舉搜索完成這個最小值計算。把最小值計算的速度從線性時間提升爲對數時間是堆的存在理由,這時我們的大腦裏就會產生這樣的想法:Dijkstra算法需要用到堆!

4.4.2 計劃

我們應該在堆裏存儲什麼?它們的鍵應該是什麼?我們首先想到的是可以把輸入圖中的邊存儲在堆中,然後將目標定爲把簡單實現中的最小值計算(針對邊)替換爲ExtractMin調用。

這種思路是可行的,但是一種更取巧和快速的實現是把頂點存儲在堆中。這可能會讓人覺得奇怪,因爲Dijkstra得分是根據邊而不是頂點來定義的。但換個思路,我們之所以關注Dijkstra得分,是因爲它可以指示我們接下來處理哪個頂點。我們能不能通過堆走個捷徑,直接計算這個頂點呢?

具體的計劃是把尚未處理的頂點(Dijkstra僞碼中的V−X)存儲在一個堆中,同時維護下面提到的不變性。

不變性

頂點w∈V−X的鍵是一條尾頂點爲v∈X且頭頂點爲w的邊的最低Dijkstra得分(如果不存在這樣的邊,則是+∞)。

也就是說,我們需要下面的等式

(4.1)

對於每個w∈V−X在所有時候都是成立的,其中len(v)表示在算法的一次早期迭代中所計算的v的最短路徑的長度(圖4.2)。

圖4.2 頂點w∈V−X的鍵被定義爲頭頂點爲w且尾頂點在X中的邊的最低Dijkstra得分

這是怎麼回事呢?想象一下,我們正在使用兩輪的淘汰賽確定具有最低Dijkstra得分的邊(v,w),其中v∈X,w∉X。第一輪是在每個頂點w∈V−X之間進行的本地錦標賽,參與者是邊(v,w),其中v∈X且w是邊的頭頂點。第一輪的勝者就是最低Dijkstra得分競賽的參與者(如果存在)。第一輪的勝者(每個頂點w∈V−X最多有1個勝者)繼續進行第二輪的比賽,最終的冠軍就是第一輪勝者中具有最低Dijkstra得分的那個。這條冠軍邊與窮舉搜索所確認的邊是同一條。

頂點w∈V−X的鍵值(式(4.1))就是w的本地錦標賽中的最低Dijkstra得分,因此,我們的不變性有效地實現了所有的第一輪競賽。提取具有最小鍵值的頂點,然後開展第二輪錦標賽,閃閃發光的獎盃的持有者正是下一個需要處理的頂點,也就是跨越邊界的邊中具有最低Dijkstra得分的那條邊的頭頂點。關鍵在於,只要我們維持這個不變性,就可以用一個堆操作實現Dijkstra算法的每次迭代。

它的僞碼如下:[14]

Dijkstra(基於堆的算法,第1部分)

輸入:鄰接列表表示形式的有向圖G=(V, E),頂點s∈V,對於每個e∈E,其長

度e≥0。

完成狀態:對於每個頂點v,len(v)的值等於真正的最短路徑長度dist(s,v)。

// 初始化 1 X := 空集合, H := 空堆 2 key(s) := 0 3 for every v ≠ s do 4 key(v) := +∞ 5 for every v ∈ V do 6 把v插入到 H // 或使用Heapify // 主循環 7 while H 非空 do 8 w* := ExtractMin(H) 9 把w*加到X 10 len(w*) := key(w*) // 對堆進行更新以維持不變性 11 (待宣佈)

但是,爲了維持這個不變性,需要多大的工作量呢?

4.4.3 維持不變性

現在是付出“代價”的時候了。我們享受了這個不變性的成果,它把Dijkstra算法所需要的每個最小計算減少爲一個堆操作。作爲交換,我們必須解釋怎樣在不付出過多工作量的前提下維持這個不變性。

算法的每次迭代把一個頂點x從V−X移動到X,從而改變了它們之間的邊界(圖4.3)。從X的某個頂點出發到v的邊現在完全處於X的內部,不再跨越邊界。更成問題的是,從v到V−X的其他頂點的邊不再完全處於V−X內部,而是從X跨越到V−X。爲什麼這會成爲問題呢?因爲我們的不變性式(4.1)表示,對於每個頂點w∈V−X,w的鍵等於一條終點在w的跨越邊的最小Dijkstra得分。新的跨越邊意味着出現了最小Dijkstra得分的新候選者,因此對於有些頂點w,式(4.1)的右邊可能會變小。例如,當滿足(v,w)∈E的頂點v第一次處於X的內部時,這個表達式就從+∞縮減爲一個確定的數字了(即len(v)+

)。

圖4.3 當一個新頂點v從V−X移動到X時,從v出發的邊可能成爲跨越邊界的邊

每次當我們從堆中提取一個頂點w*並把它從V−X移動到X時,可能需要減小仍然位於V−X中的一些頂點的鍵值,以反映新的跨越邊。由於所有的新跨越邊都是從w*出發的,因此我們只需要對以w*爲起點的邊進行迭代,檢查邊(w*, y)的頂點y∈V−X。對於每個這樣的頂點y,在y的本地錦標賽中,第一輪勝者有兩個候選:要麼與此前相同,要麼就是新的參賽選手(w*, y)。因此,y的新鍵值要麼是它的舊值,要麼是新的跨越邊的Dijkstra得分len(w*)+

,以更小的那個爲準。

我們怎樣減小堆中一個對象的鍵值呢?一個容易的方法是首先使用4.2.2節所描述的Delete操作將它刪除,接着更新它的值,然後使用Insert操作把它添加回堆中。[15]這樣,我們就完成了基於堆的Dijkstra算法的實現。

Dijkstra(基於堆的算法,第2部分)

// 對堆進行更新以維持不變性 12 for 每條邊(w* ,y) do 13 從H中刪除y 14 key(y) := min{ key(y), len(w*)+} 15 把y插入到H

4.4.4 運行時間

Dijkstra基於堆的實現的算法所完成的幾乎所有工作都是由堆操作組成的(可以進行驗證)。每個堆操作需要O(log n)的時間,其中n表示頂點的數量。(堆中對象的數量絕不可能超過n−1個。)

這個算法所執行的堆操作有多少個?基於堆的算法第1部分的第6~8行每一行都有n −1個操作,除起始頂點s之外的每個頂點均需要1個堆操作。那麼基於堆的算法第2部分的第13行和第15行呢?

小測驗4.2

Dijkstra將執行第13行和第15行多少次?選擇適用的最小邊界。(與往常一樣,n和m分別表示頂點的數量和邊的數量。)

(a)O(n)

(b)O(m)

(c)O(n2)

(d)O(mn)

(正確答案和詳細解釋如下。)

正確答案:(b)。第13行和第15行看上去可能有點奇怪。在主循環的一次迭代中,這兩行被執行的次數可能多達n −1次,w*的每條外向邊各執行1次。一共有n −1次迭代,看上去堆操作的數量是平方級的。對於稠密圖,情況確實如此。但是一般而言,我們可以做得更好。爲什麼?因爲我們把這些堆操作的責任交給邊而不是頂點。圖中的每條邊(v,w)在第12行最多出現1次,也就是當v第一次從堆中被提取並從V−X轉移到X時。[16]因此,第13行和第15行對於每條邊最多隻執行1次,總共是2m個操作,其中m是邊的數量。

小測驗4.2顯示了Dijkstra算法基於堆的實現使用了O(m+n)的堆操作,每個操作需要O(log n)的時間。總體運行時間是O((m+n)log n),這是由定理4.4保證的。

本文摘自《算法詳解(卷2)——圖算法和數據結構》

本書涵蓋的內容

本書介紹了下面3個主題的基礎知識。

圖的搜索和應用

圖可用於對許多不同類型的網絡,包括道路網、通信網絡、社交網絡,以及任務之間的依賴性網絡進行建模。圖可能非常複雜,但圖存在一些運算速度非常快的基本算法。我們首先討論對圖進行搜索的線性算法,其應用範圍極廣,包括網絡分析以及任務序列化等。

最短路徑

在最短路徑問題中,其目標是計算網絡中從點A到點B的最佳路線。這個問題具有一些顯而易見的應用,例如計算行車路線等。許多更爲通用的規劃問題的本質就是計算最短路徑的問題。我們將對其中一種圖搜索算法進行歸納,進而引出著名的Dijkstra最短路徑算法。

數據結構

本書將幫助讀者熟悉幾種不同的數據結構,它們用於維護不斷變化的具有鍵的對象集合。我們的基本目標是培養一種能力,也就是能夠判斷哪種數據結構比較適合自己的應用。選讀的高級章節對如何從頭實現這些數據結構提供了一些指導方針。

我們首先討論堆,它可以快速識別它所存儲對象中具有最小鍵值的對象,適用於排序、實現優先隊列以及以線性時間實現Dijkstra算法。搜索樹可以維護它所存儲對象的整體鍵順序,並支持更豐富的數組操作。散列表對超級快速的查找方式進行了優化,在現代程序中具有極其廣泛的應用。我們還將討論布隆過濾器,它是散列表的“近親”。布隆過濾器的空間需求較散列表更低,但它偶爾會出現錯誤。

關於本書內容的更詳細介紹,可以閱讀每章的“本章要點”,它對每一章的內容,特別是那些重要的概念進行了總結。書中帶星號的章節是難度較高的章節。時間較爲緊張的讀者在第一遍閱讀時可以跳過這些章節,這並不會影響本書閱讀的連續性。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章