想系统提升编程能力、查看更完整的学习路线,欢迎访问 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 <= 300grid[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
- 启动DFS递归,将这个岛的所有相邻陆地标记为
'0'(表示已访问) - 继续遍历下一个格子
图解过程
示例输入:
["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层)
- 代码结构清晰,用显式队列管理待访问节点,易于调试
面试建议:
- 先口述暴力法思路(直接计数会重复),表明理解题意
- 立即优化到🏆最优解(BFS洪泛填充),展示扎实的图论基础
- 重点讲解:"遇到新陆地时,用队列标记整个岛,确保每个岛只计数一次"
- 手动测试边界用例(全是水/全是陆地/对角不连接)
- 若面试官问递归版本,说明DFS也是O(m×n),但可能栈溢出,所以BFS更稳妥
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下这道岛屿数量的题目。
你:(审题30秒)好的,这道题要求统计网格中岛屿的个数,岛屿是由水平或垂直相邻的陆地 '1' 组成的。让我先想一下...
我的第一个想法是遍历所有格子,遇到 '1' 就计数+1。但这样会有问题:一个6格的岛会被算成6个岛,因为每个格子都计数一次。
正确做法是当遇到一个新陆地时,立即用 DFS 或 BFS 标记整个岛的所有格子,这样每个岛只被计数一次。我选择 BFS队列,避免深度递归可能的栈溢出。
面试官:很好,请写一下代码。
你:(边写边说)我用一个队列,每次发现陆地 '1' 时:
- 岛屿计数 +1
- 将这个格子加入队列,标记为
'0' - 逐层扩展:弹出队列头部,检查上下左右四个方向,若是陆地就标记并加入队列
- 队列为空表示整个岛标记完毕
核心是用 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)
易错点 ⚠️
-
边界检查顺序错误
- ❌ 错误:
if grid[r][c] != '1': return(越界时 r 或 c 非法) - ✅ 正确:先检查
0 <= r < rows,再访问grid[r][c]
- ❌ 错误:
-
忘记标记已访问
- ❌ 错误:只计数不标记,导致重复访问陷入死循环
- ✅ 正确:在加入队列或递归调用前立即标记
grid[r][c] = '0'
-
对角线误判为连接
- ❌ 错误:用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 学习资料都在这里,后续复习和拓展会更省时间。