Dijkstra’s Algorithm
範圍 — 用優先佇列解非負權重的單源最短路徑。 另見:Dijkstra_examples.md — 撐起這些模板的十一題詳解;shortest_path_comparison.md — 該挑哪個演算法;Bellman-Ford.md — 權重可能為負時用它;Floyd-Warshall.md — 需要全點對時用它;heap.md — 底下那個優先佇列。
LeetCode 題目清單
總覽
Dijkstra(戴克斯特拉)演算法是一個貪婪演算法,專門解邊權 NON-NEGATIVE(非負)圖上的單源最短路徑問題。它會算出起點(source)到圖中所有其他節點的最短路徑。
關鍵性質
- 時間複雜度:用二元堆積是 O((V + E) log V),用陣列是 O(V²)
- 空間複雜度:O(V),存距離陣列跟走訪集合
- 核心想法:每次貪婪地挑出「還沒定案、距離最小」的節點
- 什麼時候用:非負權重的單源最短路徑
- 限制:
不能處理負邊權、最大化路徑上的收益總和(最長路徑),也不能處理「已用過的格子決定接下來能走哪裡」的路徑(LC 1219) —— 見 Dijkstra 不適用的情況
核心特徵
- 貪婪演算法:永遠先挑距離最小的節點
- 優先佇列:用最小堆積(min-heap)快速取出最小值
- 鬆弛(Relaxation):找到更短的路徑就更新距離
- 定案(Finalization):一個節點一旦被走訪過,它的距離就是最佳解
參考資料
- Dijkstra’s Algorithm Visualization
- CP Algorithms - Dijkstra
- Bellman-Ford Cheatsheet - 負權重處理的對照
- Floyd-Warshall Cheatsheet - 全點對最短路徑的對照
題型分類
分類 1:經典最短路徑
- 說明:標準的單源最短路徑題
- 例子:LC 743(Network Delay)、LC 1514(Path with Max Probability)
- 模式:直接套 Dijkstra
分類 2:帶限制的最短路徑 ⚠️ Dijkstra 變形
- 說明:最短路徑多了一個限制維度(轉機次數、障礙物、鑰匙、時間)
- 例子:LC 787(Cheapest Flights K Stops)、LC 1293(Shortest Path K Obstacle Removal)、LC 864(Get All Keys)、LC 1928(Minimum Cost K Waypoints)
- 模式:二維狀態 Dijkstra — 狀態是
(cost, node, constraint),不是(cost, node) - 為什麼算變形:同一個節點在不同限制值下是不同的狀態。標準的
visited[node]或dist[node]剪枝在這裡是錯的 — 它會丟掉那些「用不同剩餘額度抵達同一節點」的合法路徑。 - 剪枝規則:
best[(node, constraint)] <= cost(二維 best map,不是一維 dist 陣列)
分類 3:格子上的最短路徑
- 說明:在二維格子裡找最佳路徑
- 例子:LC 64(Minimum Path Sum)、LC 1631(Path Min Effort)、LC 778(Swim in Rising Water)
- 模式:在隱式圖上跑 Dijkstra(把格子當節點)
- ⚠️ 特別注意:LC 64 可以純 DP 解,不必動用 Dijkstra(見下)
分類 4:多源最短路徑
- 說明:從多個起點出發找最短路徑
- 例子:LC 2812(Find Safest Path)、LC 1162(As Far from Land)
- 模式:一開始就把多個源頭全丟進去,或建一個超級源點
分類 5:與時間相關的最短路徑
- 說明:路徑成本會隨時間或順序改變
- 例子:LC 2045(Second Minimum Time)、LC 882(Reachable Nodes)
- 模式:在優先佇列裡一起追蹤時間/狀態
該不該用 Dijkstra:兩個問題
這份表以前有九個小節在用各種說法吵 LC 1631。其實都可以收斂成兩個問題 —— 任何最短路徑題都能拿來問一遍 —— 再加一個值得知道的替代視角。
1) 成本是可加的嗎?格子能不能排出順序?— LC 64 vs LC 1631 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
問題:跑 Dijkstra 真的需要 dist[r][c](記錄到每格的最小成本)嗎?還是純 DP 就夠了?
答案:看移動方向:
LC 64: Minimum Path Sum ✅ 純 DP 就夠
Movement: RIGHT only ↓ or DOWN only →
- DP 為什麼行得通:
(i,j)這格只可能從(i-1,j)或(i,j-1)過來 - 不需要 dist[][]:每格照拓撲順序剛好算一次
- 不會回頭:一格算完之後,不可能再冒出「更好的路徑」
- 解法:單純的二維 DP,或壓成 O(min(m,n)) 空間的一維 DP
// Pure DP - NO dist[][] needed
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
// First column: only from above
for (int i = 1; i < m; i++)
dp[i][0] = dp[i-1][0] + grid[i][0];
// First row: only from left
for (int j = 1; j < n; j++)
dp[0][j] = dp[0][j-1] + grid[0][j];
// Fill rest
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
dp[i][j] = grid[i][j] + Math.min(dp[i-1][j], dp[i][j-1]);
return dp[m-1][n-1];
}
// Time: O(m*n), Space: O(min(m,n))
LC 1631: Path With Minimum Effort ⚠️ 需要 Dijkstra + dist[][]
Movement: UP, DOWN, LEFT, RIGHT (all 4 directions)
- 為什麼需要 Dijkstra:同一格可能從很多條路徑抵達,而且更好的路徑可能晚一點才出現
- dist[][] 不能省:它記錄每格「目前找到的最佳成本」
- 會回頭重訪:四個方向都能走的時候,你可能用更好的成本再次踩到同一格
- 解法:Dijkstra 搭配 dist[][] + PriorityQueue
// Dijkstra + dist[][] - NECESSARY for 4-directional movement
public int minimumEffortPath(int[][] heights) {
int m = heights.length, n = heights[0].length;
// dist[r][c] = minimum effort found so far to reach (r,c)
int[][] dist = new int[m][n];
for (int[] row : dist)
Arrays.fill(row, Integer.MAX_VALUE);
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[2], b[2]));
pq.offer(new int[]{0, 0, 0});
dist[0][0] = 0;
int[][] dirs = {{0,1}, {0,-1}, {1,0}, {-1,0}};
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int r = cur[0], c = cur[1], cost = cur[2];
// Already processed with better cost
if (cost > dist[r][c]) continue;
if (r == m-1 && c == n-1) return cost;
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr >= 0 && nr < m && nc >= 0 && nc < n) {
int newCost = Math.max(cost, Math.abs(heights[nr][nc] - heights[r][c]));
if (newCost < dist[nr][nc]) {
dist[nr][nc] = newCost;
pq.offer(new int[]{nr, nc, newCost});
}
}
}
}
return -1;
}
// Time: O(m*n*log(m*n)), Space: O(m*n)
對照表 — LC 64 vs LC 1631
| 題目 | 移動方向 | 成本模型 | 最佳解法 | 需要 dist[][]? | 需要 visited? |
|---|---|---|---|---|---|
| LC 64 | 右 + 下 | 加總 | 二維 DP | ❌ 不用 | ❌ 不用 |
| LC 1631 | 四方向 | 差值取最大 | Dijkstra | ✅ 要 | ✅ 要(靠 dist 檢查) |
| LC 1263 | 四方向 | 加總成本 | Dijkstra | ✅ 要 | ✅ 要(靠 dist 檢查) |
有兩個推論值得白紙黑字寫下來,因為這正是兩種最常見的翻車方式:
為什麼 DP 救不了 LC 1631。 答:問題出在移動方向:
- LC 64:只能往右/往下 → 存在拓撲順序 → DP 可行 ✅
- LC 1631:上下左右都能走 → 有環 → DP 失效 ❌
四方向移動會產生循環依賴:
(1,1) → (1,2) → (2,2) → (2,1) → (1,1)
DP 要求依賴關係構成 DAG(無環),所以這題只能用 Dijkstra 或二分搜尋。
為什麼「cost」和「effort」不是同一種量。 答:不同題量的是不同東西:
- Cost(LC 64、1263):路徑上所有值的總和 =
cost += value - Effort(LC 1631):相鄰格子差值的最大值 =
effort = max(effort, |diff|)
Cost 可加,effort 不可加。就是這個不可加性讓 DP 掛掉。
2) 你需要 dist[],還是 visited[] 就夠? 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
dist[r][c] = "What's the MINIMUM cost I've found SO FAR to reach (r,c)?"
- 初始化:
dist[r][c] = Integer.MAX_VALUE(未知) - 更新:從 PQ 彈出某格、成本為 C 時,檢查
if (C > dist[r][c]) continue;- 條件成立表示我們早就找到更好的路徑 → 跳過不處理
- 這等於自動避免重複處理,不需要另外開 visited 陣列
- 什麼時候非要不可:同一格有多條路徑可達 → 需要 Dijkstra 的鬆弛機制
答:不用,兩者擇一:
- 選項 A:dist[][] → 處理前檢查
if (newCost < dist[r][c]) - 選項 B:visited[] → 從 PQ 第一次彈出後就標記已走訪
兩種都能避免重複處理同一格。哪個你看得順眼就用哪個 —— 但它們不是永遠可以互換。下面會說明什麼時候 best[]/dist[] 是真的必要,什麼時候可以簡化成「直接拿下一條候選路徑跟目前(剛彈出)這條比」。
Dijkstra 在看候選邊 cur_node -> nxt_node 時,可以做兩種完全不同的比較:
類型 1:跟 best[] 比 |
類型 2:下一條路徑跟剛彈出的 cur 路徑比 | |
|---|---|---|
| 比的是什麼 | candidate_value 對上 best[nxt_node](該節點/狀態歷來記錄過的最佳值) |
什麼都沒存 —— candidate_value 直接由 cur_node 已定案的值推出來,沒有查表這回事 |
| 檢查發生在什麼時候 | 推進堆積之前(鬆弛步驟) | 彈出之後 —— 只有一個 visited[node] 布林關卡,完全不比值 |
| 為什麼需要/為什麼行得通 | 整趟跑下來,同一個節點(或節點+限制狀態)可能被用不同的值抵達很多次;你得記住目前最好的那個,才知道新路徑是不是真的更好 | Dijkstra 的最小堆積不變量保證一個節點第一次被彈出時就已經是全域最佳,所以彈出後根本沒東西好比 —— 之後那些比較差的重複項,visited 檢查一律跳過 |
| 會跨越多次更新持續存在嗎? | ✅ 會 —— 一個節點定案前,best[node] 可能被覆寫好幾次 |
❌ 不會 —— 一個節點只寫一次(visited[node] = true),之後再也不動 |
| 什麼時候非用不可… | 狀態多了一個維度((node, constraint) —— 同一個節點在好幾個不同限制值下都算「合法抵達」),或者你需要偵測平手(例如數路徑條數),或者你想在推進堆積前就把不會變好的候選擋掉 |
狀態就是每個節點一個純量、邊權非負、沒有額外限制維度 —— 也就是最單純的單源情況 |
| 用錯了會不會默默出錯 | 不會(永遠正確,只是多吃點記憶體) | ❌ 在帶限制的題目(LC 787 那類)只用 visited[] 是錯的 —— 它會抹掉那個額外維度,丟掉合法路徑(具體推演見 LC 787) |
一句話講完核心概念:
best[]回答的是:「這個候選,比我對這個狀態看過的任何東西都好嗎?」 —— 只要一個狀態可以合法地用不同的值重訪,就需要它。 只用visited[]回答的是:「這個狀態已經定案了嗎?」 —— 只有在堆積的彈出順序保證(第一次彈出=最佳)完全涵蓋整個狀態時才夠,也就是沒有額外限制維度的情況。
經典 Dijkstra 題目分類:
| 類型 | LC # | 題目 | 原因 |
|---|---|---|---|
| 類型 1 — 需要 best[]/dist[] | 743 | Network Delay Time | 標準單源,推進堆積前先鬆弛 |
| 1514 | Path with Maximum Probability | best[]/max_prob[] 記錄每個節點目前的最大乘積 |
|
| 1976 | Number of Ways to Arrive at Destination | 同時需要 dist[] 跟 ways[] —— 必須抓到精確的平手(==),沒有存下來的值就辦不到 |
|
| 787 | Cheapest Flights Within K Stops | 必須用二維 best[(node, stops)] —— 只用 visited[node] 可證明是錯的(見 LC 787 的推演) |
|
| 1293 / 864 / 2093 | 帶限制狀態的 Dijkstra 變形 | 跟 787 同理 —— 多一個限制維度就代表一個節點會有多個合法的定案狀態 | |
| 類型 2 — 只用 visited[] 就夠 | 1631 | Path With Minimum Effort | 變形 2(visited[][])—— 每格狀態是純量,第一次彈出就是最佳 effort |
| 778 | Swim in Rising Water | visited[][] 標記格子已定案;下一條路徑的值 = max(目前路徑值, 下一格高度) |
|
| 743 | Network Delay Time(另一種寫法) | LC 743 展示的「visited 集合版本」—— 跟 dist[] 等價,只是改成彈出後檢查而非推進前檢查 |
|
| 2290 | Minimum Obstacle Removal | 權重只有 0/1 → 用雙端佇列跑 0-1 BFS + visited[] 也行,不需要值表 |
經驗法則:如果「光靠 node 就能完整描述我在搜尋中的位置嗎?」這個問題答案是是,那只用 visited[] 就安全。一旦答案變成「不行,我還得知道自己用掉了幾次轉機/幾把鑰匙/清了幾個障礙」,就必須升級成以 (node, constraint) 為鍵的 best[]/dist[]。
答:對!if (cost > dist[r][c]) continue; 這個檢查就是你的 visited 機制:
- 第一次彈出 (r,c):
cost == dist[r][c]→ 處理 - 之後再彈出 (r,c):
cost > dist[r][c]→ 跳過(等同「已走訪」)
所以你不用多開一個陣列,就享有 visited[] 的語意。
3) 什麼時候併查集是更好的視角
答:以下情況用併查集:
- 你習慣自己把邊列表建出來
- 你想把問題看成圖的連通性問題
- 你在練 Kruskal 演算法
兩者時間複雜度一樣是 O(m×n×log(m×n)),但格子題通常 Dijkstra 比較直覺。
模板與演算法
模板對照表
| 模板類型 | 適用情境 | 追蹤的狀態 | 什麼時候用 |
|---|---|---|---|
| 基本 Dijkstra | 標準最短路徑 | (distance, node) | 沒有額外限制 |
| 帶限制路徑 | 有上限的路徑 | (cost, node, constraint) | K 次轉機、預算 |
| 格子 Dijkstra | 二維格子移動 | (cost, x, y) | 矩陣題 |
| 多源 | 多個起點 | (dist, node, source) | 多個源頭 |
| 時間變動 | 成本隨時間變 | (time, node, state) | 動態成本 |
通用 Dijkstra 模板 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
import heapq
import collections
def dijkstra(n, edges, src, dst):
# Build adjacency list
graph = collections.defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
# Min heap: (distance, node)
pq = [(0, src)]
# Distance array
dist = [float('inf')] * n
dist[src] = 0
# Visited set (optional but recommended)
visited = set()
while pq:
d, u = heapq.heappop(pq)
# Skip if already processed with better distance
if u in visited:
continue
visited.add(u)
# Found destination
if u == dst:
return d
# Relax edges
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(pq, (dist[v], v))
return dist[dst] if dist[dst] != float('inf') else -1
核心步驟 — 七步走完整個迴圈(LC 743)
同一個迴圈,以 LC 743 Network Delay Time 來讀,起點是題目給的節點 k。執行流程:
- 從起點
k出發,初始距離為0。 - 把起點
(0, k)放進 Min Heap。 - 從 Min Heap 取出目前累積距離最小的節點。
- 探索這個節點的所有 neighbors。
- 計算到每個 neighbor 的新距離:
cur_dist + edge_cost。 - 將尚未確定最短距離的 neighbor 放入 Min Heap。
- 持續執行,直到 Min Heap 為空。
為什麼第一次 pop 就是最終答案:每個 heap entry 都帶著從 k 出發的完整路徑成本,而 heap 永遠先吐出最小的那個 — 沒有負權邊時,之後 pop 出來的不可能更小。所以一個節點第一次被 pop 時就確定了最短距離,之後再 pop 到同一個節點都是過期的 entry,直接跳過。
陷阱:這不是逐層 BFS。每一輪只 pop 一個節點;用 for _ in range(len(q)) 的逐層迴圈會依跳數而非成本處理節點,破壞上面的保證。
# LC 743 - Network Delay Time
# IDEA: Dijkstra (min-heap PQ + BFS); stop as soon as all n nodes are finalized
# time = O((V + E) log V), space = O(V + E)
import heapq
from collections import defaultdict
def network_delay_time(times, n, k):
graph = defaultdict(list)
for u, v, w in times:
graph[u].append((v, w)) # u -> (neighbor, travel time)
min_heap = [(0, k)] # steps 1-2: (accumulated time, node)
visited = set() # nodes whose shortest time is finalized
while min_heap: # step 7
time, node = heapq.heappop(min_heap) # step 3
if node in visited: # stale entry: already finalized cheaper
continue
visited.add(node)
if len(visited) == n: # last node reached = network delay
return time
for neighbor, weight in graph[node]: # step 4
if neighbor not in visited: # step 6
heapq.heappush(min_heap, (time + weight, neighbor)) # step 5
return -1 # heap emptied, some node unreachable
模板 1:基本 Dijkstra
def dijkstra_basic(n, edges, src):
"""Find shortest paths from src to all nodes"""
graph = collections.defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
dist = [float('inf')] * n
dist[src] = 0
pq = [(0, src)] # (distance, node)
while pq:
d, u = heapq.heappop(pq)
if d > dist[u]: # Already processed
continue
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(pq, (dist[v], v))
return dist
模板 2:帶限制的 Dijkstra(二維狀態變形) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
核心想法 —— 為什麼這不是標準 Dijkstra:
| 標準 Dijkstra | 帶限制的 Dijkstra | |
|---|---|---|
| 狀態 | (cost, node) |
(cost, node, constraint) |
| 狀態空間 | 一維 —— 每個節點一筆 | 二維 —— 每組 (node, constraint) 一筆 |
| 剪枝 | dist[node] <= cost |
best[(node, stops)] <= cost |
| 第一次彈出的不變量 | node 第一次彈出=全域最佳 |
(node, stops) 第一次彈出=該 stops 值下的最佳 |
| visited[node] 有用嗎? | ✅ 有 | ❌ 沒有 —— 同一節點在不同轉機次數下都合法 |
為什麼 visited[node]/dist[node] 會壞掉:
Example: src=0, dst=3, K=2
Path A: 0→1→3 cost=900 stops=1 ← fewer stops but more expensive
Path B: 0→1→2→3 cost=210 stops=2 ← more stops but cheaper
Standard dist[1] would be finalized at first pop (cost=100).
Path B would try to re-expand node 1 at stops=1, but dist[1] check blocks it.
→ Wrong: path B never explored, answer is incorrect.
With best[(node, stops)]: (node=1, stops=0) and (node=1, stops=1) are DIFFERENT states.
Both get explored independently. Correct answer found.
def dijkstra_constrained(n, edges, src, dst, k):
graph = collections.defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
# (cost, node, stops_used)
pq = [(0, src, 0)]
# KEY: 2D best-map — best[(node, stops)] = min cost to reach node using exactly 'stops' edges
# This replaces the 1D dist[] array used in standard Dijkstra
best = {}
while pq:
cost, u, stops = heapq.heappop(pq)
# First pop of (u, dst) is optimal (min-heap guarantee for this state)
if u == dst:
return cost
# Constraint exceeded — prune this branch
if stops > k:
continue
# 2D pruning: skip if we've already reached (node, stops) cheaper
if (u, stops) in best and best[(u, stops)] <= cost:
continue
best[(u, stops)] = cost
for v, w in graph[u]:
heapq.heappush(pq, (cost + w, v, stops + 1))
return -1
通用的帶限制 Dijkstra 骨架:
# Replace 'stops' with whatever constraint your problem has:
# - stops remaining (LC 787)
# - obstacles budget (LC 1293)
# - keys bitmask (LC 864)
# - discount count (LC 2093)
pq = [(0, src, initial_constraint)]
best = {} # best[(node, constraint)] = min cost
while pq:
cost, node, constraint = heapq.heappop(pq)
if node == dst: return cost
if constraint is exhausted: continue
if (node, constraint) in best and best[(node, constraint)] <= cost: continue
best[(node, constraint)] = cost
for nei, w in graph[node]:
heapq.heappush(pq, (cost + w, nei, updated_constraint))
相似題目(同樣的二維狀態模式):
| LC # | 題目 | 限制維度 | 狀態 |
|---|---|---|---|
| 787 | Cheapest Flights K Stops | 已用轉機次數 (0…K) | (node, stops) |
| 1293 | Shortest Path K Obstacle Removal | 已清障礙數 (0…K) | (node, obstacles) |
| 864 | Shortest Path to Get All Keys | 已收集的鑰匙(bitmask) | (node, keys) |
| 2093 | Minimum Cost to Reach City With Discounts | 已用折扣次數 (0…K) | (node, discounts) |
| 1928 | Min Cost to Reach Destination in Time | 剩餘時間 | (node, time) |
模板 3:格子上的 Dijkstra 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
def dijkstra_grid(grid):
"""Find minimum cost path in 2D grid"""
rows, cols = len(grid), len(grid[0])
# Min heap: (cost, row, col)
pq = [(0, 0, 0)]
# Distance matrix
dist = [[float('inf')] * cols for _ in range(rows)]
dist[0][0] = 0
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
while pq:
cost, r, c = heapq.heappop(pq)
if r == rows - 1 and c == cols - 1:
return cost
if cost > dist[r][c]:
continue
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols:
# Calculate new cost (problem-specific)
new_cost = max(cost, abs(grid[nr][nc] - grid[r][c]))
if new_cost < dist[nr][nc]:
dist[nr][nc] = new_cost
heapq.heappush(pq, (new_cost, nr, nc))
return -1
模板 4:多源 Dijkstra 優先度 3/5 — 值得會 — 多半是必備模式的變形
def dijkstra_multi_source(n, edges, sources):
"""Shortest paths from multiple sources"""
graph = collections.defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
dist = [float('inf')] * n
pq = []
# Initialize all sources
for src in sources:
dist[src] = 0
heapq.heappush(pq, (0, src))
while pq:
d, u = heapq.heappop(pq)
if d > dist[u]:
continue
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(pq, (dist[v], v))
return dist
模板 5:雙向 Dijkstra
def dijkstra_bidirectional(n, edges, src, dst):
"""Optimize by searching from both ends"""
graph = collections.defaultdict(list)
reverse = collections.defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
reverse[v].append((u, w))
def dijkstra_helper(start, adj, other_dist):
dist = [float('inf')] * n
dist[start] = 0
pq = [(0, start)]
visited = set()
min_path = float('inf')
while pq:
d, u = heapq.heappop(pq)
if u in visited:
continue
visited.add(u)
# Check if we can form a complete path
if other_dist[u] != float('inf'):
min_path = min(min_path, d + other_dist[u])
for v, w in adj[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(pq, (dist[v], v))
return dist, min_path
# Run both directions
dist_fwd, path1 = dijkstra_helper(src, graph, [float('inf')] * n)
dist_bwd, path2 = dijkstra_helper(dst, reverse, dist_fwd)
return min(path1, path2, dist_fwd[dst])
依模式分類的題目
經典最短路徑題
| 題目 | LC # | 關鍵技巧 | 難度 |
|---|---|---|---|
| Network Delay Time | 743 | 基本 Dijkstra | Medium |
| Path with Maximum Probability | 1514 | 最大堆積變形 | Medium |
| Find the City With Smallest Number | 1334 | 全點對最短路徑 | Medium |
| Minimum Weighted Subgraph | 2203 | 三個源頭跑 Dijkstra | Hard |
| Number of Ways to Arrive | 1976 | 數最短路徑條數 | Medium |
| Shortest Path in Binary Matrix | 1091 | 格子 Dijkstra | Medium |
帶限制的路徑題
| 題目 | LC # | 關鍵技巧 | 難度 |
|---|---|---|---|
| Cheapest Flights Within K Stops | 787 | 狀態追蹤 | Medium |
| Minimum Cost to Reach City | 1928 | K 個中繼點 | Hard |
| Shortest Path to Get All Keys | 864 | 狀態 bitmask | Hard |
| Escape a Large Maze | 1036 | 有上限的 BFS/Dijkstra | Hard |
| Minimum Obstacle Removal | 2290 | 0-1 BFS 變形 | Hard |
格子題
| 題目 | LC # | 關鍵技巧 | 難度 |
|---|---|---|---|
| Minimum Path Sum | 64 | DAG 格子(DP 較佳,Dijkstra 也能解) | Medium |
| Path With Minimum Effort | 1631 | 格子 Dijkstra | Medium |
| Swim in Rising Water | 778 | 最小時間路徑 | Hard |
| Minimum Cost to Make Valid Path | 1368 | 改造過的成本 | Hard |
| Shortest Path in a Grid | 1293 | K 個障礙 | Hard |
| Trap Rain Water II | 407 | 優先佇列 | Hard |
多源題
| 題目 | LC # | 關鍵技巧 | 難度 |
|---|---|---|---|
| Find Safest Path in Grid | 2812 | 多源初始化 | Medium |
| As Far from Land as Possible | 1162 | 多源 BFS | Medium |
| Shortest Distance from All Buildings | 317 | 跑多次 Dijkstra | Hard |
| Minimum Height Trees | 310 | 找中心點 | Medium |
與時間/狀態相關的題
| 題目 | LC # | 關鍵技巧 | 難度 |
|---|---|---|---|
| Second Minimum Time to Destination | 2045 | 追蹤兩個值 | Hard |
| Reachable Nodes In Subdivided Graph | 882 | 邊的細分 | Hard |
| Minimum Time to Visit All Points | 2065 | 狀態追蹤 | Hard |
| The Maze III | 499 | 字典序路徑 | Hard |
詳解範例
十一道題放在 Dijkstra_examples.md,依「狀態長什麼樣」分組 —— 因為決定實作方式的就是這件事:
| 分組 | 題目 | 狀態由什麼構成 |
|---|---|---|
| 經典單源 | LC 743, 1514, 1976 | 每個節點一個純量 |
| 帶限制的狀態 | LC 787 | (node, budget) —— 多一個維度 |
| 格子 | LC 1631, 778, 64, 2290, 1368 | 一個格子,再加上成本是可加還是取 running max |
| 多源與隱式圖 | LC 407, 373 | 圖從來不是以邊列表給你的 |
決策框架
模式選擇策略
Dijkstra Algorithm Selection Flowchart:
1. What does the path's value do as the path grows?
├── MINIMISE a cost that can only grow → Continue to 2
├── MAXIMISE a value that can only shrink AND keeps two arrivals
│ in the same order (× p with 0 ≤ p ≤ 1, the smallest cell so far)
│ → max-heap Dijkstra (LC 1514, 1102); with a K-edge / stop limit,
│ key the state on (node, edges_used) as in step 5 — a likelier
│ arrival with no edges left must not block a viable one
└── MAXIMISE a value that keeps growing (a sum of gold / score)
→ not Dijkstra: backtracking (LC 1219), or DP on a DAG —
see "When Dijkstra does not apply"
2. Are all edge weights non-negative?
├── NO → Use Bellman-Ford or SPFA
└── YES → Continue to 3
3. Single source or multiple sources?
├── Multiple → Use Multi-Source Dijkstra (Template 4)
└── Single → Continue to 4
4. Is it on a graph or grid?
├── Grid → Use Grid-based Dijkstra (Template 3)
└── Graph → Continue to 5
5. Any constraints (K stops, budget, time)?
├── YES → Use Constrained Dijkstra (Template 2)
└── NO → Use Basic Dijkstra (Template 1)
6. Need optimization for large graphs?
├── YES → Consider Bidirectional Dijkstra (Template 5)
└── NO → Use selected template from above
什麼時候用 Dijkstra、什麼時候用 BFS
| 判準 | Dijkstra | BFS |
|---|---|---|
| 邊權 | 非負且不一樣 | 全部相同(無權重)或 0/1 |
| 資料結構 | 優先佇列(最小堆積) | 佇列(LinkedList) |
| 時間複雜度 | O((V + E) log V) | O(V + E) |
| 第一次抵達=最短? | ❌ 不是(必須靠 PQ 鬆弛) | ✅ 是(層數=距離) |
| 「最小成本/權重」 | ✅ 用 Dijkstra | ❌ 會算錯 |
| 「最少步數/移動次數」 | ❌ 殺雞用牛刀 | ✅ 用 BFS |
| 成本不一的格子 | ✅ 在隱式圖上跑 Dijkstra | ❌ |
| 成本一致的格子 | ❌ 多餘的開銷 | ✅ BFS |
決策規則:每條邊成本都一樣(或都是 1)就用 BFS —— 更簡單而且是 O(V+E)。一旦邊有不同的非負權重,改用 Dijkstra。
常見陷阱:像 LC 279 Perfect Squares 或 LC 752 Open the Lock 這種所有邊成本都是 1 的題目,還硬要用 Dijkstra(PQ)—— 單純 BFS 就夠了,而且更快。
0-1 BFS 特例:如果邊權只有 0 或 1,用雙端佇列 —— 權重 0 的邊往前推、權重 1 的往後推。複雜度跟 BFS 一樣是 O(V+E),又能正確處理兩種權重。例子:LC 2290 Minimum Obstacle Removal。
Dijkstra 不適用的情況 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
Dijkstra 做的每一件事都建立在一個主張上:節點第一次被彈出時,它的值就是最終答案。 這個主張要成立,需要兩件事同時為真,而下面每一條限制,都是其中一件不成立:
- 把路徑延長,永遠不會讓它變更好,也不會讓兩個抵達結果的先後對調。 多走一條邊,只會讓你要最小化的成本變大(或讓你要最大化的值變小),而且同一條邊套在同一節點的兩個抵達結果上,原本較好的仍然較好。
w ≥ 0的+ w、0 ≤ p ≤ 1的× p、max和min都符合。這樣之後彈出的任何東西,都不可能贏過先彈出的。 - 只看節點就知道你在哪裡。 如果接下來能做什麼,取決於你怎麼走到這裡(已經用過哪些格子、還剩幾站、手上有哪些鑰匙),那
dist[node]就會丟掉仍然需要的路徑。
| 情況 | 哪個條件不成立 | 為什麼第一次彈出不是最終答案 | 改用 | LC |
|---|---|---|---|---|
| 最大化路徑上非負收益的總和(黃金、分數、長度) | 1 | 每多走一格路徑就更好,所以最佳路徑是最後才找到,不是最先 —— 這是最長路徑問題,在一般圖上是 NP-hard | 回溯(輸入小時),或圖是 DAG 時依拓撲順序做 DP | 1219 |
| 每格最多走一次,而且用過的格子決定哪些路徑還走得通 | 2 | 真正的狀態是 (格子, 已用過的格子集合);best[cell] 每格只留一條路徑,把其他路徑擋掉 |
回溯 | 1219 |
| 答案取決於已經看過哪些節點(要走遍所有節點;可以重複造訪) | 2 | 同一個節點、看過的集合不同,就是不同的狀態 | 節點 ≤ ~20 個時,在 (node, bitmask) 上跑 BFS / Dijkstra |
847 |
| 有負邊權 | 1 | 之後一條負邊可能壓過一個已經定案的節點 | Bellman-Ford / SPFA | 787 |
| 有站數上限(最多 K 個中轉站) | 2 | 同一個節點、剩下的站數較少,就是不同的狀態 | 在 (node, stops) 上跑 Template 2,或跑 K + 1 輪 Bellman-Ford —— K 個中轉站就是 K + 1 條邊 |
787 |
| 要最小化的成本之外還有時間預算 | 2 | 比較便宜的抵達結果,可能已經花掉太多時間而走不完 | 在 (node, 已用時間) 上跑 Dijkstra,或以時間為索引做 DP |
1928 |
| 要求第二短 / 第 k 短,不是最短 | — | 第一次彈出只給出最短的 | 針對 LC 2045 的「第二個不同的抵達時間」:讓每個節點最多被彈出 k 次,只計算不同的時間(兩條一樣短的路線不算第二短)。這算的是走法(walk),可能重複經過節點 —— 不是第 k 短的簡單路徑(那要用 Yen’s algorithm) | 2045 |
| 所有邊成本相同,或只有 0 / 1 | —(能用,但大材小用) | 堆積帶來的好處,佇列一樣能給 | BFS / 0-1 BFS(見上) | 2290 |
| 要求每一對節點 | —(能用,跑 V 次) | 小圖用 Floyd-Warshall | 1334 |
問題不在「最大化」—— 在於值往哪個方向變。 只要把路徑延長只會讓它變差,而且不會讓兩個抵達結果的先後對調(上面的條件 1),Dijkstra 就能處理最大值:
| 題目 | 路徑上的值 | 延長路徑時 | Dijkstra? |
|---|---|---|---|
| LC 1514 Path with Maximum Probability | 機率的乘積 ≤ 1 | 只會變小 | ✅ 最大堆積 |
| LC 1102 Path With Maximum Minimum Value | 目前為止最小的格子 | 只會變小 | ✅ 最大堆積 |
| LC 1631 Path With Minimum Effort | 目前為止最大的一步,要最小化 | 只會變大 | ✅ 最小堆積 |
| LC 1219 Path with Maximum Gold | 收集到的黃金總和,要最大化 | 只會變大 | ❌ 回溯 |
完整的規則,以及條件 2 的 (node, state) 解法,見
shortest_path_comparison.md § D
與 § B。
Path with Maximum Gold — LC 1219
Key point — 答案是以收集黃金計算的最長簡單路徑,而且每格只能用一次,所以上面兩個條件同時不成立:沒有任何
dist[cell]能代表「走到這格的最佳方式」,因為最先走到它的那條路徑,可能已經用掉了最佳路徑需要的格子。要從每個起點出發、試遍每條簡單路徑,進入格子時標記、離開時還原;黃金格最多 25 個,這樣就夠快。
Reduces to:dfs.md § Template 9: Grid DFS with Restore (Backtracking), 解法放在那裡;標記生命週期的追蹤在 backtrack.md § Template 9。
Dry run —「拿 Dijkstra 求最大值」(以目前黃金數建最大堆積、每格第一次彈出就定案、每格都當一次起點)對上回溯。不管平手時怎麼排序(720 種全試過),貪婪法最多都只到 7,所以失敗不是平手造成的:
grid = 1 2 1 cells: (0,0) (0,1) (0,2)
1 2 1 (1,0) (1,1) (1,2)
start (0,0), max-heap + best[cell]:
pop (0,0) gold 1 push (0,1)=3, (1,0)=2
pop (0,1) gold 3 <- 3 beats 2 outright; (0,1) is now FINAL
with the path (0,0)->(0,1)
pop (1,1) gold 5, (1,0) gold 6, (1,2) gold 6, (0,2) gold 7
best from this start: 7
backtracking -> 8 (0,0) -> (1,0) -> (1,1) -> (0,1) -> (0,2) -> (1,2)
reaches (0,1) FOURTH, through a longer path that
best[(0,1)] had already ruled out
Traps
- 最大堆積加
best[cell]能通過題目自己的兩個範例(24 和 28),卻在上面的 2×3 網格失敗,不論平手怎麼排序都一樣:範例答對,不代表這個貪婪成立。 - 複雜度是
O(k · 3^k),k是黃金格數,不是多項式:第一步之後,每格最多只有 3 個沒走過的鄰居。能過是因為限制條件(k ≤ 25),面試時要講出來。
演算法比較:Dijkstra vs Floyd-Warshall vs Bellman-Ford
這一節原本放的是和其他最短路徑演算法的比較 —— 複雜度、負權重、提前結束、 路徑還原,以及什麼情境該選哪一個 —— 現在統一放在一個地方: shortest_path_comparison.md § Side-by-Side Comparison 與 § Scenario → Algorithm。這份 sheet 的一句話版本:單一起點、權重非負時預設用 Dijkstra;有負權重或跳數限制就改用 Bellman-Ford,小圖上要所有點對就用 Floyd-Warshall,權重全相同就用 BFS(見上面)。
總結與速查
複雜度速查
| 實作方式 | 時間複雜度 | 空間複雜度 | 備註 |
|---|---|---|---|
| 陣列版 | O(V²) | O(V) | 稠密圖適用 |
| 二元堆積 | O((V+E)logV) | O(V) | 最常見 |
| 費氏堆積 | O(E + VlogV) | O(V) | 理論最佳 |
| 格子版 | O(RC log(RC)) | O(RC) | R=列數、C=行數 |
模板速查
| 模板 | 最適合 | 關鍵程式碼模式 |
|---|---|---|
| 基本 | 標準最短路徑 | heapq.heappop(pq) → 鬆弛邊 |
| 帶限制 | K 次轉機、預算上限 | 追蹤狀態:(cost, node, constraint) |
| 格子 | 二維矩陣題 | 四方向移動 |
| 多源 | 多個起點 | 一開始就把所有源頭放進去 |
| 雙向 | 大型圖 | 從兩端同時搜 |
常見模式與小技巧
優先佇列的狀態
# Basic state
(distance, node)
# With constraints
(cost, node, stops_remaining)
# Grid problems
(cost, row, col)
# With path tracking
(distance, node, path)
走訪集合的最佳化
# Option 1: Check after pop (recommended)
if node in visited:
continue
visited.add(node)
# Option 2: Check distance
if d > dist[node]:
continue
路徑還原
parent = {}
# During relaxation:
parent[v] = u
# Reconstruct path:
path = []
while node != source:
path.append(node)
node = parent[node]
path.reverse()
解題步驟
- 辨認圖的結構:邊是明給的,還是隱式的(格子)?
- 檢查限制:權重非負嗎?是單源嗎?
- 選模板:基本、帶限制、格子,還是多源?
- 定義狀態:優先佇列裡要追蹤什麼?
- 實作鬆弛:距離要怎麼更新?
- 處理終止條件:什麼時候停?要回傳什麼值?
相似 LeetCode 題目參考
格子類題目
| LC # | 標題 | 移動方向 | 關鍵特性 | 主要解法 | 其他解法 | 需要 dist[][]? |
|---|---|---|---|---|---|---|
| 64 | Minimum Path Sum | 只有 ↓→ | 可加成本 | 二維 DP | 一維 DP、Dijkstra(殺雞用牛刀) | ❌ 不用 |
| 1631 | Path With Minimum Effort | 四方向 | 單步最大差值(不可加) | Dijkstra | 二分搜尋、併查集 | ✅ 要 |
| 778 | Swim in Rising Water | 四方向 | 格子值取最大 | Dijkstra | 併查集 | ✅ 要 |
| 1263 | Minimum Moves to Move Box | 四方向 | 推箱子機制 | Dijkstra + 狀態 | - | ✅ 要 |
| 882 | Reachable Nodes In Subdivided Graph | 圖 | 節點細分 | Dijkstra | - | ✅ 要 |
LC 1631 深入解析:
- 現有解法:四種主要作法(Dijkstra dist[]、Dijkstra visited、二分搜尋、併查集)
- 最常見:Dijkstra 搭配
dist[][]或visited[]陣列 - 關鍵洞見:成本模型是
Math.max(effort, step_diff),不是加總 —— 這讓 DP 不可行 - 參考檔:
leetcode_java/src/main/java/LeetCodeJava/Graph/PathWithMinimumEffort.java(V0-V4.3)
經典最短路徑題目
| LC # | 標題 | 類型 | 關鍵特性 |
|---|---|---|---|
| 743 | Network Delay Time | 圖 | 廣播延遲 |
| 787 | Cheapest Flights K Stops | 圖 | K 次轉機限制 |
| 1514 | Path with Maximum Probability | 圖 | 最大化機率 |
| 1928 | Minimum Cost to Reach Destination | 加權圖 | K 個中繼點 |
多源最短路徑
| LC # | 標題 | 關鍵特性 |
|---|---|---|
| 1162 | As Far from Land as Possible | 多源 BFS-Dijkstra |
| 2812 | Find the Safest Path | 格子上的多源 |
| 2290 | Minimum Obstacle Removal | 0-1 BFS 變形 |
主要實作檔案
- Java 參考:
leetcode_java/src/main/java/LeetCodeJava/DynamicProgramming/MinimumPathSum.java- V0:Dijkstra 搭配 dist[][](能過,但殺雞用牛刀)
- V0-0-1、V1、V2:純 DP 作法(LC 64 的最佳解)
- Python 參考:
leetcode_python/Dynamic_Programming/minimum-path-sum.py- V0-1、V0-2:Dijkstra(最小堆積 +
cost_grid[][])—— 見 7) LC 64 - V1、V2:原地 DP
O(1)空間/一維滾動列(LC 64 的最佳解)
- V0-1、V0-2:Dijkstra(最小堆積 +
常見錯誤與提醒
🚫 常見錯誤:
- 忘了檢查是否已走訪
- 對負權重硬套 Dijkstra
- 沒用優先佇列(用了普通佇列)
- 帶限制的題目狀態比較寫錯
- 沒處理不連通的分量
✅ 最佳實務:
- 優先佇列一律用最小堆積
- 記錄走訪過的節點,避免重複處理
- 除了源點,其他距離一律初始化成無限大
- 想清楚該用距離陣列還是走訪集合
- 處理好邊界情況(空圖、無路可達)
面試提示
- 先問清楚限制:一定要問有沒有負權重
- 主動講複雜度:一開始就把時間/空間複雜度說出來
- 解釋鬆弛:更新距離是整個演算法的核心概念
- 提到替代方案:說明什麼時候 BFS 或 Bellman-Ford 更適合
- 需要就再最佳化:大型圖可以聊聊雙向搜尋
相關主題
- BFS:無權重的最短路徑
- Bellman-Ford:能處理負權重(詳細比較見上)
- Floyd-Warshall:全點對最短路徑(詳細比較見上)
- A 演算法*:由啟發函數引導的搜尋
- SPFA:以佇列最佳化的 Bellman-Ford 變形
- Johnson’s Algorithm:用重新賦權技巧解全點對