圖論演算法

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

範圍 — 圖的表示法、走訪、連通性、環偵測,以及一般圖題的完整目錄。 另見 — 從本檔拆出去的深入主題:graph_advanced.md — Tarjan(SCC、橋、關節點)、尤拉迴路、最大流/最小割、二分圖匹配與 k-著色;graph_examples.md — 解題存檔(LC 133 / 200 / 207 / 323 / 329 / 399 / 695 / 742 / 802 / 815 / 886 / 947 / 1319)。 鄰近文件:bfs.md — 廣度優先走訪;dfs.md — 深度優先走訪;topology_sorting.md — DAG 排序;union_find.md — 無向圖連通性;shortest_path_comparison.md — 怎麼挑帶權最短路徑演算法;Dijkstra.md — 非負權重;Bellman-Ford.md — 負權重/限制跳數;Floyd-Warshall.md — 全點對。

LeetCode 題目清單

總覽

圖論演算法是用來處理圖這種資料結構的技巧,圖由頂點(節點)和邊(節點之間的連結)組成。

關鍵性質

  • 複雜度:見總結區的複雜度速查表
  • 核心想法:把實體之間的關係與連結模型化
  • 什麼時候用:網路問題、相依關係、路徑、連通性
  • 關鍵演算法:BFS、DFS、Dijkstra、併查集、拓撲排序

核心特徵

  • 有向 vs 無向:邊是單向還是雙向
  • 帶權 vs 不帶權:邊有沒有成本
  • 有環 vs 無環:圖裡有沒有環
  • 連通 vs 不連通:是不是所有節點都互相可達

Recognition

先在題目敘述裡找第一欄的字眼,再拿限制條件對照限制條件尺規。 有好幾列會把你導出這份文件:題目帶權、要排順序或屬於進階主題時,這就是正確答案。

敘述裡的訊號 限制提示 該用 不是這個的情況
「有幾個省份/群組/連通分量」 V + E ≤ 10⁵ Template 1 — 每個連通分量一次走訪 輸入是網格 → dfs.md § Template 2
「能不能進到每個房間」、「a 到 b 有沒有路徑」 V + E ≤ 10⁵ Template 2 — DFS 可達性 「最少步數」 → bfs.md
「回傳從 0 到 n − 1 的每一條路徑」 n ≤ 15 Template 2 的 LC 797 變形 — 回溯,不共用 visited 圖裡有環 → 另外維護一份「在路徑上」的集合
「多餘的連線」、「邊一條一條加進來」、「因為共用 email/同一列而相連」 n ≤ 10⁵ Template 3 — 併查集(LC 721、947:把共用的屬性當成節點) 要的是路徑或它的長度 → BFS / DFS
「先修條件」、「任務的順序」、「外星字典」 有向,V + E ≤ 10⁵ Template 4 — Kahn 的 BFS 無向圖 → 根本沒有順序可找
「有向圖裡的環」、「死結」、「最終安全的節點」 有向,V ≤ 10⁴ Template 5 — 三態 DFS 無向圖 → 併查集
「分成兩組」、「是不是二分圖」、「彼此討厭」 n ≤ 2,000 Template 6 — 2-著色 三組以上 → 回溯著色
帶權的「最短路徑」、「網路延遲」、「最便宜的航班」 E ≤ 10⁵ Dijkstra.md、Bellman-Ford.md、Floyd-Warshall.md 每條邊成本都是 1 → BFS
「比值」、「匯率」、「計算 a / b」 V ≤ 40 dfs.md § Template 10 — 帶權 DFS 同一張圖上有大量查詢 → 帶權併查集
「最長路徑,每一步都嚴格遞增」 網格 ≤ 200 × 200 隱式 DAG,DFS + memo(topology_sorting.md § Template 9) 可以往下走 → 不是 DAG,不能 memo
「用最小總成本把所有節點連起來」 V ≤ 1,000 minimum_spanning_tree.md 要的是兩點間的路徑而不是一棵樹 → 最短路徑
「拿掉這條邊/這個點會讓圖不連通」、「關鍵連線」 V ≤ 10⁵ Tarjan low-link(graph_advanced.md) 只問拿掉前後的連通性 → 併查集
「每條邊/每張機票恰好用一次」 E ≤ 300 尤拉路徑,Hierholzer(graph_advanced.md) 每個節點走一次 → 哈密頓路徑,bitmask DP
「最大流」、「二分圖匹配」 V 很小 Ford-Fulkerson / Edmonds-Karp(graph_advanced.md) 只問能不能分成兩組 → Template 6

模板與演算法

哪個演算法歸哪份文件管 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

這份文件負責表示法、走訪、連通性與環偵測。每一個帶權或排序類的演算法都有專屬文件 — 要看實作就去那裡,別在這裡重推一次。

需求 這裡的模板 負責的文件
從 LC 的輸入格式建圖 下方的「圖的表示法」 本文件
數連通分量、測可達性 Template 1 / Template 2 本文件;走訪本身在 bfs.md、dfs.md
最短路徑,不帶權 — bfs.md
最短路徑,帶權 — 見下方表格
替有相依關係的節點排序 Template 4(Kahn) topology_sorting.md
動態連通性、合併集合 Template 3(DSU) union_find.md
偵測環 Template 5 本文件
分成兩組/衝突著色 Template 6 本文件
SCC、橋、關節點、尤拉迴路、最大流、k-著色 — graph_advanced.md
每題一份完整解答 — graph_examples.md

帶權最短路徑 — 看專屬文件

這些各自有完整的文件;在這裡再寫一次實作,正是害 graph.md 和它們脫節的原因。

需求 文件 複雜度 代表題
單源,非負權重 Dijkstra.md O((V+E) log V) LC 743
單源,負權重/限制跳數/偵測負環 Bellman-Ford.md O(V·E) LC 787
全點對,稠密圖 Floyd-Warshall.md O(V³) LC 1334
不帶權,或 0-1 權重 bfs.md — 一般 BFS/用雙端佇列的 0-1 BFS O(V+E) LC 1091, LC 1368

不確定要用哪個?→ shortest_path_comparison.md。

圖的表示法 — 怎麼建出來 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

幾乎每一道 LC 圖題丟給你的輸入,都逃不出那幾種形狀。認出形狀並把它轉成圖,就是解題的 頭三十秒。

LC 輸入形狀 怎麼建 典型題目
edges = [[u,v], ...] 鄰接串列,無向圖要建雙向 LC 323, 684, 1971
edges = [[u,v,w], ...] 存 (neighbor, weight) 配對的鄰接串列 LC 743, 787
isConnected[i][j] / graph[i][j] 本來就是鄰接矩陣 LC 547, 1334
graph[i] = [neighbors] 本來就是鄰接串列 LC 785, 797, 802
網格 char[][] / int[][] 隱式圖 — 格子 = 節點,4 或 8 個鄰居 = 邊 LC 200, 695, 1091
帶 neighbors 的 Node 物件 指標圖 — 用 {original: copy} 對照表走訪 LC 133
字串配對(["a","b"]) 隱式圖 — 從輸入把節點挖出來丟進 defaultdict LC 399, 721

該選哪種表示法

空間 測試 u→v 這條邊 走訪 u 的鄰居 什麼時候用
鄰接串列 O(V + E) O(deg u) O(deg u) 稀疏圖 — LC 的預設情況
鄰接矩陣 O(V²) O(1) O(V) 稠密圖,或 V 很小(Floyd-Warshall)
邊串列 O(E) O(E) O(E) Kruskal、Bellman-Ford — 這類會掃過所有邊的演算法

邊串列完全不用建:題目丟給你的 edges 本身就是表示法。Kruskal 就排序它, Bellman-Ford 就把它掃 V-1 次。

鄰接串列

python
# For edges list
graph = defaultdict(list)
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)  # Undirected

# For weighted edges
graph = defaultdict(list)
for u, v, w in edges:
    graph[u].append((v, w))

鄰接矩陣

python
# For unweighted
graph = [[0] * n for _ in range(n)]
for u, v in edges:
    graph[u][v] = 1
    graph[v][u] = 1  # Undirected

# For weighted
graph = [[float('inf')] * n for _ in range(n)]
for u, v, w in edges:
    graph[u][v] = w

把網格當成圖

python
# 4-directional movement
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]

# 8-directional movement
directions = [(0, 1), (1, 0), (0, -1), (-1, 0),
              (1, 1), (1, -1), (-1, 1), (-1, -1)]

# Check bounds
def is_valid(r, c, rows, cols):
    return 0 <= r < rows and 0 <= c < cols

有向 vs 無向,以及入分支度/出分支度

python
# undirected: add BOTH directions -- forgetting this is the most common graph bug
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)

# directed: one direction only, and degree splits into two counts
out_deg = [0] * n
in_deg = [0] * n
for u, v in edges:
    graph[u].append(v)
    out_deg[u] += 1
    in_deg[v] += 1
  • 無向圖:sum(degrees) == 2 * E。一棵樹剛好有 V - 1 條邊而且沒有環。
  • 有向圖:源點的 in_deg == 0(Kahn 的起始集合,模板 4);匯點的 out_deg == 0 (LC 802 最終安全狀態)。
  • 有些題目光靠分支度就能回答,根本不用走訪 — LC 997 Find the Town Judge(in == n-1 and out == 0)、LC 2924 Find Champion II(in == 0,而且唯一)、 LC 1361 Validate Binary Tree Nodes。這個模式與它的各種坑放在 topology_sorting.md。

visited 集合的紀律 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

你在哪裡標記節點,決定了走訪會不會停下來、以及答案對不對。

  • 入隊時標記,不是出隊時標記(BFS)。出隊才標記會讓同一個節點被塞進佇列很多次 — 單純判斷可達性時結果還是對的,但最差情況會變成平方級;而只要走訪時會更新計數器或距離, 答案就錯了(Template 1)。
  • 要數節點或判斷可達性 → 一份共用的 visited 集合;每個節點只處理一次。
  • 要列舉所有路徑 → 不要共用集合;往下走時推入、往回走時彈出 (Template 2 的 LC 797 變形)。
  • 有向圖的環偵測 → 三種狀態,不是布林值(Template 5)。
  • 在網格上可以直接改輸入,不必另外配置 visited(grid[r][c] = '0')— 額外空間 O(1),但會毀掉輸入;面試時記得講出來。

三種記錄方式

python
# Set for simple visited
visited = set()

# Array for state tracking
# 0: unvisited, 1: visiting, 2: visited
state = [0] * n

# Dictionary for path reconstruction
parent = {}

Template 1:用走訪數連通分量 — LC 547 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「有幾個省份/群組/連通分量」、「是不是每個節點都連通」,輸入是不帶權的邊列表或鄰接矩陣。 V + E ≤ 10⁵ → 預期一次 O(V + E) 掃描。

關鍵 — 在所有發射加起來之下,每個節點恰好被走訪一次,所以「從還沒走訪過的節點發射了幾次」 就是連通分量的數量。

python
# python
# LC 547 - Number of Provinces
# IDEA: launch a BFS from every node not yet visited; each launch covers exactly one component
# time = O(n^2), space = O(n)   n^2 because the input is an adjacency matrix
from collections import deque

def findCircleNum(isConnected):
    n = len(isConnected)
    visited = [False] * n
    components = 0
    for start in range(n):
        if visited[start]:
            continue
        components += 1                     # a new launch = a new component
        visited[start] = True
        queue = deque([start])
        while queue:
            node = queue.popleft()
            for nxt in range(n):            # one matrix row = node's neighbours
                if isConnected[node][nxt] == 1 and not visited[nxt]:
                    visited[nxt] = True     # mark when enqueued
                    queue.append(nxt)
    return components
java
// java
// LC 547 - Number of Provinces
// IDEA: launch a BFS from every node not yet visited; each launch covers exactly one component
// time = O(n^2), space = O(n)
public int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    boolean[] visited = new boolean[n];
    int components = 0;
    for (int start = 0; start < n; start++) {
        if (visited[start]) {
            continue;
        }
        components++;                        // a new launch = a new component
        visited[start] = true;
        Deque<Integer> queue = new ArrayDeque<>();
        queue.offer(start);
        while (!queue.isEmpty()) {
            int node = queue.poll();
            for (int nxt = 0; nxt < n; nxt++) {
                if (isConnected[node][nxt] == 1 && !visited[nxt]) {
                    visited[nxt] = true;     // mark when enqueued
                    queue.offer(nxt);
                }
            }
        }
    }
    return components;
}

複雜度

  • 時間 O(n²):內層 for nxt in range(n) 對 n 個節點各掃一整列矩陣。換成鄰接列表, 同一個迴圈每條邊只讀一次:O(V + E)。
  • 空間 O(n):visited 陣列和佇列。

陷阱

  • 只從節點 0 發射,不管有幾個分量都只數到 1: [[1,0],[0,1]] 回傳 1 而不是 2。外層的 for start 迴圈就是這個模板。
  • 無向邊列表只加了一個方向:LC 323 的 n = 2, edges = [[1,0]] 從 0 發射永遠到不了 1,於是把 1 算成第二個分量。兩個方向都要加。
  • 孤立節點也是連通分量:n = 3, edges = [] 答案是 3。只走 defaultdict 鄰接列表的 key,會漏掉每一個沒有邊的節點。

不適用時 — 邊一條一條加進來,而且每加一條就要回答「現在連通了嗎?」 → 併查集(Template 3)。島嶼網格 → dfs.md § Template 2,格子本身就是節點, 不用建列表。

練習 — LC 547 · LC 1971 (兩個節點在不在同一個分量?) · LC 323 ⭐ (邊列表) · LC 261 ⭐ (樹 = 一個分量且恰好 n − 1 條邊) · LC 1319 (分量數 − 1 條線)

Template 2:DFS 可達性 — LC 841 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「能不能進到每個房間」、「a 到 b 有沒有路徑」,圖以 graph[i] = [neighbours] 的形式給你, 而且題目裡完全沒出現「最短」或「最少」:任何一條路都行。V + E ≤ 10⁵。

關鍵 — 節點第一次被走到就加進 visited,之後永遠不移除,所以每個節點只展開一次、每條邊只讀一次; 堆疊清空時,visited 恰好就是從起點可達的集合。

python
# python
# LC 841 - Keys and Rooms
# IDEA: DFS from room 0 with an explicit stack; visited = every room reached so far
# time = O(V + E), space = O(V)
def canVisitAllRooms(rooms):
    visited = {0}
    stack = [0]
    while stack:
        room = stack.pop()
        for key in rooms[room]:
            if key not in visited:
                visited.add(key)            # mark when pushed: each room is pushed once
                stack.append(key)
    return len(visited) == len(rooms)
java
// java
// LC 841 - Keys and Rooms
// IDEA: DFS from room 0 with an explicit stack; visited = every room reached so far
// time = O(V + E), space = O(V)
public boolean canVisitAllRooms(List<List<Integer>> rooms) {
    boolean[] visited = new boolean[rooms.size()];
    visited[0] = true;
    int reached = 1;
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(0);
    while (!stack.isEmpty()) {
        int room = stack.pop();
        for (int key : rooms.get(room)) {
            if (!visited[key]) {
                visited[key] = true;         // mark when pushed: each room is pushed once
                reached++;
                stack.push(key);
            }
        }
    }
    return reached == rooms.size();
}

複雜度

  • 時間 O(V + E):visited 這道關卡讓每個房間只進堆疊一次,而 for key in rooms[room] 在整趟執行中每把鑰匙只讀一次。
  • 空間 O(V):visited 加上堆疊,各自最多放 V 個房間。

陷阱

  • 在 Python 用遞迴 DFS 跑 10⁵ 個節點的鏈,會丟出 RecursionError(預設上限是 1,000)。明確的堆疊沒有深度限制;Java 的遞迴寫法撐到 ~10⁴ 沒問題。
  • 在彈出時才檢查 visited、而不是推入時:每把能開這個房間的鑰匙都會把它推一次—— 答案還是對的,但堆疊會長到 O(E)。

不適用時 — 「最少移動/步數」 → BFS (bfs.md § Template 2):DFS 第一次走到某個節點, 不見得是最短的路。「回傳每一條路徑」 → 下方的 LC 797 變形,它拿掉了共用的 visited 集合。

練習 — LC 841 · LC 1971 (邊列表,兩個方向) · LC 797 (所有路徑:回溯) · LC 133 ⭐ (visited 就是 {original: copy} 對照表) · LC 417 ⭐ (從兩個大洋各自反向求可達性)

All Paths From Source to Target — LC 797

關鍵 — 這張圖是 DAG,任何走法都不會繞回來:拿掉 visited 集合,改為維護目前的路徑, 往下時推入、往上時彈出。每次抵達 n - 1 就是一條路徑,複製出來。

歸約到:Template 2 — 用回溯取代共用的 visited 集合 (dfs.md § Template 4)。

python
# python
# LC 797 - All Paths From Source to Target
# IDEA: DFS + backtracking on a DAG (no visited set needed)
# time = O(2^n * n), space = O(n) excluding output
class Solution(object):
    def allPathsSourceTarget(self, graph):
        n = len(graph)
        res, path = [], [0]

        def dfs(u):
            if u == n - 1:
                res.append(path[:])    # copy!
                return
            for v in graph[u]:
                path.append(v)
                dfs(v)
                path.pop()             # backtrack

        dfs(0)
        return res

# [[1,2],[3],[3],[]] -> [[0,1,3],[0,2,3]]
java
// java
// LC 797 - All Paths From Source to Target
// IDEA: DFS + backtracking on a DAG. No visited set (DAG => no cycles),
//       and a node may legitimately appear in many different paths.
// time = O(2^n * n), space = O(n) excluding output
import java.util.*;

public class Solution {
    public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        path.add(0);                       // start node is always in the path
        dfs(graph, 0, path, res);
        return res;
    }

    private void dfs(int[][] graph, int u, List<Integer> path, List<List<Integer>> res) {
        if (u == graph.length - 1) {
            res.add(new ArrayList<>(path));   // copy, not the live list
            return;
        }
        for (int v : graph[u]) {
            path.add(v);
            dfs(graph, v, path, res);
            path.remove(path.size() - 1);     // backtrack
        }
    }
}

陷阱

  • 共用的 visited 集合在這裡是錯的:它會擋住一個節點出現在第二條路徑裡。數/到達一個節點 → visited;列舉路徑 → 回溯,往上時取消標記。
  • 沒複製就 res.append(path),存的是同一個串列,之後的 pop 會把它清空。

Template 3:帶路徑壓縮的併查集 — LC 684 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「多餘的連線」、「邊一條一條加進來」、「a 和 b 在不在同一組」、「合併共用同一個 email 的帳號」。 n ≤ 10⁵,而且只問連通性——從不問路徑或距離。

關鍵 — 每個集合是一棵樹,由樹根代表它。find(a) == find(b) 恰好在 a 和 b 屬於同一集合時成立, 所以當一次 union 發現兩端已經共用同一個樹根,這條邊就是讓圖形成環的那條邊。

python
# python
# LC 684 - Redundant Connection
# IDEA: union every edge; the first edge whose two ends already share a root closes the cycle
# time = O(E * α(n)), space = O(n)
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.count = n                       # number of components

    def find(self, x):
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:        # path compression: point each node at the root
            next_node = self.parent[x]
            self.parent[x] = root
            x = next_node
        return root

    def union(self, x, y):
        root_x, root_y = self.find(x), self.find(y)
        if root_x == root_y:
            return False                     # already connected: this edge is a cycle
        if self.rank[root_x] < self.rank[root_y]:
            root_x, root_y = root_y, root_x  # hang the shorter tree under the taller
        self.parent[root_y] = root_x
        if self.rank[root_x] == self.rank[root_y]:
            self.rank[root_x] += 1
        self.count -= 1
        return True

def findRedundantConnection(edges):
    uf = UnionFind(len(edges) + 1)           # nodes are 1..n and n == len(edges)
    for u, v in edges:
        if not uf.union(u, v):
            return [u, v]
    return []
java
// java
// LC 684 - Redundant Connection
// IDEA: union every edge; the first edge whose two ends already share a root closes the cycle
// time = O(E * α(n)), space = O(n)
class UnionFind {
    private final int[] parent;
    private final int[] rank;

    UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }

    int find(int x) {
        int root = x;
        while (parent[root] != root) {
            root = parent[root];
        }
        while (parent[x] != root) {          // path compression
            int next = parent[x];
            parent[x] = root;
            x = next;
        }
        return root;
    }

    boolean union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX == rootY) {
            return false;                    // already connected: this edge is a cycle
        }
        if (rank[rootX] < rank[rootY]) {
            int tmp = rootX;
            rootX = rootY;
            rootY = tmp;
        }
        parent[rootY] = rootX;
        if (rank[rootX] == rank[rootY]) {
            rank[rootX]++;
        }
        return true;
    }
}

public int[] findRedundantConnection(int[][] edges) {
    UnionFind uf = new UnionFind(edges.length + 1);
    for (int[] edge : edges) {
        if (!uf.union(edge[0], edge[1])) {
            return edge;
        }
    }
    return new int[0];
}

複雜度

  • 時間 O(E · α(n)):每條邊一次 union;同時有路徑壓縮和按秩合併時,每次 find 攤還下來是 α(n),也就是反阿克曼函數——任何實際的 n 都小於 5。
  • 空間 O(n):parent 和 rank。

陷阱

  • 節點編號是 1…n:UnionFind(n) 在 LC 684 的 [[1,2],[1,3],[2,3]] 會出界,因為它碰到了 索引 3。大小要開 n + 1。
  • parent[a] == parent[b] 不等於 find(a) == find(b):同一集合的兩個節點,在某次 find 把它們之間的路徑壓縮之前,直接父節點可能不同。
  • 併查集對有向環一無所知:LC 685(有向版的多餘連線)必須先處理入度為 2 的情況, 再做合併。

不適用時 — 你需要路徑、路徑長度,或圖是有向的 → BFS / DFS。邊會隨時間被刪掉 → 併查集沒辦法拆開集合;把刪除改成離線、倒過來處理。

練習 — LC 684 ⭐ · LC 547 · LC 323 ⭐ · LC 261 ⭐ · LC 721 (每個 email 是一個節點) · LC 947 (每一列、每一行各是一個節點)。帶權併查集、回滾等其他內容: union_find.md。

Template 4:拓撲排序(Kahn 的 BFS) — LC 207 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

各種排序變形——字典序最小、所有順序、平行排程、樹的中心、DAG 上的 DP——都在 topology_sorting.md。

辨識 — 「先修條件」、「能不能修完所有課」、「讓每個任務都排在它的相依項之後的順序」、 「外星字典」。有向邊,V + E ≤ 10⁵。

關鍵 — 節點只有在入度降到 0 時才進佇列,也就是它的每個先修項都已經輸出之後。 環上的節點永遠到不了 0,所以 taken < n 恰好在圖有環時成立。

python
# python
# LC 207 - Course Schedule
# IDEA: keep taking a course with no unmet prerequisite; a cycle leaves courses never taken
# time = O(V + E), space = O(V + E)
from collections import deque

def canFinish(numCourses, prerequisites):
    graph = [[] for _ in range(numCourses)]
    indegree = [0] * numCourses
    for course, pre in prerequisites:        # [a, b] means b must come before a
        graph[pre].append(course)
        indegree[course] += 1
    queue = deque()
    for course in range(numCourses):
        if indegree[course] == 0:            # every source, not only node 0
            queue.append(course)
    taken = 0
    while queue:
        node = queue.popleft()
        taken += 1
        for nxt in graph[node]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                queue.append(nxt)
    return taken == numCourses
java
// java
// LC 207 - Course Schedule
// IDEA: keep taking a course with no unmet prerequisite; a cycle leaves courses never taken
// time = O(V + E), space = O(V + E)
public boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    int[] indegree = new int[numCourses];
    for (int[] p : prerequisites) {          // [a, b] means b must come before a
        graph.get(p[1]).add(p[0]);
        indegree[p[0]]++;
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 0; i < numCourses; i++) {
        if (indegree[i] == 0) {
            queue.offer(i);
        }
    }
    int taken = 0;
    while (!queue.isEmpty()) {
        int node = queue.poll();
        taken++;
        for (int nxt : graph.get(node)) {
            indegree[nxt]--;
            if (indegree[nxt] == 0) {
                queue.offer(nxt);
            }
        }
    }
    return taken == numCourses;
}

複雜度

  • 時間 O(V + E):每個節點出隊一次,內層 for nxt in graph[node] 在整趟執行中對每條邊的終點只遞減一次。
  • 空間 O(V + E):鄰接列表;indegree 和佇列是 O(V)。

試跑

text
numCourses = 2, prerequisites = [[1,0]]        edge 0 -> 1
indegree = [0, 1]   queue = [0]
pop 0  taken=1  indegree[1] -> 0, push 1
pop 1  taken=2                                  2 == 2 -> True

prerequisites = [[1,0],[0,1]]                  edges 0 -> 1, 1 -> 0
indegree = [1, 1]   queue = []                  taken=0 -> False (the cycle never empties)

陷阱

  • 邊的方向:[a, b] 的意思是先 b 再 a。建成 a → b 仍然能過 LC 207(反向的圖環一樣), 但到了 LC 210 回傳的順序就是倒的。
  • 只把節點 0 放進佇列:完全沒有邊的課、或第二個源點,永遠不會被修到。 每個入度為 0 的節點都要放進去。

不適用時 — 圖是無向的 → 根本沒有順序可找;判斷有沒有環用 併查集(Template 3)。要字典序最小的順序 → 用 min-heap 取代佇列(topology_sorting.md)。

練習 — LC 207 ⭐ · LC 210 (回傳順序) · LC 1136 (BFS 的層 = 學期) · LC 269 ⭐ (邊來自相鄰的單字) · LC 329 ⭐ (在遞增格子構成的隱式 DAG 上跑 Kahn)

第二種解法:三狀態 DFS

Kahn 演算法是一直剝掉沒有未滿足先修課的課程;DFS 寫法則是把環當成一條回邊找出來 —— 走到一個 仍在目前路徑上的節點。同樣 O(V + E),不需要入度陣列,但遞迴深度可達 V。它就是把 Template 5 的不變量套用到 LC 207;面試官要 DFS 版本時就給這個。

python
# python
# LC 207 - Course Schedule (three-state DFS)
# IDEA: a cycle exists iff the DFS meets a node that is still on the current path (state 1)
# time = O(V + E), space = O(V + E)
from collections import defaultdict

def canFinish(numCourses, prerequisites):
    graph = defaultdict(list)
    for course, pre in prerequisites:
        graph[course].append(pre)          # course -> what it needs
    state = [0] * numCourses               # 0 = unseen, 1 = on the current path, 2 = done
    def dfs(node):
        if state[node] == 1:
            return False                   # back edge: a cycle
        if state[node] == 2:
            return True
        state[node] = 1
        for nxt in graph[node]:
            if not dfs(nxt):
                return False
        state[node] = 2
        return True
    return all(dfs(c) for c in range(numCourses))

Template 5:三態 DFS 環偵測 — LC 802 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「這張有向圖有沒有環」、「死結」、「最終安全的節點」、 「從這個節點出發的每條路都停在終點節點」。有向邊,V ≤ 10⁴,E ≤ 4·10⁴。

關鍵 — 節點的 DFS 還在跑時是 ON_PATH,跑完且沒遇到環時是 SAFE。 走到一個 ON_PATH 節點,代表沿著一條邊回到目前路徑的上方,那就是環;SAFE 節點已經被證明過, 永遠不會再探索一次。

python
# python
# LC 802 - Find Eventual Safe States
# IDEA: 3-state DFS; a node is unsafe iff its DFS meets a node still on the current path
# time = O(V + E), space = O(V)
def eventualSafeNodes(graph):
    UNVISITED, ON_PATH, SAFE = 0, 1, 2
    state = [UNVISITED] * len(graph)

    def leads_to_cycle(node):
        if state[node] == ON_PATH:          # back edge: node is on the path we came down
            return True
        if state[node] == SAFE:             # finished earlier and proven cycle-free
            return False
        state[node] = ON_PATH
        for nxt in graph[node]:
            if leads_to_cycle(nxt):
                return True                 # leave node ON_PATH: it is unsafe for good
        state[node] = SAFE
        return False

    safe = []
    for node in range(len(graph)):
        if not leads_to_cycle(node):
            safe.append(node)
    return safe
java
// java
// LC 802 - Find Eventual Safe States
// IDEA: 3-state DFS; a node is unsafe iff its DFS meets a node still on the current path
// time = O(V + E), space = O(V)
private static final int UNVISITED = 0, ON_PATH = 1, SAFE = 2;

public List<Integer> eventualSafeNodes(int[][] graph) {
    int[] state = new int[graph.length];
    List<Integer> safe = new ArrayList<>();
    for (int node = 0; node < graph.length; node++) {
        if (!leadsToCycle(graph, node, state)) {
            safe.add(node);
        }
    }
    return safe;
}

private boolean leadsToCycle(int[][] graph, int node, int[] state) {
    if (state[node] == ON_PATH) {
        return true;                         // back edge
    }
    if (state[node] == SAFE) {
        return false;
    }
    state[node] = ON_PATH;
    for (int nxt : graph[node]) {
        if (leadsToCycle(graph, nxt, state)) {
            return true;                     // node stays ON_PATH: unsafe for good
        }
    }
    state[node] = SAFE;
    return false;
}

複雜度

  • 時間 O(V + E):節點只離開 UNVISITED 一次;之後對它的每次呼叫都會在前兩個 if 之一返回,所以 for nxt in graph[node] 裡的每條邊只走一次。
  • 空間 O(V):state 加上遞迴堆疊,深度最多 V。

陷阱

  • 用布林的 visited 而不是三種狀態,會在菱形圖裡誤報環: [[1,2],[3],[3],[]] 經由 2 第二次走到節點 3,就把它當成回邊。
  • 把跑完的節點重設回 UNVISITED(回溯)答案還是對的,但每條路徑都會重新探索它: 在路徑很多的 DAG 上是指數級。要把它標成 SAFE。
  • Python 預設遞迴上限是 1,000;10⁴ 個節點的鏈需要 sys.setrecursionlimit(20000),或改在反向圖上跑 Kahn 演算法,完全不用遞迴。

不適用時 — 圖是無向的:回到父節點的那條邊看起來就像回邊。 改用併查集(Template 3——回傳 False 的 union 就是環),或把父節點往下傳。只想知道一組先修條件有沒有環 → Kahn(Template 4)。

練習 — LC 802 · LC 207 ⭐ (有環代表課修不完) · LC 1059 (每條路都停在 目的地) · LC 2360 (出度 ≤ 1 時的最長環)

第二種解法:在反向圖上跑 Kahn

不用遞迴:把每條邊反過來,從終點節點往回剝。一個節點的所有出邊都通往安全節點時(出度歸零)它就 是安全的;在環上或通往環的節點永遠到不了 0。同樣 O(V + E),在很長的鏈上也沒有遞迴深度的風險 —— V 很大時偏好它的理由。

python
# python
# LC 802 - Find Eventual Safe States (Kahn on the reversed graph)
# IDEA: terminals are safe; a node is safe once every out-edge leads to a safe node -> peel backwards
# time = O(V + E), space = O(V + E)
from collections import deque

def eventualSafeNodes(graph):
    n = len(graph)
    reverse = [[] for _ in range(n)]
    out_degree = [0] * n
    for node in range(n):
        out_degree[node] = len(graph[node])
        for nxt in graph[node]:
            reverse[nxt].append(node)      # edge flipped: nxt -> node
    queue = deque(node for node in range(n) if out_degree[node] == 0)  # terminals are safe
    safe = [False] * n
    while queue:
        node = queue.popleft()
        safe[node] = True
        for prev in reverse[node]:
            out_degree[prev] -= 1          # one more of prev's edges leads somewhere safe
            if out_degree[prev] == 0:
                queue.append(prev)
    return [node for node in range(n) if safe[node]]

Template 6:二分圖判定(2-著色) — LC 785 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「把節點分成兩組,讓每條邊都跨在兩組之間」、 「這張圖是不是二分圖」、「彼此討厭的人必須分開」。n ≤ 100(LC 785)到 2,000(LC 886)。

關鍵 — 每個上了色的節點都和替它上色的節點顏色相反,所以一條兩端同色的邊會形成奇數環—— 而奇數環正是讓一張圖不是二分圖的唯一原因。

python
# python
# LC 785 - Is Graph Bipartite?
# IDEA: BFS paints each neighbour the opposite colour; a same-colour edge means an odd cycle
# time = O(V + E), space = O(V)
from collections import deque

def isBipartite(graph):
    n = len(graph)
    colors = [-1] * n                    # -1 = uncoloured, which also means unvisited
    for start in range(n):               # one BFS per component
        if colors[start] != -1:
            continue
        colors[start] = 0
        queue = deque([start])
        while queue:
            node = queue.popleft()
            for neighbor in graph[node]:
                if colors[neighbor] == -1:
                    colors[neighbor] = 1 - colors[node]
                    queue.append(neighbor)
                elif colors[neighbor] == colors[node]:
                    return False         # same colour on both ends of an edge
    return True
java
// java
// LC 785 - Is Graph Bipartite?
// IDEA: BFS paints each neighbour the opposite colour; a same-colour edge means an odd cycle
// time = O(V + E), space = O(V)
public boolean isBipartite(int[][] graph) {
    int n = graph.length;
    int[] colors = new int[n];
    Arrays.fill(colors, -1);                 // -1 = uncoloured, which also means unvisited
    for (int start = 0; start < n; start++) {
        if (colors[start] != -1) {
            continue;
        }
        colors[start] = 0;
        Deque<Integer> queue = new ArrayDeque<>();
        queue.offer(start);
        while (!queue.isEmpty()) {
            int node = queue.poll();
            for (int neighbor : graph[node]) {
                if (colors[neighbor] == -1) {
                    colors[neighbor] = 1 - colors[node];
                    queue.offer(neighbor);
                } else if (colors[neighbor] == colors[node]) {
                    return false;
                }
            }
        }
    }
    return true;
}

複雜度

  • 時間 O(V + E):每個節點只上色一次,每份鄰接列表在 for neighbor in graph[node] 裡只讀一次。
  • 空間 O(V):colors 加上佇列。

陷阱

  • 只從節點 0 跑一次 BFS,只會檢查節點 0 所在的分量:[[],[2,3],[1,3],[1,2]] 有一個孤立的 節點 0 和一個三角形,少了外層迴圈就會回傳 True。
  • 自環(graph[0] = [0])永遠不是二分圖。同色檢查會抓到它,因為 節點和它的鄰居是同一個節點。
  • 在 colors 之外再開一個 visited 陣列,等於兩份可能不一致的陣列;-1 本身就代表 還沒走訪。

不適用時 — 三組以上 → k-著色一般是 NP-hard;用回溯,或在度數很小時用貪婪 (graph_advanced.md)。兩組必須一樣大 → 那是分割或匹配問題, 不是著色問題。

練習 — LC 785 ⭐ · LC 886 (從討厭的配對建圖) · LC 1042 (四種顏色, 度數 ≤ 3:貪婪) · LC 2493 (先做二分圖判定,再對每個分量跑 BFS)

解法 2:DFS 著色

python
# python
# LC 785 - Is Graph Bipartite? (recursive form, same invariant)
# IDEA: colour a node, recurse into neighbours with the opposite colour
# time = O(V + E), space = O(V)
def is_bipartite_dfs(graph):
    n = len(graph)
    colors = [-1] * n

    def dfs(node, color):
        colors[node] = color
        for neighbor in graph[node]:
            if colors[neighbor] == -1:
                if not dfs(neighbor, 1 - color):
                    return False
            elif colors[neighbor] == colors[node]:
                return False
        return True

    for i in range(n):
        if colors[i] == -1 and not dfs(i, 0):
            return False
    return True

用併查集偵測二分圖、最大二分圖匹配和貪婪 k-著色都在 graph_advanced.md;LC 886 Possible Bipartition 的完整解法在 graph_examples.md。

練習路徑

階段 題目(由易到難) 新增的能力
Core ⭐ LC 323 ok · LC 261 ok · LC 684 ok · LC 133 ok · LC 207 ok · LC 269 again 連通分量、判斷是不是樹、併查集、透過對照表複製圖、Kahn、先建圖再排序
Practice LC 1971 未評定 · LC 997 ok · LC 547 ok · LC 841 未評定 · LC 797 ok · LC 785 again · LC 802 未評定 · LC 210 ok 邊列表/矩陣/列表三種輸入、只看度數、所有路徑、2-著色、三態 DFS、回傳順序
Stretch LC 721 again · LC 947 未評定 · LC 1192 未評定 · LC 332 未評定 把屬性當成 DSU 節點、橋、尤拉路徑

總結與速查

決策表 — 該用哪個圖的模式?

text
Graph Algorithm Selection Flowchart:

1. What is the problem asking for?
   ├── Find shortest path → Continue to 2
   ├── Check connectivity → Use Union-Find or DFS/BFS
   ├── Order with dependencies → Use Topological Sort
   ├── Detect cycles → Use DFS with states or Union-Find
   └── Traverse all nodes → Use BFS or DFS

2. For shortest path problems:
   ├── Unweighted graph → Use BFS
   ├── Non-negative weights → Use Dijkstra
   ├── Negative weights → Use Bellman-Ford
   └── All pairs → Use Floyd-Warshall

3. For connectivity problems:
   ├── Static graph → Use DFS/BFS once
   ├── Dynamic connections → Use Union-Find
   └── Count components → Use either approach

4. For traversal problems:
   ├── Level-by-level → Use BFS
   ├── Path finding → Use DFS with backtracking
   └── State space search → Use BFS for optimal

5. Is the graph special?
   ├── Tree → Simpler DFS/BFS
   ├── DAG → Topological sort possible
   ├── Bipartite → Two-coloring
   └── Grid → Treat as implicit graph

複雜度速查

演算法 時間複雜度 空間複雜度 備註
BFS/DFS O(V + E) O(V) 標準走訪
Dijkstra O((V+E)logV) O(V) 搭配二元堆積
Bellman-Ford O(VE) O(V) 可處理負權重
Floyd-Warshall O(V³) O(V²) 全點對
併查集 O(α(n)) O(V) 近似常數
拓撲排序 O(V + E) O(V) 線性時間

依模式分類的題目

圖走訪類題目

題目 LC # 關鍵技巧 難度
Number of Islands 200 網格上的 DFS/BFS Medium
Max Area of Island 695 DFS 搭配計數 Medium
Clone Graph 133 BFS/DFS 搭配對照表 Medium
Pacific Atlantic Water 417 多源 DFS Medium
Word Ladder 127 BFS 求最短路徑 Hard
Surrounded Regions 130 從邊界出發的 DFS Medium
Evaluate Division 399 帶權 DFS(比值圖) — dfs.md § Template 10 Medium
Longest Increasing Path in Matrix 329 隱式 DAG 上的 DFS + 記憶化 — topology_sorting.md § Template 9 Hard
All Paths From Source to Target 797 DAG 上的 DFS 回溯 Medium
Keys and Rooms 841 從節點 0 出發的單純 DFS/BFS 可達性 Medium
Find if Path Exists in Graph 1971 BFS/DFS 或併查集判連通 Easy
Find the Town Judge 997 統計入/出分支度,不需要鄰接串列 — topology_sorting.md Easy

最短路徑類題目

題目 LC # 關鍵技巧 難度
Network Delay Time 743 Dijkstra Medium
Cheapest Flights K Stops 787 改造過的 Dijkstra Medium
Path with Min Effort 1631 網格上的 Dijkstra Medium
Bus Routes 815 在路線上做 BFS Hard
Shortest Path Binary Matrix 1091 BFS Medium

併查集類題目

題目 LC # 關鍵技巧 難度
Number of Connected Components 323 基本併查集 Medium
Redundant Connection 684 偵測環 Medium
Accounts Merge 721 併查集搭配對照表 Medium
Number of Provinces 547 併查集或 DFS Medium
Satisfiability of Equality 990 併查集 Medium
Most Stones Removed 947 對共用的列/行屬性做 DSU — graph_examples.md Medium
Make Network Connected 1319 DSU:連通分量 + 多餘的邊 Medium

拓撲排序類題目

題目 LC # 關鍵技巧 難度
Course Schedule 207 偵測環 Medium
Course Schedule II 210 輸出拓撲順序 Medium
Alien Dictionary 269 建圖 + 排序 Hard
Minimum Height Trees 310 逐層剝葉子 Medium
Parallel Courses 1136 一層一層的 BFS Medium

二分圖類題目

題目 LC # 關鍵技巧 難度
Is Graph Bipartite 785 BFS 著色 Medium
Possible Bipartition 886 DFS 著色 Medium
Flower Planting With No Adjacent 1042 貪婪 k-著色(分支度 < k) Medium

進階圖論題目

題目 LC # 關鍵技巧 難度
Critical Connections 1192 Tarjan 找橋 — graph_advanced.md Hard
Find Eventual Safe States 802 偵測環 Medium
Reconstruct Itinerary 332 Hierholzer 演算法 Hard
Cracking the Safe 753 de Bruijn 圖 + 尤拉迴路 — graph_advanced.md Hard
Minimum Spanning Tree 1135 Kruskal / Prim — minimum_spanning_tree.md Medium

解題步驟

  1. 判斷圖的種類:有向/無向、帶權/不帶權
  2. 選表示法:鄰接串列還是矩陣
  3. 選演算法:依題目需求決定
  4. 處理邊界情況:空圖、不連通的分量
  5. 好好追蹤狀態:已走訪節點、路徑、距離
  6. 必要時再最佳化:改善空間或時間

常見錯誤與技巧

🚫 常見錯誤:

  • 沒有處理不連通的分量
  • visited 狀態管理錯誤
  • 遞迴 DFS 裡漏掉環偵測
  • 選錯圖的表示法
  • 沒有考慮邊界情況(自環、重邊)

✅ 最佳實務:

  • 稀疏圖用鄰接串列
  • 明確定好 visited 的追蹤策略
  • 有向和無向兩種情況都要處理
  • 動態連通性考慮用併查集
  • 用不連通的分量測試

面試技巧

  1. 先問清楚圖的性質:有向嗎?帶權嗎?連通嗎?
  2. 畫小例子:把問題視覺化
  3. 選對表示法:串列還是矩陣
  4. 先講複雜度:時間和空間都要
  5. 處理邊界情況:空圖、單一節點、環
  6. 逐步最佳化:先寫簡單版,再改進

相關主題

  • 樹:圖的特例(連通且無環)
  • 動態規劃:圖上的 DP(路徑、樹)
  • 貪婪演算法:MST 演算法
  • 堆積/優先佇列:Dijkstra、Prim 會用到
  • 遞迴/回溯:DFS 的實作方式