跳到主内容

岛屿数量面试标准解:DFS 淹没法从会写到写对 ​

问题背景:为什么岛屿数量是图论题 ​

LeetCode 200「岛屿数量」几乎是网格图面试的入门必考题。题目给出一个由 '1'(陆地)和 '0'(水域)组成的二维网格,要求统计岛屿数量。这里的岛屿定义为:通过水平或垂直方向相邻的陆地连接形成的区域,斜向相邻不算连通。

很多初学者把它当成“遍历二维数组并计数”,结果越写越乱。本质上,这是一个典型的无向图连通分量计数问题:

  • 每个值为 '1' 的格子是一个图节点;
  • 上下左右四个方向代表节点之间的边;
  • 一次 DFS 或 BFS 能访问完一个岛屿中的全部陆地;
  • 启动了多少次搜索,就有多少个连通分量。

所谓“淹没标记法”,是在访问陆地后立刻把它改成水域 '0',相当于把已访问节点从图中删除。这样不需要额外维护 visited 集合,也能保证每个格子只被处理一次。

方案对比:DFS、BFS 与并查集怎么选 ​

常见标准解法有三类。

方案核心思路优点注意点
网格 DFS从陆地出发递归淹没整座岛代码短、面试表达清晰网格极大时可能递归栈溢出
网格 BFS用队列逐层扩展陆地避免递归深度问题入队时就要标记访问
并查集合并相邻陆地,最后统计集合数适合动态连通性扩展本题代码量相对更大

对静态网格的岛屿数量,DFS 淹没法是最常见的面试标准答案;如果面试官追问递归深度,可主动给出 BFS 或显式栈迭代 DFS。

扫描网格发现陆地 1岛屿数加一启动 DFS/BFS淹没连通陆地当前格改为 0继续检查上下左右四方向整座岛访问完回溯到主循环扫描剩余格子只统计新陆地

实操步骤:递归 DFS 淹没标记法 ​

标准做法分两层:

  1. 外层双重循环扫描每一个格子;
  2. 当遇到未访问的陆地 '1',说明发现一座新岛,答案加一;
  3. 从该格子启动 DFS,将当前格子标记为 '0';
  4. 递归访问上下左右四个方向;
  5. 搜索结束后,这座岛的所有陆地都已被淹没,不会被重复计数。

下面代码可直接运行:

python
from typing import List


def num_islands(grid: List[List[str]]) -> int:
    """统计二维网格中的岛屿数量。"""
    if not grid or not grid[0]:
        return 0

    rows, cols = len(grid), len(grid[0])
    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]

    def dfs(r: int, c: int) -> None:
        # 越界或当前格子不是陆地,直接返回
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        if grid[r][c] != "1":
            return

        # 关键:先淹没当前格子,再递归扩展,避免重复访问
        grid[r][c] = "0"

        for dr, dc in directions:
            dfs(r + dr, c + dc)

    count = 0

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1":
                count += 1
                dfs(r, c)

    return count


# 可直接运行的测试用例
grid = [
    ["1", "1", "0", "0", "0"],
    ["1", "1", "0", "0", "0"],
    ["0", "0", "1", "0", "0"],
    ["0", "0", "0", "1", "1"],
]

print(num_islands(grid))  # 输出:3

为什么改成 '0' 就等价于 visited ​

DFS 最容易出现的问题是重复访问。假设 A 格子访问了右边的 B,B 又尝试访问左边的 A,如果 A 没有被标记,程序会在两个节点之间无限递归。

把当前陆地改成 '0' 后:

  • 它不会再次触发外层计数;
  • 邻居递归到它时会被 grid[r][c] != "1" 拦截;
  • 每个格子最多进入一次有效 DFS。

因此,原地修改输入虽然节省了 visited 空间,但也改变了原网格。若题目明确要求保留输入,应使用访问数组,或先复制网格。

BFS 写法:避免长路径递归深度问题 ​

当整张网格都是陆地且规模很大时,递归 DFS 的调用深度可能达到格子数量级别,部分语言环境会触发栈溢出。BFS 使用队列保存待访问节点,更适合说明你考虑了工程边界。

python
from collections import deque
from typing import List


def num_islands_bfs(grid: List[List[str]]) -> int:
    """使用 BFS 统计岛屿数量。"""
    if not grid or not grid[0]:
        return 0

    rows, cols = len(grid), len(grid[0])
    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
    count = 0

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] != "1":
                continue

            count += 1
            grid[r][c] = "0"
            queue = deque([(r, c)])

            while queue:
                x, y = queue.popleft()

                for dx, dy in directions:
                    nx, ny = x + dx, y + dy

                    if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == "1":
                        # 入队前必须立即标记,否则同一格子可能被重复加入队列
                        grid[nx][ny] = "0"
                        queue.append((nx, ny))

    return count

BFS 与 DFS 的时间复杂度完全相同,区别主要在空间使用形式:DFS 依赖递归调用栈,BFS 依赖队列。队列最大长度通常取决于网格宽度或某一层边界规模。

复杂度分析 ​

设网格行数为 m,列数为 n,格子总数为 m × n。

  • 时间复杂度:O(mn)。每个格子在外层循环中被扫描一次;陆地格子最多被搜索一次;水域不会进入 DFS/BFS。
  • 空间复杂度:O(mn)。最坏情况下整张网格都是陆地,递归栈或队列可能容纳 O(mn) 个节点;平均情况通常小于该上界。
  • 额外标记空间:O(1)。淹没法直接修改原网格,没有单独的 visited 数组,但代价是输入被破坏。

面试时建议主动说明:如果不能修改输入,应新增 visited: List[List[bool]],此时额外空间为 O(mn)。

变体扩展:从标准答案到举一反三 ​

掌握连通分量模板后,很多网格题只是判定条件和统计目标发生变化:

  • 岛屿最大面积:DFS 返回当前连通分量大小,取最大值;
  • 封闭岛屿数量:边界上的陆地不算封闭,需要先淹没边界连通区域;
  • 飞地数量:统计无法从边界到达的陆地格子数;
  • 被围绕的区域:从边界的 'O' 做 BFS 标记,再翻转未标记区域;
  • 岛屿数量 II:逐步填海并动态查询连通分量数,并查集更合适。

这些题的共同点仍是:先定义节点和连通方向,再决定搜索起点、访问标记和答案统计时机。

避坑总结:面试中最容易丢分的细节 ​

1. 字符和数字类型混淆 ​

常见错误是把网格元素写成数字判断:

python
# 错误:题目输入通常是字符 "1",不是整数 1
if grid[r][c] == 1:
    count += 1

修正方法是严格按照输入类型判断 "1" 和 "0"。如果面试中输入可能是整数矩阵,应先和面试官确认,再统一比较条件。

2. 递归后才标记导致重复访问或死循环 ​

错误写法先递归四个方向,最后才把当前格子设为 '0'。这样相邻格子会再次访问当前格子,调用链无法终止。正确顺序是:进入格子后立即标记,再向四方向扩展。

3. BFS 出队时才标记,队列可能指数膨胀 ​

很多人写 BFS 时在 popleft() 后才设置访问标记。问题是同一个陆地可能被上方、左方邻居重复加入队列,造成大量重复节点。修正方法是在发现合法邻居并加入队列的瞬间标记为 '0'。

4. 忘记处理空网格 ​

直接访问 grid[0] 在空列表时会抛出索引异常。函数开头应判断:

python
if not grid or not grid[0]:
    return 0

5. 把斜向相邻误认为连通 ​

岛屿数量默认只考虑上下左右,方向数组应只有四个元素。若误加入四个对角方向,会把本不相连的岛屿合并。只有题目明确说明八方向连通时,才使用八个方向。

6. 忽视原地修改输入的副作用 ​

淹没法代码最短,但调用函数后原网格会全部变成水域视角。若后续还要使用原始地图,必须复制输入或使用独立访问数组。面试中主动指出这一点,往往比只写出答案更能体现工程意识。

结语 ​

岛屿数量的标准解并不复杂,但它非常适合考察候选人是否能把网格抽象成图、是否真正理解 DFS/BFS 的访问时机,以及是否能分析最坏复杂度。记住核心模板:外层扫描发现新岛屿,内层搜索淹没整个连通分量;计数只在搜索启动时发生一次。按这个框架表达,既能快速写出代码,也能自然应对面积、边界、动态连通等变体题。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。