圖論演算法
範圍 — 圖的表示法、走訪、連通性、環偵測,以及一般圖題的完整目錄。 另見 — 從本檔拆出去的深入主題: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 次。
鄰接串列
# 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))
鄰接矩陣
# 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
把網格當成圖
# 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 無向,以及入分支度/出分支度
# 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),但會毀掉輸入;面試時記得講出來。
三種記錄方式
# 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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
// 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)。
試跑
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
# 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
# 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
// 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
# 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
# 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
// 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
# 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 節點、橋、尤拉路徑 |
總結與速查
決策表 — 該用哪個圖的模式?
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 |
解題步驟
- 判斷圖的種類:有向/無向、帶權/不帶權
- 選表示法:鄰接串列還是矩陣
- 選演算法:依題目需求決定
- 處理邊界情況:空圖、不連通的分量
- 好好追蹤狀態:已走訪節點、路徑、距離
- 必要時再最佳化:改善空間或時間
常見錯誤與技巧
🚫 常見錯誤:
- 沒有處理不連通的分量
- visited 狀態管理錯誤
- 遞迴 DFS 裡漏掉環偵測
- 選錯圖的表示法
- 沒有考慮邊界情況(自環、重邊)
✅ 最佳實務:
- 稀疏圖用鄰接串列
- 明確定好 visited 的追蹤策略
- 有向和無向兩種情況都要處理
- 動態連通性考慮用併查集
- 用不連通的分量測試
面試技巧
- 先問清楚圖的性質:有向嗎?帶權嗎?連通嗎?
- 畫小例子:把問題視覺化
- 選對表示法:串列還是矩陣
- 先講複雜度:時間和空間都要
- 處理邊界情況:空圖、單一節點、環
- 逐步最佳化:先寫簡單版,再改進
相關主題
- 樹:圖的特例(連通且無環)
- 動態規劃:圖上的 DP(路徑、樹)
- 貪婪演算法:MST 演算法
- 堆積/優先佇列:Dijkstra、Prim 會用到
- 遞迴/回溯:DFS 的實作方式