Shortest Path in DAGDAG 最短路徑
先拓撲排序再依序鬆弛,負權也可以。
用在:專案排程的關鍵路徑
01為什麼需要它
蓋一棟大樓有上千項工作,每項有預估天數,也有「這項做完另一項才能開始」的前後關係。業主想知道最快多久能完工,以及哪些工作絕對不能延誤、哪些可以晚幾天也沒關係。
為什麼用它工作之間的相依關係不能有環,是一張 DAG。依拓撲順序算每項工作的最早開始時間,就是從起點出發的最長路徑;再依反向順序算最晚開始時間,兩者相減是浮動時間,浮動時間為 0 的工作連成「關鍵路徑」。這個方法叫 CPM,1950 年代就用在大型工程管理,整個計算只要 O(V + E)。
排版一整段文字時,每一行要在哪個字後面換行?一行一行貪心地塞滿,常常讓後面某一行被拉得很稀疏,整段看起來很醜;要考慮所有斷行組合,數量是指數級的。
為什麼用它把段落中每個可以斷行的位置當成節點,「從位置 i 斷到位置 j 當成一行」是一條邊,權重是這行被拉伸或壓縮的難看程度。位置只會往後,圖一定無環,整段最好看的排法就是從開頭到結尾的最短路徑。TeX 的斷行演算法就是在這張圖上做動態規劃,只保留可行的斷點,所以長段落也算得很快。
使用者打了一串注音,每一段注音都對應很多同音字和詞,組合起來有成千上萬種句子。輸入法要即時挑出最像正常中文的一句,而且使用者每多打一個音就要重算。
為什麼用它句子的位置是節點,每個候選詞是從它的起點連到終點的一條邊,權重是這個詞出現的機率取對數。所有邊都往後指,這張「詞格」是 DAG,機率最高的整句就是最長路徑,依位置順序做一次 O(V + E) 就找得到。這個做法和語音辨識裡的 Viterbi 解碼是同一個想法。
看到這些關鍵字就想到它:圖保證沒有環(相依關係、時間只往前、位置只往後)、有負權邊但沒有環、要求最長路徑或關鍵路徑、DP 狀態之間的轉移、路徑數量計數。
02核心概念
一般的圖上,Dijkstra 要求權重不能是負的,Bellman-Ford 允許負權但要 O(VE),而最長路徑在一般圖上甚至是 NP-hard。可是如果圖是 DAG(有向無環圖),就能排出拓撲順序,讓每一條邊都從前面的節點指向後面的節點。照這個順序處理節點、把每個節點的出邊鬆弛一次,就能在 O(V + E) 時間內得到單源最短路徑,而且負權邊完全沒有問題。
為什麼對:輪到節點 u 的時候,所有指向 u 的邊,起點都排在 u 前面,早就處理過了,所以 dist[u] 已經是最終答案,不會再被改動。任何一條到 v 的最短路徑,最後一條邊 u → v 的 u 一定排在 v 前面;輪到 u 時 dist[u] 已經正確,鬆弛這條邊就讓 dist[v] 正確。用拓撲順序做歸納就證完了。DAG 沒有環,自然也沒有負環。排在起點前面、或從起點走不到的節點會一直是 ∞,這些節點不能拿來鬆弛。
複雜度:拓撲排序 O(V + E),鬆弛時每個節點、每條邊各看一次,也是 O(V + E),總共 O(V + E) 時間、O(V) 額外空間。最長路徑只要把初值改成 −∞、比較改成取最大;或把所有權重變號後求最短路徑。專案排程的關鍵路徑分兩趟:順著拓撲順序算最早開始時間(最長路徑),再逆著順序算最晚開始時間,兩者的差是浮動時間。同一個順序還能做很多事,例如 ways[v] += ways[u] 計算路徑數量。事實上,每一個動態規劃都可以看成「狀態是節點、轉移是邊」的 DAG 上的最短或最長路徑。
常見的坑:圖其實有環卻當成 DAG,Kahn 輸出的節點數少於 V 就要報錯;從 ∞ 的節點出發鬆弛,∞ 用大整數表示時加上負權會變得比 ∞ 小,產生假路徑;以為要從起點開始排拓撲順序,其實是對整張圖排序,只是起點以外的前段節點會停在 ∞;最長路徑的初值寫成 0 而不是 −∞,會把走不到的節點算成可達;把這個技巧用到有環的圖上求最長路徑,結果是錯的。和鄰近課程的關係:拓撲順序來自 Topological Sort;圖沒有環時它比 Bellman-Ford 快得多;Dijkstra 可以看成「在執行中動態決定處理順序」,DAG 則是事先就知道正確順序。
03演算法步驟
- 1確認圖沒有環,求出拓撲順序(Kahn 或 DFS 完成順序反轉);Kahn 輸出的節點數少於 V 就代表有環。
- 2
dist全部設為 ∞(求最長路徑時設為 −∞),起點設為 0;需要路徑就準備parent。 - 3依拓撲順序取出 u;若
dist[u]仍是 ∞,表示從起點走不到,直接跳過。 - 4對每條出邊
u → v(權重 w):若dist[u] + w < dist[v](最長路徑用 >),就更新dist[v]並令parent[v] = u。 - 5全部處理完,
dist就是答案,沿parent往回走得到路徑。關鍵路徑再依反向拓撲順序算最晚開始時間,浮動時間為 0 的工作就在關鍵路徑上。
04互動示範
上方切換兩個例子,下方那一列是拓撲順序和目前的距離,藍色是正在處理的節點、綠色是處理完的。「最短路徑(含負權)」的起點是 S,R 排在 S 前面、根本走不到,輪到它時直接跳過。之後每條出邊都會變成黃色鬆弛一次,藍色的邊是目前的最短路徑樹:Y 先由 T 更新成 6,再經過負權邊 X → Y 變成 5;Z 先是 4,最後經過 Y → Z 的 −2 變成 3。「最長路徑:專案排程」裡邊上的數字是前一項工作的天數,改成取最大值:測試要等後端(第 9 天)和前端(第 7 天)都完成,所以取 9;上線最早第 12 天。最後一步標出關鍵路徑 開工 → 規格 → 後端 → 測試 → 上線,並算出前端和文件的浮動時間。
05程式碼
Python 放 Kahn 拓撲排序加鬆弛的最短路徑、還原路徑,以及順逆兩趟算出總工期和浮動時間的關鍵路徑。C++ 放用 DFS 完成順序的版本,另外示範斷詞:位置本身就是拓撲順序,把詞的分數當權重求最長路徑,「研究生命起源」會切成「研究 生命 起源」。
from collections import deque
INF = float("inf")
def topo_order(n, adj):
"""Kahn 拓撲排序;輸出少於 n 個節點就代表圖裡有環"""
indeg = [0] * n
for u in range(n):
for v, _ in adj[u]:
indeg[v] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
order = []
while q:
u = q.popleft()
order.append(u)
for v, _ in adj[u]:
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
if len(order) != n:
raise ValueError("圖裡有環,不是 DAG")
return order
def dag_shortest(n, edges, src):
"""DAG 單源最短路徑,允許負權。回傳 (dist, parent),O(V + E)"""
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
dist, parent = [INF] * n, [-1] * n
dist[src] = 0
for u in topo_order(n, adj):
if dist[u] == INF: # 走不到的點不能拿來鬆弛
continue
for v, w in adj[u]:
if dist[u] + w < dist[v]:
dist[v], parent[v] = dist[u] + w, u
return dist, parent
def critical_path(durations, deps):
"""專案排程:durations[i] 是工作 i 的天數,deps 是 (前置, 後續) 的清單。
回傳 (總工期, 每項工作的浮動時間)。浮動時間 0 的工作就在關鍵路徑上"""
n = len(durations)
adj = [[] for _ in range(n)]
for u, v in deps:
adj[u].append((v, durations[u]))
order = topo_order(n, adj)
earliest = [0] * n # 最早開始 = 從起點出發的最長路徑
for u in order:
for v, w in adj[u]:
earliest[v] = max(earliest[v], earliest[u] + w)
total = max(earliest[i] + durations[i] for i in range(n))
latest = [total - durations[i] for i in range(n)] # 最晚開始:再晚就會拖到總工期
for u in reversed(order): # 反向拓撲順序,由後往前推
for v, w in adj[u]:
latest[u] = min(latest[u], latest[v] - w)
return total, [latest[i] - earliest[i] for i in range(n)]
if __name__ == "__main__":
R, S, T, X, Y, Z = range(6)
edges = [(R, S, 5), (R, T, 3), (S, T, 2), (S, X, 6), (T, X, 7), (T, Y, 4), (T, Z, 2), (X, Y, -1), (X, Z, 1), (Y, Z, -2)]
dist, parent = dag_shortest(6, edges, S)
print(dist) # [inf, 0, 2, 6, 5, 3]
path, v = [], Z
while v != -1:
path.append("RSTXYZ"[v])
v = parent[v]
print("".join(reversed(path))) # SXYZ:6 + (-1) + (-2) = 3
# 開工、規格、後端、前端、文件、測試、上線
dur = [0, 3, 6, 4, 2, 3, 0]
deps = [(0, 1), (1, 2), (1, 3), (1, 4), (2, 5), (3, 5), (4, 6), (5, 6)]
print(critical_path(dur, deps)) # (12, [0, 0, 0, 2, 7, 0, 0])06練習題
- LeetCode 3243Shortest Distance After Road Addition Queries I(編號只往後,每次查詢重跑 DAG 最短路徑)Medium
- LeetCode 2192All Ancestors of a Node in a Directed Acyclic Graph(沿拓撲順序傳遞祖先集合)Medium
- LeetCode 1786Number of Restricted Paths From First to Last Node(先 Dijkstra,再依距離排序做路徑計數)Medium
- LeetCode 329Longest Increasing Path in a Matrix(隱含的 DAG 上求最長路徑)Hard
- LeetCode 1857Largest Color Value in a Directed Graph(拓撲順序 DP,順便判斷有沒有環)Hard