解析 LeetCode 200. 岛屿数量:DFS 遍历与原地标记法
一、题目分析
(一)问题定义

给定一个 m 行 n 列的二维字符网格 grid,每个元素是 '1' 或 '0'。'1' 表示陆地,'0' 表示水。岛屿是由水平或竖直方向相邻的 '1' 组成的最大连通区域。对角线不视为连通。要求返回网格中岛屿的总数量。
比如 grid = \['1,1,1,1,0','1,1,0,1,0','1,1,0,0,0','0,0,0,0,0'],左上角那片 8 个 '1' 全部上下左右挨着,算作 1 个岛屿。
再比如 grid = \['1,1,0,0,0','1,1,0,0,0','0,0,1,0,0','0,0,0,1,1'],有三块互不连通的 '1' 区域,答案就是 3。
(二)核心挑战
- 连通性必须只认上下左右,不能把对角线两个 '1' 当成一块
- 同一块岛屿里的所有 '1' 只能被数一次,不能因为从不同入口进去就重复加一
- 网格最大可能到 300×300,得保证每个格子最多访问一次,不然容易超时或爆栈
- 不想多开一个布尔数组存访问状态,空间要省着用
- 边界检查得兜住全部情况:i 小于 0、i 大于等于行数、j 小于 0、j 大于等于列数,还有当前格子根本不是 '1'
二、算法思路:DFS 深度优先遍历
(一)整体策略
外层用双重 for 循环扫完整个网格,每碰到一个还没被标记过的 '1',就调一次 dfs,把这块岛屿上所有 '1' 全改成 '0',同时岛屿总数加一。
(二)dfs 内部执行逻辑
- 先做统一终止判断:任意一个条件成立就立刻返回,不往下走 ------ i 越界、j 越界、当前格子不是 '1'
- 确认能进之后马上改 gridij = '0',这一步既是标记也是清除,后续扫描就不会再把它当新陆地
- 按上、下、左、右顺序分别调 dfs,每次传新坐标,不带其他参数
- 递归自然结束,不需要手动把 gridij 改回去 ------ 因为这不是找路径,而是清掉整块陆地,改回去反而会出错
三、代码实现与详细解析
java
public class Solution
{
public int numIslands(char[][] grid)
{
// 空输入保护:grid 本身为 null、行数为 0、列数为 0,任意一个为真就返回 0
if (grid == null || grid.length == 0 || grid[0].length == 0)
{
return 0;
}
int m = grid.length;
int n = grid[0].length;
int count = 0;
// 主循环:逐行逐列扫描每个格子
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++)
{
// 发现新陆地:只有 grid[i][j] 是 '1' 才触发 dfs
if (grid[i][j] == '1')
{
dfs(grid, i, j);
count++;
}
}
}
return count;
}
// dfs 函数作用:从 (i,j) 出发,把所有与它连通的 '1' 全部改成 '0'
private void dfs(char[][] grid, int i, int j)
{
int m = grid.length;
int n = grid[0].length;
// 终止条件合并写:任意一个为真就退出,不继续递归
// 包括:i 小于 0、i 大于等于 m、j 小于 0、j 大于等于 n、当前格子不是 '1'
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1')
{
return;
}
// 标记动作:把当前陆地改为水,表示已访问且不再参与后续计数
grid[i][j] = '0';
// 四个方向分别递归:顺序不影响结果,但固定顺序利于调试
dfs(grid, i - 1, j); // 向上走一行
dfs(grid, i + 1, j); // 向下走一行
dfs(grid, i, j - 1); // 向左走一列
dfs(grid, i, j + 1); // 向右走一列
// 递归全部返回后函数结束,没有需要恢复的状态
}
}
(一)代码流程拆解
- 外层空校验:先看 grid 是否为 null,再看行数是否为 0,再看列数是否为 0,任意一个为真就返回 0
- 获取维度:m 记录行数,n 记录列数,count 初始化为 0
- 主扫描循环:i 从 0 到 m-1,j 从 0 到 n-1,每个位置检查 gridij
- 触发条件:仅当 gridij == '1' 时才调用 dfs 并使 count 加一
- dfs 入口判断:先读取当前网格尺寸,再一次性判断五个终止条件
- 原地标记:确认可访问后立刻赋值 gridij = '0',这是整个算法节省空间的关键
- 方向展开:四次独立 dfs 调用,分别对应上下左右,每次传入新坐标
- 无状态恢复:因为目标是清除整块岛屿,不是试探路径,所以不需要回溯还原
(二)关键逻辑解析
- 原地标记法:不新建 visited 数组,而是复用 grid 本身。把 '1' 改成 '0' 既表示已访问,又让后续扫描跳过,一举两得。注意这个操作必须在所有判断通过后立即执行,不能延后
- 终止条件合并:把越界和非陆地判断写在一个 if 里,避免嵌套或重复代码。其中 gridij != '1' 包含了原为 '0' 和已被改过的 '0' 两种情况
- 方向调用顺序:虽然上下左右顺序不影响最终计数,但固定顺序能让调试时坐标变化可预期。如果将来扩展八方向,可替换为 dirs 数组循环
- 递归深度控制:最坏情况是全 '1' 网格,此时 dfs 单次调用栈深达 m×n,但实际运行中因网格稀疏,平均深度远低于此
- 非回溯语义:本题不是找路径或组合,而是连通分量染色,因此不需要在递归返回后重置 gridij。这点和全排列、子集等回溯题有本质区别
四、复杂度分析
(一)时间复杂度
O(m×n),每个格子最多被主循环访问一次,又被 dfs 访问一次。两次访问不会重叠:主循环只进未标记的 '1',dfs 把这些 '1' 全部变成 '0',后续主循环就跳过了
(二)空间复杂度
O(m×n),仅由递归系统栈贡献。最坏情况下全 '1' 网格会形成一条蛇形递归链,深度接近 m×n。不使用额外数组,空间效率最优