圖論題看起來最唬人,但拆到底就兩步:① 把題目翻譯成「節點 + 邊」,② 選對工具。工具其實就三把:
- 遍歷(DFS / BFS):走訪、連通塊、最短步數。
- 拓撲排序(Topological Sort):有相依關係、要排順序、偵測環。
- Union-Find(並查集):動態合併、數連通分量、判斷同一組。
這篇用 5 題把這三把工具一次練齊,延續一貫的完整推導流程:
📋 題目 → 🧭 拆解 → 暴力 → 瓶頸 → 洞察 → 最優 → 程式碼
- 網格 flood fill(200)→ 拓撲排序偵測環(207)→ 雜湊複製圖(133)→ Union-Find 數連通(547)→ 字元拓撲定序(269)
① LC 200 — Number of Islands
📋 題目:給定一個由 '1'(陸地)和 '0'(水)組成的二維網格,計算島嶼數量。島嶼由水平/垂直相鄰的陸地連成,四周視為被水包圍。
範例:grid = [["1","1","0"],["1","0","0"],["0","0","1"]] → 2
🌐 EN — Given a 2D grid of
'1'(land) and'0'(water), count the number of islands. An island is land connected 4-directionally (horizontal/vertical), surrounded by water. Example:[["1","1","0"],["1","0","0"],["0","0","1"]]→2
🧭 從讀題到解法
- 拆解:網格就是一張隱式圖——每格是節點,上下左右是邊。「島嶼數量」= 連通分量數量。
- 暴力解:對每格做 DFS/BFS,但要避免重複數同一座島。
- 關鍵洞察(flood fill):掃過每一格,遇到
'1'就把島數 +1,然後用 DFS 把整座島「淹掉」(標記成'0'),確保同座島只被數一次。 - 最優解:遍歷 + DFS 淹島。直接改原網格當 visited 標記,省額外空間。
def numIslands(grid):
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
def dfs(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
return
grid[r][c] = '0' # sink it (mark visited)
dfs(r+1, c); dfs(r-1, c); dfs(r, c+1); dfs(r, c-1)
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return count
void dfs(vector<vector<char>>& grid, int r, int c) {
int rows = grid.size(), cols = grid[0].size();
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] != '1') return;
grid[r][c] = '0'; // sink it
dfs(grid, r+1, c); dfs(grid, r-1, c);
dfs(grid, r, c+1); dfs(grid, r, c-1);
}
int numIslands(vector<vector<char>>& grid) {
if (grid.empty()) return 0;
int count = 0;
for (int r = 0; r < grid.size(); r++)
for (int c = 0; c < grid[0].size(); c++)
if (grid[r][c] == '1') { count++; dfs(grid, r, c); }
return count;
}
- 複雜度:時間 O(m·n),空間 O(m·n)(遞迴最壞;或改 BFS 用佇列)。
- 坑:① 邊界檢查要寫在 DFS 開頭,省去呼叫前判斷;② 淹島(標記 visited)是關鍵,否則無限遞迴或重複數;③ 網格極大時遞迴可能爆堆疊,改 BFS 較穩。
② LC 207 — Course Schedule
📋 題目:共 numCourses 門課(0 到 n-1),prerequisites[i] = [a, b] 表示修 a 前必須先修 b。判斷是否能修完所有課。
範例:numCourses = 2, prerequisites = [[1,0]] → true;[[1,0],[0,1]] → false(互為先修,成環)
🌐 EN — There are
numCoursescourses (0ton-1);prerequisites[i] = [a, b]means you must takebbeforea. Return whether you can finish all courses. Example:numCourses = 2, [[1,0]]→true;[[1,0],[0,1]]→false(mutual prerequisite → cycle)
🧭 從讀題到解法
- 拆解:課程是節點,「先修」是有向邊。「能否修完」⟺ 這張有向圖有沒有環(有環就永遠卡死)。
- 暴力解:對每個節點 DFS 找環,要小心狀態標記,容易寫錯。
- 關鍵洞察(拓撲排序 / Kahn's BFS):算每個節點的入度(多少先修課指向它)。把入度為 0 的(沒有未修先修課)放進佇列,逐一「修課」並把它指向的課入度 -1,變 0 就入列。若最後能修完所有課 = 無環;卡住代表有環。
- 最優解:Kahn's 演算法(BFS 拓撲排序),順便就是合法修課順序。
from collections import deque, defaultdict
def canFinish(numCourses, prerequisites):
graph = defaultdict(list)
indegree = [0] * numCourses
for course, pre in prerequisites:
graph[pre].append(course) # pre → course
indegree[course] += 1
q = deque(i for i in range(numCourses) if indegree[i] == 0)
seen = 0
while q:
node = q.popleft()
seen += 1
for nxt in graph[node]:
indegree[nxt] -= 1
if indegree[nxt] == 0:
q.append(nxt)
return seen == numCourses # all taken → no cycle
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses);
vector<int> indegree(numCourses, 0);
for (auto& p : prerequisites) {
graph[p[1]].push_back(p[0]); // pre → course
indegree[p[0]]++;
}
queue<int> q;
for (int i = 0; i < numCourses; i++)
if (indegree[i] == 0) q.push(i);
int seen = 0;
while (!q.empty()) {
int node = q.front(); q.pop();
seen++;
for (int nxt : graph[node])
if (--indegree[nxt] == 0) q.push(nxt);
}
return seen == numCourses; // no cycle
}
- 複雜度:時間 O(V + E),空間 O(V + E)。
- 坑:① 邊的方向要想清楚(
pre → course);② 判斷有無環 = 最後是否拓撲走訪了全部節點;③ 若題目要回傳順序(LC 210),把seen改成記錄陣列即可。
③ LC 133 — Clone Graph
📋 題目:給定一個連通無向圖中的一個節點,回傳該圖的深拷貝。每個節點含 val 與 neighbors 清單。
範例:adjList = [[2,4],[1,3],[2,4],[1,3]] → 回傳結構相同的全新圖
🌐 EN — Given a node in a connected undirected graph, return a deep copy of the graph. Each node has a
valand a list ofneighbors. Example:adjList = [[2,4],[1,3],[2,4],[1,3]]→ a brand-new graph with identical structure
🧭 從讀題到解法
- 拆解:要複製整張圖——難點是處理環與共享鄰居:複製到一半遇到已經複製過的節點,不能再複製一份。
- 錯誤的直覺:單純 DFS 複製,遇到環會無限遞迴。
- 關鍵洞察(雜湊表記錄 舊→新):用一個 map 記「原節點 → 它的複本」。複製某節點前先查 map:已存在就直接回那個複本(這就切斷了環);否則建立複本、登記進 map,再遞迴複製它的鄰居。
- 最優解:DFS +
clones雜湊表。
def cloneGraph(node):
if not node:
return None
clones = {}
def dfs(n):
if n in clones: # already cloned → reuse
return clones[n]
copy = Node(n.val)
clones[n] = copy # register BEFORE recursing
for nei in n.neighbors:
copy.neighbors.append(dfs(nei))
return copy
return dfs(node)
Node* cloneGraph(Node* node) {
if (!node) return nullptr;
unordered_map<Node*, Node*> clones;
function<Node*(Node*)> dfs = [&](Node* n) -> Node* {
if (clones.count(n)) return clones[n]; // reuse
Node* copy = new Node(n->val);
clones[n] = copy; // register before recursing
for (Node* nei : n->neighbors)
copy->neighbors.push_back(dfs(nei));
return copy;
};
return dfs(node);
}
- 複雜度:時間 O(V + E),空間 O(V)。
- 坑:一定要在遞迴鄰居「之前」就把複本登記進 map——否則遇到環時,鄰居反過來找自己還沒登記,會無限遞迴。這是本題的命門。
④ LC 547 — Number of Provinces
📋 題目:n 個城市,isConnected[i][j] = 1 表示城市 i 和 j 直接相連。相連(含間接)的城市組成一個「省」。回傳省的數量。
範例:isConnected = [[1,1,0],[1,1,0],[0,0,1]] → 2
🌐 EN —
ncities;isConnected[i][j] = 1means cities i and j are directly connected. A "province" is a group of directly or indirectly connected cities. Return the number of provinces. Example:[[1,1,0],[1,1,0],[0,0,1]]→2
🧭 從讀題到解法
- 拆解:又是「數連通分量」,和島嶼題本質相同。可以 DFS,但這題的鄰接矩陣形式特別適合展示 Union-Find。
- 思路:把每個城市先當成獨立一組(各自的 parent 是自己)。掃描矩陣,只要 i、j 相連就合併(union) 兩組。
- 關鍵洞察(Union-Find):最後還是自己父節點(即 root)的城市數量,就是連通分量(省)的數量。配上路徑壓縮,find 幾乎 O(1)。
- 最優解:並查集,掃上三角矩陣即可。
def findCircleNum(isConnected):
n = len(isConnected)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path compression
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
for i in range(n):
for j in range(i + 1, n): # upper triangle
if isConnected[i][j]:
union(i, j)
return sum(1 for i in range(n) if find(i) == i)
class DSU {
public:
vector<int> parent;
DSU(int n) : parent(n) { iota(parent.begin(), parent.end(), 0); }
int find(int x) {
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x; // path compression
}
void unite(int a, int b) { parent[find(a)] = find(b); }
};
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
DSU dsu(n);
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if (isConnected[i][j]) dsu.unite(i, j);
int count = 0;
for (int i = 0; i < n; i++) if (dsu.find(i) == i) count++;
return count;
}
- 複雜度:時間 O(n²·α(n))(掃矩陣為主),空間 O(n)。
- 坑:① 矩陣對稱,只掃上三角避免重複;② 路徑壓縮讓 find 攤銷近 O(1),沒有它會退化;③ 數省份 = 數有幾個 root(
find(i) == i),不是數 union 次數。
💡 Union-Find 適用訊號:「動態合併、判斷是否同一組、數連通分量」且邊是逐步給的,優先想並查集;若圖一次給定且要走訪路徑,DFS/BFS 也可。
⑤ LC 269 — Alien Dictionary
📋 題目:有一種外星語言用小寫英文字母,但字母順序未知。給定一串已按該語言字典序排好的單字 words,推導出字母的順序(任一合法解)。無解回傳 ""。
範例:words = ["wrt","wrf","er","ett","rftt"] → "wertf";words = ["z","x","z"] → ""(矛盾)
🌐 EN — An alien language uses lowercase letters in an unknown order. Given
wordssorted by that language's rules, derive an order of letters (any valid one); return""if none exists. Example:["wrt","wrf","er","ett","rftt"]→"wertf";["z","x","z"]→""
🧭 從讀題到解法
- 拆解:字母是節點,「字母 a 在 b 之前」是有向邊——這又是拓撲排序!只是節點從整數換成字元。
- 怎麼建邊? 相鄰兩個單字比較,找到第一個不同的字元
a、b,就得到一條邊a → b(a 排在 b 前),然後停止比較(後面的字元推不出關係)。 - 關鍵洞察 + 兩個陷阱:① 邊只看「第一個相異字元」;② 前綴矛盾:若
["abc","ab"],長的排在短的前面卻是其前綴,這是非法輸入,回""。最後對字元圖做拓撲排序;有環(矛盾)也回""。 - 最優解:建圖 → Kahn's 拓撲排序,檢查是否走完所有字元。
from collections import deque
def alienOrder(words):
graph = {c: set() for w in words for c in w}
indegree = {c: 0 for c in graph}
for first, second in zip(words, words[1:]):
for a, b in zip(first, second):
if a != b: # first differing char → edge
if b not in graph[a]:
graph[a].add(b)
indegree[b] += 1
break
else: # no diff in the shared prefix
if len(second) < len(first): # prefix violation → invalid
return ""
q = deque(c for c in indegree if indegree[c] == 0)
res = []
while q:
c = q.popleft()
res.append(c)
for nxt in graph[c]:
indegree[nxt] -= 1
if indegree[nxt] == 0:
q.append(nxt)
return ''.join(res) if len(res) == len(graph) else "" # cycle → ""
string alienOrder(vector<string>& words) {
unordered_map<char, unordered_set<char>> graph;
unordered_map<char, int> indegree;
for (auto& w : words)
for (char c : w) { graph[c]; indegree[c]; } // register all chars
for (int i = 0; i + 1 < (int)words.size(); i++) {
string& a = words[i]; string& b = words[i+1];
int len = min(a.size(), b.size()), j = 0;
for (; j < len; j++) {
if (a[j] != b[j]) { // first differing char
if (!graph[a[j]].count(b[j])) {
graph[a[j]].insert(b[j]);
indegree[b[j]]++;
}
break;
}
}
if (j == len && a.size() > b.size()) return ""; // prefix violation
}
queue<char> q;
for (auto& [c, d] : indegree) if (d == 0) q.push(c);
string res;
while (!q.empty()) {
char c = q.front(); q.pop();
res += c;
for (char nxt : graph[c])
if (--indegree[nxt] == 0) q.push(nxt);
}
return res.size() == graph.size() ? res : ""; // cycle → ""
}
- 複雜度:時間 O(總字元數),空間 O(1)(字母集 ≤ 26)。
- 坑:① 只從「第一個相異字元」建一條邊,之後 break;② 前綴矛盾(長字在短字前且為其前綴)要回
"",最容易漏;③ 別忘了把所有出現過的字元都註冊進圖(即使它沒有任何邊);④ 拓撲走訪不完全 = 有環 = 回""。
一張表記住全部
| 題 | 翻譯成圖 | 工具 | 時間 | 關鍵 / 坑 |
|---|---|---|---|---|
| LC 200 | 網格 = 隱式圖 | DFS flood fill | O(mn) | 淹島標記 visited |
| LC 207 | 課程相依 = 有向邊 | 拓撲排序(Kahn) | O(V+E) | 走完全部=無環 |
| LC 133 | 節點 + 鄰居 | DFS + 雜湊表 | O(V+E) | 遞迴前先登記複本 |
| LC 547 | 城市連通 = 合併 | Union-Find | O(n²·α) | 路徑壓縮;數 root |
| LC 269 | 字母序 = 有向邊 | 字元拓撲排序 | O(總長) | 第一相異字元、前綴矛盾 |
面試辨識口訣
- 看到「網格 / 上下左右相鄰 / 區域」→ DFS/BFS flood fill,記得標記 visited。
- 看到「相依 / 先後順序 / 能否完成 / 排程」→ 拓撲排序,順便偵測環。
- 看到「複製圖 / 帶環結構」→ DFS/BFS + 舊→新雜湊表,遞迴前先登記。
- 看到「動態合併 / 同一組 / 連通分量」且邊逐步給 → Union-Find(路徑壓縮)。
- 看到「從排好的序列推導順序」→ 多半是拓撲排序(節點可能是字元)。
圖論題的精髓只有兩步:先把題目翻成「節點 + 邊」,再從「遍歷 / 拓撲 / Union-Find」三把工具選對一把。 翻譯對了,工具選對了,程式碼就是套模板。