拓撲排序 - 完整指南
範圍 — 為 DAG 排序 — Kahn 的 BFS 解法、DFS 後序、環的偵測,以及建構在它們之上的排程類題目。 另見 — topology_sorting_examples.md — 這些模板背後的八道完整解題;diff_toposort_quickunion.md — 拓撲排序 vs 併查集 — 什麼題該用哪個工具;union_find.md — 無向圖的連通性;graph.md — 一般的圖論素材。
LeetCode 題目清單
總覽
拓撲排序是把有向無環圖(DAG)中的頂點排成一個線性順序,使得對每一條有向邊 (u, v),頂點 u 都排在 v 之前。
關鍵特性
- 只適用 DAG:僅在有向無環圖上成立
- 答案不唯一:可能存在很多組合法的拓撲順序
- 解決相依關係:處理有先修/相依關係的問題
- 應用場景:任務排程、建置系統、課程規劃、相依性解析
複雜度分析
| 做法 | 時間複雜度 | 空間複雜度 | 適用情境 |
|---|---|---|---|
| DFS(三色後序) | O(V + E) | O(V) | 通用、偵測環 |
| BFS(入度) | O(V + E) | O(V) | 找出所有順序、逐層處理 |
| 找樹的重心 | O(V + E) | O(V) | 無向樹,找中心/最小化高度 |
| 列舉所有拓撲排序 | O(V! × (V + E)) | O(V) | 小圖、所有排列 |
參考資料
- techbridge : topological-sort
- DFS-based topological sort
- topological_sort.py
- TopologicalSort.java
- NumberOfProvinces.java(連通分量/併查集)
- MinimumHeightTrees.java(找樹的重心)
- minimum-height-trees.py(LC 310 — 葉節點剝除的各種解法,外加可以拿來對照的 O(n^2) 暴力解)
- find-the-town-judge.py(LC 997 — 度數特徵的計數解法,以及集合差集的替代寫法)
題型分類
1. 課程排程
牽涉先修關係與課程順序的題目。
- 模式:建出相依圖、檢查是否有環、找出合法順序
- 代表題目:LC 207、210、630、1462
2. 任務排程
牽涉任務相依與平行執行的題目。
- 模式:求最短時間、平行處理的層數
- 代表題目:LC 1136、2050、1857
3. 字典序排序
要求字典序最小/最大之拓撲順序的題目。
- 模式:用優先佇列決定順序、外星文字典
- 代表題目:LC 269、953、1203
4. 建置順序與相依性
牽涉建置系統與套件相依的題目。
- 模式:偵測環、找出建置順序、處理群組
- 代表題目:LC 444、802、851
5. 圖的分層
牽涉在 DAG 上逐層處理的題目。
- 模式:帶層數的 BFS、DAG 上的最長路徑
- 代表題目:LC 2192、2115、1857
6. 環的偵測與安全狀態
聚焦在偵測環與找出安全節點的題目。
- 模式:三色 DFS、辨識安全狀態
- 代表題目:LC 802、207、1059
7. 連通分量(併查集/DFS)
牽涉在無向圖中找連通分量的題目。
- 模式:帶路徑壓縮的併查集、用 DFS/BFS 遍歷計數分量
- 代表題目:LC 547、200、323、684
8. 找樹的重心
牽涉尋找無向樹之中心/重心的題目。
- 模式:逐層剝除葉節點 — 就是把 Kahn 剝除法的種子條件從
in_degree == 0換成degree == 1 - 代表題目:LC 310、1245、2603
核心模板
模板 1:BFS(Kahn 演算法)Priority 5 of 5 — Must know — expect it in almost every loop
def topologicalSort_BFS(numNodes, edges):
"""
BFS-based topological sort using in-degree tracking.
Time: O(V + E), Space: O(V)
"""
from collections import defaultdict, deque
# Build graph and calculate in-degrees
graph = defaultdict(list)
in_degree = [0] * numNodes
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
# Initialize queue with nodes having no dependencies
queue = deque([i for i in range(numNodes) if in_degree[i] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
# Process neighbors
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Check for cycles
return result if len(result) == numNodes else []
// Java version
public List<Integer> topologicalSort_BFS(int numNodes, int[][] edges) {
Map<Integer, List<Integer>> graph = new HashMap<>();
int[] inDegree = new int[numNodes];
// Build graph
for (int i = 0; i < numNodes; i++) {
graph.put(i, new ArrayList<>());
}
for (int[] edge : edges) {
graph.get(edge[0]).add(edge[1]);
inDegree[edge[1]]++;
}
// BFS
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numNodes; i++) {
if (inDegree[i] == 0) queue.offer(i);
}
List<Integer> result = new ArrayList<>();
while (!queue.isEmpty()) {
int node = queue.poll();
result.add(node);
for (int neighbor : graph.get(node)) {
if (--inDegree[neighbor] == 0) {
queue.offer(neighbor);
}
}
}
return result.size() == numNodes ? result : new ArrayList<>();
}
模板 2:DFS(三色標記)Priority 4 of 5 — High value — a gap here costs you rounds
def topologicalSort_DFS(numNodes, edges):
"""
DFS-based topological sort with three-color marking.
Time: O(V + E), Space: O(V)
"""
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
# 0: white (unvisited), 1: gray (visiting), 2: black (visited)
color = [0] * numNodes
result = []
has_cycle = False
def dfs(node):
nonlocal has_cycle
if color[node] == 1: # Gray = cycle detected
has_cycle = True
return
if color[node] == 2: # Black = already processed
return
color[node] = 1 # Mark as visiting
for neighbor in graph[node]:
dfs(neighbor)
color[node] = 2 # Mark as visited
result.append(node) # Add to result in reverse order
for i in range(numNodes):
if color[i] == 0:
dfs(i)
return [] if has_cycle else result[::-1]
// Java version
public List<Integer> topologicalSort_DFS(int numNodes, int[][] edges) {
Map<Integer, List<Integer>> graph = new HashMap<>();
for (int i = 0; i < numNodes; i++) {
graph.put(i, new ArrayList<>());
}
for (int[] edge : edges) {
graph.get(edge[0]).add(edge[1]);
}
int[] color = new int[numNodes]; // 0: white, 1: gray, 2: black
List<Integer> result = new ArrayList<>();
boolean[] hasCycle = {false};
for (int i = 0; i < numNodes; i++) {
if (color[i] == 0) {
dfs(i, graph, color, result, hasCycle);
}
}
if (hasCycle[0]) return new ArrayList<>();
Collections.reverse(result);
return result;
}
private void dfs(int node, Map<Integer, List<Integer>> graph,
int[] color, List<Integer> result, boolean[] hasCycle) {
if (color[node] == 1) {
hasCycle[0] = true;
return;
}
if (color[node] == 2) return;
color[node] = 1;
for (int neighbor : graph.get(node)) {
dfs(neighbor, graph, color, result, hasCycle);
}
color[node] = 2;
result.add(node);
}
模板 3:DFS(用堆疊)
# V0
# IDEA : implement topologicalSortUtil, topologicalSort, and addEdge methods
# step 1) maintain a stack, save "ordering" nodes in it (and return in final step)
# step 2) init visited as [False]*self.V (all nodes are NOT visited yet)
# step 3) iterate over all vertices in graph, if not visited, then run topologicalSortUtil
# step 4) return result (stack)
from collections import defaultdict
class Graph:
def __init__(self, vertices):
self.graph = defaultdict(list)
self.V = vertices
# for build graph
def addEdge(self, u, v):
self.graph[u].append(v)
def topologicalSortUtil(self, v, visited, stack):
visited[v] = True
### NOTE this !!! (self.graph[v])
for k in self.graph[v]:
if visited[k] == False:
self.topologicalSortUtil(k, visited, stack)
# stack.insert(0,v) # instead of insert v to idx = 0, we can still append v to stack and reverse it and return (e.g. return stack[::-1])
"""
### NOTE !! stack.append(v) is wrong, we SHOULD use stack.insert(0,v)
"""
stack.insert(0,v)
def topologicalSort(self):
visited = [False] * self.V
stack = []
### NOTE this !!! (range(self.V))
for v in range(self.V):
# call tologicalSortUtil only if visited[v] == False (the vertice is not visited yet)
if visited[v] == False:
self.topologicalSortUtil(v, visited, stack)
# return the result in inverse order
return stack[::-1]
### TEST
{"A": 0, "B":1, "C":2, "D": 3}
v = 4
g = Graph(v)
g.addEdge(0, 1)
g.addEdge(0, 2)
g.addEdge(2, 3)
g.addEdge(3, 1)
print (g.graph)
# ans should be TableB, TableD, TableC, TableA.
r = g.topologicalSort()
print (r)
模板 4:字典序
def topologicalSort_Lexicographical(numNodes, edges):
"""
Find smallest lexicographical topological order.
Time: O((V + E) log V), Space: O(V)
"""
from collections import defaultdict
import heapq
graph = defaultdict(list)
in_degree = [0] * numNodes
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
# Use min-heap for smallest lexicographical order
heap = [i for i in range(numNodes) if in_degree[i] == 0]
heapq.heapify(heap)
result = []
while heap:
node = heapq.heappop(heap)
result.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
heapq.heappush(heap, neighbor)
return result if len(result) == numNodes else []
// Java version
public List<Integer> topologicalSort_Lexicographical(int numNodes, int[][] edges) {
Map<Integer, List<Integer>> graph = new HashMap<>();
int[] inDegree = new int[numNodes];
for (int i = 0; i < numNodes; i++) {
graph.put(i, new ArrayList<>());
}
for (int[] edge : edges) {
graph.get(edge[0]).add(edge[1]);
inDegree[edge[1]]++;
}
// Min-heap for lexicographical order
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int i = 0; i < numNodes; i++) {
if (inDegree[i] == 0) pq.offer(i);
}
List<Integer> result = new ArrayList<>();
while (!pq.isEmpty()) {
int node = pq.poll();
result.add(node);
for (int neighbor : graph.get(node)) {
if (--inDegree[neighbor] == 0) {
pq.offer(neighbor);
}
}
}
return result.size() == numNodes ? result : new ArrayList<>();
}
模板 5:所有拓撲順序
def allTopologicalSorts(graph, in_degree, path, result, visited):
"""
Find all possible topological orderings.
Time: O(V! × (V + E)), Space: O(V)
"""
if len(path) == len(graph):
result.append(path[:])
return
for node in range(len(graph)):
if in_degree[node] == 0 and not visited[node]:
# Choose node
visited[node] = True
path.append(node)
# Update in-degrees
for neighbor in graph[node]:
in_degree[neighbor] -= 1
# Recurse
allTopologicalSorts(graph, in_degree, path, result, visited)
# Backtrack
for neighbor in graph[node]:
in_degree[neighbor] += 1
path.pop()
visited[node] = False
# Usage
def findAllOrders(numNodes, edges):
from collections import defaultdict
graph = defaultdict(list)
in_degree = [0] * numNodes
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
result = []
visited = [False] * numNodes
allTopologicalSorts(graph, in_degree, [], result, visited)
return result
模板 6:平行任務排程
def parallelTaskScheduling(numTasks, edges, times):
"""
Find minimum time to complete all tasks with parallel execution.
Time: O(V + E), Space: O(V)
"""
from collections import defaultdict, deque
graph = defaultdict(list)
in_degree = [0] * numTasks
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
# Track completion time for each task
completion_time = [0] * numTasks
queue = deque()
# Initialize with tasks having no dependencies
for i in range(numTasks):
if in_degree[i] == 0:
queue.append(i)
completion_time[i] = times[i]
while queue:
task = queue.popleft()
for next_task in graph[task]:
# Update completion time
completion_time[next_task] = max(
completion_time[next_task],
completion_time[task] + times[next_task]
)
in_degree[next_task] -= 1
if in_degree[next_task] == 0:
queue.append(next_task)
return max(completion_time)
// Java version
public int parallelTaskScheduling(int numTasks, int[][] edges, int[] times) {
Map<Integer, List<Integer>> graph = new HashMap<>();
int[] inDegree = new int[numTasks];
int[] completionTime = new int[numTasks];
for (int i = 0; i < numTasks; i++) {
graph.put(i, new ArrayList<>());
}
for (int[] edge : edges) {
graph.get(edge[0]).add(edge[1]);
inDegree[edge[1]]++;
}
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numTasks; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
completionTime[i] = times[i];
}
}
while (!queue.isEmpty()) {
int task = queue.poll();
for (int nextTask : graph.get(task)) {
completionTime[nextTask] = Math.max(
completionTime[nextTask],
completionTime[task] + times[nextTask]
);
if (--inDegree[nextTask] == 0) {
queue.offer(nextTask);
}
}
}
int maxTime = 0;
for (int time : completionTime) {
maxTime = Math.max(maxTime, time);
}
return maxTime;
}
模板 7:找樹的重心(無向樹的葉節點剝除法)Priority 4 of 5 — High value — a gap here costs you rounds
LC 模式 — 題目敘述裡有三個訊號,LC 310 三個都有:
| 訊號 | 它告訴你的事 |
|---|---|
無向、n 個節點加 n - 1 條邊、連通 |
它是一棵樹 — 沒有環,所以不用處理環,也不需要 visited |
| 「你可以選任何節點當根」、「最小高度」 | 要你回答的是一組節點,不是一個順序 |
| 答案是一個 list,而且暗示「最多 2 個」 | 剝除之後存活下來的節點就是答案 |
LC 310 = 找 tree center = 不斷 remove leaves。
核心想法 — 讓高度最小的根,就是直徑的中點。與其把每個節點都試一遍當根(從每個節點各做一次
BFS,也就是 O(n^2),解題檔裡的 V1''),不如由外往內剝:葉節點永遠是最差的根,所以一次就把整層
葉節點丟掉,然後重複。活下來的節點與直徑兩端等距 — 那就是中心。
0 layer 1 leaves: 2, 3, 5 remaining = 6 - 3 = 3
| remove them
1 ────────────────────────────────
/ | \ 0 - 1 - 4
2 3 4 layer 2 leaves: 0, 4 remaining = 3 - 2 = 1
| remove them
5 ────────────────────────────────
1 remaining = 1 → stop, answer [1]
為什麼只會剩 1 或 2 個,不會是 3 個 — 存活的節點就是直徑路徑的中點,而一條路徑的中點只會有 一個或兩個:
even node count on the diameter odd node count on the diameter
A - B - C - D A - B - C - D - E
↑ ↑ ↑
two centers → [B, C] one center → [C]
這也是為什麼迴圈條件是 while remaining > 2 而不是 while queue — 佇列不會自己變空,是你去
停它的。
和 Kahn 是同一套機制,只差四點(這四點就是整個模板):
| 模板 1(Kahn,DAG) | 模板 7(葉節點剝除,樹) | |
|---|---|---|
| 加邊 | 單向,in_degree[v] += 1 |
雙向,degree[u] += 1 且 degree[v] += 1 |
| 種子 | in_degree == 0(源點) |
degree == 1(葉節點) |
| 停止 | 佇列變空 → 完整的順序 | remaining <= 2 → 佇列本身就是答案 |
| 輸出 | pop 出來的順序 | 從沒被 pop 到的節點 |
def findMinHeightTrees(n, edges):
"""
Find tree centroids by trimming leaves layer by layer.
Time: O(V + E), Space: O(V)
"""
from collections import deque
# Edge case: single node
if n == 1:
return [0]
# Build adjacency list and track degrees
graph = [[] for _ in range(n)]
degree = [0] * n
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
degree[u] += 1
degree[v] += 1
# Initialize queue with all leaf nodes (degree = 1)
leaves = deque([i for i in range(n) if degree[i] == 1])
# Trim leaves layer by layer
remaining = n
while remaining > 2:
leaf_count = len(leaves)
remaining -= leaf_count
for _ in range(leaf_count):
leaf = leaves.popleft()
# Process neighbors of current leaf
for neighbor in graph[leaf]:
degree[neighbor] -= 1
# If neighbor becomes a leaf, add to queue
if degree[neighbor] == 1:
leaves.append(neighbor)
# The remaining nodes (1 or 2) are the centroids
return list(leaves)
// Java version
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
// Edge case: single node
if (n == 1) {
return Collections.singletonList(0);
}
// Build adjacency list and track degrees
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
int[] degree = new int[n];
for (int[] edge : edges) {
int u = edge[0], v = edge[1];
graph.get(u).add(v);
graph.get(v).add(u);
degree[u]++;
degree[v]++;
}
// Initialize queue with all leaf nodes (degree = 1)
Queue<Integer> leaves = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (degree[i] == 1) {
leaves.offer(i);
}
}
// Trim leaves layer by layer
int remaining = n;
while (remaining > 2) {
int leafCount = leaves.size();
remaining -= leafCount;
for (int i = 0; i < leafCount; i++) {
int leaf = leaves.poll();
// Process neighbors of current leaf
for (int neighbor : graph.get(leaf)) {
degree[neighbor]--;
// If neighbor becomes a leaf, add to queue
if (degree[neighbor] == 1) {
leaves.offer(neighbor);
}
}
}
}
// The remaining nodes (1 or 2) are the centroids
return new ArrayList<>(leaves);
}
這個模板專屬的坑
n == 1會讓佇列是空的,不是滿的:沒有邊,就沒有任何節點的度數會等於 1,於是while一次 都不會跑,你回傳[]。要特判。if n <= 2: return list(range(n))一次蓋掉兩種瑣碎情況,也比只 寫n == 1好讀(解題檔裡的V0-2)。- 不需要
visited。degree只會遞減,所以一個節點最多只會經過1這個值一次,也就最多只會 被推進佇列一次 — 去重是樹的結構幫你做掉的。 - 要另外用
remaining計數,在把整層抽乾之前先扣掉這一層的大小。事後再讀len(queue)得到的 不是還活著的節點數。 - 葉節點被剝掉時
degree[leaf]不會被歸零。 這無害:被剝掉的葉節點之後被同層的兄弟節點遞減 時是從 1 變 0,而 0 永遠不會再觸發推入佇列的判斷。
相似題目:見下方的找樹的重心。
模板 8:併查集(連通分量)
class UnionFind:
"""
Union Find with path compression and union by rank.
Time: O(α(N)) per operation (nearly O(1)), Space: O(N)
"""
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.count = n # number of connected components
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # path compression
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py:
return
# union by rank
if self.rank[px] < self.rank[py]:
px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]:
self.rank[px] += 1
self.count -= 1
// Java version
class UnionFind {
int[] parent;
int[] rank;
int count;
UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // path compression
}
return parent[x];
}
void union(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return;
// union by rank
if (rank[px] < rank[py]) {
parent[px] = py;
} else if (rank[px] > rank[py]) {
parent[py] = px;
} else {
parent[py] = px;
rank[px]++;
}
count--;
}
}
模板 9:在隱式 DAG 上的拓撲排序 + DP Priority 5 of 5 — Must know — expect it in almost every loop
核心想法:圖從來不是以邊列表給你的——它是由一條比較規則隱含出來的
(grid[a] < grid[b] ⇒ 存在邊 a → b)。邊走邊算入度/出度,再用 Kahn 的做法逐層剝除。
BFS 的層數就是 DAG 中的最長路徑(因為邊的規則是嚴格遞增的,所以不可能有環)。
何時使用:格子或矩陣上的「最長遞增路徑/鏈」,或任何需要依相依順序處理節點、又不想承擔遞迴深度風險的 DAG 上 DP。
與模板 1 的差異:這裡從匯點(出度 0 = 區域極大值)而非源點開始剝,這樣每一層剛好就是 DP 的一步。從源點剝也可以——把比較方向反過來即可。
// java
// LC 329 - Longest Increasing Path in a Matrix
// IDEA: implicit DAG (cell -> strictly larger neighbour) + Kahn's peeling from sinks;
// answer = number of peeling layers = longest path length
public class Solution {
// time = O(m * n), space = O(m * n)
private static final int[][] DIRS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
public int longestIncreasingPath(int[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0;
int m = matrix.length, n = matrix[0].length;
// step 1) out-degree = how many strictly-larger neighbours a cell points to
int[][] outdeg = new int[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
for (int[] d : DIRS) {
int x = i + d[0], y = j + d[1];
if (x >= 0 && x < m && y >= 0 && y < n && matrix[x][y] > matrix[i][j]) {
outdeg[i][j]++;
}
}
}
}
// step 2) seed the queue with the sinks (local maxima)
Deque<int[]> q = new ArrayDeque<>();
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (outdeg[i][j] == 0) q.offer(new int[]{i, j});
}
}
// step 3) peel layer by layer; each layer = +1 on the longest path
int length = 0;
while (!q.isEmpty()) {
length++;
for (int sz = q.size(); sz > 0; sz--) {
int[] cur = q.poll();
int i = cur[0], j = cur[1];
for (int[] d : DIRS) {
int x = i + d[0], y = j + d[1];
// walk backwards along the implicit edge (smaller neighbour)
if (x >= 0 && x < m && y >= 0 && y < n && matrix[x][y] < matrix[i][j]) {
if (--outdeg[x][y] == 0) q.offer(new int[]{x, y});
}
}
}
}
return length;
}
}
# python
# LC 329 - Longest Increasing Path in a Matrix
# IDEA: implicit DAG (cell -> strictly larger neighbour) + Kahn's peeling from sinks
from collections import deque
def longestIncreasingPath(matrix):
# time = O(m * n), space = O(m * n)
if not matrix or not matrix[0]:
return 0
m, n = len(matrix), len(matrix[0])
dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))
# step 1) out-degree = number of strictly-larger neighbours
outdeg = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
for di, dj in dirs:
x, y = i + di, j + dj
if 0 <= x < m and 0 <= y < n and matrix[x][y] > matrix[i][j]:
outdeg[i][j] += 1
# step 2) seed with sinks (local maxima)
q = deque((i, j) for i in range(m) for j in range(n) if outdeg[i][j] == 0)
# step 3) peel; layer count == longest increasing path
length = 0
while q:
length += 1
for _ in range(len(q)):
i, j = q.popleft()
for di, dj in dirs:
x, y = i + di, j + dj
if 0 <= x < m and 0 <= y < n and matrix[x][y] < matrix[i][j]:
outdeg[x][y] -= 1
if outdeg[x][y] == 0:
q.append((x, y))
return length
注意:經典的替代做法是 DFS + 記憶化(dp[i][j] = 1 + max(dp[neighbour])),同樣是
O(m·n)。當格子大到遞迴可能爆堆疊,或面試官明確要求用拓撲排序時,優先選 Kahn 版本。
同樣的形狀,但圖是顯式的:LC 1857 (Largest Color Value in a Directed Graph) — 在 Kahn 的流程中帶著一個
count[node][26] 的 DP 陣列,而不是單一的層數計數器;若佇列在所有節點都被取出之前就空了,代表有環 → 回傳 -1。
模板 10:入度特徵(答案直接從度數讀出來)Priority 4 of 5 — High value — a gap here costs you rounds
核心想法:有些「圖」題根本不需要遍歷——答案完全由入度/出度的計數決定。認出這一點能把一題 Medium 變成三行程式。
關鍵洞見(LC 1557):在 DAG 上,入度為 0 的節點只能從它自己開始才會被走到,而其餘每個節點都能從某個入度 0 的節點到達。所以入度 0 的節點集合既必要又充分——因此就是唯一的最小答案。
// java
// LC 1557 - Minimum Number of Vertices to Reach All Nodes
// IDEA: on a DAG, the minimal start set == exactly the nodes with in-degree 0
public class Solution {
// time = O(V + E), space = O(V)
public List<Integer> findSmallestSetOfVertices(int n, List<List<Integer>> edges) {
boolean[] hasIncoming = new boolean[n];
for (List<Integer> e : edges) {
hasIncoming[e.get(1)] = true;
}
List<Integer> res = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (!hasIncoming[i]) res.add(i);
}
return res;
}
}
# python
# LC 1557 - Minimum Number of Vertices to Reach All Nodes
# IDEA: on a DAG, the minimal start set == exactly the nodes with in-degree 0
def findSmallestSetOfVertices(n, edges):
# time = O(V + E), space = O(V)
has_incoming = [False] * n
for _, v in edges:
has_incoming[v] = True
return [i for i in range(n) if not has_incoming[i]]
變化 A — 度數特徵比對:LC 997 Find the Town Judge
LC 模式 — 關鍵線索是:要找的那個節點完全是用「有幾條邊碰到它」來描述的,從來不是用它能走到誰:
| 題目敘述裡的訊號 | 它的意思 |
|---|---|
| 「法官不信任任何人」 | out_degree == 0 |
| 「除了法官以外,每個人都信任法官」 | in_degree == n - 1 |
| 「恰好只有一個這樣的人」 | 掃描找這個指紋;沒有節點符合就回 -1 |
這裡沒有任何一句在問誰能走到誰,所以不用鄰接表、不用佇列、不用 DFS — 兩個計數陣列、兩趟迴圈
就結束。它和上面的 LC 1557(in_degree == 0)是同一家族,只是換了指紋。
LC 997 = 找一個 node:
in_degree == n - 1且out_degree == 0。
核心想法,第 1 步 — 老實地把兩種度數都數出來。 這是應該先寫下來的版本:它和題目敘述的兩句話 一對一對應,緊張的時候也不會推錯。
# python
# LC 997 - Find the Town Judge
# IDEA: judge == trusted by everyone else (in = n-1) and trusts nobody (out = 0)
def findJudge(n, trust):
# time = O(V + E), space = O(V)
# labels are 1..n, so size n + 1 and ignore index 0
in_degree = [0] * (n + 1)
out_degree = [0] * (n + 1)
for a, b in trust: # a trusts b
out_degree[a] += 1
in_degree[b] += 1
for person in range(1, n + 1):
if out_degree[person] == 0 and in_degree[person] == n - 1:
return person
return -1
核心想法,第 2 步 — 把兩個陣列摺成一個。 值得寫第二個版本,因為它把空間砍半,而且多數面試官
預期看到的就是這一版:追蹤 score = in_degree - out_degree,然後掃描找 n - 1。
為什麼可以這樣摺(把這段講出來 — 這是唯一不顯然的一步):in_degree <= n - 1 恆成立,因為
trust 裡的 pair 不重複而且 a != b,最多只有 n - 1 個不同的人能信任你。有了這個上限,
in - out == n - 1 就強迫 in == n - 1 且 out == 0 — 不可能靠很大的入度再被負的出度抵掉來
湊出 n - 1。如果哪天題目允許重複的 trust pair,in_degree 就可能超過 n - 1,這個摺疊會報出
一個假法官;而第 1 步的版本仍然正確。
// java
// LC 997 - Find the Town Judge
// IDEA: judge == in-degree (n-1) and out-degree 0 => score = in - out == n - 1
public class Solution {
// time = O(V + E), space = O(V)
public int findJudge(int n, int[][] trust) {
int[] score = new int[n + 1];
for (int[] t : trust) {
score[t[0]]--; // t[0] trusts someone -> out-degree
score[t[1]]++; // t[1] is trusted -> in-degree
}
for (int i = 1; i <= n; i++) {
if (score[i] == n - 1) return i;
}
return -1;
}
}
# python
# LC 997 - Find the Town Judge
def findJudge(n, trust):
# time = O(V + E), space = O(V)
score = [0] * (n + 1)
for a, b in trust:
score[a] -= 1 # a trusts someone -> out-degree
score[b] += 1 # b is trusted -> in-degree
for i in range(1, n + 1):
if score[i] == n - 1:
return i
return -1
坑
- 編號是 1-indexed。 陣列開
n + 1、迴圈跑1..n;開n的陣列在第n個人身上就會越界。 n == 1且trust == []必須回傳1。上面兩個版本都免費拿到這個結果 —n - 1 == 0,而每個 score 都是0— 這也是它們比集合解法好的一個理由,後者需要額外寫if n == 1: return 1。- 不要只驗到「有人信任他、而他不信任任何人」就收手。 那只是候選人。計數必須恰好是
n - 1; 以n = 3, trust = [[1,3]]為例,節點 3 通得過這個弱化的檢查,但它不是法官。集合差集的版本 (trusted - trusting,解題檔裡的V2)正是因為這樣才需要再補一趟n - 1的驗證 — 程式更長, 複雜度一樣。 -1是一個真正的答案,不是錯誤分支:有環時([[1,3],[2,3],[3,1]])沒有人的out_degree == 0。
相似題目 — 答案就是一個度數指紋
| 題目 | 難度 | 指紋是什麼、又換了什麼 |
|---|---|---|
| 997 Find the Town Judge | Easy | in == n - 1 且 out == 0 — 模板本身 |
| 277 Find the Celebrity | Medium | 同一個指紋,但邊只能透過 knows(a, b) API 取得,所以你數不了度數 — 先用一趟 O(n) 淘汰候選人,再驗證存活者的那一列與那一行 |
| 2924 Find Champion II | Medium | in == 0 而且必須唯一:恰好一個入度 0 的節點才回它,否則 -1 |
| 2923 Find Champion I | Easy | 和 2924 相同,但給的是鄰接矩陣 — 找沒有任何入邊 1 的那一列 |
| 1557 Minimum Number of Vertices to Reach All Nodes | Medium | in == 0,但要回傳全部(上面的模板 10) |
| 1361 Validate Binary Tree Nodes | Medium | 所有節點 in <= 1 + 恰好一個 in == 0 + 一趟可達性檢查(下面的變化 B) |
| 2374 Node With Highest Edge Score | Medium | 累加的是指進來的編號總和,不是次數 — 同樣的一趟掃描,只是換了累加器 |
| 1615 Maximal Network Rank | Medium | 無向圖,只要一個 degree 陣列;一對節點相鄰時,rank 是 deg(a) + deg(b) - 1 |
變化 B — 度數 + 一次可達性檢查:LC 1361 Validate Binary Tree Nodes
變化點:光靠度數還不夠。一棵合法的二元樹需要三個條件——
(1) 每個節點入度 ≤ 1、(2) 恰好一個節點入度為 0(根)、
(3) 全部 n 個節點都能從該根到達。只有條件 1+2 而沒有 3 的話,仍然可能出現一棵看似合法的樹外加一個分離的環,那正是這題的陷阱情境。
// java
// LC 1361 - Validate Binary Tree Nodes
// IDEA: in-degree <= 1 for all + exactly one in-degree-0 root + root reaches all n nodes
public class Solution {
// time = O(N), space = O(N)
public boolean validateBinaryTreeNodes(int n, int[] leftChild, int[] rightChild) {
// (1) no node may have two parents
int[] indeg = new int[n];
for (int i = 0; i < n; i++) {
for (int c : new int[]{leftChild[i], rightChild[i]}) {
if (c != -1 && ++indeg[c] > 1) return false;
}
}
// (2) exactly one root
int root = -1;
for (int i = 0; i < n; i++) {
if (indeg[i] == 0) {
if (root != -1) return false; // 2+ roots => forest
root = i;
}
}
if (root == -1) return false; // 0 roots => everything is in a cycle
// (3) the root must reach every node (else a detached cycle exists)
int seen = 1;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
int node = stack.pop();
for (int c : new int[]{leftChild[node], rightChild[node]}) {
if (c != -1) {
seen++;
stack.push(c);
}
}
}
return seen == n;
}
}
# python
# LC 1361 - Validate Binary Tree Nodes
def validateBinaryTreeNodes(n, leftChild, rightChild):
# time = O(N), space = O(N)
# (1) no node may have two parents
indeg = [0] * n
for i in range(n):
for c in (leftChild[i], rightChild[i]):
if c != -1:
indeg[c] += 1
if indeg[c] > 1:
return False
# (2) exactly one root
roots = [i for i in range(n) if indeg[i] == 0]
if len(roots) != 1:
return False
# (3) root must reach every node
seen, stack = 1, [roots[0]]
while stack:
node = stack.pop()
for c in (leftChild[node], rightChild[node]):
if c != -1:
seen += 1
stack.append(c)
return seen == n
題目分類
依分類整理的題型
課程排程類
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 基本的環偵測 | 207 | 檢查是否為 DAG |
| 找出合法順序 | 210 | 回傳拓撲順序 |
| 帶時間限制 | 630 | 貪婪 + 優先佇列 |
| 查詢先修關係 | 1462 | Floyd-Warshall/DFS |
任務排程類
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 最短時間 | 1136、2050 | 逐層 BFS |
| 平行執行 | 1136 | 計算層數 |
| 帶執行時長 | 2050 | 在完成時間上做 DP |
字典序排序
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 字元順序 | 269 | 由相鄰比較建圖 |
| 驗證順序 | 953 | 檢查一致性 |
| 自訂比較器 | 269 | 從範例中萃取規則 |
建置順序與相依性
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 唯一重建 | 444 | 佇列大小恆為 1 |
| 食譜相依 | 2115 | 處理初始原料 |
| 群組相依 | 1203 | 兩層拓撲排序 |
圖的分層
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 找出所有祖先 | 2192 | 遍歷反向圖 |
| 可達的最富有者 | 851 | DFS + 記憶化 |
| 路徑值最大化 | 1857 | DAG 上的 DP |
| 隱式 DAG 上的最長路徑 | 329 | 邊由 a < b 隱含;層數 = 路徑長度 |
度數計算(不需遍歷)
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 最小起始集合 | 1557 | 在 DAG 上答案就是入度 0 的節點 |
| 節點指紋 | 997、277 | 法官/名人 = 入度 n-1 且出度 0;可摺成 score = in - out == n - 1 |
| 唯一源點 | 2924、2923 | 冠軍 = 唯一那個入度 0 的節點,否則 -1 |
| 帶權度數 | 2374、1615 | 累加編號或單純的度數,而不是數邊 |
| 驗證樹的形狀 | 1361 | 入度 ≤ 1 + 唯一的根 + 根能到達全部 n 個節點 |
環的偵測
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 安全狀態 | 802 | 反向圖 + 出度 |
| 所有路徑皆安全 | 1059 | DFS 並追蹤路徑 |
| 偵測任意環 | 207 | 三色 DFS |
連通分量(併查集/DFS)
| 模式 | 題目 | 關鍵洞見 |
|---|---|---|
| 計算分量數 | 547、323 | 併查集或 DFS 遍歷 |
| 找出多餘的邊 | 684 | 併查集偵測環 |
| 島嶼數量 | 200 | 在格子上做 DFS/BFS |
找樹的重心
同一套機制 — 建 degree 陣列、用葉節點當佇列種子、由外往內剝(模板 7):
| 題目 | 難度 | 和 LC 310 的差別 |
|---|---|---|
| 310 Minimum Height Trees | Medium | 模板本身 — 剝到 remaining <= 2,回傳存活的節點 |
| 1245 Tree Diameter | Medium | 讀的是剝了幾層而不是存活者:剩 1 個是 2 * layers,剩 2 個是 2 * layers + 1。(課本解法是兩次 BFS;這裡是重用同一套剝除。) |
| 2603 Collect Coins in a Tree | Hard | 剝兩輪:先反覆丟掉身上沒有硬幣的葉節點,再丟掉剛好 2 層葉節點;答案 = 2 * remaining_edges |
| 802 Find Eventual Safe States | Medium | 有向版的表親 — 以 out_degree == 0 當種子剝反向圖,存活下來的是不安全的節點 |
同一棵樹,換工具 — 當答案不是樹的正中間時,改用這些:
| 題目 | 難度 | 為什麼葉節點剝除不適用 |
|---|---|---|
| 543 Diameter of Binary Tree | Easy | 樹已經有根了 — 一趟回傳高度的 DFS 就好,不必算度數 |
| 1522 Diameter of N-Ary Tree | Medium | 同樣的 DFS,取最大的兩個子節點高度相加 |
| 834 Sum of Distances in Tree | Hard | 要的是每個節點的答案 → 換根 DP,不是剝一次就好 |
| 863 All Nodes Distance K in Binary Tree | Medium | 先補上父節點連結,再從某個節點往外 BFS,方向剛好相反 |
| 1443 Minimum Time to Collect All Apples in a Tree | Medium | 剪的依據是內容(子樹裡沒有蘋果),不是度數 |
決策框架
START: Topological Sort Problem
│
├── Working with undirected tree?
│ │
│ ├── YES → Finding tree center/centroid?
│ │ │
│ │ ├── YES → Use Template 7 (Tree Centroid Finding)
│ │ │
│ │ └── NO → Continue
│ │
│ └── NO → Continue
│
├── Need all valid orderings?
│ │
│ ├── YES → Use Template 5 (Backtracking)
│ │
│ └── NO → Continue
│
├── Need lexicographical order?
│ │
│ ├── YES → Use Template 4 (Priority Queue)
│ │
│ └── NO → Continue
│
├── Need parallel execution time?
│ │
│ ├── YES → Use Template 6 (Level BFS)
│ │
│ └── NO → Continue
│
├── Need to count connected components?
│ │
│ ├── YES → Use Template 8 (Union Find)
│ │
│ └── NO → Continue
│
├── Need cycle detection only?
│ │
│ ├── YES → Use Template 2 (Three-Color DFS)
│ │
│ └── NO → Continue
│
└── DEFAULT → Use Template 1 (BFS Kahn's Algorithm)
上面的
UnionFind類別就是模板;LC 547 用三種方式套用它—— 併查集、DFS 與 BFS——見 topology_sorting_examples.md。
完整解題範例
八道題目,依它們所編碼的相依關係形狀分組,收錄在 topology_sorting_examples.md:
| 分組 | 題目 | 練到的模板 |
|---|---|---|
| 課程排程與排序 | LC 210、207、269、444 | Kahn(T1)、三色 DFS(T2)、字典序(T4) |
| 分層與平行排程 | LC 1136 | 平行任務排程(T6) |
| 環的偵測與安全狀態 | LC 802 | 三色 DFS(T2) |
| 無向圖 — 連通分量與重心 | LC 310、547 | 葉節點剝除(T7)、併查集(T8) |
總結與面試提示
常見陷阱
- 忘了偵測環:永遠要檢查結果的大小是否等於節點數
- 邊的方向搞反:記住邊是從先修指向相依者
- 沒處理不連通的部分:所有尚未拜訪的節點都要處理
- 入度初始化錯誤:確保所有節點都被納入
- 漏掉邊界情況:空圖、單一節點、自環
- 混淆度數與入度:無向樹用總度數,DAG 用入度
- 停止條件錯誤:找樹的重心時要在剩下 ≤2 個節點時停(不是等佇列空)
關鍵洞見
- BFS vs DFS:要找出一個順序時 BFS 比較簡單,要列出所有順序則用 DFS
- 追蹤入度:入度為 0 的節點才可以被處理
- 三色 DFS:白(未拜訪)、灰(拜訪中)、黑(已完成)
- 反向圖:對安全狀態這類題目很有用
- 逐層處理:用於平行執行與求最短時間
- 找樹的重心:無向樹要用度數(不是入度),逐層剝除葉節點直到剩下 1-2 個節點
- 無向 vs 有向:無向樹需要雙向邊並追蹤度數,DAG 則使用入度
面試作法
-
釐清需求:
- 圖是否保證為 DAG?
- 需要所有順序還是只要一個?
- 對順序有沒有特別偏好?
-
選擇演算法:
- 預設用 BFS(Kahn)因為最單純
- 遞迴類問題用 DFS
- 需要字典序時用優先佇列
-
處理邊界情況:
- 空圖
- 單一節點
- 不連通的部分
- 圖中有環
-
必要時再最佳化:
- 偵測到環就提早終止
- 用原地修改節省空間
- 用更好的資料結構縮短時間
時間/空間複雜度總表
| 操作 | 時間 | 空間 | 備註 |
|---|---|---|---|
| 建圖 | O(E) | O(V + E) | 鄰接表 |
| 計算入度 | O(E) | O(V) | 陣列或雜湊表 |
| BFS/DFS 遍歷 | O(V + E) | O(V) | 每個節點/邊只走一次 |
| 偵測環 | O(V + E) | O(V) | 三色標記 |
| 列舉所有順序 | O(V! × E) | O(V) | 所有排列,指數級 |
相關概念
- 強連通分量:Tarjan/Kosaraju 演算法
- DAG 上的最短路徑:拓撲排序 + 鬆弛
- 要徑法(Critical Path Method):專案排程
- 相依性解析:套件管理器、建置系統
- 資料流分析:編譯器最佳化