BFS — 進階變形

圖論優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 第一輪可以先跳過的 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
# 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
// 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 島的每一格,可能占了大半個格子。

試跑

text
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
# 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
// 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
# 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
// 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 每個(站,路線)配對存一筆。

試跑

text
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
# 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
// 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 存每個單字在上一層的前驅;輸出不計。

試跑

text
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
# 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
// 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 最多同時裝兩層,較大的那層就是輸出本身。

試跑

text
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
# 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
// 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 的鍵。

text
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
# 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
// 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
# 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
// 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
# 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
// 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,一次是更便宜的成本 0 dq.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
# 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
# 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
// 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 清單。

試跑

text
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。