BFS — 進階變形
範圍 — 第一輪可以先跳過的 BFS 技巧:雙向 BFS、搭配 deque 的 0-1 BFS、超出標準模板的多源 BFS、在隱式或擴充狀態空間上的 BFS,以及列舉所有最短路徑的 DAG —— 必會的佇列模板留在主文件。 另見:bfs.md — 這些變形所依據的模板;bfs_examples.md — 經典 BFS 題目,每題一張 card;Dijkstra.md — 邊帶有任意權重時。
LeetCode 題目清單
總覽
這裡每一項都是 bfs.md 裡某個模板的變形:佇列裝的東西變了、佇列變成 deque、圖變成隱式的,
或一次 BFS 變成很多次。大部分 L3 不會考。會考的 —— 擴充狀態(Pattern 16)和 0-1 BFS(Pattern 15)
—— 通常是格子題上的 Medium/Hard 轉折。Pattern N 的名稱就是 bfs.md 引用的那些。
| 變形 | 推入/內容規則 | 成本 |
|---|---|---|
| 多源(標準版:bfs § Template 4) | 所有來源都放在第 0 層 | O(V + E) |
| 各自獨立的 BFS(Pattern 4.6) | 每個來源用全新的 visited |
O(k(V + E)) |
| 擴充狀態(Pattern 16) | visited 以 (node, resource) 為鍵 |
O(V · |resource|) |
| 0-1 BFS(Pattern 15) | deque:成本 0 放前端、成本 1 放後端 | O(V + E) |
| 雙向 | 擴展比較小的那一邊前緣 | ~O(b^(d/2)) |
先備知識
- bfs § Template 2:入列時標記的圖 BFS。下面每個 pattern 都維持這個不變量。
- bfs § Template 4:多源 BFS。Pattern 4.5 和 4.6 是它的延伸。
- bfs § Template 5:即時產生鄰居的 BFS。Pattern 8、8.5、12 和雙向搜尋是它的延伸。
辨識
| 題目敘述中的訊號 | 限制條件提示 | 該用 | 不適用時 |
|---|---|---|---|
| 「最短的橋」、用最少翻轉把兩個連通塊接起來 | 格子 ≤ 100 × 100 | Pattern 4.5 — 標出一個連通塊,從它全部格子做多源 BFS | 只有一個起點格 → 一般的 bfs § Template 3 |
| 「到所有建築物的距離總和」、「每個來源都走得到」 | k 個來源 × 格子 ≤ 10⁶ | Pattern 4.6 — 每個來源跑一次 BFS,各用全新的 visited |
「到最近來源的距離」→ 一次多源 BFS |
| 「最少搭幾班公車/轉幾次車/幾條線」 | 路線 ≤ 500 | Pattern 8 — 以路線為節點的 BFS | 數的是站數而不是轉乘 → 以站為節點的 BFS |
| 「回傳所有最短轉換序列」 | 單字表 ≤ 500 | Pattern 8.5 — BFS 建前驅 DAG,DFS 列舉 | 只要最短長度 → bfs § Template 5 |
| 「所有組合」,從 k 個互相獨立的群組各選一個 | 輸出大小 ≤ 10⁴ | Pattern 9 — 一個群組一層 | 選擇之間互相限制 → 回溯 |
| 「移除最少幾個字元讓它合法」,並列出所有答案 | n ≤ 25 | Pattern 12 — 一層刪一個,停在第一個有結果的層 | 只問個數 → 貪心計數器 |
| 「收集所有鑰匙」、「最多移除 k 個障礙物」、「最多 k 站」 | m·n·|resource| ≤ 10⁶ | Pattern 16 — visited 以 (cell, resource) 為鍵 |
資源不影響之後的移動 → 一般格子 BFS |
| 移動成本只有 0 或 1(「順著箭頭免費」、「改一個標誌花 1」) | 格子 ≤ 10⁴ 格 | Pattern 15 — 搭配 deque 的 0-1 BFS | 權重大於 1 → Dijkstra.md |
| 兩個端點都已知、分支因子很大 | 單字表 ≤ 5,000 | 雙向 BFS | 只有一個端點,或目標集合未知 → 單向 BFS |
| 必須照固定順序拜訪目標(最矮的樹先砍) | 目標數 × 格子 ≤ 10⁶ | Pattern 6 — 排序,再對每對相鄰目標各跑一次 BFS | 順序不限 → 在距離矩陣上做 TSP |
多源 BFS — 深入探討
標準的多源 BFS 與它的距離變形 LC 542 01 Matrix,在 bfs § Template 4 與它的 01 Matrix card 裡解過。下面兩個 pattern 是那個模板自己做不到的: 從一個得先找出來的連通塊出發,以及讓各個來源彼此分開。
Pattern 4.5:DFS + 多源 BFS(島嶼擴張)— LC 934
關鍵 — 兩座島之間最短的橋,一定從第一座島的某一格出發,所以把它的所有格子都當 BFS 起點。先 flood fill 第一座島(標成
2並把每格入列),再往水面向外 BFS;BFS 第一次碰到1的 那一層,就是要翻的格子數。
辨識 — 「恰好兩座島」、「翻最少的 0 把它們連起來」,或任何兩個連通塊(而不是兩格)之間的最短距離。n ≤ 100,所以一座島可以有 10⁴ 格。
歸約到:bfs § Template 4 — 起點是先用 flood fill 找出來的整個連通塊。
# python
# LC 934 - Shortest Bridge
# IDEA: flood-fill island A (mark 2, enqueue every cell), then multi-source BFS over water to island B
# time = O(n^2), space = O(n^2) each cell is marked once by the fill and once by the BFS
from collections import deque
def shortestBridge(grid):
n = len(grid)
queue = deque()
def mark(r, c): # iterative flood fill: no recursion limit
stack = [(r, c)]
grid[r][c] = 2
while stack:
r, c = stack.pop()
queue.append((r, c)) # every cell of island A is a source
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 1:
grid[nr][nc] = 2
stack.append((nr, nc))
first = next((r, c) for r in range(n) for c in range(n) if grid[r][c] == 1)
mark(*first)
flips = 0
while queue:
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if not (0 <= nr < n and 0 <= nc < n) or grid[nr][nc] == 2:
continue
if grid[nr][nc] == 1:
return flips # touched island B
grid[nr][nc] = 2 # water is now part of the bridge frontier
queue.append((nr, nc))
flips += 1
return -1
// java
// LC 934 - Shortest Bridge
// IDEA: DFS marks the first island and enqueues all of it; BFS over water until island B is touched
// time = O(n^2), space = O(n^2)
public int shortestBridge(int[][] grid) {
int n = grid.length;
Queue<int[]> queue = new LinkedList<>();
boolean found = false;
for (int y = 0; y < n && !found; y++) {
for (int x = 0; x < n && !found; x++) {
if (grid[y][x] == 1) {
dfsMarkIsland(grid, x, y, queue);
found = true;
}
}
}
int[][] dirs = {{1,0}, {-1,0}, {0,1}, {0,-1}};
int steps = 0;
boolean[][] visited = new boolean[n][n];
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int[] cur = queue.poll();
int x = cur[0], y = cur[1];
for (int[] d : dirs) {
int nx = x + d[0], ny = y + d[1];
if (nx >= 0 && nx < n && ny >= 0 && ny < n && !visited[ny][nx]) {
visited[ny][nx] = true;
if (grid[ny][nx] == 1) return steps; // reached the second island
if (grid[ny][nx] == 0) queue.add(new int[]{nx, ny});
}
}
}
steps++;
}
return -1;
}
void dfsMarkIsland(int[][] grid, int x, int y, Queue<int[]> queue) {
int n = grid.length;
if (x < 0 || x >= n || y < 0 || y >= n || grid[y][x] != 1) return;
grid[y][x] = 2; // part of the first island
queue.add(new int[]{x, y});
dfsMarkIsland(grid, x + 1, y, queue);
dfsMarkIsland(grid, x - 1, y, queue);
dfsMarkIsland(grid, x, y + 1, queue);
dfsMarkIsland(grid, x, y - 1, queue);
}
複雜度
- 時間 O(n²):fill 的
while stack在推入前就把 A 島的每格標成2,BFS 也在入列前把每個水格標成2,所以每格最多進容器一次,看 4 個鄰居。 - 空間 O(n²):
queue一開始就裝著 A 島的每一格,可能占了大半個格子。
試跑
grid = [[0,1], fill marks (0,1) as 2, queue = [(0,1)]
[1,0]]
level 0 (flips = 0): (0,1) -> water (0,0), (1,1) enqueued
level 1 (flips = 1): (0,0) -> (1,0) is 1 -> return 1
陷阱
- 只用 A 島的一格當 BFS 起點,找到的是從那一格出發的某座橋,不是最短的。
- 在 100 × 100 的島上做遞迴 fill 會有 10⁴ 層深:超過 Python 預設的遞迴上限,所以 Python 版用 stack 來 fill。
不適用時 — 只有一個起點格和一個終點,而不是兩個連通塊:一般的 bfs § Template 3。
練習 — LC 934 ⭐ · LC 1020 Number of Enclaves (只有 fill,從邊界開始) · LC 542 (每個 0 都是起點,不用 fill) · LC 1162 As Far from Land as Possible (每塊陸地都是起點;答案是最後一層)
Pattern 4.6:多源 BFS vs 各自獨立的 BFS(關鍵區別)
關鍵 — 一次共用、以所有來源為起點的 BFS,回答的是「到最近來源的距離」,因為最先抵達某格 的來源獲勝,那格就關閉了。問的是每一個來源 —— 距離總和,或「每個來源都走得到」—— 就需要 每個來源到每格各自的距離,所以是每個來源跑一次 BFS,每次都用全新的
visited。
辨識 — 「到所有建築物的總距離」、「每個來源都走得到的格子」,任何對所有來源做彙總、而不是取最近者的答案。m, n ≤ 50,建築物最多 m·n 棟,所以跑 k 次、每次 m·n 的 BFS 仍然放得下。
| 面向 | 同時進行的多源 | 各自獨立的 BFS |
|---|---|---|
| 佇列初始化 | 一次放入所有來源 | 每個來源自己開一次 BFS |
visited |
一份,共用 | 每次都是全新的 |
| 時間 | O(m·n) | O(k · m·n),k = 來源數 |
| 第一次到達代表 | 到最近來源的距離 | 到目前這個來源的距離 |
| 例題 | LC 542、994、1162 | LC 317 |
獨立版本,用在 LC 317 Shortest Distance from All Buildings(讓走到每棟建築物的總距離最小):
# python
# LC 317 - Shortest Distance from All Buildings
# IDEA: one BFS per building with a fresh visited; add its distances into total, count who reached each cell
# time = O(k * m * n), space = O(m * n) k = number of buildings
from collections import deque
def shortestDistance(grid):
m, n = len(grid), len(grid[0])
total = [[0] * n for _ in range(m)] # summed distance from every building so far
reach = [[0] * n for _ in range(m)] # how many buildings reached this cell
buildings = 0
for sr in range(m):
for sc in range(n):
if grid[sr][sc] != 1:
continue
buildings += 1
seen = [[False] * n for _ in range(m)] # fresh for every building
queue = deque([(sr, sc)])
seen[sr][sc] = True
dist = 0
while queue:
dist += 1
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and not seen[nr][nc] and grid[nr][nc] == 0:
seen[nr][nc] = True
total[nr][nc] += dist
reach[nr][nc] += 1
queue.append((nr, nc))
return min((total[r][c] for r in range(m) for c in range(n)
if grid[r][c] == 0 and reach[r][c] == buildings), default=-1)
// java
// LC 317 - Shortest Distance from All Buildings
// IDEA: independent BFS per building, fresh visited each; accumulate distance and reach count
// time = O(k * m * n), space = O(m * n)
public int shortestDistance(int[][] grid) {
int rows = grid.length, cols = grid[0].length;
int[][] totalDist = new int[rows][cols];
int[][] reachCount = new int[rows][cols];
int buildingCount = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 1) {
buildingCount++;
boolean[][] visited = new boolean[rows][cols]; // fresh for this building
bfsSingleSource(grid, r, c, visited, totalDist, reachCount);
}
}
}
int minDist = Integer.MAX_VALUE;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (grid[r][c] == 0 && reachCount[r][c] == buildingCount)
minDist = Math.min(minDist, totalDist[r][c]);
return minDist == Integer.MAX_VALUE ? -1 : minDist;
}
private void bfsSingleSource(int[][] grid, int sr, int sc, boolean[][] visited,
int[][] totalDist, int[][] reachCount) {
Queue<int[]> queue = new LinkedList<>();
queue.offer(new int[]{sr, sc});
visited[sr][sc] = true;
int[][] dirs = {{0,1}, {0,-1}, {1,0}, {-1,0}};
int dist = 0;
while (!queue.isEmpty()) {
int size = queue.size();
dist++;
for (int i = 0; i < size; i++) {
int[] cur = queue.poll();
for (int[] d : dirs) {
int nr = cur[0] + d[0], nc = cur[1] + d[1];
if (nr >= 0 && nr < grid.length && nc >= 0 && nc < grid[0].length
&& !visited[nr][nc] && grid[nr][nc] == 0) {
visited[nr][nc] = true;
totalDist[nr][nc] += dist;
reachCount[nr][nc]++;
queue.offer(new int[]{nr, nc});
}
}
}
}
}
複雜度
- 時間 O(k·m·n):外層的
for sr / for sc每棟建築物開一次 BFS,而每次 BFS 的seen都是全新的,所以每個空格最多入列一次。 - 空間 O(m·n):
total、reach,加上同一時間只有一份seen;每棟建築物的seen在建下一份之前就丟掉了。
陷阱
- 在各建築物之間共用一份
visited:第二棟建築物看不到第一棟已關閉的格子,它的距離永遠加不進去,所以答案是錯的,而不只是慢。 - 有某棟建築物走不到的空地不是候選:要檢查
reach == buildings。 - 省空間的技巧是拿格子本身取代
visited:第 i 次 BFS 只走值為-i的格子並寫成-(i + 1),所以之後的 BFS 都會跳過先前任一棟走不到的格子。答案相同,不用visited陣列。
不適用時 — 問的是最近的來源:一次 多源 BFS 就能得到同樣的距離,快 k 倍。
練習 — LC 317 ⭐ · LC 542 (最近來源:那一次共用的 BFS,用來對照) · LC 1162 (同樣的對照,取最大值) · LC 296 Best Meeting Point (沒有障礙物時的距離總和:曼哈頓距離取中位數,不需要 BFS)
狀態空間與隱式圖上的 BFS
標準的狀態空間 BFS —— 產生每個鄰居、拿去查字典、再把字元還原 —— 是 bfs § Template 5, 在它的錨定題 LC 127 上解過,還原那一步寫在它的陷阱裡。它的經典題(LC 752、LC 773、LC 279)在 bfs_examples.md 各有一張 card。下面的 pattern 改變的是節點是什麼。
Pattern 8:抽象圖上的 BFS(以路線為節點)— LC 815
關鍵 — 答案數的是公車,所以讓每條路線當一個節點:兩條路線共用某個站就相鄰。把經過
source的每條路線都當起點;BFS 層數 = 搭過的公車數,第一條含有target的路線就結束搜尋。 對站做的visited讓繁忙的轉乘站不會把經過它的每條路線重新展開一次。
辨識 — 「最少要搭幾班公車」、「最少轉乘/路線數」,每條路線以站的清單給出。路線 ≤ 500,但站數加總可達 10⁵,所以「同一路線上每兩站都連邊」的圖大到建不出來。
歸約到:bfs § Template 2 — 在以路線為節點的圖上,這張圖從「站 → 路線」對應表建出來。
# python
# LC 815 - Bus Routes
# IDEA: BFS over routes; a stop links every route through it; level = buses taken
# time = O(sum of route lengths), space = O(sum of route lengths)
from collections import defaultdict, deque
def numBusesToDestination(routes, source, target):
if source == target:
return 0
stop_to_routes = defaultdict(list)
for i, route in enumerate(routes):
for stop in route:
stop_to_routes[stop].append(i)
queue = deque()
used_routes = set()
for r in stop_to_routes[source]: # every bus through the source is ride 1
queue.append(r)
used_routes.add(r)
seen_stops = {source}
buses = 1
while queue:
for _ in range(len(queue)):
route = queue.popleft()
for stop in routes[route]:
if stop == target:
return buses
if stop in seen_stops:
continue # this stop's transfers were already enqueued
seen_stops.add(stop)
for nxt in stop_to_routes[stop]:
if nxt not in used_routes:
used_routes.add(nxt)
queue.append(nxt)
buses += 1
return -1
// java
// LC 815 - Bus Routes
// IDEA: build stop -> routes; BFS on route ids with two visited sets (routes and stops)
// time = O(sum of route lengths), space = O(sum of route lengths)
public int numBusesToDestination(int[][] routes, int source, int target) {
if (source == target) return 0;
Map<Integer, List<Integer>> stopToRoutes = new HashMap<>();
for (int i = 0; i < routes.length; i++)
for (int stop : routes[i])
stopToRoutes.computeIfAbsent(stop, k -> new ArrayList<>()).add(i);
Queue<Integer> queue = new LinkedList<>();
Set<Integer> visitedRoutes = new HashSet<>();
Set<Integer> visitedStops = new HashSet<>();
for (int routeId : stopToRoutes.getOrDefault(source, new ArrayList<>())) {
queue.offer(routeId);
visitedRoutes.add(routeId);
}
int busCount = 1; // already on the first bus
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int currRoute = queue.poll();
for (int stop : routes[currRoute]) {
if (stop == target) return busCount;
if (!visitedStops.add(stop)) continue;
for (int nextRoute : stopToRoutes.getOrDefault(stop, new ArrayList<>())) {
if (visitedRoutes.add(nextRoute)) queue.offer(nextRoute);
}
}
}
busCount++;
}
return -1;
}
複雜度
- 時間 O(S),S = 所有路線長度的總和:建
stop_to_routes時每個站讀一次;used_routes讓每條路線只入列一次,所以for stop in routes[route]每條路線只掃一次;seen_stops讓每個站的stop_to_routes清單只掃一次。 - 空間 O(S):
stop_to_routes每個(站,路線)配對存一筆。
試跑
routes = [[1,2,7],[3,6,7]], source = 1, target = 6
stop -> routes: 1:[0] 2:[0] 7:[0,1] 3:[1] 6:[1]
buses 1: route 0 -> stops 1, 2, 7; stop 7 enqueues route 1
buses 2: route 1 -> stop 3, stop 6 == target -> return 2
陷阱
- 以站為節點、同一路線上每兩站之間都連邊的 BFS,每條路線是 O(L²),而且數的是站不是公車。
source == target是 0 班公車,即使沒有任何路線經過它。- 沒有
seen_stops,位在很多路線上的轉運站,每被一條路線走到就要重掃一次它的路線清單。
不適用時 — 答案數的是站或邊,不是路線:以站為節點做 BFS, bfs § Template 2。
練習 — LC 815 ⭐ · LC 841 Keys and Rooms (房間裡的鑰匙就是它的邊) · LC 127 (以單字為節點) · LC 1345 Jump Game IV (值 → 索引對應表;某個值用過就清空它的清單,作用和 seen_stops 相同)
Pattern 8.5:BFS + DFS(找出所有最短路徑 — DAG 列舉)— LC 126
關鍵 — 逐層 BFS,並為每個單字記下它在上一層的所有前驅。一整層做完之後才把那一層從字典 移除,這樣同一層被兩個父節點走到的單字兩個都會保留。在走到終點單字的那一層之後就停;再從 前驅清單往回 DFS,清單裡只有最短路徑。
辨識 — 「回傳所有最短轉換序列」、「從 s 到 t 的每一條最短路徑」—— 要的是路徑清單,不是長度。單字表 ≤ 500、單字長度 ≤ 5,但最短路徑的數量仍可能是指數級,所以只能走最短的那些。
歸約到:bfs § Template 5 — 記錄的是前驅 DAG,而不只是一個距離。
# python
# LC 126 - Word Ladder II
# IDEA: level BFS records parents[word] = every word one level above it; DFS back from endWord
# time = O(N * L * 26 + paths * L), space = O(N * L) N words of length L; output not counted
from collections import defaultdict
def findLadders(beginWord, endWord, wordList):
words = set(wordList)
if endWord not in words:
return []
parents = defaultdict(list)
level = {beginWord}
words.discard(beginWord)
found = False
while level and not found:
words -= level # remove the WHOLE level only after it is built
next_level = set()
for word in level:
for i in range(len(word)):
for ch in 'abcdefghijklmnopqrstuvwxyz':
nxt = word[:i] + ch + word[i + 1:]
if nxt in words:
if nxt == endWord:
found = True # finish this level, never build the next
next_level.add(nxt)
parents[nxt].append(word)
level = next_level
if not found:
return []
paths, path = [], [endWord]
def walk(word): # DFS from endWord back to beginWord
if word == beginWord:
paths.append(path[::-1])
return
for p in parents[word]:
path.append(p)
walk(p)
path.pop()
walk(endWord)
return paths
// java
// LC 126 - Word Ladder II
// IDEA: BFS records every predecessor at the shortest distance; DFS from endWord enumerates the DAG
// time = O(N * L * 26 + paths * L), space = O(N * L)
public List<List<String>> findLadders(String beginWord, String endWord, List<String> wordList) {
List<List<String>> result = new ArrayList<>();
Set<String> wordSet = new HashSet<>(wordList);
if (!wordSet.contains(endWord)) return result;
Map<String, List<String>> parents = new HashMap<>();
Map<String, Integer> distances = new HashMap<>();
Queue<String> queue = new LinkedList<>();
queue.add(beginWord);
distances.put(beginWord, 0);
boolean found = false;
while (!queue.isEmpty() && !found) {
int size = queue.size();
for (int i = 0; i < size; i++) {
String word = queue.poll();
char[] chars = word.toCharArray();
for (int j = 0; j < chars.length; j++) {
char original = chars[j];
for (char c = 'a'; c <= 'z'; c++) {
if (c == original) continue;
chars[j] = c;
String nextWord = new String(chars);
if (!wordSet.contains(nextWord)) continue;
int newDistance = distances.get(word) + 1;
if (!distances.containsKey(nextWord)) { // first time: enqueue once
distances.put(nextWord, newDistance);
parents.computeIfAbsent(nextWord, k -> new ArrayList<>()).add(word);
queue.add(nextWord);
if (nextWord.equals(endWord)) found = true;
} else if (distances.get(nextWord) == newDistance) { // another shortest parent
parents.get(nextWord).add(word);
}
}
chars[j] = original;
}
}
}
if (distances.containsKey(endWord)) {
dfsEnumeratePaths(endWord, beginWord, parents, new LinkedList<>(), result);
}
return result;
}
private void dfsEnumeratePaths(String current, String beginWord, Map<String, List<String>> parents,
List<String> path, List<List<String>> result) {
path.add(0, current); // building the path backwards
if (current.equals(beginWord)) {
result.add(new ArrayList<>(path));
} else {
for (String prev : parents.getOrDefault(current, new ArrayList<>())) {
dfsEnumeratePaths(prev, beginWord, parents, path, result);
}
}
path.remove(0);
}
複雜度
- 時間 O(N·L·26 + paths·L):
words -= level在每層結束時把該層單字移除,所以 N 個單字每個只被for i / for ch迴圈展開一次(26·L 個候選,每個是長度 L 的字串,L ≤ 5 時上界把它當常數);之後walk對每條輸出路徑花 O(L)。 - 空間 O(N·L):
parents存每個單字在上一層的前驅;輸出不計。
試跑
hit -> cog, words = [hot, dot, dog, lot, log, cog]
level 1: hot parents[hot] = [hit]
level 2: dot, lot parents[dot] = [hot], parents[lot] = [hot]
level 3: dog, log parents[dog] = [dot], parents[log] = [lot]
level 4: cog parents[cog] = [dog, log] <- two parents on one level: found, stop
DFS from cog: cog<-dog<-dot<-hot<-hit and cog<-log<-lot<-hot<-hit
陷阱
- 單字一被第一次走到就從字典移除,會丟掉它在同一層的第二個父節點(上面的
cog),連帶丟掉經過log的所有路徑。 - 從
beginWord在整張單字圖上往前 DFS,會探索比最短還長的路徑而超時;要沿前驅清單往回走。 found之後還建下一層,會讓較長的路徑混進parents。
不適用時 — 只問長度,或任意一條最短路徑:bfs § Template 5 加上每個單字一個父節點。
練習 — LC 126 ⭐ · LC 433 Minimum Genetic Mutation (只要長度) · LC 127 (只要長度,單字表更大) · LC 1976 Number of Ways to Arrive at Destination (數最短路徑而不是列出來;有權重,所以用 Dijkstra)
Pattern 9:BFS 式的笛卡兒積生成(逐層組合建構)— LC 1087
關鍵 — 當輸出是從 k 個互相獨立的群組各選一個時,每個群組就是一層 BFS:把第 i 層清空、並把 群組 i 的每個選項接到每個前綴後面,留下的正好是群組
0 … i的笛卡兒積。沒有圖、沒有visited; 從 BFS 借來的只有分層結構。
辨識 — 「回傳所有能組成的單字」、「{a,b}c{d,e}」、「每一種組合」,其中每個位置從自己的群組選一個選項,而且沒有任何選擇會限制另一個。輸出量就是工作量的上限:LC 1087 的 s ≤ 50 個字元,所以乘積不大。
歸約到:bfs § Template 1 — 一個群組一層,節點的子節點是把它的前綴接上每個選項。
# python
# LC 1087 - Brace Expansion
# IDEA: parse into groups, sort each; level i = every prefix extended by every option of group i
# time = O(G * |result| * L), space = O(|result| * L) G groups, L = output word length
def expand(s):
groups = []
i = 0
while i < len(s):
if s[i] == '{':
j = s.index('}', i)
groups.append(sorted(s[i + 1:j].split(','))) # sorted -> output is sorted
i = j + 1
else:
groups.append([s[i]])
i += 1
level = [""]
for group in groups:
next_level = []
for prefix in level:
for option in group:
next_level.append(prefix + option)
level = next_level
return level
// java
// LC 1087 - Brace Expansion (the level loop, given the parsed groups)
// IDEA: snapshot the queue size, extend every prefix of this level by every option of the group
// time = O(G * |result| * L), space = O(|result| * L)
public String[] cartesianBFS(List<List<String>> groups) {
Queue<String> queue = new LinkedList<>();
queue.add(""); // one empty prefix at depth 0
for (List<String> group : groups) {
int size = queue.size(); // snapshot the current level
for (int k = 0; k < size; k++) {
String prefix = queue.poll();
for (String option : group) queue.add(prefix + option);
}
}
return queue.toArray(new String[0]);
}
複雜度
- 時間 O(G·|result|·L):
for group迴圈跑 G 次,每一層都不比最後一層大,而每個prefix + option最多複製 L 個字元。 - 空間 O(|result|·L):
level和next_level最多同時裝兩層,較大的那層就是輸出本身。
試跑
s = "{a,b}c{d,e}f" groups = [[a,b], [c], [d,e], [f]]
level 0: [""]
level 1: [a, b]
level 2: [ac, bc]
level 3: [acd, ace, bcd, bce]
level 4: [acdf, acef, bcdf, bcef] <- the answer, already sorted
陷阱
- 展開前先把每個群組排序;事後才排序輸出要多付 O(|result| log |result|)。
"{b,a}c"不排序展開會回傳["bc", "ac"]。 - 最後一層一次裝著整個輸出;當選擇之間互相限制(不能重複字母、總和上限)時改用回溯,它能剪枝,而且只需要 O(depth) 記憶體。
不適用時 — 選擇之間互相限制:用回溯 (dfs § Template 4),它能提早剪掉走不通的前綴。
練習 — LC 1087 ⭐ · LC 784 Letter Case Permutation (一個群組是一個字母的大小寫) · LC 17 Letter Combinations of a Phone Number (每個數字一個群組) · LC 320 Generalized Abbreviation (每個字母:保留或計數) · LC 1096 Brace Expansion II (巢狀大括號:聯集與乘積,用 stack 解析)
Pattern 12:在字串狀態上做 BFS — 停在第一個有結果的層 — LC 301 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
關鍵 — 「移除最少幾個字元」讓一次刪除 = 一層 BFS:第 k 層裝的是恰好刪 k 個字元能到達的 所有字串。第一個含有合法字串的層就是答案層 —— 回傳該層所有合法字串,絕不建下一層。
辨識 — 「移除最少數量的不合法括號」、「回傳所有可能的結果」。s ≤ 25 且括號最多 20 個:正是這個上限讓一層最多 2ⁿ 個字串的集合負擔得起。
歸約到:bfs § Template 5 — 鄰居是刪掉一個括號後的字串。
為什麼這裡 BFS 勝過 DFS:DFS 能找到「某個」合法字串,但你還得證明它是最少的;BFS 直接從層數免費得到最小性,而且一次就回傳該長度下的所有答案。
# python
# LC 301 - Remove Invalid Parentheses
# IDEA: level = number of deletions; return the first level that has valid strings
# time = O(2^n * n), space = O(n * 2^n) up to 2^n strings of length n in one level
def removeInvalidParentheses(s):
def valid(t):
cnt = 0
for ch in t:
if ch == '(':
cnt += 1
elif ch == ')':
cnt -= 1
if cnt < 0:
return False
return cnt == 0
level = {s} # a set IS the visited-dedup for this level
while level:
found = [t for t in level if valid(t)]
if found:
return found # minimal deletions -> all answers of this size
nxt = set()
for t in level:
for i, ch in enumerate(t):
if ch in '()': # letters are never removed
nxt.add(t[:i] + t[i + 1:])
level = nxt
return [""]
// java
// LC 301 - Remove Invalid Parentheses
// IDEA: 1 BFS level = 1 deletion. First level containing a valid string is the answer level.
// time = O(2^n * n), space = O(n * 2^n) up to 2^n strings of length n in one level
public List<String> removeInvalidParentheses(String s) {
List<String> res = new ArrayList<>();
Set<String> visited = new HashSet<>();
Queue<String> q = new LinkedList<>();
q.offer(s);
visited.add(s);
boolean found = false;
while (!q.isEmpty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
String cur = q.poll();
if (isValid(cur)) { res.add(cur); found = true; }
if (found) continue; // drain this level, but stop expanding
for (int j = 0; j < cur.length(); j++) {
char c = cur.charAt(j);
if (c != '(' && c != ')') continue; // only parens are removable
String next = cur.substring(0, j) + cur.substring(j + 1);
if (visited.add(next)) q.offer(next); // add() returns false if dup
}
}
if (found) return res; // this level is minimal -> done
}
return res;
}
private boolean isValid(String t) {
int cnt = 0;
for (char c : t.toCharArray()) {
if (c == '(') cnt++;
else if (c == ')' && --cnt < 0) return false; // ')' before its '('
}
return cnt == 0;
}
複雜度
- 時間 O(2ⁿ·n):每個字串都是原始位置的一個子集,所以進過
level集合的不同字串最多 2ⁿ 個,而valid(t)對每個掃 O(n)。 - 空間 O(n·2ⁿ):
level和nxt存著最寬的那層,大約 C(n, n/2) 個字串,每個最長 n 個字元。
陷阱
- 不去重的話,
"(())"可以由很多種刪除順序到達,層會爆掉。 - 找到合法字串後就停止擴展,但該層要掃完:同樣長度可能有好幾個答案。
"()())()"有兩個,"(())()"和"()()()"。 ")("沒有非空的合法答案;迴圈會走到空字串,它是合法的,所以回傳[""]。
不適用時 — 只問刪除的次數,或任意一個合法字串:一個由左到右的平衡計數器(LC 921)或一個 stack(LC 1249),都是 O(n)。
練習 — LC 301 ⭐ · LC 20 Valid Parentheses (合法性檢查) · LC 921 (只要次數) · LC 1249 Minimum Remove to Make Valid Parentheses (一個答案:stack,O(n))
Pattern 16:在擴增狀態上做 BFS — (cell, resource) — LC 864 & LC 1293 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
關鍵 — 一般的格子 BFS 以格子當
visited的鍵。只有在「抵達某格之後,未來的一切都相同」 時這才正確。一旦你在行走途中攜帶了某樣東西 —— 收集到的鑰匙、剩餘的消除次數、燃料、顏色、 奇偶性 —— 兩次抵達同一格就是搜尋中不同的位置,而修正方式永遠是同一行。
辨識 — 「收集所有鑰匙,鎖需要對應的鑰匙」、「最多可以消除 k 個障礙物」、「最多 k 站」:行走途中攜帶的某樣東西會改變之後哪些移動合法。狀態數 m·n·|resource| 必須放得下 —— LC 864 是 30·30·2⁶,LC 1293 是 40·40·(k+1)。
歸約到:bfs § Template 3 — 把資源加進 visited 的鍵。
visited on (r, c) -> visited on (r, c, resource)
其餘部分仍是教科書式的佇列 BFS,所以第一次彈出終點時,你拿到的依然是最短路徑。代價在於狀態數量:m * n * |resource|。
| 題目敘述中的訊號 | 額外的維度 |
|---|---|
| 「收集鑰匙,門需要對應的鑰匙」(LC 864) | 鑰匙位元遮罩,2^k 種值 |
「最多可以移除 k 個障礙物」(LC 1293) |
剩餘消除次數,k+1 種值 |
「最多 k 站/k 條邊」(LC 787) |
目前已使用的邊數 |
| 「最多可以反轉一條邊」 | 一個 0/1 旗標 |
| 「移動在兩位玩家/兩種顏色之間交替」(LC 1129) | 上一次使用的顏色 |
為什麼
k必須是鍵的一部分,而不是best[r][c]純量 — 以更多剩餘預算抵達某格絕不會更差,所以當新的抵達擁有嚴格更多預算時,這一格就值得再訪。只以格子為鍵會把這次抵達丟掉,並在可解的網格上回報-1。以(cell, k)為鍵永遠正確;best[r][c] = 見過的最大預算是同一件事的壓縮版,也是常見的記憶體最佳化。
LC 864 — 資源是鑰匙的位元遮罩
關鍵 — 小寫
a..f是鑰匙、大寫A..F是鎖;k ≤ 6,所以鑰匙串用 6 個位元就放得下,狀態 空間是m * n * 64。只有遮罩裡已經有那個位元時鎖才能通過;答案是第一次彈出時手上已經有全部鑰匙。
辨識 — 「取得所有鑰匙所需的最少步數」,格子上有 a..f 鑰匙和 A..F 鎖。k ≤ 6 就是提示:鑰匙串是一個位元遮罩。
歸約到:bfs § Template 3 — visited 以 (r, c, mask) 為鍵。
# python
# LC 864 - Shortest Path to Get All Keys
# IDEA: same BFS, state = (r, c, keyMask); picking up a key moves you to a DIFFERENT
# layer of the search space, which is why a cell can be visited up to 2^k times
# time = O(m*n*2^k), space = O(m*n*2^k)
from collections import deque
def shortestPathAllKeys(grid):
m, n = len(grid), len(grid[0])
all_keys = 0
start = (0, 0)
for r in range(m):
for c in range(n):
ch = grid[r][c]
if ch == '@':
start = (r, c)
elif ch.islower():
all_keys |= 1 << (ord(ch) - ord('a'))
q = deque([(start[0], start[1], 0, 0)]) # r, c, mask, steps
seen = {(start[0], start[1], 0)}
while q:
r, c, mask, steps = q.popleft()
if mask == all_keys:
return steps
for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nr, nc = r + dr, c + dc
if not (0 <= nr < m and 0 <= nc < n):
continue
ch = grid[nr][nc]
if ch == '#':
continue
if ch.isupper() and not (mask >> (ord(ch) - ord('A'))) & 1:
continue # locked door, no key -> a wall
n_mask = mask | (1 << (ord(ch) - ord('a'))) if ch.islower() else mask
if (nr, nc, n_mask) in seen:
continue
seen.add((nr, nc, n_mask))
q.append((nr, nc, n_mask, steps + 1))
return -1
// java
// LC 864 - Shortest Path to Get All Keys
// IDEA: BFS over (row, col, keyMask). A lock is passable only when its bit is already
// in the mask; stepping on a key ORs its bit in. Answer = first pop with all keys.
// time = O(m*n*2^k), space = O(m*n*2^k)
public int shortestPathAllKeys(String[] grid) {
int m = grid.length, n = grid[0].length();
int startR = 0, startC = 0, allKeys = 0;
for (int r = 0; r < m; r++) {
for (int c = 0; c < n; c++) {
char ch = grid[r].charAt(c);
if (ch == '@') { startR = r; startC = c; }
else if (ch >= 'a' && ch <= 'f') allKeys |= 1 << (ch - 'a');
}
}
int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
boolean[][][] seen = new boolean[m][n][1 << 6]; // the mask is part of the visited key
Queue<int[]> q = new LinkedList<>();
q.offer(new int[]{startR, startC, 0});
seen[startR][startC][0] = true;
int steps = 0;
while (!q.isEmpty()) {
int size = q.size();
for (int s = 0; s < size; s++) {
int[] cur = q.poll();
int r = cur[0], c = cur[1], mask = cur[2];
if (mask == allKeys) return steps;
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr < 0 || nr >= m || nc < 0 || nc >= n) continue;
char ch = grid[nr].charAt(nc);
if (ch == '#') continue;
// a lock we have no key for is a wall
if (ch >= 'A' && ch <= 'F' && (mask & (1 << (ch - 'A'))) == 0) continue;
int nMask = mask;
if (ch >= 'a' && ch <= 'f') nMask |= 1 << (ch - 'a');
if (seen[nr][nc][nMask]) continue;
seen[nr][nc][nMask] = true;
q.offer(new int[]{nr, nc, nMask});
}
}
steps++;
}
return -1;
}
複雜度
- 時間 O(m·n·2^k):
seen以(nr, nc, n_mask)為鍵,所以 m·n·2^k 個狀態每個只入列一次、試 4 個方向;找all_keys的掃描只要 O(m·n)。 - 空間 O(m·n·2^k):
seen和佇列涵蓋的就是這些狀態。
陷阱
- 在格子上標記
seen:LC 864 就會在「撿到鑰匙後必須穿越自己先前路線」時回報-1,而測資大多是這種情況。 - 在狀態彈出時才檢查
seen,而不是放入時:同一個(cell, mask)會被每個走得到它的鄰居各放入一次,佇列最多膨脹 4 倍才丟掉重複的。放入時就標記。
練習 — LC 864 ⭐ · LC 1129 Shortest Path with Alternating Colors ((node, lastColour)) · LC 847 Shortest Path Visiting All Nodes ((node, visitedMask),每個節點都是起點 —— 見
dp_bitmask.md)
LC 1293 — 資源是倒數計數
關鍵 — 額外的維度是「我還能移除幾個障礙物」。只有在比先前任何一次抵達該格的預算嚴格更多 時,這次抵達才值得保留,所以用
best[r][c]記下見過的最大預算,取代三維visited。
辨識 — 「最多可以消除 k 個障礙物」、「從左上角走到右下角的最少步數」。m, n ≤ 40 且 k ≤ m·n,所以 k 必須先被封頂,才不會把狀態數乘上去。
歸約到:bfs § Template 3 — 預算放在佇列元素裡一起帶著走。
有兩件事值得帶進面試:
- 捷徑。 若
k >= m + n - 2,你可以直接剷平一路穿過去,答案就是曼哈頓距離m + n - 2,這個分支以O(1)回傳。但它的價值不只是一個提早結束:過了這關,所有剩下的輸入都滿足k < m + n - 2,所以上界是O(m*n*(m+n)),而不會隨著無上限的k成長。 - 用
best[r][c]取代三維visited。 在每格儲存曾見過的最大剩餘預算,並跳過任何沒有嚴格更好的抵達。答案相同,而 visited 表是O(m*n)而不是O(m*n*k)。
# python
# LC 1293 - Shortest Path in a Grid with Obstacles Elimination
# IDEA: BFS over (r, c, k remaining); prune with the best budget ever seen at a cell
# time = O(m*n*k), space = O(m*n*k) the queue's worst case; best[][] itself is O(m*n)
from collections import deque
def shortestPath(grid, k):
m, n = len(grid), len(grid[0])
if k >= m + n - 2:
return m + n - 2
best = [[-1] * n for _ in range(m)]
best[0][0] = k
q = deque([(0, 0, k, 0)]) # r, c, k left, steps
while q:
r, c, left, steps = q.popleft()
if (r, c) == (m - 1, n - 1):
return steps
for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nr, nc = r + dr, c + dc
if not (0 <= nr < m and 0 <= nc < n):
continue
n_left = left - grid[nr][nc]
if n_left < 0 or n_left <= best[nr][nc]:
continue # out of budget, or already been here richer
best[nr][nc] = n_left
q.append((nr, nc, n_left, steps + 1))
return -1
// java
// LC 1293 - Shortest Path in a Grid with Obstacles Elimination
// IDEA: BFS over (row, col, k left). best[r][c] = most budget ever seen here; an arrival
// with <= that budget can never do better, so drop it.
// time = O(m*n*k), space = O(m*n*k) the queue's worst case; best[][] itself is O(m*n)
public int shortestPath(int[][] grid, int k) {
int m = grid.length, n = grid[0].length;
if (k >= m + n - 2) return m + n - 2; // enough budget to walk the diagonal
int[][] best = new int[m][n];
for (int[] row : best) Arrays.fill(row, -1);
int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
Queue<int[]> q = new LinkedList<>();
q.offer(new int[]{0, 0, k});
best[0][0] = k;
int steps = 0;
while (!q.isEmpty()) {
int size = q.size();
for (int s = 0; s < size; s++) {
int[] cur = q.poll();
int r = cur[0], c = cur[1], left = cur[2];
if (r == m - 1 && c == n - 1) return steps;
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr < 0 || nr >= m || nc < 0 || nc >= n) continue;
int nLeft = left - grid[nr][nc]; // grid is 0/1, so this spends the budget
// `<=`, not `<`: an equal budget arriving no earlier can never win
if (nLeft < 0 || nLeft <= best[nr][nc]) continue;
best[nr][nc] = nLeft;
q.offer(new int[]{nr, nc, nLeft});
}
}
steps++;
}
return -1;
}
複雜度
- 時間 O(m·n·k):
n_left <= best[nr][nc]只在預算嚴格比之前多時才讓格子進來,所以每格最多入列 k + 1 次、試 4 個方向。過了k >= m + n - 2這個捷徑後 k < m + n,所以這也是 O(m·n·(m + n))。 - 空間 O(m·n·k)(最壞情況),來自佇列:同一格每多一種更大的預算就可能再放一次;
best本身是 O(m·n)。
陷阱
- 在進入的格子檢查預算,卻在離開的格子扣預算。單獨採用任一種慣例都正確 —— 兩個端點都是
0,所以一條路徑付費的障礙物相同 —— 但混用兩者,會讓一整串相鄰的障礙物只用 1 的預算就通過。 best檢查用<而不是<=,會讓較晚抵達、預算相同的狀態再進來,而且沒有東西會移除這些重複:目標到不了時佇列永遠不會清空(一塊免費區域會一直把自己再放進去),return -1永遠走不到;目標到得了時答案正確,但在全為0的格子上佇列可能呈指數成長。
不適用時 — 額外的維度讓邊變成有權重(每步有成本或時間):同樣的狀態改放進堆積而不是佇列 —— Dijkstra.md。
練習 — LC 1293 ⭐ · LC 787 Cheapest Flights Within K Stops ((node, stops);有權重,所以用 Dijkstra 或 Bellman-Ford) · LC 1928 Minimum Cost to Reach Destination in Time ((node, time),有權重)
0-1 BFS 與雙向 BFS
兩種改變的是佇列、而不是節點的 BFS 形狀。邊帶有任意非負權重就根本不是 BFS 了:用堆積
(Dijkstra.md)。沿路徑帶著一個值 —— 例如 LC 399 Evaluate Division 的比值乘積 ——
不需要新技巧:那是 dfs § Template 10,
帶著 (node, product) 的 BFS 給出一樣的答案。
Pattern 15:搭配 deque 的 0-1 BFS — LC 1368 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵 — 當每條邊的成本只有 0 或 1,用 deque 取代 Dijkstra 的堆積:成本 0 的邊放前端 (同一層),成本 1 的邊放後端(下一層)。deque 裡最多只有兩種距離值,而且有序,所以節點第一次 被取出時的距離就是最終的 —— 用 O(V + E) 拿到 Dijkstra 的保證,而不是
O(E log V)。
辨識 — 「讓路徑合法的最小成本」、「最少要移除幾個障礙物」:有些移動免費,其餘恰好成本 1。m, n ≤ 100;Dijkstra 也會過,所以 0-1 BFS 是最乾淨的答案,而不是唯一的答案。
歸約到:bfs § Template 3 — 改用 deque,並用 dist 鬆弛取代入列時標記。
LC 1368:格子告訴你每一格的「免費」方向。順著箭頭走成本為 0;其餘 3 個方向各成本 1(改一個標誌)。
# python
# LC 1368 - Minimum Cost to Make at Least One Valid Path in a Grid
# IDEA: deque BFS - appendleft for 0-cost moves, append for 1-cost moves
# time = O(m*n), space = O(m*n)
from collections import deque
def minCost(grid):
m, n = len(grid), len(grid[0])
dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)] # grid value 1,2,3,4
INF = float('inf')
dist = [[INF] * n for _ in range(m)]
dist[0][0] = 0
dq = deque([(0, 0)])
while dq:
r, c = dq.popleft()
for k, (dr, dc) in enumerate(dirs):
nr, nc = r + dr, c + dc
if not (0 <= nr < m and 0 <= nc < n):
continue
cost = 0 if grid[r][c] == k + 1 else 1
if dist[r][c] + cost < dist[nr][nc]:
dist[nr][nc] = dist[r][c] + cost
if cost == 0:
dq.appendleft((nr, nc)) # free move -> front
else:
dq.append((nr, nc)) # paid move -> back
return dist[m - 1][n - 1]
// java
// LC 1368 - Minimum Cost to Make at Least One Valid Path in a Grid
// IDEA: 0-1 BFS. Following grid[r][c]'s arrow costs 0 -> push FRONT; turning costs 1 -> push BACK.
// time = O(m*n), space = O(m*n)
public int minCost(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}}; // index k <-> grid value k+1
int[][] dist = new int[m][n];
for (int[] row : dist) Arrays.fill(row, Integer.MAX_VALUE);
dist[0][0] = 0;
Deque<int[]> dq = new ArrayDeque<>();
dq.offerFirst(new int[]{0, 0});
while (!dq.isEmpty()) {
int[] cur = dq.pollFirst();
int r = cur[0], c = cur[1];
for (int k = 0; k < 4; k++) {
int nr = r + dirs[k][0], nc = c + dirs[k][1];
if (nr < 0 || nr >= m || nc < 0 || nc >= n) continue;
int cost = (grid[r][c] == k + 1) ? 0 : 1;
if (dist[r][c] + cost < dist[nr][nc]) {
dist[nr][nc] = dist[r][c] + cost;
if (cost == 0) dq.offerFirst(new int[]{nr, nc}); // 0-weight: front
else dq.offerLast(new int[]{nr, nc}); // 1-weight: back
}
}
}
return dist[m - 1][n - 1];
}
複雜度
- 時間 O(m·n):deque 裡最多只有兩種距離,d 再來是 d + 1,所以一格最多被推入兩次 —— 一次是成本 1 的
dq.append,一次是更便宜的成本 0dq.appendleft—— 每次取出看 4 個方向。 - 空間 O(m·n):
dist,加上 deque 裡每格最多兩筆。
BFS vs 0-1 BFS vs Dijkstra
| 邊權重 | 資料結構 | 推入規則 | 時間 |
|---|---|---|---|
| 全為 1 | 佇列 | 一律放後端 | O(V + E) |
| 0 或 1 | Deque | 0 → 前端、1 → 後端 | O(V + E) |
| 任意 ≥ 0 | PriorityQueue | 依距離排序 | O(E log V) |
陷阱
- 要用
dist[r][c] + cost < dist[nr][nc]鬆弛,不能用visited集合:第一次經由成本 1 的邊抵達的節點,之後可能經由成本 0 的邊以更低成本抵達。 - 每條邊都推到後端就退化成普通 BFS,數的是步數,不是改了幾個標誌:在
[[1,1,3],[3,2,2],[1,1,4]]上答案是 0,但普通 BFS 回傳 4,也就是最少步數。
不適用時 — 每一步成本都相同(用普通佇列),或出現 0 和 1 以外的成本 (Dijkstra.md)。
練習 — LC 1368 ⭐ · LC 2290 Minimum Obstacle Removal to Reach Corner (走進牆成本 1) · LC 1263 Minimum Moves to Move a Box to Their Target Location (推箱子成本 1、玩家自己走動成本 0 —— 狀態是 (box, player))
雙向 BFS
關鍵 — 兩個端點都已知時,從兩端同時搜尋,前緣相遇就停。分支因子 b、距離 d 時,兩個深度 d/2 的搜尋大約拜訪
2·b^(d/2)個節點,而不是b^d。永遠擴展比較小的那一邊,而且一次擴展 一整層,這樣第一次相遇就是最短長度。
辨識 — 「從 beginWord 到 endWord 最短轉換序列的單字數」:兩個端點都已給定,而且分支因子很大(每個單字產生 26·L 個鄰居)。單字表 ≤ 5,000 —— 單向 BFS 也會過;這是用來縮小搜尋的追問。
歸約到:bfs § Template 5 — 在 LC 127 Word Ladder 上從兩端跑。
# python
# LC 127 - Word Ladder (bidirectional)
# IDEA: two frontier sets; expand the smaller one level at a time; a neighbour in the other set ends it
# time = O(N * L^2 * 26) worst case (each candidate is an O(L) string to build and hash), space = O(N * L)
def ladderLength(beginWord, endWord, wordList):
words = set(wordList)
if endWord not in words:
return 0
front, back = {beginWord}, {endWord}
words.discard(beginWord)
length = 1 # words on the path so far
while front and back:
if len(front) > len(back):
front, back = back, front # always grow the smaller side
next_front = set()
for word in front:
for i in range(len(word)):
for ch in 'abcdefghijklmnopqrstuvwxyz':
nxt = word[:i] + ch + word[i + 1:]
if nxt in back:
return length + 1 # the two searches meet
if nxt in words:
words.discard(nxt) # mark on enqueue, shared by both sides
next_front.add(nxt)
front = next_front
length += 1
return 0
複雜度
- 時間 O(N·L²·26)(最壞情況):
words.discard(nxt)對兩邊共用地把單字標記一次,所以每個單字最多被for i / for ch迴圈展開一次,而它的 26·L 個候選每個都是要建立並雜湊的長度 L 字串(只有把 L 當成小常數時才是 O(N·L·26))。好處在實務上 —— 兩個深度 d/2 的前緣,而不是一個深度 d 的。 - 空間 O(N·L):
words加上兩個前緣集合。
陷阱
- 只在單字被彈出時檢查相遇(而不是產生時)可能多走一層。
- 一個節點一個節點地擴展(而不是一層一層),可能先在較長的路徑上相遇。
- 終點未知時(任一葉節點、任一扇門),沒有東西可以往回搜:用單向 BFS。
不適用時 — 目標是一個集合或一個條件而不是單一節點,或圖很小:單向的 bfs § Template 5。
練習 — LC 127 ⭐ · LC 433 Minimum Genetic Mutation (同樣的搜尋,用在 8 個字母的基因上) · LC 752 Open the Lock (兩端都已知:"0000" 和目標) · LC 1197 Minimum Knight Moves (無邊界的棋盤,把搜尋深度減半最有用)
重複與連續執行的 BFS
Pattern 6:排序 + 重複 BFS(連續最短路徑)— LC 675
關鍵 — 拜訪順序是固定的(最矮的樹先砍),所以總步數是相鄰目標之間最短路徑的和。把目標排序, 再對每對相鄰目標各跑一次普通的格子 BFS;任何一對走不到,整個答案就是 -1。
辨識 — 「必須依照由矮到高的順序砍樹」、「依指定順序拜訪目標」,在有障礙物的格子上。m, n ≤ 50,所以即使每棵樹跑一次 BFS,也大約是 2,500 × 2,500 次格子拜訪。
歸約到:bfs § Template 3 — 對每對相鄰目標呼叫一次。
# python
# LC 675 - Cut Off Trees for Golf Event
# IDEA: sort trees by height; total = sum of grid-BFS distances between consecutive trees
# time = O(t * m * n), space = O(m * n) t = number of trees, one BFS each
from collections import deque
def cutOffTree(forest):
m, n = len(forest), len(forest[0])
trees = sorted((forest[r][c], r, c) for r in range(m) for c in range(n) if forest[r][c] > 1)
def bfs(sr, sc, tr, tc):
if (sr, sc) == (tr, tc):
return 0
queue = deque([(sr, sc)])
seen = {(sr, sc)}
steps = 0
while queue:
steps += 1
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and (nr, nc) not in seen and forest[nr][nc] != 0:
if (nr, nc) == (tr, tc):
return steps
seen.add((nr, nc))
queue.append((nr, nc))
return -1
total = 0
r, c = 0, 0
for _, tr, tc in trees:
steps = bfs(r, c, tr, tc)
if steps == -1:
return -1 # one unreachable tree fails the whole walk
total += steps
r, c = tr, tc
return total
// java
// LC 675 - Cut Off Trees for Golf Event
// IDEA: sort the targets by height, then one BFS per consecutive pair; sum the distances
// time = O(t * m * n), space = O(m * n)
public int cutOffTree(List<List<Integer>> grid) {
int rows = grid.size(), cols = grid.get(0).size();
List<int[]> targets = new ArrayList<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (grid.get(r).get(c) > 1) targets.add(new int[]{grid.get(r).get(c), r, c});
targets.sort((a, b) -> Integer.compare(a[0], b[0]));
int totalSteps = 0, startR = 0, startC = 0;
for (int[] target : targets) {
int steps = bfs(grid, startR, startC, target[1], target[2]);
if (steps == -1) return -1;
totalSteps += steps;
startR = target[1];
startC = target[2];
}
return totalSteps;
}
private int bfs(List<List<Integer>> grid, int sr, int sc, int tr, int tc) {
if (sr == tr && sc == tc) return 0;
int rows = grid.size(), cols = grid.get(0).size();
Queue<int[]> queue = new LinkedList<>();
queue.offer(new int[]{sr, sc});
boolean[][] visited = new boolean[rows][cols];
visited[sr][sc] = true;
int[][] dirs = {{0,1}, {0,-1}, {1,0}, {-1,0}};
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
steps++;
for (int i = 0; i < size; i++) {
int[] cur = queue.poll();
for (int[] dir : dirs) {
int nr = cur[0] + dir[0], nc = cur[1] + dir[1];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols
|| visited[nr][nc] || grid.get(nr).get(nc) == 0) continue;
if (nr == tr && nc == tc) return steps;
visited[nr][nc] = true;
queue.offer(new int[]{nr, nc});
}
}
}
return -1;
}
複雜度
- 時間 O(t·m·n):
for _, tr, tc in trees每棵樹跑一次bfs,每次 BFS 每格最多拜訪一次(全新的seen);sorted是 O(t log t),被前者主導。 - 空間 O(m·n):同一時間只有一份
seen集合和佇列,加上trees清單。
試跑
forest = [[1,2,3], trees by height: 2 (0,1), 3 (0,2), 4 (1,2), 5 (2,2), 6 (2,1), 7 (2,0)
[0,0,4],
[7,6,5]]
(0,0)->(0,1) 1 · (0,1)->(0,2) 1 · (0,2)->(1,2) 1 · (1,2)->(2,2) 1 · (2,2)->(2,1) 1 · (2,1)->(2,0) 1
total = 6
陷阱
(0, 0)上可能就有樹;第一次 BFS 回傳 0,而不是失敗。- 砍掉的樹變成
1(可通行),永遠不是0;每次 BFS 都要全新的visited。 - 用曼哈頓距離當啟發的 A* 能加快每次搜尋,但不改變上界。
不適用時 — 目標可以用任意順序拜訪:那是在距離矩陣上的 TSP 式搜尋,而不是相鄰 BFS 的總和。
練習 — LC 675 ⭐ · LC 1091 (每一步跑的那一次格子 BFS) · LC 317 (每個來源一次 BFS,逐格加總)
不適用時
| 技巧 | 通常就夠用的、更簡單的核心模板 |
|---|---|
| Pattern 4.5(先 fill,再多源 BFS) | 只有一個起點格 → bfs § Template 3 |
| Pattern 4.6(各自獨立的 BFS) | 問的是最近的來源 → 一次多源 BFS;每個來源各跑一次答案一樣但慢 k 倍 |
| Pattern 8(以路線為節點) | 答案數的是站或邊,而不是路線 → bfs § Template 2 |
| Pattern 8.5(前驅 DAG) | 只問長度或一條路徑 → bfs § Template 5,路徑再加一個父節點表 |
| Pattern 9(笛卡兒積 BFS) | 選擇之間互相限制,或在意記憶體 → 回溯(dfs § Template 4) |
| Pattern 12(一層刪一個) | 只問要刪幾個 → 一次從左到右的平衡計數器,O(n) |
| Pattern 16(擴充狀態) | 帶著的值從不改變哪些移動合法 → 一般格子 BFS;它只是答案的一部分 |
| Pattern 15(0-1 BFS) | 每步成本都一樣 → 普通佇列;成本不只 0 和 1 → Dijkstra.md |
| 雙向 BFS | 圖很小,或目標是一個集合或條件 → 單向 BFS;L3 很少需要它才能過 |
| Pattern 6(排序後重複 BFS) | 目標可以任意順序拜訪 → 那是 TSP 式的搜尋,不是這個 |
| 葉節點之間的樹上距離(LC 1530) | 回傳各深度葉節點數的後序 DFS 是 O(n),不需要建圖(dfs_advanced.md) |
總結
| 題目中的訊號 | 變形 | 章節 |
|---|---|---|
| 「到最近來源的距離」vs「到所有來源的距離總和」 | 一份共用的 visited vs 每個來源一份全新的 visited |
Pattern 4.6 |
| 兩個連通塊,用最少翻轉把它們接起來 | flood fill 一個連通塊,再從它全部格子向外做多源 BFS | Pattern 4.5 |
| 「最少轉乘/公車/路線」 | 節點是路線而不是站的 BFS | Pattern 8 |
| 「回傳所有最短序列」 | BFS 建前驅 DAG,DFS 列舉它 | Pattern 8.5 |
| 「最少移除幾個讓它合法」 | 一層 BFS = 一次移除;停在第一個有結果的層 | Pattern 12 |
| 從互相獨立的群組列舉所有組合 | 一個群組一層 BFS(笛卡兒積) | Pattern 9 |
| 鑰匙、預算或顏色會改變哪些移動合法 | visited 以 (cell, resource) 為鍵 |
Pattern 16 |
| 每條邊成本只有 0 或 1 | deque:成本 0 放前端、成本 1 放後端 | Pattern 15 |
| 兩個端點都已知且路徑很長 | 從兩端各擴展比較小的那一邊 | 雙向 BFS |
| 必須照固定順序拜訪目標 | 排序,再對每對相鄰目標各跑一次 BFS | Pattern 6 |
這些所依據的必會佇列模板在 bfs.md;經典題目在 bfs_examples.md。