LeetCode LCR 105. 岛屿的最大面积
题目描述
题意分析
给一个由 0 和 1 组成的二维网格,1 表示陆地。相邻(只算上下左右四个方向)的陆地属于同一座岛,问最大的一座岛占多少个格子;没有陆地时返回 0。
「相邻的陆地属于同一座岛」本质上是在网格上定义了一张隐式的图:每个陆地格是一个点,上下左右相邻的两个陆地格之间有一条边。题目要的是最大连通块的大小。这是网格题里最典型的建模,值得在面试里第一句就说出来。
约束里
m, n ≤ 50,总格子数不超过 2500,规模极小,说明只要做到每个格子被访问常数次就足够,不需要任何优化技巧;重点考的是遍历的写法是否干净、边界是否处理正确。「四个方向」这个限定要特别留意:如果误按八方向搜索,对角相连的两块陆地会被并成一座岛,面积直接算大。题面里这类方向约定是必须逐字核对的信息。
还有一条隐含要求:每个陆地格只能被计入一座岛、且只能被计一次。也就是说遍历过程中必须有一种「已访问」的标记机制,否则同一块陆地会沿着不同路径被反复累加,面积无限膨胀甚至递归不终止。
边界:网格全是 0 时答案为 0;整张网格全是 1 时答案是
m * n;单行、单列的网格必须能被同一套下标判断安全覆盖。
解法:并查集维护连通性
核心思路
最朴素的想法是:对每个陆地格出发做一次搜索,数出它所在连通块的大小,再取最大。问题在于同一座岛上有多少个格子,就会被完整搜索多少次,复杂度退化到 $O((mn)^2)$。
瓶颈显然是重复搜索同一个连通块。既然一座岛只需要被量一次,那么在搜索过程中就应该把走过的陆地「消掉」,让它不会再作为新的起点、也不会在同一次搜索里被二次计数。
于是得到这条不变量:任何时刻,
grid中值为 1 的格子都是「尚未被计入任何一座岛」的格子。搜索一进入某个陆地格就立刻把它置 0,这条不变量就在整个过程中始终成立。它同时承担了两件事——防止同一次深搜绕圈无限递归,以及保证外层双重循环不会从已数过的岛上重新起跳。有了这条不变量,递归函数的语义就可以定得非常干净:
dfs(i, j)返回「从(i, j)出发、沿着尚未被消除的陆地能到达的格子总数」。它有两条分支——若(i, j)不是陆地(越界之外的情形已在调用前挡住),返回 0;否则先把自己置 0 并计 1,再把四个方向的返回值累加上来。因为整张网格被这样「淹没」一遍,每个格子最多被置 0 一次、被访问常数次,总代价是线性的。外层双重循环对每个格子调用一次
dfs并取最大值:落在已淹没区域的调用会立刻返回 0,不产生额外开销;落在新岛上的调用则一次性量出整座岛的面积。这种「就地改写输入当访问标记」的写法省掉了
visited数组,代价是破坏了入参。面试时应当主动说明这一点,并补一句「如果调用方不允许修改入参,就换成同规模的visited布尔数组,复杂度不变」。
解题步骤
- 记录
m、n并把grid存起来供递归访问。为什么:递归函数每一层都要判越界和取值,把行列数与网格提到共享位置可以避免层层传参,也让递归签名保持成(i, j)这两个真正变化的量。- 外层双重循环遍历每个格子,用
answer = max(answer, dfs(i, j))更新答案。为什么要对每个格子都调用而不是只对陆地调用:dfs内部第一行就会判掉非陆地并返回 0,把判断收敛到一个地方,主循环更干净;也避免了「主循环判一次、递归里再判一次」的重复逻辑。dfs第一行判grid[i][j] == 0则返回 0。为什么是返回 0 而不是抛错:这一行同时覆盖了三种情况——本来就是海水、已经被淹没过、以及从邻居递归过来时撞上非陆地,统一用「贡献 0 个格子」表达,语义自洽。- 确认是陆地后,先把
grid[i][j] = 0,再向四周递归。为什么顺序不能反:如果先递归再置 0,邻居会沿着(i,j)走回来,两个格子互相递归,直接栈溢出。置 0 必须发生在任何递归调用之前,这是本题的铁律。- 用方向数组
{-1, 0, 1, 0, -1}配合dirs[k]、dirs[k+1]取出四个偏移。为什么这么写:五个元素的滑动窗口恰好给出(-1,0)、(0,1)、(1,0)、(0,-1)四组偏移,比写四段重复的if短且不易漏;同时它天然只含四方向,不会误引入对角线。- 递归前判
0 <= x < m && 0 <= y < n。为什么在调用点判而不是在函数入口判:入口判也可以,但放在调用点能让dfs的入参始终保证合法,函数体内不必再考虑越界,两处只需维护一处约定。- 返回累加值
answer,初值为 1。为什么初值是 1:当前格子自己就贡献一个面积单位,四个方向的返回值是它「带来的」额外面积。以下面这张
3 × 3网格走一遍:第 0 行
1 1 0,第 1 行0 1 0,第 2 行1 0 1。
(0,0)是陆地,调用dfs(0,0)。置grid[0][0] = 0,answer = 1。向上越界跳过;向右到(0,1)是陆地,递归进去:置grid[0][1] = 0,它自己的answer = 1,向左回到(0,0)已是 0 返回 0(这一步正是「先置 0」在起作用,否则会来回弹跳),向右(0,2)是海水返回 0,向下(1,1)是陆地,再递归:置grid[1][1] = 0,四邻分别是已淹没的(0,1)、海水(1,0)、海水(1,2)、海水(2,1),全部返回 0,于是dfs(1,1) = 1。回到(0,1):1 + 1 = 2。回到(0,0):1 + 2 = 3。第一座岛面积 3,answer更新为 3。主循环继续。
(0,1)、(0,2)、(1,0)、(1,1)、(1,2)此刻全是 0,dfs一进门就返回 0,不做任何递归——这就是「淹没」带来的去重效果。
(2,0)是陆地,dfs置 0 后四邻全是海水或越界,返回 1,不足以更新最大值。(2,1)是海水返回 0。(2,2)是陆地,同样返回 1。循环结束,返回 3。
若把「置 0」这一步删掉:
dfs(0,0)会走到(0,1),(0,1)又走回(0,0),两者无限互相调用,立刻栈溢出——这是本题最经典的崩溃方式。
代码实现
class Solution {
private int m;
private int n;
private int[][] grid;
public int maxAreaOfIsland(int[][] grid) {
m = grid.length;
n = grid[0].length;
this.grid = grid;
int answer = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
answer = Math.max(answer, dfs(i, j));
}
}
return answer;
}
private int dfs(int i, int j) {
// 海水或已被淹没的陆地,贡献 0 个格子。
if (grid[i][j] == 0) {
return 0;
}
int answer = 1;
// 必须在递归之前淹没自己,否则邻居会走回来造成无限递归。
grid[i][j] = 0;
int[] dirs = {-1, 0, 1, 0, -1};
for (int k = 0; k < 4; ++k) {
int x = i + dirs[k], y = j + dirs[k + 1];
if (x >= 0 && x < m && y >= 0 && y < n) {
answer += dfs(x, y);
}
}
return answer;
}
}
func maxAreaOfIsland(grid [][]int) (answer int) {
m, n := len(grid), len(grid[0])
dirs := [5]int{-1, 0, 1, 0, -1}
var dfs func(i, j int) int
dfs = func(i, j int) int {
// 海水或已被淹没的陆地,贡献 0 个格子。
if grid[i][j] == 0 {
return 0
}
answer := 1
// 必须在递归之前淹没自己,否则邻居会走回来造成无限递归。
grid[i][j] = 0
for k := 0; k < 4; k++ {
x, y := i+dirs[k], j+dirs[k+1]
if x >= 0 && x < m && y >= 0 && y < n {
answer += dfs(x, y)
}
}
return answer
}
for i := range grid {
for j := range grid[i] {
answer = max(answer, dfs(i, j))
}
}
return
}
复杂度分析
- 时间复杂度:$O(mn)$。凭什么:每个格子最多被置 0 一次,此后所有到达它的调用都在第一行返回;主循环 $mn$ 次调用加上递归中每个格子常数次(四个方向)的邻居访问,总量与格子数成正比。
- 空间复杂度:$O(mn)$。凭什么:没有额外的
visited数组,唯一的开销是递归栈;最坏情况是整张网格连成一条蛇形的岛,递归深度达到 $mn$。若换成显式栈的迭代写法或 BFS,峰值仍是同一量级。
关键点总结
- 网格连通块题的第一步是建模成图:格子是点、四邻是边,问题随即变成「最大连通分量的大小」,后面所有写法都是这句话的实现细节。
- 「先标记再递归」是所有 DFS 洪水填充的铁律;标记晚一步就会在相邻两格之间来回弹跳导致栈溢出,这条比任何优化都重要。
- 就地把 1 改成 0 当访问标记,省掉
visited数组,但破坏了入参;面试时要主动说明这个副作用并给出「改用visited」的替代方案,这体现工程意识。- 方向数组
{-1, 0, 1, 0, -1}的滑动窗口写法能压掉四段重复分支,且天然只覆盖四方向;改成八方向时对应的是另一组偏移,不要混用。- 让递归函数返回「本次搜索覆盖的格子数」,比用全局变量累加更容易保证正确性——每一层的语义自洽,也便于口头向面试官解释。
- 面试视角:本题的 BFS 写法(队列 + 入队时标记)与 DFS 等价,递归深度可能达到 2500,若网格规模再放大,主动提出改用 BFS 规避栈溢出是加分项。
易错点总结
- 递归之前忘记置 0:
grid = [[1,1]]时(0,0)与(0,1)互相递归,立刻StackOverflowError(Go 是 goroutine stack exceeded)。- 只在主循环判陆地却不在
dfs入口判:grid = [[1,0],[0,0]]时递归会把海水格也计成面积,(0,0)返回 3 而不是 1。- 按八方向搜索:
grid = [[1,0],[0,1]]中两个对角的陆地会被并成一座岛,返回 2 而正确答案是 1。- 越界判断漏掉某一侧:只写
x < m && y < n而漏掉x >= 0,grid = [[1]]在向上探测时访问grid[-1][0],Java 抛越界异常、Go panic。answer初值写成 1 而不是 0:grid = [[0,0],[0,0]]会返回 1,而全是海水时正确答案是 0。dfs里answer初值写成 0:grid = [[1]]返回 0,当前格子自己没有被计入面积,所有答案都会偏小。- 用全局变量累加面积却忘了在每座岛之间清零:
grid = [[1,0,1]]会把两座岛的面积累加成 2,而正确答案是 1。n = grid[0].length之前没考虑空网格:若测试数据可能给出grid = [],这一行会直接越界;题目保证m ≥ 1才可以这样写,换到不保证的场景要先判空。- 主循环里对已淹没的格子额外加
if (grid[i][j] == 1)之外还重复置 0:grid = [[1,1]]会在主循环里提前把(0,1)抹掉,导致岛被切开,返回 1 而不是 2。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 695. 岛屿的最大面积 | 中等 | 与本题同题,可直接套用同一份代码 |
| 200. 岛屿数量 | 中等 | 只数连通块个数不求大小,dfs 无需返回值,主循环改成计数 |
| 463. 岛屿的周长 | 简单 | 统计的是陆地与海水/边界的交界数,遇到越界与海水时要 +1 而非返回 0 |
| 1020. 飞地的数量 | 中等 | 先从边界出发淹掉所有能出海的陆地,再统计剩余格子,是反向染色 |
| 130. 被围绕的区域 | 中等 | 同样从边界反向染色,但要就地改写字符并做两次遍历还原 |
| 1254. 统计封闭岛屿的数目 | 中等 | 0 表示陆地,且要判断整座岛是否触碰边界,递归需带回布尔标志 |
| 694. 不同岛屿的数量 | 中等 | 需要把搜索路径序列化成形状签名去重,考察的是如何刻画连通块的形状 |
| 面试题 16.19. 水域大小 | 中等 | 八方向连通,且要返回所有水域大小并排序,方向数组要换成八组偏移 |