Home
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

4

Local AI

--

Uedu Code

--

Taipei AQI 57 · Taichung AQI 74 · Tainan AQI 63 · Kaohsiung AQI 64
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 18

最短路徑 Dijkstra

有權重的最短路徑、優先佇列,以及負權重為什麼不行
Interaction實作 30 minute(s)

Learning Objectives

  • 說明有權重的圖裡,「經過的邊最少」不等於「權重總和最小」,所以 BFS 不夠用
  • 說明鬆弛(relaxation)在做什麼,並追蹤 dist 與 parent 怎麼被更新
  • 用優先佇列(最小堆積存 (距離, 節點))實作 Dijkstra 演算法,處理佇列裡的舊紀錄
  • 用 parent 還原最短路徑
  • 用小例子說明負權重為什麼會讓 Dijkstra 出錯,並用 Bellman-Ford 算出正確答案、偵測負環

邊有了長短

導航軟體在找路時,關心的不是「經過幾個路口」,而是「總共要花幾分鐘」。每條路的時間不一樣, 這種邊上帶著數字的圖叫做有權重的圖(weighted graph),一條路徑的長度是路上所有邊的權重總和。

上一課的 BFS 在這裡不夠用。看這個例子:

A --10-- B
 \      /
  1    1
   \  /
    C

BFS 會說 A 到 B 最近,因為只要 1 條邊;但走 A → C → B 只要 1 + 1 = 2,比直接走的 10 短得多。 「邊最少」和「權重總和最小」是兩回事。

這一課的主角是 Dijkstra 演算法:荷蘭計算機科學家 Edsger W. Dijkstra 在 1959 年發表, 用來找「從一個起點到其他所有節點」的最短路徑,前提是所有權重都不是負數。

鬆弛:發現一條更近的路

所有最短路徑演算法都建立在同一個動作上,叫做鬆弛(relaxation)。

dist[v] 是「目前知道的、從起點到 v 的最短距離」,一開始除了起點是 0,其他都是 ∞(還不知道怎麼到)。 現在有一條邊 u → v,權重 w。如果「先到 u,再走這條邊」比目前知道的還近,就更新:

if dist[u] + w < dist[v]:
    dist[v] = dist[u] + w      # 找到更近的走法
    parent[v] = u              # 記下:最短路徑上,v 的前一站是 u

dist 只會越來越小,最後停在真正的最短距離。不同的演算法差在「按照什麼順序鬆弛哪些邊」:

  • Dijkstra:每次挑「目前距離最小、還沒確定」的節點,鬆弛它的所有邊;每條邊只需要鬆弛一次
  • Bellman-Ford(本課最後):不挑順序,把所有邊一輪一輪地鬆弛,最多 V - 1 輪

Dijkstra:每次確定距離最小的那一個

Dijkstra 演算法的步驟:

  1. dist[起點] = 0,其他都是 ∞
  2. 在「還沒確定」的節點裡,挑 dist 最小的節點 u,宣布 u 的最短距離確定了
  3. 鬆弛 u 的每一條邊
  4. 重複 2、3,直到所有走得到的節點都確定

為什麼第 2 步可以直接「確定」?因為所有權重都 ≥ 0:其他還沒確定的節點,距離都 ≥ dist[u], 從它們繞過來只會更長(或一樣長),不可能讓 u 變得更近。

「挑最小的」要快,就用第 15 課的優先佇列(最小堆積)。佇列裡存 (距離, 節點),先比距離,距離相同再比節點編號:

語言 寫法
Python heapq.heappush(pq, (d, v))、heapq.heappop(pq):tuple 本來就是先比第一個、再比第二個
C++ priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>>:預設是最大堆積,加 greater 變成最小堆積
Java PriorityQueue<int[]> 加比較器:a[0] != b[0] ? Integer.compare(a[0], b[0]) : Integer.compare(a[1], b[1])

一般的堆積不能「把某個節點的距離改小」,所以常見的做法是:距離變小時直接再放一筆新的進去, 舊的那筆留在裡面。之後取出舊紀錄時,發現那個節點已經確定過了,就丟掉它。下面的範例用 done 陣列判斷。

Dijkstra:優先佇列、dist、parent

注意節點 1:一開始從 0 直接過去是 4,後來從 2 繞過去只要 1 + 2 = 3,dist[1] 被改小,優先佇列裡同時有 (3, 1) 和 (4, 1) 兩筆。(3, 1) 先被取出,1 確定;之後取出 (4, 1) 時,1 已經確定過,這筆舊紀錄就被丟掉。節點 3、4 也各有一筆舊紀錄。

要花多少時間

每條邊最多讓優先佇列多一筆紀錄,所以佇列裡最多有 O(E) 筆;每次放進、取出都是 O(log E)。 所有邊都會被鬆弛一次,總共是 O((V + E) log V)(log E 和 log V 只差常數倍,因為 E 最多約 V²)。

演算法 適用 時間複雜度
BFS 沒有權重(或權重都相同) O(V + E)
Dijkstra(優先佇列) 權重都不是負數 O((V + E) log V)
Bellman-Ford 可以有負權重,還能偵測負環 O(V × E)

下面的實驗室可以自己改權重,看最短路徑怎麼跟著改變。

動手做:改權重看 Dijkstra 怎麼走

節點 A~E 對應上面範例的 0~4。右邊的優先佇列依距離排好,表格的 dist 有變化時會反白。點圖上的權重數字,或用下面的「改權重」選一條邊,就能改它的權重。

還原路徑,以及舊紀錄的另一種處理方式

和 BFS 一樣,parent[v] 記著最短路徑上 v 的前一站。從終點沿著 parent 往回走到起點,就得到整條路徑。 上一課是先收集再倒過來;這次示範另一種寫法:每走一步就插在串列最前面,收集完就是正確的順序 (插在前面每次要 O(n),路徑不長時沒有關係)。

下面的範例也換了一種判斷舊紀錄的方式,不用 done 陣列:

d, u = heapq.heappop(pq)
if d > dist[u]:
    continue     # 取出的距離比目前知道的還大:這筆是舊的

一個節點被確定時,取出的 d 一定等於 dist[u];如果 d 比較大,表示放進這筆之後又找到了更短的路,這筆就沒用了。 兩種寫法的結果相同,在權重都不是負數時可以互換。

圖是幾個城市之間的示意行車時間(分鐘)。台北直接到新竹是 80 分鐘,但經過桃園只要 40 + 35 = 75 分鐘。

還原最短路徑:台北到台中

前半段是 Dijkstra(這次用 d > dist[u] 丟掉舊紀錄);回到主程式後,從台中(4)沿著 parent 往回走,每一站都插在 path 的最前面。新竹到台中直接走要 100 分鐘,經過苗栗只要 40 + 45 = 85 分鐘。

想一想:Dijkstra 的步驟

1.

下面的程式對一張有向圖執行 Dijkstra,會印出什麼?

import heapq
adj = [[(1, 6), (2, 2)], [(3, 1)], [(1, 3), (3, 7)], []]
dist = [10**9] * 4
dist[0] = 0
pq = [(0, 0)]
order = []
while pq:
    d, u = heapq.heappop(pq)
    if d > dist[u]:
        continue
    order.append(u)
    for v, w in adj[u]:
        if d + w < dist[v]:
            dist[v] = d + w
            heapq.heappush(pq, (dist[v], v))
print(order, dist)
2.

優先佇列裡同時有 (4, 1) 和 (3, 1) 兩筆節點 1 的紀錄,這是怎麼來的?

3.

Dijkstra 為什麼可以在節點被取出的那一刻,就宣布它的最短距離確定了?

第 1 次作答

負權重:Dijkstra 會算錯

權重有時候可以是負數,例如「這段路有補助,反而賺錢」、或是金融上的匯率套利。 這時 Dijkstra 的關鍵推論「取出時就確定」就不成立了。看這張有向圖:

0 → 1  權重 2
0 → 2  權重 5
2 → 1  權重 -4
1 → 3  權重 1

正確答案:0 到 1 最短是 0 → 2 → 1,5 - 4 = 1;0 到 3 最短是 0 → 2 → 1 → 3,1 + 1 = 2。

Dijkstra 會先確定 1(距離 2),接著用它更新 3(距離 3)並確定 3。等到處理 2 時,才發現經過 2 到 1 只要 1, 可是 1 和 3 都已經「確定」了,3 不會再被更新,最後印出錯的 3。

下面的範例用和前面相同的 Dijkstra 程式(用 done 陣列的寫法),看它在哪一步出錯。

負權重讓「已確定」的距離又變小

看輸出:確定的順序是 0、1、3、2,其中 3 在 2 之前就確定了,距離 3。處理 2 的時候,dist[1] 從 2 變成 1,但 1 早就確定過了,1 → 3 這條邊不會再被鬆弛,所以最後 0 到 3 印出 3,正確答案是 2。

Bellman-Ford:一輪一輪鬆弛所有的邊

有負權重時,可以改用 Bellman-Ford 演算法。名稱來自 Lester Ford Jr.(1956)和 Richard Bellman(1958)各自發表的方法, Edward F. Moore 在 1959 年也發表過類似的做法。

做法很直接:不挑順序,把每一條邊都鬆弛一次,這樣算一輪;做 V - 1 輪。

為什麼 V - 1 輪就夠?如果沒有負環,最短路徑最多經過 V 個節點、V - 1 條邊。 第 1 輪之後,「只用 1 條邊」的最短路徑一定已經算對;第 2 輪之後,「最多 2 條邊」的也算對……第 V - 1 輪之後,全部都對了。

負環(negative cycle)是權重總和為負的環:沿著它一直繞,距離可以無限變小,「最短距離」就沒有定義。 偵測方法是做完 V - 1 輪後再多檢查一輪:如果還有邊能讓距離變小,就表示有負環。

時間複雜度是 O(V × E),比 Dijkstra 慢,所以只有在可能出現負權重時才用它。

Bellman-Ford:每一輪 dist 怎麼變

邊故意排成「從終點往起點」的順序,所以每一輪只能往前推進一條邊:第 1 輪算對了 1、2 的初步距離,第 2 輪才發現 0 → 2 → 1 更近,第 3 輪才把 3 更新成 2。最後多檢查一輪,沒有邊能再讓距離變小,表示沒有負環。

想一想:負權重與 Bellman-Ford

1.

一張圖有 6 個節點、可能有負權重(但沒有負環)。Bellman-Ford 最多需要做幾輪,才保證所有 dist 都正確?

2.

做完 V - 1 輪之後,再檢查一輪時發現 dist[u] + w < dist[v] 仍然成立,這代表什麼?

3.

下面哪一種情況,適合用 Dijkstra?

第 1 次作答

實作:補上鬆弛

下面是一張 7 個節點的有向圖,Dijkstra 的骨架已經寫好,只缺迴圈裡的鬆弛。請補上:經過 u 到 v 比較近,就更新 dist[v] 並把新的紀錄放進優先佇列。節點 6 從 0 走不到,要印出「到不了」。

你的輸出
預期輸出

重點整理

  • 有權重的圖裡,路徑長度是權重總和;「邊最少」不等於「最短」,BFS 不夠用
  • 鬆弛:dist[u] + w < dist[v] 時更新 dist[v] 與 parent[v];最短路徑演算法的差別在鬆弛的順序
  • Dijkstra(1959)每次從優先佇列取出距離最小、還沒確定的節點,宣布它確定,再鬆弛它的邊;O((V + E) log V)
  • 優先佇列存 (距離, 節點);距離變小時再放一筆,取出舊紀錄時用 done[u] 或 d > dist[u] 丟掉
  • 沿著 parent 從終點往回走,就能還原最短路徑
  • 有負權重時 Dijkstra 可能算錯;Bellman-Ford 把所有邊鬆弛 V - 1 輪,O(V × E),多檢查一輪還能偵測負環

下一課:最小生成樹。不是找兩點之間的最短路,而是用總權重最小的一組邊把所有節點連起來;會用到 Kruskal 演算法與 Union-Find。

這一課完成了!
Next lesson:最小生成樹與 Union-Find
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。