進階圖論演算法
範圍 — 第一輪面試準備可以先跳過的圖論技巧:Tarjan 的 low-link 家族(強連通分量、橋、關節點)、Euler 路徑與迴路,以及二分圖的延伸主題 — 奇偶併查集、最大匹配(以及它的最大流觀點)和貪婪 k-著色。 另見:graph.md — 圖的表示法、走訪、連通性、環偵測與 2-著色,以及把你導向這裡的選擇表;graph_examples.md — 建立在 graph.md 模板上的經典題;dfs_advanced.md — 不屬於圖論的冷門 DFS 技巧(雙網格驗證、字典樹 DFS、以深度為索引的堆疊);union_find.md — Template 3 所依賴的併查集原語。
LeetCode 題目清單
總覽
這裡的東西全都是 low-link DFS、吃掉邊的 DFS,或找增廣路徑的搜尋。它們共用同一個形狀:
一次普通的走訪,再加一份額外的狀態 — low[]、被消耗掉的鄰接表,或配對/殘餘容量陣列 —
就把走訪變成了一個證明。L3 面試很少考其中任何一個;真的考時,幾乎都是找橋(LC 1192)
或行程重建(LC 332),而且題目會直接點名那個結構。
先備知識
- graph.md § Template 5:帶著每個節點狀態的
DFS。Tarjan 在同一個 DFS 上多加一個發現時間戳和一個
low[]值。 - graph.md § Template 6:BFS / DFS 2-著色。下面的 Templates 3–5 是它不夠用時該做的事。
- graph.md § Template 3: 帶路徑壓縮的併查集,Template 3 把它加倍成奇偶併查集。
- dfs.md § Template 2:這裡每個模板的起點, 遞迴的圖 DFS。
辨識
| 題目裡的訊號 | 限制條件線索 | 該用 | 不適用時 |
|---|---|---|---|
| 「關鍵連線」、「拿掉哪條邊會讓網路斷開」 | V ≤ 10⁵,E ≤ 10⁵ | Template 1 — low[child] > disc[parent] 就是橋 |
只問拿掉一條邊後還連不連通 → 重跑 graph.md § Template 1 |
| 「拿掉哪台伺服器/哪個格子會讓網路分裂」 | V ≤ 10⁵ | Template 1 的關節點變形 | 網格題答案永遠 ≤ 2(LC 1568)→ 逐格嘗試拿掉 |
| 「互相可達」、「強連通」 | 有向,V ≤ 10⁵ | Template 1 的 SCC 變形 | 無向 → 那就只是連通分量 |
| 「每張機票/每條邊恰好用一次」 | E ≤ 300 | Template 2 — Hierholzer | 每個節點走一次 → 哈密頓路徑,bitmask DP |
| 「包含所有組合的最短字串」 | k^n ≤ 4,096 | Cracking the Safe — LC 753 — de Bruijn 圖 + 尤拉迴路 | — |
| 「分兩組,這些配對必須分開」,配對逐一到來 | n ≤ 2,000 | Template 3 — 奇偶併查集 | 所有配對一開始就給齊 → BFS 2-著色,graph.md § Template 6 |
| 「最多能配成幾對」、「每個 X 最多指派給一個 Y」 | 每邊 ≤ 200 | Template 4 — Kuhn | 每個配對有成本 → 最小成本指派(匈牙利演算法/bitmask DP) |
| 「最大吞吐量」、「切斷哪些連線最便宜」 | V 最多幾百 | Template 4 的最大流推廣 | — |
| 「k 種顏色,相鄰不同色」,每個度數 < k | V ≤ 10⁴ | Template 5 — 貪婪 | k = 2 → 二分圖檢查;沒有度數上界 → 回溯 |
Low-Link DFS
Template 1:Tarjan 的 Low-Link DFS — SCC、橋、關節點 — LC 1192 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「關鍵連線」、「拿掉哪條邊會讓圖不連通」、「割點」。無向圖,V 最多 10⁵: 逐條拿掉邊再檢查連通性是 O(E·(V + E)),太慢;一次 DFS 是 O(V + E)。
關鍵 — disc[u] 是 DFS 第一次到達 u 的時間;low[u] 是 u 的子樹只用樹邊加上一條回邊
能到達的最小 disc。樹邊 u → v 恰好在 low[v] > disc[u] 時是橋:v 子樹裡沒有任何東西能爬回
u 或更上面,所以切斷 u–v 就把它孤立了。
# python
# LC 1192 - Critical Connections in a Network
# IDEA: Tarjan low-link; tree edge u -> v is a bridge iff low[v] > disc[u]
# time = O(V + E), space = O(V + E)
import sys
def criticalConnections(n, connections):
sys.setrecursionlimit(max(1000, 2 * n + 10)) # the DFS can be n deep
graph = [[] for _ in range(n)]
for a, b in connections:
graph[a].append(b)
graph[b].append(a)
disc = [-1] * n # discovery time, -1 = unvisited
low = [0] * n # lowest disc reachable from u's subtree via one back edge
timer = [0]
bridges = []
def dfs(u, parent):
disc[u] = low[u] = timer[0]
timer[0] += 1
for v in graph[u]:
if v == parent:
continue # do not walk straight back up the tree edge
if disc[v] == -1: # tree edge
dfs(v, u)
low[u] = min(low[u], low[v]) # pull the child's reach up
if low[v] > disc[u]: # v's subtree cannot reach u or above
bridges.append([u, v])
else: # back edge
low[u] = min(low[u], disc[v])
for start in range(n): # the loop also covers a disconnected graph
if disc[start] == -1:
dfs(start, -1)
return bridges
// java
// LC 1192 - Critical Connections in a Network
// IDEA: Tarjan low-link; tree edge u -> v is a bridge iff low[v] > disc[u]
// time = O(V + E), space = O(V + E)
class Solution {
private List<List<Integer>> graph;
private int[] disc;
private int[] low;
private int timer;
private List<List<Integer>> bridges;
public List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
for (List<Integer> e : connections) {
graph.get(e.get(0)).add(e.get(1));
graph.get(e.get(1)).add(e.get(0));
}
disc = new int[n];
low = new int[n];
Arrays.fill(disc, -1);
timer = 0;
bridges = new ArrayList<>();
for (int start = 0; start < n; start++) {
if (disc[start] == -1) {
dfs(start, -1);
}
}
return bridges;
}
private void dfs(int u, int parent) {
disc[u] = low[u] = timer++;
for (int v : graph.get(u)) {
if (v == parent) {
continue;
}
if (disc[v] == -1) {
dfs(v, u);
low[u] = Math.min(low[u], low[v]);
if (low[v] > disc[u]) {
bridges.add(Arrays.asList(u, v));
}
} else {
low[u] = Math.min(low[u], disc[v]); // back edge: disc[v], not low[v]
}
}
}
}
複雜度
- 時間 O(V + E):
dfs對每個頂點只跑一次(由disc[v] == -1把關),它的for v in graph[u]迴圈每個鄰接項目只讀一次,總共 2E 個。 - 空間 O(V + E):鄰接表,加上
disc/low和深度最多 V 的遞迴堆疊。
試跑 — n = 4,邊 [[0,1],[1,2],[2,0],[1,3]]:
step disc low (after) note
dfs(0) 0:0 low0=0
dfs(1) from 0 1:1 low1=1
dfs(2) from 1 2:2 2 sees 0 (visited) -> low2 = min(2, disc0=0) = 0
back in 1 low1 = min(1, low2=0) = 0; low2=0 > disc1=1? no
dfs(3) from 1 3:3 3 sees only its parent low3 = 3
back in 1 low3=3 > disc1=1 -> bridge [1, 3]
back in 0 low0 = 0; low1=0 > disc0=0? no
result: [[1, 3]]
陷阱
- 遇到回邊取
disc[v],不是low[v]。找橋時兩者剛好都對,但找關節點時low[v]會把祖先自己的可達範圍再往上帶,藏住一個割點。 v == parent假設沒有平行邊(LC 1192 成立)。如果u–v有兩條,要改用邊的編號跳過, 否則重複的那條會被誤報成橋。- Python 預設遞迴上限是 1,000;10⁵ 個節點的鏈需要
setrecursionlimit那一行, 或改用明確的堆疊。
不適用時 — 問題只是「拿掉這一條邊後圖還連通嗎」:查詢少時,每次查詢做一次走訪 (graph.md § Template 1)比較簡單。 邊隨時間加入、而且只在乎連通性 → 併查集。
練習 — LC 1192 ⭐ · LC 1568 (網格上的關節點;答案最多是 2) · LC 2360 (函數圖裡的環,不用 Tarjan 的 SCC 想法)
變形:強連通分量(有向)
在有向圖上跑同一個 DFS,再加一個堆疊存放目前 DFS 路徑上的頂點。
當 v 的子節點都跑完後 low[v] == disc[v],v 就是一個 SCC 的根:把堆疊彈到 v 為止。
回邊只有在目標還在堆疊上時才算數(已完成的 SCC 是封閉的)。
# python
# Tarjan SCC
# IDEA: v roots an SCC when low[v] == disc[v]; pop the path stack down to v
# time = O(V + E), space = O(V)
def tarjan_scc(n, graph):
disc = [-1] * n
low = [0] * n
on_stack = [False] * n
stack = []
sccs = []
timer = [0]
def dfs(v):
disc[v] = low[v] = timer[0]
timer[0] += 1
stack.append(v)
on_stack[v] = True
for u in graph[v]:
if disc[u] == -1:
dfs(u)
low[v] = min(low[v], low[u])
elif on_stack[u]: # back edge into the open SCC
low[v] = min(low[v], disc[u])
if low[v] == disc[v]: # v is the root of an SCC
component = []
while True:
u = stack.pop()
on_stack[u] = False
component.append(u)
if u == v:
break
sccs.append(component)
for v in range(n):
if disc[v] == -1:
dfs(v)
return sccs
# 0 -> 1 -> 2 -> 0, 2 -> 3, 3 -> 4 -> 5 -> 3 => [[5, 4, 3], [2, 1, 0]]
Kosaraju 是兩趟法的替代方案:先 DFS 一次記錄完成順序,再依完成順序的反向,在反向圖上 DFS; 第二趟的每棵樹就是一個 SCC。一樣 O(V + E),在壓力下比較容易寫對,但需要把反向圖存在記憶體裡。
變形:關節點(割點)
非根節點 u 是割點,當它有某個子節點 v 滿足 low[v] >= disc[u](注意是 >=:子節點可以到達 u
本身,但到不了更上面)。DFS 的根恰好在有兩個以上 DFS 子節點時是割點。
# python
# Tarjan articulation points
# IDEA: non-root u is a cut vertex iff some child has low[child] >= disc[u]; root iff >= 2 children
# time = O(V + E), space = O(V + E)
def articulation_points(n, edges):
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b)
graph[b].append(a)
disc = [-1] * n
low = [0] * n
cut = set()
timer = [0]
def dfs(u, parent):
disc[u] = low[u] = timer[0]
timer[0] += 1
children = 0
for v in graph[u]:
if v == parent:
continue
if disc[v] == -1:
children += 1
dfs(v, u)
low[u] = min(low[u], low[v])
if parent != -1 and low[v] >= disc[u]:
cut.add(u)
else:
low[u] = min(low[u], disc[v])
if parent == -1 and children >= 2:
cut.add(u)
for start in range(n):
if disc[start] == -1:
dfs(start, -1)
return sorted(cut)
# edges [[0,1],[1,2],[2,0],[1,3],[3,4]] => [1, 3]
橋 u–v |
關節點 u |
SCC 根 v |
|
|---|---|---|---|
| 圖 | 無向 | 無向 | 有向 |
| 判斷 | low[v] > disc[u] |
low[v] >= disc[u](根:≥ 2 個子節點) |
low[v] == disc[v] |
覆蓋所有邊的走法
Template 2:尤拉路徑/迴路(Hierholzer) — LC 332 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「每張機票/每條邊恰好用一次」、「重建行程」。圖上有一條把每條邊用一次的路徑 (不是每個節點)。LC 332 的 E ≤ 300,但對邊的順序做普通回溯在最壞情況是指數級;Hierholzer 是線性的。
關鍵 — 一路往前走、把邊吃掉,直到卡住;卡住的那個節點只可能是剩下路程的終點, 所以在那時(後序)把它加進路線。把後序反轉就是整條走法。消耗的是邊,節點從不標記: 同一個機場本來就可以重複經過。
| 圖 | 尤拉迴路存在的充要條件 | 尤拉路徑存在的充要條件 |
|---|---|---|
| 無向 | 每個度數都是偶數 | 奇數度的頂點有 0 或 2 個 |
| 有向 | 每處 in == out |
一個 out − in = 1(起點)、一個 in − out = 1(終點),其他相等 |
# python
# LC 332 - Reconstruct Itinerary
# IDEA: Hierholzer; walk consuming edges, append a node when it is stuck, reverse at the end
# time = O(E log E), space = O(E)
from collections import defaultdict
def findItinerary(tickets):
graph = defaultdict(list)
# sort descending so list.pop() from the tail yields the smallest airport first
for src, dst in sorted(tickets, reverse=True):
graph[src].append(dst)
route = []
stack = ["JFK"]
while stack:
airport = stack[-1]
if graph[airport]:
stack.append(graph[airport].pop()) # consume one ticket, walk forward
else:
route.append(stack.pop()) # stuck: this airport is final, post-order
return route[::-1]
// java
// LC 332 - Reconstruct Itinerary
// IDEA: Hierholzer; walk consuming edges, append a node when it is stuck, reverse at the end
// time = O(E log E), space = O(E)
public List<String> findItinerary(List<List<String>> tickets) {
Map<String, PriorityQueue<String>> graph = new HashMap<>();
for (List<String> t : tickets) {
graph.computeIfAbsent(t.get(0), k -> new PriorityQueue<>()).add(t.get(1));
}
LinkedList<String> route = new LinkedList<>();
Deque<String> stack = new ArrayDeque<>();
stack.push("JFK");
while (!stack.isEmpty()) {
PriorityQueue<String> next = graph.get(stack.peek());
if (next != null && !next.isEmpty()) {
stack.push(next.poll()); // consume the smallest ticket
} else {
route.addFirst(stack.pop()); // post-order, reversed as it is built
}
}
return route;
}
複雜度
- 時間 O(E log E):
sorted(tickets, ...)那一行(Java 是堆積);之後每張機票在while stack迴圈裡只被推入、彈出各一次。 - 空間 O(E):鄰接表、堆疊和路線各自最多存 E + 1 個項目。
試跑 — 機票 JFK→SFO, JFK→ATL, SFO→ATL, ATL→JFK, ATL→SFO:
stack action
[JFK] take ATL (smallest)
[JFK ATL] take JFK
[JFK ATL JFK] take SFO
[JFK ATL JFK SFO] take ATL
[JFK ATL JFK SFO ATL] take SFO
[JFK ATL JFK SFO ATL SFO] SFO stuck -> route [SFO]; then ATL, SFO, JFK, ATL, JFK pop in turn
route reversed: JFK ATL JFK SFO ATL SFO
陷阱
- 對機場用
visited集合就會壞掉:JFK → ATL → JFK是故意重訪 JFK。 - 只做「永遠取最小的」貪婪而沒有後序,會在
JFK→KUL, JFK→NRT, NRT→JFK上失敗: KUL 是死路,所以它必須排在最後。後序會把它放到那裡。 - 路線是倒著建的;忘了最後的反轉(或
addFirst)是經典 bug。
不適用時 — 每個節點都要走一次(哈密頓路徑,LC 847):一般情況下是 NP-hard, 小 n 時用 bitmask BFS / DP,不是 Hierholzer。
練習 — LC 332 ⭐ · LC 753 (de Bruijn 圖,見下) · LC 2097 (合法的配對排列: 先從度數差找出起點)
Cracking the Safe — LC 753
關鍵 — 節點是最後打進去的
n − 1個數字,k個數字各是一條通往下一個節點的邊,所以k^n條邊正好就是k^n組密碼。每個節點都是in = out = k,所以尤拉迴路存在,沿著它走一圈 就把每組密碼剛好打一次:最短答案長度是k^n + n − 1。
歸約到:Template 2 — 在隱式圖上跑 Hierholzer, 消耗一條邊就是取用該節點下一個還沒用過的數字。
# python
# LC 753 - Cracking the Safe
# IDEA: Hierholzer on the de Bruijn graph; node = last n-1 digits, edge = one appended digit
# time = O(n * k^n), space = O(n * k^n)
def crackSafe(n, k):
start = "0" * (n - 1)
next_digit = {} # node -> the next digit (edge) it has not used yet
stack = [(start, "")] # (node, digit of the edge that led here)
digits = []
while stack:
node, came_by = stack[-1]
d = next_digit.get(node, 0)
if d < k:
next_digit[node] = d + 1 # consume edge `d` out of `node`
stack.append(((node + str(d))[1:], str(d)))
else:
stack.pop() # stuck: emit in post-order
digits.append(came_by)
return start + "".join(reversed(digits))
陷阱
- 遞迴寫法(在數字迴圈裡呼叫
dfs(edge[1:]))每組密碼遞迴一層,最深 4,096 層, 超過 Python 預設的 1,000 上限。上面明確堆疊的寫法沒有這個限制。 - 別忘了
start前綴:走法輸出的是邊(數字),而第一組密碼之前要先打好n − 1個數字。
匹配、流量與著色
Template 3:奇偶併查集(二分圖檢查) — LC 886
辨識 — 「分成兩組,讓每對互相討厭的人分開」,配對以清單給出,或一次來一個。 n ≤ 2,000,配對 ≤ 10⁴。
關鍵 — 給每個人 x 一個分身 x + n,代表「另一組」。配對 (a, b) 表示 a 和 b 的分身同組、
b 和 a 的分身同組。一旦某個配對的兩人已經在同一個集合裡,就不可能了:某條長度為偶數的
「相反」約束鏈把他們放在一起,所以這個配對封閉了一個奇數環。
# python
# LC 886 - Possible Bipartition
# IDEA: parity DSU; union a with twin(b) and b with twin(a); a and b already joined -> odd cycle
# time = O((n + m) * α(n)), space = O(n)
def possibleBipartition(n, dislikes):
parent = list(range(2 * (n + 1))) # people 1..n, twins n+1..2n+1
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def union(x, y):
parent[find(x)] = find(y)
for a, b in dislikes:
if find(a) == find(b): # forced into the same group already
return False
union(a, b + n + 1) # a joins b's opposite
union(b, a + n + 1) # b joins a's opposite
return True
// java
// LC 886 - Possible Bipartition
// IDEA: parity DSU; union a with twin(b) and b with twin(a); a and b already joined -> odd cycle
// time = O((n + m) * α(n)), space = O(n)
class Solution {
private int[] parent;
public boolean possibleBipartition(int n, int[][] dislikes) {
parent = new int[2 * (n + 1)]; // people 1..n, twins n+1..2n+1
for (int i = 0; i < parent.length; i++) parent[i] = i;
for (int[] d : dislikes) {
int a = d[0], b = d[1];
if (find(a) == find(b)) return false; // forced into the same group already
union(a, b + n + 1); // a joins b's opposite
union(b, a + n + 1); // b joins a's opposite
}
return true;
}
private int find(int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]]; // path halving
x = parent[x];
}
return x;
}
private void union(int x, int y) {
parent[find(x)] = find(y);
}
}
複雜度
- 時間 O((n + m)·α(n)):m 個配對各做兩次
find和兩次union;路徑減半讓每次都接近常數。 - 空間 O(n):長度 2(n + 1) 的
parent陣列。
陷阱
- 檢查必須在同一個配對的 union 之前;放在之後永遠會發現
a和b的分身在一起, 永遠抓不到衝突。 - LC 886 的人編號從 1 開始:陣列大小要
2 * (n + 1),不是2 * n。
不適用時 — 所有配對在作答前都已知:BFS 2-著色 (graph.md § Template 6)才是面試官預期的答案, 而且還能回傳兩個組別。LC 886 完整的 BFS 解法在 graph_examples.md。
練習 — LC 886 ⭐ · LC 785 · LC 990 (先處理等式、再處理不等式:一般併查集, 不用分身)
Template 4:用增廣路徑做二分匹配(Kuhn) — LC 1820
辨識 — 「最多能配成幾對」、「每個男生最多邀一個女生,每個女生最多接受一個」。 兩邊、一個允許配對的矩陣,每邊 ≤ 200。
關鍵 — 依序替每個左側頂點找配對。左側 u 可以拿到右側 v,條件是 v 還沒配對,
或者 v 目前的伴侶能換到另一個右側頂點 — 這個遞迴嘗試就是一條增廣路徑。
匹配是最大的,恰好在沒有增廣路徑剩下時,所以每個左側頂點嘗試一次就夠了。
# python
# LC 1820 - Maximum Number of Accepted Invitations
# IDEA: Kuhn; for each boy, DFS for an augmenting path (free girl, or re-seat her partner)
# time = O(m * m * n), space = O(n)
def maximumInvitations(grid):
m, n = len(grid), len(grid[0])
partner = [-1] * n # partner[girl] = boy, or -1
def try_match(boy, tried):
for girl in range(n):
if grid[boy][girl] and not tried[girl]:
tried[girl] = True # each girl is tried once per augmenting search
if partner[girl] == -1 or try_match(partner[girl], tried):
partner[girl] = boy
return True
return False
matched = 0
for boy in range(m):
if try_match(boy, [False] * n):
matched += 1
return matched
// java
// LC 1820 - Maximum Number of Accepted Invitations
// IDEA: Kuhn; for each boy, DFS for an augmenting path (free girl, or re-seat her partner)
// time = O(m * m * n), space = O(n)
public int maximumInvitations(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[] partner = new int[n];
Arrays.fill(partner, -1);
int matched = 0;
for (int boy = 0; boy < m; boy++) {
if (tryMatch(grid, boy, new boolean[n], partner)) {
matched++;
}
}
return matched;
}
private boolean tryMatch(int[][] grid, int boy, boolean[] tried, int[] partner) {
for (int girl = 0; girl < grid[0].length; girl++) {
if (grid[boy][girl] == 1 && !tried[girl]) {
tried[girl] = true;
if (partner[girl] == -1 || tryMatch(grid, partner[girl], tried, partner)) {
partner[girl] = boy;
return true;
}
}
}
return false;
}
複雜度
- 時間 O(m·m·n):外層迴圈呼叫 m 次
try_match;每一次裡面,tried讓每個女生只被進入一次, 而每次進入都掃過她男伴那一列的 n 格 — 每次搜尋 O(m·n)。 - 空間 O(n):
partner和tried,加上深度最多 m 的遞迴堆疊。
陷阱
tried必須每個男生都重新建立。跨男生重用,會擋住後面增廣路徑需要的女生; 完全沒有它,則會在一圈互相換位上無限循環。- 「每個男生拿第一個空著的女生」的貪婪不是最大的:
[[1,1],[1,0]]貪婪讓男生 0 拿女生 0, 男生 1 落單;Kuhn 會把男生 0 換到女生 1。
不適用時 — 配對有成本,要找最便宜的完美指派(LC 1066、LC 1947): n ≤ 10 時那是 bitmask DP,不是無權匹配。
練習 — LC 1820 ⭐ · LC 1349 (二分座位圖上的最大獨立集 = 座位數 − 匹配數) · LC 2123 (最少操作次數,König 定理)
推廣:最大流/最小割(Edmonds-Karp)
匹配就是每條容量都是 1 的最大流:源點 → 每個左側頂點 → 允許的配對 → 每個右側頂點 → 匯點。 一般容量需要 Edmonds-Karp:在殘餘圖上 BFS 找最短的增廣路徑,推送它的瓶頸量,並加上反向容量, 讓之後的路徑可以撤銷它。根據最大流最小割定理,流量值同時也是最便宜的割。
# python
# Edmonds-Karp max flow on a capacity matrix
# IDEA: BFS the residual graph for a shortest augmenting path; push its bottleneck; repeat
# time = O(V * E^2), space = O(V^2)
from collections import deque
def max_flow(capacity, source, sink):
n = len(capacity)
residual = [row[:] for row in capacity]
flow = 0
while True:
parent = [-1] * n
parent[source] = source
queue = deque([source])
while queue and parent[sink] == -1:
u = queue.popleft()
for v in range(n):
if parent[v] == -1 and residual[u][v] > 0:
parent[v] = u
queue.append(v)
if parent[sink] == -1: # no augmenting path left
return flow
bottleneck = float('inf')
v = sink
while v != source:
bottleneck = min(bottleneck, residual[parent[v]][v])
v = parent[v]
v = sink
while v != source:
residual[parent[v]][v] -= bottleneck
residual[v][parent[v]] += bottleneck # lets a later path cancel this push
v = parent[v]
flow += bottleneck
Template 5:貪婪 k-著色 — LC 1042
辨識 — 「從 k 種顏色/類型中選一種,相連的節點不能相同」,並附上「每個節點最多 k − 1 個鄰居」 的上界。LC 1042:度數 ≤ 3,4 種花。
關鍵 — 鄰居少於 k 個的節點,不管鄰居已經用了什麼,永遠都有一個空著的顏色。 所以按任意順序著色、每個節點取第一個鄰居沒用的顏色,永遠不會卡住:不用搜尋,也不用回溯。
# python
# LC 1042 - Flower Planting With No Adjacent
# IDEA: degree <= 3 and 4 colours, so a free colour always exists; take the first one
# time = O(V + E), space = O(V + E)
def gardenNoAdj(n, paths):
graph = [[] for _ in range(n + 1)]
for a, b in paths:
graph[a].append(b)
graph[b].append(a)
colour = [0] * (n + 1) # 0 = not coloured yet; gardens are 1..n
for garden in range(1, n + 1):
used = set()
for neighbour in graph[garden]:
used.add(colour[neighbour])
for c in (1, 2, 3, 4):
if c not in used:
colour[garden] = c
break
return colour[1:]
// java
// LC 1042 - Flower Planting With No Adjacent
// IDEA: degree <= 3 and 4 colours, so a free colour always exists; take the first one
// time = O(V + E), space = O(V + E)
public int[] gardenNoAdj(int n, int[][] paths) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
for (int[] p : paths) {
graph.get(p[0]).add(p[1]);
graph.get(p[1]).add(p[0]);
}
int[] colour = new int[n]; // colour[g - 1]; 0 = not coloured yet
for (int garden = 1; garden <= n; garden++) {
boolean[] used = new boolean[5];
for (int neighbour : graph.get(garden)) {
used[colour[neighbour - 1]] = true;
}
for (int c = 1; c <= 4; c++) {
if (!used[c]) {
colour[garden - 1] = c;
break;
}
}
}
return colour;
}
複雜度
- 時間 O(V + E):每個花園在
for neighbour in graph[garden]裡只讀一次鄰居串列; 顏色掃描最多 4 步。 - 空間 O(V + E):鄰接表。
陷阱
- 花園從 1 開始編號,答案串列從 0 開始。這裡差一,會變成替錯的花園著色。
- 沒有度數保證時貪婪可能失敗(用 2 種顏色、以壞順序替 4-環著色);那就是回溯, 或在 k = 2 時做二分圖檢查。
不適用時 — k = 2:選一個顏色會強制每個鄰居的顏色,那是 BFS 2-著色(graph.md § Template 6)。
練習 — LC 1042 ⭐ · LC 785 (k = 2:傳播,不是貪婪)
何時不要用
| 技巧 | 通常改用什麼就夠 | 什麼時候簡單的卡片就夠 |
|---|---|---|
| Tarjan 橋/割點 | 拿掉那條邊或那個頂點再重跑一次走訪(graph.md § Template 1) | 只有一兩個候選要拿掉,或答案最多是 2 的網格題(LC 1568) |
| Tarjan / Kosaraju SCC | 三態 DFS(graph.md § Template 5)或 Kahn(graph.md § Template 4) | 問題只是「有沒有環」或「哪些節點不在任何環上」,而不是哪些節點共用一個環 |
| Hierholzer | 對邊做 DFS + 回溯 | E 很小,而且面試官要的是任一合法順序,不是字典序最小的 |
| 奇偶併查集 | BFS 2-著色(graph.md § Template 6) | 所有配對一開始就給齊,LeetCode 每一題二分圖題都是這樣 |
| Kuhn 匹配/最大流 | 貪婪,或 bitmask DP | 某一邊 ≤ 10 個成員(bitmask DP),或結構本身強制唯一選擇 |
| 貪婪 k-著色 | 回溯著色 | 沒有給度數上界:這時貪婪不保證能完成 |
總結
| 演算法 | 時間 | 空間 | 備註 |
|---|---|---|---|
| Tarjan(橋/割點/SCC) | O(V + E) | O(V + E) | 一次 DFS,disc[] + low[] |
| Kosaraju SCC | O(V + E) | O(V + E) | 兩趟 DFS,需要反向圖 |
| Hierholzer | O(E log E) | O(E) | 邊的順序不重要時是 O(E) |
| 奇偶併查集 | O((n + m)·α(n)) | O(n) | 2n 個節點:x 和它的分身 |
| Kuhn 匹配 | O(V·E) | O(V) | 每個左側頂點一次增廣搜尋 |
| Edmonds-Karp | O(V·E²) | O(V²) | 最小割 = 最大流 |
| 貪婪 k-著色 | O(V + E) | O(V + E) | 只在每個度數 < k 時成立 |
這份文件特有的陷阱
- Tarjan 遇到回邊讀
disc[v];橋的判斷是>,割點的判斷是>=。 - Hierholzer 消耗的是邊,從不標記節點;路線是反著出來的。
- Kuhn 對每個左側頂點都需要一個新的
tried陣列。 - 遞迴很深:Templates 1 和 2 在 Python 需要
setrecursionlimit或明確的堆疊。
相關主題
- graph.md:表示法、走訪、環偵測與 2-著色 — 先讀這份
- union_find.md:Template 3 加倍使用的併查集原語
- topology_sorting.md:把 SCC 縮點後得到一個可以排序的 DAG
- minimum_spanning_tree.md:Kruskal(排序邊 + 併查集) 和 Prim(堆積或稠密陣列)。LC 1135、LC 1584。