BFS(廣度優先搜尋)
範圍 — BFS 的主要參考文件:佇列模板、逐層展開、格子與多源 BFS,以及為什麼在無權圖上「第一次拜訪」就等於最短路徑;較重的變形與大量解題範例則各自放在自己的文件裡。 另見 — 從本文拆分出去的深入探討:bfs_advanced.md — 雙向 BFS、搭配 deque 的 0-1 BFS、狀態空間/隱式圖 BFS、DAG 上所有最短路徑的列舉,以及多源與各自獨立執行的差別;bfs_examples.md — 完整解題彙整(LC 130 / 207 / 279 / 286 / 310 / 417 / 623 / 742 / 752 / 909 / 116-117 …)以及 LC 994 的計時逐步說明。 相鄰文件:dfs.md — 深度優先的對照組,以及該怎麼選;graph.md — 圖的表示法與題型總覽;Dijkstra.md — 一旦邊有了權重就看這裡;topology_sorting.md — Kahn 演算法本質上就是 BFS。
LeetCode 題目清單
總覽
廣度優先搜尋是一種逐層探索節點的圖走訪演算法:先拜訪完當前深度的所有節點,才前進到下一個深度。
關鍵性質
- 完備:只要解存在就一定找得到
- 最佳:在
unweighted(無權)圖上能找到最短路徑 - 複雜度:見下方 Time & Space Complexity 表格
核心特徵
- 使用佇列資料結構(FIFO,先進先出)
- 在無權圖上保證最短路徑
- 逐層探索節點(先廣後深)
- 相較於 DFS 較耗記憶體
節點狀態(用於偵測環)
- 狀態 0:尚未拜訪(白)
- 狀態 1:處理中(灰)
- 狀態 2:已處理完畢(黑)
BFS vs DFS
🔹 BFS(廣度優先搜尋)
- 使用佇列
- 順序:FIFO(先進先出)
- 運作方式:拜訪一個節點 → 把所有鄰居加入佇列 → 依加入順序處理
- 👉 想成:逐層走訪
🔹 DFS(深度優先搜尋)
- 使用堆疊(顯式,或透過遞迴)
- 順序:FILO / LIFO(後進先出)
- 運作方式:沿著一條路徑盡量往深處走 → 需要時再回溯
- 👉 想成:先走深,再回溯
| 面向 | BFS | DFS |
|---|---|---|
| 資料結構 | 佇列(FIFO) | 堆疊/遞迴(LIFO) |
| 走訪順序 | 逐層 | 先走深路徑,再回溯 |
| 記憶體 | O(w) — 樹的寬度 | O(h) — 樹的高度 |
| 最短路徑 | ✅ 是(無權) | ❌ 否 |
| 完備性 | ✅ 是 | ❌ 否(無限空間時) |
| 使用時機 | 最短路徑、逐層走訪 | 探索所有路徑、拓撲排序、偵測環 |
時間與空間複雜度
BFS 時間複雜度分析
BFS 的時間複雜度取決於圖的表示法:
🔹 圖的表示法
鄰接串列(實務上最常見):
- 每個頂點入列/出列各一次 → O(V)
- 每條邊最多被檢視一次 → O(E)
- ✅ 總計 = O(V + E)
鄰接矩陣:
- 檢查一個頂點的所有鄰居要 O(V)
- 對所有頂點都做一次要 O(V²)
- ✅ 總計 = O(V²)
依資料結構的細部拆解
樹的 BFS
- 時間:O(n) - 每個節點拜訪一次
- 空間:O(w) - 樹的最大寬度
- 說明:每個節點恰好被拜訪一次,佇列最多存放一層
圖的 BFS(鄰接串列)
- 時間:O(V + E) - 每個頂點與每條邊各處理一次
- 空間:O(V) - 佇列與 visited 集合
- 說明:
- 頂點處理:每個頂點入列/出列各一次 = O(V)
- 邊的處理:每條邊檢視一次 = O(E)
- 佇列空間:最多 O(V) 個頂點
- visited 集合:O(V) 個頂點
圖的 BFS(鄰接矩陣)
- 時間:O(V²) - 檢查所有可能的邊
- 空間:O(V) - 佇列與 visited 集合
- 說明:
- 對每個頂點,檢查全部 V 個可能鄰居
- 頂點總數 × 每個頂點的鄰居數 = V × V = O(V²)
格子的 BFS
- 時間:O(m × n) - 每一格拜訪一次
- 空間:O(m × n) - 最差情況的佇列大小
- 說明:
- 每一格最多被拜訪一次
- 最差情況下佇列可能裝下所有格子
- 格子本質上就是一張有 m×n 個頂點、四方向邊的圖
效能比較表
| 圖的類型 | 表示法 | 時間複雜度 | 空間複雜度 | 適用情境 |
|---|---|---|---|---|
| 稀疏圖 | 鄰接串列 | O(V + E) | O(V) | E << V² |
| 稠密圖 | 鄰接矩陣 | O(V²) | O(V²) | E ≈ V² |
| 樹 | 父子連結 | O(n) | O(w) | 階層式資料 |
| 格子 | 二維陣列 | O(m × n) | O(m × n) | 空間類問題 |
鄰接串列為什麼是 O(V + E)?
# Detailed analysis of BFS with adjacency list
def bfs_analysis(graph, start):
queue = deque([start]) # O(1)
visited = {start} # O(1)
while queue: # Executes at most V times
vertex = queue.popleft() # O(1) - each vertex dequeued once
# This inner loop runs exactly deg(vertex) times
for neighbor in graph[vertex]: # Total across all vertices = E
if neighbor not in visited: # O(1) with set
visited.add(neighbor) # O(1) - each vertex added once
queue.append(neighbor) # O(1) - each vertex enqueued once
# Analysis:
# - Outer while loop: O(V) iterations
# - Inner for loop: Sum of deg(v) for all v = 2E (undirected) or E (directed)
# - Each operation inside: O(1)
# Total: O(V + E)
實作模式
Pattern 編號在三份 BFS 文件之間保持一致。Pattern 4.5、4.6、6、8、8.5、9、10、12、14 與 15 是深入探討,放在 bfs_advanced.md;
§2-N的解題範例放在 bfs_examples.md。
Pattern 1:基本的樹 BFS
from collections import deque
def bfs_tree(root):
if not root:
return []
queue = deque([root])
result = []
while queue:
node = queue.popleft()
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
Pattern 2:逐層 BFS — LC 102 Priority 5 of 5 — Must know — expect it in almost every loop
def bfs_levels(root):
if not root:
return []
queue = deque([root])
levels = []
while queue:
level_size = len(queue)
current_level = []
for _ in range(level_size):
node = queue.popleft()
current_level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
levels.append(current_level)
return levels
變形:彈出第一個葉節點時就回傳 — LC 111 (Minimum Depth of Binary Tree)
轉折:不必走完整棵樹——BFS 會最先抵達最淺的葉節點,所以一旦出列的節點沒有子節點就立刻回傳。在一條長長的左歪斜脊上,DFS 會拜訪每個節點;BFS 在第一個葉節點就收工。(對照 LC 104 Maximum Depth:那題你必須看過每一層,所以 DFS 遞迴才是更俐落的工具。)
# python
# LC 111 - Minimum Depth of Binary Tree
# time = O(N) worst case but exits early, space = O(W)
# IDEA: first dequeued leaf = shallowest leaf -> answer
def minDepth(root):
if not root:
return 0
q = deque([root])
depth = 1
while q:
for _ in range(len(q)):
node = q.popleft()
if not node.left and not node.right:
return depth # early exit: BFS found the shallowest leaf
if node.left:
q.append(node.left)
if node.right:
q.append(node.right)
depth += 1
return depth
另外兩個逐層 BFS 的變形——攜帶 heap index(LC 662)以及把
null子節點也入列(LC 958)——放在 bfs_examples.md。
Pattern 3:搭配 visited 集合的圖 BFS — LC 200
def bfs_graph(start, graph):
queue = deque([start])
visited = set([start])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return result
Pattern 3.1:visited 集合的擺放規則——入列前就標記 Priority 5 of 5 — Must know — expect it in almost every loop
一個關鍵的 BFS 實作細節:永遠在把某一格加入佇列「之前」就標記為已拜訪(更新格子狀態與計數器),而不是等到出列時才做。
規則
Mark visited + update count → THEN add to queue
通用 BFS 模板(標準形式):
visited = {start}
q.append(start)
while q:
node = q.popleft()
for nei in neighbors(node):
if nei not in visited:
visited.add(nei) # <-- before enqueue
q.append(nei)
當狀態更新不單純時(格子變更、計數器)的三步驟模式:
# 1. Validate the neighbor
if neighbor_is_valid and neighbor_not_visited:
# 2. Update state IMMEDIATELY (mark visited / mutate grid / decrement counter)
mark_as_visited(neighbor)
# 3. Enqueue the neighbor
queue.append(neighbor)
// ✅ CORRECT: Mark BEFORE enqueue
if (grid[nr][nc] == 1) {
grid[nr][nc] = 2; // mark immediately
freshOrange--; // update count immediately
q.add(new int[]{nr, nc});
}
// ❌ WRONG: Mark AFTER dequeue
int[] cur = q.poll();
grid[cur[0]][cur[1]] = 2; // too late! duplicates already in queue
為什麼這件事重要
如果你把標記延後到出列才做,多個鄰居可能在任何一個處理它之前就重複把同一格入列:
BFS Layer 1: Cells A and B are both neighbors of cell X (fresh orange)
Thread of execution:
1. Process A → sees X is fresh → enqueues X
2. Process B → sees X is STILL fresh (not marked yet!) → enqueues X AGAIN
3. Dequeue X → mark as rotten, freshOrange--
4. Dequeue X again → already rotten, but freshOrange-- happens again! (WRONG)
結果:重複計數、答案錯誤,或白白浪費運算。
入列前標記所保證的:
| 保證 | 說明 |
|---|---|
| 佇列中沒有重複 | 在任何其他鄰居看到它之前,該格已被標記為已拜訪 |
| 計數正確 | 每一格恰好被計算一次 |
| O(m x n) 時間 | 每一格最多入列一次 |
| BFS 層次正確 | 層的邊界維持準確,計時/距離才不會錯 |
適用的情況
| 情境 | 為什麼入列前標記很重要 |
|---|---|
| 計數(新鮮橘子、感染數) | 避免計數器被重複遞減 |
| 計時/距離(經過的分鐘數) | 確保該格被歸到正確的 BFS 層 |
| 格子變更(腐爛擴散、洪水填充) | 避免同一格被處理多次 |
| 用格子值來追蹤已拜訪 | 格子本身就是 visited 集合;必須在入列前標記 |
使用獨立 visited 集合時
同樣的原則成立——在入列時加入 visited,而不是出列時:
// CORRECT
if (!visited[nr][nc]) {
visited[nr][nc] = true; // mark BEFORE enqueue
queue.offer(new int[]{nr, nc});
}
// WRONG
int[] cur = queue.poll();
visited[cur[0]][cur[1]] = true; // too late
相關 LeetCode 題目
| 題目 | 為什麼入列前標記至關重要 |
|---|---|
| LC 994 - Rotting Oranges | 計數器 freshOrange-- 每一格必須恰好執行一次 |
| LC 542 - 01 Matrix | 距離必須在第一次(也就是最短)拜訪時就指派 |
| LC 286 - Walls and Gates | 房間距離不能被較長的路徑覆寫 |
| LC 1162 - As Far from Land as Possible | 同樣是多源 BFS,距離必須在第一次抵達時設定 |
| LC 200 - Number of Islands | 入列時就標記可避免重複拜訪同一塊陸地 |
| LC 934 - Shortest Bridge | 擴張島嶼邊界時不能重複計算水格 |
| LC 127 - Word Ladder | 單字必須在入列時標記已拜訪,才不會產生重複路徑 |
小結——入列前標記 vs 入列後標記
在 BFS 中,你決定某個鄰居該進佇列的那一刻,就是你做出承諾的那一刻——標記它已拜訪、更新計數器、變更格子。永遠不要把狀態變更延到出列時才做。這不是優化,這是正確性要求。
Pattern 3.2:搭配方向陣列的格子 BFS — LC 1091
從左上角穿過 0 的格子 BFS 到右下角(八方向)。
慣用寫法:把移動方向提取成
dirs陣列再迴圈跑過 — 一般四鄰格用int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}}(見 Pattern 4),要算對角線時就用下方那八組。先做邊界檢查, 再入列前標記(Pattern 3.1)——這裡格子本身就是 visited 集合。
// LC 1091 - Shortest Path in Binary Matrix
// IDEA: BFS — shortest path in unweighted graph
// time = O(N^2), space = O(N^2)
public int shortestPathBinaryMatrix(int[][] grid) {
int n = grid.length;
if (grid[0][0] == 1 || grid[n-1][n-1] == 1) return -1;
int[][] dirs = {{0,1},{1,0},{0,-1},{-1,0},{1,1},{1,-1},{-1,1},{-1,-1}};
Queue<int[]> queue = new LinkedList<>();
queue.offer(new int[]{0, 0, 1});
grid[0][0] = 1; // mark visited
while (!queue.isEmpty()) {
int[] curr = queue.poll();
int r = curr[0], c = curr[1], dist = curr[2];
if (r == n-1 && c == n-1) return dist;
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr >= 0 && nr < n && nc >= 0 && nc < n && grid[nr][nc] == 0) {
grid[nr][nc] = 1;
queue.offer(new int[]{nr, nc, dist + 1});
}
}
}
return -1;
}
Pattern 4:多源 BFS(距離計算) — LC 542 / LC 994 Priority 5 of 5 — Must know — expect it in almost every loop
def multi_source_bfs(grid, sources):
"""Start BFS from multiple sources simultaneously"""
queue = deque(sources) # All sources at once
visited = set(sources)
while queue:
x, y = queue.popleft()
for dx, dy in [(0,1), (0,-1), (1,0), (-1,0)]:
nx, ny = x + dx, y + dy
if (0 <= nx < len(grid) and 0 <= ny < len(grid[0])
and (nx, ny) not in visited):
visited.add((nx, ny))
queue.append((nx, ny))
標準題:Rotting Oranges — 多源,一層 = 一分鐘
讓所有初始的腐爛橘子同時逐層向外擴散腐爛。
// LC 994 - Rotting Oranges
// IDEA: Multi-source BFS
// time = O(M*N), space = O(M*N)
public int orangesRotting(int[][] grid) {
int rows = grid.length, cols = grid[0].length;
Queue<int[]> queue = new LinkedList<>();
int fresh = 0;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 2) queue.offer(new int[]{r, c});
else if (grid[r][c] == 1) fresh++;
}
if (fresh == 0) return 0;
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
int minutes = 0;
while (!queue.isEmpty() && fresh > 0) {
minutes++;
int size = queue.size();
for (int i = 0; i < size; i++) {
int[] cell = queue.poll();
for (int[] d : dirs) {
int nr = cell[0] + d[0], nc = cell[1] + d[1];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == 1) {
grid[nr][nc] = 2;
fresh--;
queue.offer(new int[]{nr, nc});
}
}
}
}
return fresh == 0 ? minutes : -1;
}
Python 實作 — LC 994:
# IDEA: MULTI SRC BFS
# time = O(m × n), space = O(m × n)
from collections import deque
def orangesRotting(grid):
l = len(grid)
w = len(grid[0])
fresh = 0
q = deque()
for y in range(l):
for x in range(w):
if grid[y][x] == 1:
fresh += 1
elif grid[y][x] == 2:
q.append([x, y])
if fresh == 0:
return 0
if not q:
return -1
dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]
time = 0
while q and fresh > 0:
size = len(q)
for _ in range(size):
x, y = q.popleft()
for dx, dy in dirs:
x_ = x + dx
y_ = y + dy
if 0 <= x_ < w and 0 <= y_ < l and grid[y_][x_] == 1:
# NOTE: update RIGHT AWAY — before enqueue
# to avoid the same fresh orange being rotten several times
# (two rotten neighbors in the same layer would both see it as fresh
# and enqueue it twice, causing fresh to go negative)
grid[y_][x_] = 2
fresh -= 1
q.append([x_, y_])
time += 1 # increment AFTER processing the full level (Approach B)
return time if fresh == 0 else -1
如果我們把 grid[nr][nc] = 2(Java)/grid[y_][x_] = 2(Python)延後到出列才做,同一層裡的兩個腐爛鄰居就可能把同一顆新鮮橘子都入列,導致 fresh 變成負數而回傳錯誤答案。
距離變形:01 Matrix — 到最近來源的距離
從所有 0 的格子同時開始 BFS;距離會向外傳播。
// LC 542 - 01 Matrix
// IDEA: Multi-source BFS — enqueue all 0s first, then expand
// time = O(M*N), space = O(M*N)
public int[][] updateMatrix(int[][] mat) {
int m = mat.length, n = mat[0].length;
int[][] dist = new int[m][n];
Queue<int[]> queue = new LinkedList<>();
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++) {
if (mat[i][j] == 0) queue.offer(new int[]{i, j});
else dist[i][j] = Integer.MAX_VALUE;
}
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] cell = queue.poll();
for (int[] d : dirs) {
int nr = cell[0]+d[0], nc = cell[1]+d[1];
if (nr>=0 && nr<m && nc>=0 && nc<n && dist[nr][nc] > dist[cell[0]][cell[1]]+1) {
dist[nr][nc] = dist[cell[0]][cell[1]] + 1;
queue.offer(new int[]{nr, nc});
}
}
}
return dist;
}
這個模式為什麼有效:
- 同步擴張:所有來源以相同速率擴張 → 一層一層推進
- 第一次拜訪 = 最短:在無權 BFS 中,第一次抵達即保證是最短路徑
- 不需回溯:一格被拜訪過,就代表已找到它的最短距離
- 線性時間:每一格恰好拜訪一次 → 總計 O(m×n)
關鍵洞見 — 為什麼從 0 開始而不是從 1 開始?
- ❌ 從每個 1 開始 → O(m×n) 次 BFS 呼叫 → 總時間 O(m²×n²)
- ✅ 從所有 0 一起開始 → 單趟 BFS → 總時間 O(m×n)
- 原則:把問題翻轉過來——與其問「這個 1 離任一個 0 有多遠?」,改問「所有 0 能擴散到多遠?」
時間該在哪裡遞增——經驗法則: 如果你在一層的開頭做
time++,那 while 迴圈裡就必須有提早結束的條件(&& fresh > 0)。否則就在一層的結尾搭配旗標做time++。完整的 A vs B 逐步說明:bfs_examples.md → When to Increment Time/Distance。
Pattern 5:帶路徑追蹤的 BFS(攜帶路徑,而不是距離)
def bfs_with_path(start, target):
queue = deque([(start, [start])])
visited = {start}
while queue:
node, path = queue.popleft()
if node == target:
return path
for neighbor in get_neighbors(node):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return None
Pattern 7:無權圖上的最短路徑 — BFS + 原地狀態變更 — LC 127 Priority 5 of 5 — Must know — expect it in almost every loop
/**
* Pattern: BFS + Backtracking for exploring transformations
* Use case: Word transformations, state space exploration where each state can transform to multiple neighbors
* Key insight: Modify state in-place, explore all neighbors, restore state before moving to next position
*
* Time: Depends on state space (e.g., O(N * M * 26) for word ladder where N=words, M=length)
* Space: O(N) for visited set, O(M) for char array
*/
public int bfsWithBacktracking(String beginWord, String endWord, List<String> wordList) {
Set<String> dict = new HashSet<>(wordList);
Set<String> visited = new HashSet<>();
Queue<String> q = new LinkedList<>();
q.add(beginWord);
visited.add(beginWord);
int steps = 1; // beginWord counts as step 1
String alpha = "abcdefghijklmnopqrstuvwxyz";
while (!q.isEmpty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
String cur = q.poll();
// Early exit when target found
if (cur.equals(endWord))
return steps;
// Convert to char array for efficient modification
char[] arr = cur.toCharArray();
/**
* Key Insight: Backtracking allows exploring ALL transformations
*
* For each position, we try ALL 26 letters:
* - Position 0: try a-z → explore all words with same letters at positions 1,2,...
* - Position 1: try a-z → explore all words with same letters at positions 0,2,...
* - Position 2: try a-z → explore all words with same letters at positions 0,1,...
*
* This ensures no valid neighbor is missed.
*/
// Loop 1: Try all positions in the word
for (int j = 0; j < arr.length; j++) {
char original = arr[j];
// Loop 2: Try all 26 letters at this position
for (char c : alpha.toCharArray()) {
if (c == original)
continue;
/**
* TRICK: Modify char array in-place to create new word
*
* This is more efficient than string concatenation:
* ✅ String s = beginWord.substring(0,j) + c + beginWord.substring(j+1)
* ← Creates new String each time (slow)
*
* ✅ char[] arr = word.toCharArray();
* arr[j] = c;
* String newStr = new String(arr); ← Reuse array (fast)
*/
arr[j] = c;
String newStr = new String(arr);
if (dict.contains(newStr) && !visited.contains(newStr)) {
/**
* CRITICAL: Mark as visited BEFORE adding to queue
*
* This prevents duplicate enqueuing:
* - If we defer marking until dequeue, multiple neighbors
* could see the same unvisited word and enqueue it multiple times
* - Marking before enqueue ensures each word processed exactly once
*/
visited.add(newStr);
q.add(newStr);
}
}
/**
* CRITICAL: Restore original character AFTER exploring all 26 letters at this position
*
* This is the "backtracking" step:
* - We modified arr[j] to try all 26 letters
* - Before moving to arr[j+1], we must restore arr[j]
* - Otherwise, arr[j+1] modification would operate on wrong base state
*
* Example:
* Position 0: Try 'a','b','c',... → restore to 'h'
* Position 1: Try 'a','b','c',... → restore to 'i' ← must have 'h' at position 0!
* Position 2: Try 'a','b','c',... → restore to 't' ← must have 'h','i' at positions 0,1!
*/
arr[j] = original; // Restore before next iteration
}
}
steps++;
}
return 0; // No path found
}
這個模板的逐行解說——執行追蹤、為什麼還原那一步是必要的,以及它與其他 BFS 形狀的比較——在 bfs_advanced.md。
Pattern 11:父節點對照表 + 從目標向外輻射的 BFS — LC 863 Priority 5 of 5 — Must know — expect it in almost every loop
a. 核心想法
「距離某個節點多遠」(不是距離根多遠)⇒ 把樹變成無向圖,再從那個節點向外 BFS。
二元樹只儲存向下的指標(left、right),但距離 target 為 k 的節點可能出現在三個地方:在它下面、在它上面,或在兄弟子樹裡(先上後下)。只會往下走的單一次 DFS 永遠碰不到它們。
分兩步修正:
- 先做一次 DFS 記錄
{node: parent}— 這是唯一缺少的邊方向。你不需要完整的鄰接表(像 Pattern 10 那樣):left、right本來就在節點上,所以每個節點最多有 3 個鄰居 =(left, right, parent)。 - 從
target開始以(node, dist)做 BFS,往這 3 個方向展開。因為每條邊成本都是 1,dist就是精確的樹上距離——當dist == k時收集node.val並停止展開那一支(continue)。
兩個讓它正確的關鍵:
| 元素 | 為什麼不可省 |
|---|---|
visited 集合 |
加上父邊之後圖變成無向的 → BFS 會在 child → parent → child 之間永遠來回。單純由上而下的樹走訪從不需要 visited;這裡則一定要。 |
dist == k 時 continue |
超過 k 的節點無關緊要,而且它們唯一的回頭路徑得經過一個已被收集的節點。停止展開能限制工作量,也避免多收。 |
b. 模式
# python — LC 863 All Nodes Distance K in Binary Tree
# IDEA: DFS build {node: parent}, then BFS "radiate outward" from target
# time = O(n) each node is parented once + enqueued at most once
# space = O(n) parent map + queue + visited
import collections
class Solution(object):
def distanceK(self, root, target, k):
# Step 1: map every node to its parent (the missing "up" edge)
parents = {}
def add_parents(node, parent):
if not node:
return
parents[node] = parent
add_parents(node.left, node)
add_parents(node.right, node)
add_parents(root, None)
# Step 2: BFS outward from target
queue = collections.deque([(target, 0)]) # (current_node, distance)
visited = set([target]) # MUST have: graph is undirected now
ans = []
while queue:
node, dist = queue.popleft()
if dist == k:
ans.append(node.val)
continue # don't expand past k
# 3 directions: down-left, down-right, UP (parent)
for neighbor in (node.left, node.right, parents[node]):
if neighbor and neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, dist + 1))
return ans
// java — LC 863 All Nodes Distance K in Binary Tree
// IDEA: DFS build parent map, then BFS k steps out from target
// time = O(n), space = O(n)
public List<Integer> distanceK(TreeNode root, TreeNode target, int k) {
Map<TreeNode, TreeNode> parents = new HashMap<>();
buildParents(root, null, parents);
Deque<Object[]> queue = new ArrayDeque<>();
queue.offer(new Object[]{target, 0});
Set<TreeNode> visited = new HashSet<>();
visited.add(target);
List<Integer> ans = new ArrayList<>();
while (!queue.isEmpty()) {
Object[] cur = queue.poll();
TreeNode node = (TreeNode) cur[0];
int dist = (int) cur[1];
if (dist == k) { // exactly k edges away
ans.add(node.val);
continue; // stop expanding this branch
}
for (TreeNode nei : new TreeNode[]{node.left, node.right, parents.get(node)}) {
if (nei != null && visited.add(nei)) { // add() returns false if seen
queue.offer(new Object[]{nei, dist + 1});
}
}
}
return ans;
}
private void buildParents(TreeNode node, TreeNode parent, Map<TreeNode, TreeNode> parents) {
if (node == null) return;
parents.put(node, parent);
buildParents(node.left, node, parents);
buildParents(node.right, node, parents);
}
圖解追蹤 — root = [3,5,1,6,2,0,8,null,null,7,4]、target = 5、k = 2
3 BFS from 5 (each layer = distance):
/ \ dist 0 : 5
5 1 dist 1 : 6, 2 (children) , 3 (PARENT ← the key edge)
/ \ / \ dist 2 : 7, 4 (via 2) , 1 (via 3, sibling subtree)
6 2 0 8 ^^^^^^^^^^^^^^^^^^^^^ answer = [7, 4, 1]
/ \
7 4 Without the parent edge you would only find 7 and 4 — the
node `1` requires going UP to 3, then DOWN to 1.
辨識訊號
- 距離/鄰居是從任意一個節點去量測,而不是從根。
- 答案集合可能包含祖先或兄弟子樹裡的節點。
- 出現「距離
target為k」、「從節點start擴散/感染」、「離節點k最近的 X」這類措辭。
變形:做 k 次逐層 BFS,然後把佇列倒出來 — tuple 裡不放 dist;展開 k 次之後,佇列本身就是答案集合。完整程式碼、A vs B 比較與陷阱表:bfs_examples.md §2-18。
DFS 替代解(「距離滲透」):用後序 DFS 回傳
target在各子樹中的深度;在目標上方d條邊處的節點,往另一個子節點收集k - d層下方的節點。同樣是 O(n),遞迴之外只要 O(1) 額外空間——但寫錯的機率高得多。BFS + 父節點對照表才是面試上穩妥的答案。
父節點對照表(本模式) vs 完整鄰接表(Pattern 10)
父節點表 {node: parent} |
鄰接表 {node: [neighbors]} |
|
|---|---|---|
| 建立方式 | 一次 DFS,每個節點 1 筆 | 一次 DFS,每條邊 2 筆 |
| 取得鄰居 | (node.left, node.right, parents[node]) |
graph[node] |
| 空間 | 約 n 個指標 | 約 2(n-1) 個串列項目 |
| 使用時機 | 節點是可直接解參照的真實 TreeNode 物件 |
你操作的是值,或結構本身不是二元樹 |
c. 相似 LC
| 題目 | LC # | 與本模式的關聯 |
|---|---|---|
| All Nodes Distance K in Binary Tree | 863 | 標準題 — 父節點表 + 向外 BFS k 步 |
| Amount of Time for Binary Tree to Be Infected | 2385 | 同一套父節點表 BFS;答案 = 最大距離(最後一層 BFS) |
| Closest Leaf in a Binary Tree | 742 | 從目標向外 BFS,在彈出第一個葉節點時停止(§2-15) |
| Find Distance in a Binary Tree | 1740 | 兩節點間的距離 = 從其中一個 BFS 直到另一個被彈出(或用 LCA) |
| Number of Good Leaf Nodes Pairs | 1530 | Pattern 10 — 從每個葉節點向外做有界 BFS |
| Step-By-Step Directions From a Binary Tree Node | 2096 | 同樣的先上後下洞見,但重建的是路徑而非距離 |
| Cousins in Binary Tree | 993 | 每個節點需要 parent + depth — 只要父節點表,不需向外輻射 BFS |
| All Possible Full Binary Trees / LCA 236 | 236 | LCA 就是先上後下路徑的「轉折點」 |
| Minimum Height Trees | 310 | 無向樹的 BFS,是往內修剪而非往外輻射(§2-10) |
模式重點:一旦某題樹的問題是從根以外的節點去量測某件事,就別再想「樹的遞迴」, 改想**「無向圖」**——補上父連結(
{node: parent}對照表,或node.par標註), 接著它就是一場普通的 BFS,其中每個節點有 3 個鄰居,而visited是不可省略的。
Pattern 13:BFS 二著色(二分圖檢查) — LC 785 Priority 4 of 5 — High value — a gap here costs you rounds
關鍵想法:BFS 不一定要攜帶距離——它也可以攜帶標籤。把起點塗成 0,每個鄰居塗成相反的顏色(color ^ 1)。如果 BFS 遇到某個已上色的鄰居顏色相同,就代表存在奇數長度的環 → 不是二分圖。
兩個陷阱:
- 不連通的圖 — 你必須用
for (s = 0..n-1)迴圈,對每個尚未上色的節點都重新開一次 BFS;一次 BFS 只涵蓋一個連通元件。 - 不要用單純的
visited布林值 — 以-1 = 未拜訪的color[]同時扮演已拜訪標記與答案。
// java
// LC 785 - Is Graph Bipartite?
// time = O(V + E), space = O(V)
// IDEA: BFS paints alternating colors; same-color neighbor => odd cycle => false
public boolean isBipartite(int[][] graph) {
int n = graph.length;
int[] color = new int[n];
Arrays.fill(color, -1); // -1 = uncolored (doubles as "unvisited")
for (int s = 0; s < n; s++) {
if (color[s] != -1) continue; // must restart per component
color[s] = 0;
Queue<Integer> q = new LinkedList<>();
q.offer(s);
while (!q.isEmpty()) {
int cur = q.poll();
for (int nxt : graph[cur]) {
if (color[nxt] == -1) {
color[nxt] = color[cur] ^ 1; // flip 0 <-> 1
q.offer(nxt);
} else if (color[nxt] == color[cur]) {
return false; // conflict
}
}
}
}
return true;
}
# python
# LC 785 - Is Graph Bipartite?
# time = O(V + E), space = O(V)
# IDEA: color[-1]=unvisited; BFS flips color; equal colors on an edge => not bipartite
def isBipartite(graph):
n = len(graph)
color = [-1] * n
for s in range(n):
if color[s] != -1:
continue # component already done
color[s] = 0
q = deque([s])
while q:
cur = q.popleft()
for nxt in graph[cur]:
if color[nxt] == -1:
color[nxt] = color[cur] ^ 1
q.append(nxt)
elif color[nxt] == color[cur]:
return False
return True
注意:同樣這招「BFS 攜帶的是標籤而非距離」也能解決「把互相討厭的人分成 2 組」這類題目——先從配對建出鄰接串列,然後這段程式碼原封不動就能跑。
總結與速查
下方的
Pattern 4.5 / 4.6 / 6 / 8 / 8.5 / 9 / 10 / 12 / 14 / 15放在 bfs_advanced.md;§2-N的參照放在 bfs_examples.md。
什麼時候該用 BFS
- 在無權圖上找最短路徑
- 樹的層序走訪
- 找連通元件
- 檢查圖是否為二分圖
- 網頁爬蟲(廣度優先探索)
- 同步的多源距離計算(Pattern 4)- 到最近來源的距離
- 從多個來源各自獨立執行 BFS(Pattern 4.6)- 到所有來源的距離總和
什麼時候不該用 BFS
- 很深的樹/圖,而記憶體有限
- 只需要找出「任一」條路徑(不必最短)
- 成本不一的加權圖(改用 Dijkstra)
- 需要探索所有路徑(改用 DFS)
BFS vs Dijkstra — 該用哪一個
| 判準 | BFS | Dijkstra |
|---|---|---|
| 邊的權重 | 全部相同(無權)或 0/1 | 非負、且大小不一 |
| 資料結構 | 佇列(LinkedList) |
優先佇列(最小堆積) |
| 時間複雜度 | O(V + E) | O((V + E) log V) |
| 第一次拜訪 = 最短? | ✅ 是(層數 = 距離) | ❌ 否(必須透過 PQ 鬆弛) |
| 「最少步數/移動次數」 | ✅ 用 BFS | ❌ 殺雞用牛刀 |
| 「最小成本/權重」 | ❌ 會得到錯誤答案 | ✅ 用 Dijkstra |
| 成本一致的格子 | ✅ BFS | ❌ 不必要的額外開銷 |
| 成本不一的格子 | ❌ | ✅ 在隱式圖上跑 Dijkstra |
決策規則:若每條邊成本相同(或成本為 1),就用 BFS。一旦邊有了不同的權重,就換成 Dijkstra。
常見陷阱:對 LC 279 Perfect Squares 或 LC 752 Open the Lock 這類所有邊成本都是 1 的題目使用 Dijkstra(PQ)——單純的 BFS 更簡單也更快。
0-1 BFS 特例:如果邊的權重只有 0 或 1,就用 deque ——權重 0 的邊推到前端,權重 1 的邊推到後端。時間跟 BFS 一樣是 O(V+E),又能正確處理兩種權重。
常見錯誤與最佳實務
❌ 常見錯誤
- 對 list 用
queue.pop()而不是queue.popleft() - 在圖上沒有處理 visited 集合(造成無限迴圈)
- 需要逐層處理時卻忘了分層
- 格子題的邊界檢查寫錯
✅ 最佳實務
- 用
collections.deque以獲得更好的效能 - 圖的題目一律使用 visited 集合
- 格子題在加入佇列前先檢查邊界
- 考慮用多源 BFS 來優化
- 需要最短路徑時記得追蹤層數/距離
- 在入列前而非出列後標記狀態 — 決定要把鄰居入列的那一刻就更新 grid/visited/計數器;延到出列才做會讓多個鄰居重複把同一格入列(見上方 Pattern 3.1)
依題型分類的題目
1. 樹的走訪問題
- 層序走訪:LC 102, 107, 103
- 二元樹路徑:LC 257, 1022
- 右視圖:LC 199
- 垂直順序:LC 314
- 逐層改動樹:LC 623 (Add One Row)、LC 116/117 (Next Right Pointers)
- 距離任意節點多遠(Pattern 11 — 父節點表 + 向外輻射):LC 863 (Distance K)、LC 2385 (Tree Infection)、LC 742 (Closest Leaf)、LC 1740 (Find Distance)
2. 最短路徑問題
- 無權圖:LC 127 (Word Ladder)
- 格子導航:LC 1730 (Shortest Path to Food)、LC 1091 (Shortest Path in Binary Matrix)
- 同步多源距離(Pattern 4):
- LC 542 (01 Matrix) - 每一格到最近的 0 的距離
- LC 1162 (As Far from Land) - 每個水格到最近陸地的距離
- LC 286 (Walls and Gates) - 從門到房間的距離
- LC 994 (Rotting Oranges) - 感染擴散所需時間
- 各自獨立的 BFS(Pattern 4.6):
- LC 317 (Shortest Distance from All Buildings) - 到所有建築物的距離總和(每次都用全新的 visited)
- DFS + 多源 BFS(Pattern 4.5):LC 934 (Shortest Bridge - 標記其中一個元件,再擴張去找另一個)
- 依序處理多個目標(Pattern 6):LC 675 (Cut Off Trees for Golf Event - 排序 + 重複 BFS)
- 以路線為層的 BFS(Pattern 8):LC 815 (Bus Routes - 抵達目標所需的最少公車數/轉乘數)
- 帶狀態的 BFS:LC 864 (Shortest Path to Get All Keys)、LC 1293 (Shortest Path with Obstacles Elimination)
3. 圖結構問題
- 偵測環:LC 207 (Course Schedule)
- 連通元件:LC 200 (Number of Islands)
- 圖的驗證:LC 261 (Graph Valid Tree)
- 複製圖:LC 133
4. 矩陣/格子問題
- 被包圍的區域:LC 130
- Walls and Gates:LC 286
- 迷宮問題:LC 490
5. 組合列舉問題(Pattern 9 — BFS 風格的笛卡兒積)
- Brace Expansion (LC 1087) — 解析成群組,再逐層 BFS
- Letter Combinations of a Phone Number (LC 17) — 數字 → 字母群組,笛卡兒積式 BFS
- Letter Case Permutation (LC 784) — 每個字元 1 或 2 個選項的群組
- Generalized Abbreviation (LC 320) — 每個字元「保留或跳過」的群組
關鍵 LeetCode 題目
| 難度 | 題目 | 關鍵概念 | 核心模式 |
|---|---|---|---|
| Easy | LC 102 | 層序走訪 | Pattern 2(逐層) |
| Medium | LC 127 | 最短路徑轉換 - Word Ladder | Pattern 7(無權最短路徑) |
| Medium | LC 200 | 連通元件 | Pattern 3(圖 BFS) |
| Medium | LC 742 | 最近的葉節點(樹 → 無向圖) | bfs_examples.md §2-15(樹 → 圖 + BFS) |
| Medium | LC 863 | 距離目標節點 k(父節點表、3 個鄰居) |
Pattern 11(向外輻射);bfs_examples.md §2-18 中的形狀 B |
| Medium | LC 623 | 逐層 BFS 到 depth - 1,再重接子節點指標 |
bfs_examples.md §2-17(Add One Row to Tree) |
| Medium | LC 542 | 同步多源 - 01 Matrix | Pattern 4(同步多源) |
| Medium | LC 934 | DFS + 多源 BFS(島嶼擴張) | Pattern 4.5(DFS + 多源) |
| Medium | LC 1162 | As Far from Land as Possible | Pattern 4(同步多源) |
| Hard | LC 126 | 找出所有最短路徑 - Word Ladder II | Pattern 8.5(BFS + DFS 的 DAG 列舉) |
| Hard | LC 286 | Walls and Gates | Pattern 4(同步多源) |
| Hard | LC 317 | 各自獨立的 BFS(距離總和) | Pattern 4.6(獨立 BFS) |
| Hard | LC 675 | 排序 + 重複 BFS(依序處理目標) | Pattern 6(排序 + 重複 BFS) |
| Hard | LC 752 | 在狀態空間上做 BFS - Open the Lock | Pattern 7(無權最短路徑);解題見 bfs_examples.md §2-6 |
| Hard | LC 815 | 以路線為層的 BFS(最少公車數) | Pattern 8(路線層 BFS) |
| Hard | LC 864 | 帶狀態的 BFS(收集鑰匙) | Pattern 3 + 狀態 |
| Hard | LC 1293 | 帶狀態的 BFS(消除障礙物) | Pattern 3 + 狀態 |
也常被問到(沒有新模板——它們重用上面的模板)
| LC | 題目 | 重用了哪個模板 |
|---|---|---|
| 297 / 449 | Serialize and Deserialize Binary Tree / BST | Pattern 2 逐層 BFS 並寫入 null 標記;反序列化 = 同一個佇列讀回來(見 bfs_examples.md 中的 Variation C) |
| 104 | Maximum Depth of Binary Tree | Pattern 2 — 數層數;這題 DFS 遞迴更短,BFS 只在 LC 111 才佔優勢 |
| 101 | Symmetric Tree | Pattern 2 搭配成對佇列 — 鏡像地入列 (left, right),彈出時比較 |
| 637 / 515 | Average of Levels / Largest Value in Each Tree Row | Pattern 2 — 把「蒐集這一層」換成「彙總這一層」(平均/最大) |
| 433 | Minimum Genetic Mutation | 與 LC 127 / 752 同一套模板 — 8 個字元的基因字串、4 種字母、bank = 合法狀態集合 |
| 529 | Minesweeper | Pattern 3 格子 BFS — 只有在相鄰地雷數為 0 時才展開該格,否則寫下數字就停 |
| 547 | Number of Provinces | Pattern 3 — 數要跑幾次 BFS 才能覆蓋所有節點(或用併查集) |
| 1376 | Time Needed to Inform All Employees | 在主管樹上跑 Pattern 2,佇列存放 (employee, timeSoFar) — 答案是最大值 |
| 787 | Cheapest Flights Within K Stops | BFS 的有界層數鬆弛(帶有 Bellman-Ford 味道):恰好跑 k+1 層,而且不要用全域 visited — 同一個節點可能以更低成本再次進入。見 Dijkstra.md。 |
| 329 | Longest Increasing Path in a Matrix | 這不是 BFS 題 — 用 DFS + memo,或在 DAG 上跑 Kahn 的 BFS(見 topology_sorting.md) |
| 721 / 947 / 684 / 839 | Accounts Merge / Stones Removed / Redundant Connection / Similar String Groups | 這是連通性問題而非最短路徑 — 併查集才是預期解(BFS 洪水填充也行) |