演算法圖鑑
Graph Algorithms · 03 / 11

Grid as Graph網格圖

把二維陣列當圖,四方向就是邊

用在:數島嶼、迷宮、影像連通區域

時間複雜度O(mn)
空間複雜度O(mn)
難度入門
前置知識BFS、DFS、Matrix

01為什麼需要它

病理切片裡數細胞核

一張切片掃描成 4000×3000 像素,二值化後細胞核是 1、背景是 0。醫檢系統要數出有幾個細胞核,並標出面積超過 500 像素、可能是病變的大塊。

為什麼用它每個像素是節點,上下左右都是 1 就有邊,一個細胞核就是一個連通分量。逐格掃描,碰到沒走過的 1 就把整塊走完、順便累加面積。1200 萬個像素每個只進出佇列一次,而且鄰居用座標算出來,不必真的建一張 1200 萬節點的鄰接串列。

海平面上升 2 公尺,哪裡會淹水

一份 2000×2000 格的地形高程圖,每格記錄海拔。低於 2 公尺的格子有幾十萬個,但其中有些窪地被堤防和高地圍住,海水根本進不去。

為什麼用它只看海拔會把內陸窪地也算成淹水。反過來從海出發:把所有海洋格同時放進佇列,只往海拔 ≤ 2 公尺的鄰格擴散,走得到的才會淹。一次走訪 O(mn),不必對每個低窪格各自檢查「通不通海」。

賣場每一區的逃生指示牌

一層 200×300 格的賣場平面圖,貨架是牆,共有 6 個逃生出口。消防規定每一區的指示牌要標出走到最近出口的步數。

為什麼用它對 6 個出口各跑一次 BFS 再取最小值,要走 6 趟。多源 BFS 把 6 個出口一開始全放進佇列、距離都是 0,一趟走訪就得到每格到「最近」出口的步數,等同於加一個連到所有出口的虛擬起點。

看到這些關鍵字就想到它:二維網格、m × n、上下左右相鄰、有幾塊/幾座島、最大的一塊、填色、迷宮最少步數、到最近的某物的距離、從邊界往內走。

02核心概念

網格本身就是一張圖:每一格 (r, c) 是節點,上下左右相鄰、而且兩格都可以走時,中間就有一條無向邊。這些邊不需要存下來:用方向陣列 DIRS = [(-1,0), (1,0), (0,-1), (0,1)] 加上座標就能即時算出鄰居,所以 BFS 與 DFS 可以原封不動搬過來,唯一的差別是「列出 u 的鄰居」從查鄰接串列換成四次邊界檢查。一個 m × n 的網格有 V = mn 個節點;全部格子都能走時邊最多,E = m(n−1) + n(m−1) < 2mn

網格題大多是兩種問法。數連通區域:雙重迴圈逐格掃描,碰到還沒拜訪的可走格就 count += 1,從它出發把整塊走完並標記。這樣數出來恰好是分量個數,因為一次走訪會標記起點所在分量的每一格(相連的格子一定會被放入),也標記那個分量(不相連的格子沒有邊可以到達);之後掃描再碰到未拜訪的格子,它必定屬於一個還沒數過的分量。最少步數:每走一步代價都是 1,所以用 BFS,一格第一次被標記時的 dist 就是最短步數。若起點有很多個,就把它們同時以距離 0 放進佇列,這叫多源 BFS,得到的是每格到「最近」起點的距離;問「哪些格子碰不到邊界」時,也是把邊界上所有可走的格子當起點反向走一次,沒被走到的就是答案。

複雜度:每格最多被標記一次、進出佇列一次,每次檢查 4 個方向,時間 O(4mn) = O(mn),就是 O(V + E) 代入網格的結果。外層掃描本身就要看過每一格,所以數區域沒有更好的最佳情況。空間是 visited 或 dist 陣列的 O(mn),佇列或堆疊最壞也可能同時放著 O(mn) 格。DFS 用遞迴寫時,遞迴深度等於目前路徑長,一條蛇形的陸地就能讓它深到 mn 層:1000×1000 的網格是一百萬層,Python 預設遞迴上限只有 1000,C++ 的預設堆疊通常也撐不住,大網格請用 BFS 或明確的堆疊。

常見的錯:先檢查邊界再取值,Python 的 grid[-1] 不會報錯,而是取到最後一列,越界的 -1 會悄悄接到對面;放入佇列時就標記,等到取出才標記的話,同一格會被好幾個鄰居重複放入;四方向還是八方向要看題目,斜走也算一步時 DIRS 要有 8 個;直接把走過的陸地改成 0 可以省下 visited,但會破壞輸入。和 Word Search 的網格回溯不同,這裡標記之後永遠不取消,每格只走一次才有 O(mn)。格子各有不同的通過代價時改用 Dijkstra;格子會一個一個變成陸地、還要隨時回答島嶼數時,改用併查集。

03演算法步驟

  1. 1定義圖:m = len(grid)n = len(grid[0]),決定哪些格子可以走(陸地、非牆),寫好 DIRS(四方向或八方向)。不要另外建鄰接串列。
  2. 2準備標記:m × nseen 布林陣列;需要步數就用 dist 陣列,-1 代表還沒走到。
  3. 3走訪一塊:起點標記後放入佇列。取出 (r, c),對每個 (dr, dc) 算出 (nr, nc),依序檢查 0 ≤ nr < m0 ≤ nc < n、可以走、還沒標記,全部通過才標記並放入
  4. 4數區域:雙重迴圈掃每一格,碰到還沒標記的可走格就把區域數加一,從它開始執行步驟 3,要面積就在取出時累加。
  5. 5最少步數:單一起點直接 BFS,dist[nr][nc] = dist[r][c] + 1;多個起點(最近的出口、所有海洋格、所有邊界格)先全部以距離 0 放入佇列,再跑同一個迴圈。

04互動示範

5×6 的地圖,1 是陸地、0 是水,共有 5 座島。示範逐格掃描,碰到沒走過的陸地就把整座島走完:黃色是在佇列或堆疊裡等待的格子,藍色是正在處理的格子,綠框是這一步找到、可以走的鄰居,被取出處理的格子會換成它所屬島的編號。切換「BFS(佇列)」與「DFS(堆疊)」,程式只差在從前端還是尾端取出:同一座島裡格子完成的順序不同,但最後島嶼數與每座島的格子完全一樣。

5×6 網格 · 四方向
1
1
0
0
0
1
1
0
0
1
0
1
0
0
1
1
0
0
0
0
0
1
0
0
1
0
0
0
0
1
未發現的陸地在佇列中處理中已完成(數字是島編號)可走的鄰居
佇列(前 → 後)
目前格子的四方向
沒有正在處理的格子
島嶼數
0
步驟 0/22把每一格當成節點,上下左右相鄰的陸地之間有邊。從 (0, 0) 開始逐格掃描,找還沒拜訪過的陸地。

05程式碼

兩個函式對應兩種問法:island_areas 用 BFS 數出每座島的面積(用的就是示範的地圖),nearest_exit 用多源 BFS 算每格到最近出口的步數,包含一格被牆圍住、走不到的情況。兩者共用同一段「方向陣列 + 邊界檢查 + 放入時標記」的骨架,這段寫熟了,大部分網格題只剩下「哪些格子可以走、起點是誰」要決定。

from collections import deque

DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]    # 上、下、左、右


def island_areas(grid):
    """四方向相連的 1 是一座島。回傳每座島的面積(依掃描順序),長度就是島嶼數。"""
    m, n = len(grid), len(grid[0])
    seen = [[False] * n for _ in range(m)]
    areas = []
    for r in range(m):
        for c in range(n):
            if grid[r][c] != 1 or seen[r][c]:
                continue
            seen[r][c] = True                 # 掃到沒走過的陸地:新島的起點
            queue, area = deque([(r, c)]), 0
            while queue:
                cr, cc = queue.popleft()      # 改成 queue.pop() 就是堆疊版,面積不變
                area += 1
                for dr, dc in DIRS:
                    nr, nc = cr + dr, cc + dc
                    # 先檢查邊界:Python 的 grid[-1] 不會報錯,會悄悄取到最後一列
                    if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 1 and not seen[nr][nc]:
                        seen[nr][nc] = True   # 放入時就標記,同一格才不會被放入兩次
                        queue.append((nr, nc))
            areas.append(area)
    return areas


def nearest_exit(floor):
    """多源 BFS:每格走到最近的出口 E 要幾步。牆 # 與走不到的格子是 -1。"""
    m, n = len(floor), len(floor[0])
    dist = [[-1] * n for _ in range(m)]
    queue = deque()
    for r in range(m):
        for c in range(n):
            if floor[r][c] == "E":
                dist[r][c] = 0                # 所有出口一開始就在佇列裡,距離 0
                queue.append((r, c))
    while queue:
        r, c = queue.popleft()
        for dr, dc in DIRS:
            nr, nc = r + dr, c + dc
            if 0 <= nr < m and 0 <= nc < n and floor[nr][nc] != "#" and dist[nr][nc] == -1:
                dist[nr][nc] = dist[r][c] + 1  # 第一次被走到就是最短步數
                queue.append((nr, nc))
    return dist


if __name__ == "__main__":
    grid = [
        [1, 1, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 1],
        [0, 0, 1, 1, 0, 0],
        [0, 0, 0, 1, 0, 0],
        [1, 0, 0, 0, 0, 1],
    ]
    areas = island_areas(grid)
    print(len(areas), areas, max(areas))   # 5 [3, 2, 4, 1, 1] 4

    floor = ["E.#...",
             "..#.#.",
             "....#E",
             "##.##.",
             "#.#..."]
    for r, row in enumerate(nearest_exit(floor)):
        print(" ".join(" #" if floor[r][c] == "#" else f"{d:2}" for c, d in enumerate(row)))
    # 輸出(# 是牆;(4, 1) 被牆圍住,走不到任何出口,所以是 -1):
    #  0  1  #  4  3  2
    #  1  2  #  5  #  1
    #  2  3  4  5  #  0
    #  #  #  5  #  #  1
    #  # -1  #  4  3  2

06練習題

  • LeetCode 733Flood Fill(最基本的四方向走訪)Easy
  • LeetCode 1020Number of Enclaves(從邊界反向走訪)Medium
  • LeetCode 54201 Matrix(多源 BFS)Medium
  • LeetCode 417Pacific Atlantic Water Flow(從兩個海岸各走一次)Medium
  • LeetCode 934Shortest Bridge(先標出一座島,再多源 BFS)Medium
  • LeetCode 827Making A Large Island(先標號記面積,再試每個 0)Hard