📖 第89课:岛屿数量

2 阅读18分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第89课:岛屿数量

模块:图论 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/nu… 前置知识:第39课(二叉树DFS)、第44课(BFS层序遍历) 预计学习时间:25分钟


🎯 题目描述

给你一个由字符 '1'(陆地)和 '0'(水)组成的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,每个岛屿由水平或垂直方向相邻的陆地连接而成。你可以假设网格的四条边均被水包围。

示例:

输入:grid = [  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3
解释:共有3个岛屿(左上角的6格陆地、中间的1格、右下角的2格)

约束条件:

  • m == grid.length (行数)
  • n == grid[i].length (列数)
  • 1 <= m, n <= 300
  • grid[i][j] 的值为 '0''1'

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入grid = [["1"]]1单格陆地
全是水grid = [["0","0"],["0","0"]]0无岛屿处理
全是陆地grid = [["1","1"],["1","1"]]1整片陆地算1个岛
对角不算连接grid = [["1","0"],["0","1"]]2只看上下左右
大规模300 x 300 全是 '1'1性能边界

💡 思路引导

生活化比喻

想象你是一名航拍测绘员,拿着无人机俯瞰一片群岛...

🐌 笨办法:每看到一小块陆地就记录一次,结果同一个岛被重复计数无数次。最后统计时一团乱麻,根本分不清哪些格子属于同一个岛。

🚀 聪明办法:从第一块陆地起飞,沿着连接的陆地一路"标记走过的地方"(比如涂成红色),直到这块岛的所有角落都标记完,岛屿数+1。然后继续扫描,遇到下一块未标记的陆地时重复此过程。核心是"遇到新陆地时一次性标记整个岛",这样每个岛只被计数一次。

关键洞察

岛屿问题的本质是"网格连通分量"的计数:遇到未访问的陆地时,启动DFS/BFS标记整个连通区域,计数器+1


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:二维字符数组 grid,元素为字符 '1''0'
  • 输出:整数,表示岛屿数量
  • 限制:只有水平或垂直相邻才算连接(对角线不算);需要原地修改或额外空间记录已访问

Step 2:先想笨办法(暴力法)

遍历每个格子,若是陆地 '1' 就计数+1。

  • 时间复杂度:O(m × n)
  • 瓶颈在哪:这样会重复计数同一个岛的所有格子!比如一个6格的岛会被算成6个岛。

Step 3:瓶颈分析 → 优化方向

核心问题:"如何确保每个岛只被计数一次?"

  • 关键:当发现一个新陆地时,立即把整个岛的所有格子标记为"已访问",这样后续遍历遇到这些格子时就跳过。
  • 优化思路:引入"洪泛填充"(Flood Fill)算法 → 用 DFS(深度优先)BFS(广度优先) 标记整个连通区域

Step 4:选择武器

  • 选用:DFS递归BFS队列
  • 理由:
    • DFS:代码简洁,递归自然表达"向四周扩散标记"
    • BFS:用队列逐层标记,更符合"一层层扩散"的直觉
    • 两者时间复杂度相同,都是 O(m × n)

🔑 模式识别提示:当题目出现"连通分量""相邻格子""区域标记"等关键词,优先考虑 网格DFS/BFS 模式


🔑 解法一:DFS递归(洪泛填充)

思路

遍历网格,每次遇到陆地 '1' 时:

  1. 岛屿计数 +1
  2. 启动DFS递归,将这个岛的所有相邻陆地标记为 '0'(表示已访问)
  3. 继续遍历下一个格子

图解过程

示例输入:
  ["1","1","0","0","0"]
  ["1","1","0","0","0"]
  ["0","0","1","0","0"]
  ["0","0","0","1","1"]

Step 1: 从 grid[0][0]='1' 开始,启动DFS,标记整个岛(左上角)
  岛屿数 count = 1
  ["0","0","0","0","0"]  <- 左上角6格全变为'0'
  ["0","0","0","0","0"]
  ["0","0","1","0","0"]
  ["0","0","0","1","1"]

Step 2: 继续遍历,到 grid[2][2]='1',再次启动DFS
  岛屿数 count = 2
  ["0","0","0","0","0"]
  ["0","0","0","0","0"]
  ["0","0","0","0","0"]  <- 中间1格变为'0'
  ["0","0","0","1","1"]

Step 3: 继续到 grid[3][3]='1',再次DFS
  岛屿数 count = 3
  ["0","0","0","0","0"]
  ["0","0","0","0","0"]
  ["0","0","0","0","0"]
  ["0","0","0","0","0"]  <- 右下角2格变为'0'

遍历结束,返回 count = 3

边界示例(全是陆地):

输入: [["1","1"],["1","1"]]

Step 1: grid[0][0]='1',启动DFS,四个方向递归标记:
  右 -> grid[0][1]='1' -> 继续下 -> grid[1][1]='1' -> 继续左 -> grid[1][0]='1'
  所有格子都被标记为'0',count = 1

输出: 1 (整片陆地算1个岛)

Python代码

from typing import List


def numIslands(grid: List[List[str]]) -> int:
    """
    解法一:DFS递归洪泛填充
    思路:遇到陆地时,用DFS标记整个岛,计数+1
    """
    if not grid or not grid[0]:
        return 0

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

    def dfs(r: int, c: int):
        """递归标记当前格子及其四周的陆地"""
        # 边界检查:越界或遇到水就返回
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        if grid[r][c] != '1':
            return

        # 标记为已访问(涂成'0',表示这块陆地已计入某个岛)
        grid[r][c] = '0'

        # 向四个方向递归(上下左右)
        dfs(r - 1, c)  # 上
        dfs(r + 1, c)  # 下
        dfs(r, c - 1)  # 左
        dfs(r, c + 1)  # 右

    # 遍历整个网格
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':  # 发现新陆地
                dfs(i, j)          # 标记整个岛
                count += 1         # 岛屿数+1

    return count


# ✅ 测试
print(numIslands([
    ["1","1","0","0","0"],
    ["1","1","0","0","0"],
    ["0","0","1","0","0"],
    ["0","0","0","1","1"]
]))  # 期望输出:3

print(numIslands([["1","1"],["1","1"]]))  # 期望输出:1
print(numIslands([["0","0"],["0","0"]]))  # 期望输出:0
print(numIslands([["1"]]))                # 期望输出:1

复杂度分析

  • 时间复杂度:O(m × n) — 每个格子最多访问一次(主循环遍历 + DFS递归各访问一次)
    • 具体地说:如果输入是 300 × 300 的网格,最多访问 90000 次格子
  • 空间复杂度:O(m × n) — 递归调用栈最坏情况下深度为 m × n(比如整个网格是一个蛇形岛)
    • 注意:本解法原地修改 grid,未使用额外 visited 数组

优缺点

  • ✅ 代码简洁,易于理解
  • ✅ 原地修改节省额外空间(不需要 visited 数组)
  • ⚠️ 修改了输入数据,某些场景不允许(可用 visited 集合替代)
  • ⚠️ 递归深度可能很大(Python默认递归限制约1000层)

🏆 解法二:BFS队列(最优解)

优化思路

DFS用递归隐式维护栈,如果担心递归深度过大导致栈溢出,可以用 BFS队列 显式管理待访问的格子,避免深度递归。

💡 关键想法:每次发现陆地时,用队列存储待扩展的格子,逐层向四周标记,直到整个岛标记完毕

图解过程

示例: [["1","1"],["0","1"]]

Step 1: grid[0][0]='1',加入队列 queue = [(0,0)]
  弹出 (0,0),标记为'0',检查四周:
    上/左:越界
    下:(1,0)是'0',跳过
    右:(0,1)是'1',加入队列并标记为'0'

  queue = [(0,1)]
  弹出 (0,1),检查四周:
    上:越界
    下:(1,1)是'1',加入队列并标记
    左:(0,0)已是'0',跳过
    右:越界

  queue = [(1,1)]
  弹出 (1,1),检查四周全是边界/水,结束

  岛屿数 count = 1

Python代码

from typing import List
from collections import deque


def numIslands_bfs(grid: List[List[str]]) -> int:
    """
    解法二:BFS队列洪泛填充(最优解)
    思路:用队列逐层标记岛屿,避免深度递归
    """
    if not grid or not grid[0]:
        return 0

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

    def bfs(r: int, c: int):
        """从 (r,c) 开始BFS标记整个岛"""
        queue = deque([(r, c)])
        grid[r][c] = '0'  # 标记起点

        while queue:
            x, y = queue.popleft()
            # 向四个方向扩展
            for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]:
                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))

    # 遍历网格
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                bfs(i, j)
                count += 1

    return count


# ✅ 测试
print(numIslands_bfs([
    ["1","1","0","0","0"],
    ["1","1","0","0","0"],
    ["0","0","1","0","0"],
    ["0","0","0","1","1"]
]))  # 期望输出:3

print(numIslands_bfs([["1","0"],["0","1"]]))  # 期望输出:2 (对角不算连接)

复杂度分析

  • 时间复杂度:O(m × n) — 每个格子最多入队一次
  • 空间复杂度:O(min(m, n)) — 队列最坏情况下的大小
    • 为什么是 min(m, n)?假设岛屿是一个正方形,BFS逐层扩展时,队列最多存储"对角线"上的格子,数量约为 min(m, n)

⚡ 解法三:并查集(进阶优化)

优化思路

如果要支持动态添加陆地、频繁查询连通分量,可以用 并查集(Union-Find) 维护连通性。初始时每个陆地单独成一个集合,然后合并相邻的陆地。

💡 关键想法:将二维坐标转换为一维编号,用并查集维护"哪些陆地属于同一个岛"

Python代码

from typing import List


class UnionFind:
    """并查集数据结构"""
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.count = 0  # 连通分量数

    def find(self, x: int) -> int:
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # 路径压缩
        return self.parent[x]

    def union(self, x: int, y: int):
        root_x, root_y = self.find(x), self.find(y)
        if root_x != root_y:
            self.parent[root_x] = root_y
            self.count -= 1  # 合并后连通分量-1


def numIslands_uf(grid: List[List[str]]) -> int:
    """
    解法三:并查集
    思路:将相邻陆地合并到同一集合,统计集合数
    """
    if not grid or not grid[0]:
        return 0

    rows, cols = len(grid), len(grid[0])
    uf = UnionFind(rows * cols)

    # 初始化:统计陆地数(每个陆地初始为独立岛屿)
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                uf.count += 1

    # 合并相邻陆地
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                # 二维坐标转一维编号
                current = i * cols + j
                # 只检查右和下(避免重复合并)
                for di, dj in [(0, 1), (1, 0)]:
                    ni, nj = i + di, j + dj
                    if 0 <= ni < rows and 0 <= nj < cols and grid[ni][nj] == '1':
                        neighbor = ni * cols + nj
                        uf.union(current, neighbor)

    return uf.count


# ✅ 测试
print(numIslands_uf([
    ["1","1","0","0","0"],
    ["1","1","0","0","0"],
    ["0","0","1","0","0"],
    ["0","0","0","1","1"]
]))  # 期望输出:3

复杂度分析

  • 时间复杂度:O(m × n × α(m × n)) — α是反阿克曼函数,近似O(1)
    • 实际上接近 O(m × n)
  • 空间复杂度:O(m × n) — 并查集数组

🐍 Pythonic 写法

利用 Python 的方向数组和列表推导简化代码:

def numIslands_pythonic(grid: List[List[str]]) -> int:
    """简洁版:用方向数组统一处理四个方向"""
    if not grid:
        return 0

    rows, cols = len(grid), len(grid[0])
    directions = [(-1,0), (1,0), (0,-1), (0,1)]  # 上下左右

    def dfs(r, c):
        if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != '1':
            return
        grid[r][c] = '0'
        for dr, dc in directions:
            dfs(r + dr, c + dc)

    return sum(
        1 for i in range(rows) for j in range(cols)
        if grid[i][j] == '1' and not dfs(i, j)  # dfs返回None,用not让表达式为True
    )

注意:上面的简洁写法有个技巧 — dfs 函数返回 None,not None 为 True,所以 if grid[i][j]=='1' and not dfs(i,j) 会在调用 dfs 后计数。实际面试中建议用清晰版本,避免过度追求简洁。

⚠️ 面试建议:先写清晰版本展示思路,再提简洁写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:DFS递归🏆 解法二:BFS队列(最优)解法三:并查集
时间复杂度O(m × n)O(m × n) ← 相同O(m × n × α) ≈ O(m × n)
空间复杂度O(m × n) (递归栈)O(min(m, n)) ← 队列更优O(m × n)
代码难度简单简单较难(需实现并查集)
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景网格较小,递归深度<1000通用,避免栈溢出动态添加陆地的场景

为什么BFS是最优解:

  • 时间复杂度相同,但空间更优(队列 O(min(m,n)) vs 递归栈 O(m×n))
  • 避免深度递归可能导致的栈溢出(Python默认递归限制约1000层)
  • 代码结构清晰,用显式队列管理待访问节点,易于调试

面试建议:

  1. 先口述暴力法思路(直接计数会重复),表明理解题意
  2. 立即优化到🏆最优解(BFS洪泛填充),展示扎实的图论基础
  3. 重点讲解:"遇到新陆地时,用队列标记整个岛,确保每个岛只计数一次"
  4. 手动测试边界用例(全是水/全是陆地/对角不连接)
  5. 若面试官问递归版本,说明DFS也是O(m×n),但可能栈溢出,所以BFS更稳妥

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道岛屿数量的题目。

:(审题30秒)好的,这道题要求统计网格中岛屿的个数,岛屿是由水平或垂直相邻的陆地 '1' 组成的。让我先想一下...

我的第一个想法是遍历所有格子,遇到 '1' 就计数+1。但这样会有问题:一个6格的岛会被算成6个岛,因为每个格子都计数一次。

正确做法是当遇到一个新陆地时,立即用 DFS 或 BFS 标记整个岛的所有格子,这样每个岛只被计数一次。我选择 BFS队列,避免深度递归可能的栈溢出。

面试官:很好,请写一下代码。

:(边写边说)我用一个队列,每次发现陆地 '1' 时:

  1. 岛屿计数 +1
  2. 将这个格子加入队列,标记为 '0'
  3. 逐层扩展:弹出队列头部,检查上下左右四个方向,若是陆地就标记并加入队列
  4. 队列为空表示整个岛标记完毕

核心是用 grid[r][c] = '0' 标记已访问,避免重复访问。

面试官:测试一下?

:用示例 [["1","1"],["0","1"]] 走一遍:

  • grid[0][0]='1' 开始,队列 [(0,0)],标记为 '0',检查四周,发现右边 (0,1)='1',加入队列
  • 弹出 (0,1),检查四周,发现下方 (1,1)='1',加入队列
  • 弹出 (1,1),四周都是水或边界,结束
  • 计数 = 1(正确,整个岛算一个)

再测一个边界:全是水 [["0","0"]],遍历时所有格子都不是 '1',返回 0(正确)。

高频追问

追问应答策略
"还有更优解吗?"时间已经是 O(m×n) 最优(至少要遍历所有格子),空间上 BFS 队列已优于 DFS 递归栈,不能再优化了
"如果不能修改输入呢?"可以用一个 visited 集合记录已访问的坐标,空间复杂度变为 O(m×n),代码改动很小
"对角线也算连接怎么办?"在方向数组中增加四个对角方向:[(−1,−1),(−1,1),(1,−1),(1,1)],时间复杂度不变
"如果要动态添加陆地?"可以用并查集维护连通性,每次添加陆地时与四周合并,支持 O(α) 查询岛屿数

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:方向数组简化四方向遍历
directions = [(-1,0), (1,0), (0,-1), (0,1)]  # 上下左右
for dr, dc in directions:
    nr, nc = r + dr, c + dc
    if 0 <= nr < rows and 0 <= nc < cols:
        # 处理相邻格子

# 技巧2:deque 双端队列(BFS标配)
from collections import deque
queue = deque([(0, 0)])
queue.append((1, 1))  # 尾部入队
r, c = queue.popleft()  # 头部出队,O(1)复杂度

# 技巧3:二维坐标转一维编号(并查集用)
index = r * cols + c  # (r, c) -> 一维
r, c = index // cols, index % cols  # 一维 -> (r, c)

💡 底层原理(选读)

为什么 BFS 空间复杂度是 O(min(m, n))?

在最坏情况下,岛屿是一个长方形,BFS 逐层扩展时,队列中同时存在的元素数量取决于"波前"的宽度。

举例:一个 3×5 的岛屿,BFS从左上角开始:

  • 第1层:1个元素(起点)
  • 第2层:最多2个元素(右、下)
  • 第3层:最多3个元素(波前达到短边)
  • 后续层:波前不再增长,维持在 min(3,5)=3

因此队列最大长度约为短边长度 min(m, n)。

DFS vs BFS 选择?

  • DFS:代码更简洁(递归),适合"找路径""判断连通"等场景
  • BFS:空间更优(队列),适合"最短路径""逐层处理"等场景
  • 对于岛屿计数,两者都可以,BFS 更稳妥(避免栈溢出)

算法模式卡片 📐

  • 模式名称:网格 DFS/BFS(洪泛填充 Flood Fill)
  • 适用条件:二维网格上的连通分量计数、区域标记、路径搜索
  • 识别关键词:"岛屿""连通区域""相邻格子""迷宫""感染扩散"
  • 模板代码:
def grid_dfs(grid, r, c):
    """网格DFS模板:标记从 (r,c) 开始的连通区域"""
    rows, cols = len(grid), len(grid[0])

    def dfs(x, y):
        if not (0 <= x < rows and 0 <= y < cols):
            return  # 越界
        if grid[x][y] != target_value:
            return  # 不符合条件(水/已访问)

        grid[x][y] = marked_value  # 标记
        for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]:
            dfs(x + dx, y + dy)

    dfs(r, c)

易错点 ⚠️

  1. 边界检查顺序错误

    • ❌ 错误:if grid[r][c] != '1': return (越界时 r 或 c 非法)
    • ✅ 正确:先检查 0 <= r < rows,再访问 grid[r][c]
  2. 忘记标记已访问

    • ❌ 错误:只计数不标记,导致重复访问陷入死循环
    • ✅ 正确:在加入队列或递归调用前立即标记 grid[r][c] = '0'
  3. 对角线误判为连接

    • ❌ 错误:用8个方向(包含对角线)
    • ✅ 正确:本题只有4个方向(上下左右)

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:图像处理的魔棒工具:PhotoShop 的魔棒选区工具用洪泛填充算法,点击一个像素后自动选中颜色相近的连通区域
  • 场景2:游戏地图生成:Minecraft 等游戏用类似算法生成连续的地形块(草原、沙漠等),确保区域连通性
  • 场景3:社交网络分析:将用户看作节点,好友关系看作边,用 DFS/BFS 统计有多少个独立的社交圈子(连通分量)
  • 场景4:电路板检测:工业检测中识别电路板上有多少个独立的导电区域,判断是否短路

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 695. 岛屿的最大面积Medium网格DFS在标记岛屿时顺便计数面积,返回最大值
LeetCode 733. 图像渲染(Flood Fill)Easy网格DFS/BFS从起点扩散,将颜色相同的连通区域改色
LeetCode 463. 岛屿的周长Easy网格遍历遍历所有陆地,统计有多少条边是水
LeetCode 1905. 统计子岛屿Medium网格DFS + 集合判断一个岛是否完全包含在另一个岛内
LeetCode 827. 最大人工岛Hard网格DFS + 哈希先标记所有岛屿,再尝试翻转每个水格子

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给你一个网格,岛屿可能有"湖泊"(被陆地包围的水域)。计算岛屿数量时,湖泊不影响岛屿的连通性。请问如何修改算法?

💡 提示(实在想不出来再点开)

湖泊被陆地包围,岛屿算法本身不受影响(湖泊的 '0' 不会被标记为陆地)。无需修改,直接用原算法即可。

✅ 参考答案
def numIslandsWithLakes(grid: List[List[str]]) -> int:
    """
    湖泊问题:算法不需要修改
    思路:DFS只标记陆地'1',湖泊'0'自动被跳过
    """
    # 代码与标准解法完全相同
    if not grid:
        return 0

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

    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
            return
        grid[r][c] = '0'
        for dr, dc in [(-1,0), (1,0), (0,-1), (0,1)]:
            dfs(r + dr, c + dc)

    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                dfs(i, j)
                count += 1

    return count

关键理解:DFS 遇到水 '0' 就返回,无论这个水是海洋还是湖泊。湖泊周围的陆地仍然是连通的(通过外围路径),所以算法无需修改。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。