編碼 · 演算法
Dijkstra 演算法教學:原理、程式碼與時間複雜度
簡答
Dijkstra 演算法可在邊權重皆非負的圖中,找出從單一起點到其他所有節點的最短路徑。它會反覆挑出目前已知距離最小、尚未確定的節點,將其標記為最終結果,並對它的外向邊進行鬆弛。搭配二元堆積的優先佇列,時間複雜度為 O((V + E) log V)。只要有任何一條邊的權重為負,結果就會出錯。
Dijkstra 演算法解決什麼問題
廣度優先搜尋找的是邊數最少的路徑,但道路、網路連線與航班各有不同的長度、成本或延遲。Dijkstra 演算法解決的是這類加權圖上的單源最短路徑問題:給定一個起點,找出到其他每個節點的最小總成本,以及達成該成本的路徑。
Edsger W. Dijkstra 於 1959 年在論文〈A note on two problems in connexion with graphs〉中發表了這個演算法。
用白話說明原理
- 把起點的距離設為 0,其他所有節點的距離設為無限大。
- 挑出距離最小且尚未確定的節點,它的距離此時即為最終結果。
- 對它的每條外向邊 u → v(權重為 w)進行鬆弛:如果
dist[u] + w < dist[v],就設定dist[v] = dist[u] + w,並記下 u 是 v 的父節點。 - 重複步驟 2 和 3,直到所有可到達的節點都已確定。
為什麼距離最小的節點就是最終結果?因為到達該節點的其他路線都必須經過某個距離至少一樣大的節點,而在權重非負的情況下,剩下的邊只會讓成本增加。
逐步範例
有向圖的邊為 A→B(4)、A→C(1)、C→B(2)、B→D(1)、C→D(5)、D→E(3),從 A 出發。
| 步驟 | 確定的節點 | A | B | C | D | E |
|---|---|---|---|---|---|---|
| 起始 | — | 0 | ∞ | ∞ | ∞ | ∞ |
| 1 | A (0) | 0 | 4 | 1 | ∞ | ∞ |
| 2 | C (1) | 0 | 3(經由 C) | 1 | 6(經由 C) | ∞ |
| 3 | B (3) | 0 | 3 | 1 | 4(經由 B) | ∞ |
| 4 | D (4) | 0 | 3 | 1 | 4 | 7 |
| 5 | E (7) | 0 | 3 | 1 | 4 | 7 |
B 一開始透過直接的邊得到距離 4,之後經由 C 改善為 3。到 E 的最短路徑是 A → C → B → D → E,總成本為 1 + 2 + 1 + 3 = 7。
使用 heapq 的 Python 實作
Python 的 heapq 模組提供二元最小堆積。它沒有「降低鍵值」的操作,所以每當距離有改善時,就推入一組新的 (distance, node),並在彈出時略過過期的組合。這種做法稱為惰性刪除(lazy deletion)。
import heapq
def dijkstra(graph, start):
dist = {start: 0}
parent = {start: None}
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # outdated entry
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
parent[v] = u
heapq.heappush(heap, (nd, v))
return dist, parent
graph = {
"A": [("B", 4), ("C", 1)], "B": [("D", 1)],
"C": [("B", 2), ("D", 5)], "D": [("E", 3)], "E": [],
}
dist, parent = dijkstra(graph, "A")
print(dist) # {'A': 0, 'B': 3, 'C': 1, 'D': 4, 'E': 7}若要取得到某個目標的路徑,沿著父節點連結往回走到起點即可:
def path_to(parent, target):
path = []
while target is not None:
path.append(target)
target = parent[target]
return path[::-1]
print(path_to(parent, "E")) # ['A', 'C', 'B', 'D', 'E']如果只需要到單一目標的距離,當該目標從堆積中彈出時就可以停止:此刻它的距離已是最終結果。
時間與空間複雜度
| 優先佇列 | 時間 | 說明 |
|---|---|---|
| 二元堆積(heapq) | O((V + E) log V) | 最常見的選擇。使用惰性刪除時,堆積最多可能有 E 筆資料,但每次操作仍是 O(log V),因為 log E 至多是 2 log V。 |
| 普通陣列,掃描找最小值 | O(V²) | 適合 E 接近 V² 的非常稠密圖。 |
| 費氏堆積 | O(E + V log V) | 理論上最佳,但常數很大,實務上很少更快。 |
空間為 O(V + E),用於儲存圖、距離與父節點對應表,以及堆積。
變化版本與常見錯誤
- 省略
if d > dist[u]: continue這個檢查並不會算錯,但演算法會去處理過期的資料,可能慢上許多。 - 只推入節點而不是
(distance, node),會讓堆積的排序失去意義。 - 如果節點是無法比較的物件,距離相同時
heapq會去比較節點本身,並拋出TypeError。請改推入(distance, counter, node),並使用唯一的計數器。 - A* 搜尋就是在優先順序中加入啟發函式的 Dijkstra;當你對剩餘距離有良好的估計時,它能探索較少的節點。
- 雙向 Dijkstra 同時從起點和終點出發,兩邊的搜尋相遇時停止。
何時使用與實際應用
| 圖的類型 | 演算法 |
|---|---|
| 無權重,或所有權重相同 | BFS,O(V + E) |
| 權重非負,單一起點 | Dijkstra |
| 有負權重,但沒有負環 | Bellman-Ford,O(V × E) |
| 所有節點對,小型稠密圖 | Floyd-Warshall,O(V³) |
| 單一目標,且有距離估計 | A* 搜尋 |
- 路由:OSPF、IS-IS 等鏈路狀態路由協定,其最短路徑優先計算就是以 Dijkstra 演算法為基礎。
- 地圖與導航:實際的商用系統會在 Dijkstra 式搜尋之上加入預處理。
- 面試題目:網路延遲時間、最便宜的航班(有轉機次數限制)、網格中的最小體力路徑,以及任何圖上的「最小總成本」問題。
常見問題
Dijkstra 演算法能處理負權重嗎?
不能。它假設一個節點一旦擁有最小的暫定距離,之後就不會有更便宜的路徑,而負權重邊會破壞這個假設。請改用 Bellman-Ford。
Dijkstra 演算法的時間複雜度是多少?
使用二元堆積時為 O((V + E) log V);使用簡單陣列時為 O(V²),對稠密圖可能更好。
Dijkstra 演算法是貪婪演算法嗎?
是的。每一步它都做出局部最佳的選擇,也就是確定離起點最近且尚未確定的節點;當所有權重皆非負時,這個選擇可證明是正確的。
Dijkstra 和 BFS 有什麼差別?
BFS 使用普通佇列並計算邊數,因此只有在每條邊權重相同時才正確。Dijkstra 使用依總距離排序的優先佇列,所以能處理不同的非負權重。
Dijkstra 能找出所有節點對之間的最短路徑嗎?
執行一次只會得到單一起點的最短路徑。對每個節點各執行一次即可得到所有節點對的結果,小型稠密圖也可以改用 Floyd-Warshall。