進階圖論演算法

圖論優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 第一輪面試準備可以先跳過的圖論技巧: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),而且題目會直接點名那個結構。

先備知識

辨識

題目裡的訊號 限制條件線索 該用 不適用時
「關鍵連線」、「拿掉哪條邊會讓網路斷開」 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 → 二分圖檢查;沒有度數上界 → 回溯

辨識 — 「關鍵連線」、「拿掉哪條邊會讓圖不連通」、「割點」。無向圖,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
# 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
// 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]]:

text
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
# 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
# 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
# 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
// 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:

text
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
# 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
# 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
// 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
# 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
// 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
# 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
# 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
// 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。