LeetCode 827. 最大人工岛
题目描述
题意分析
给一个 $n \times n$ 的 0/1 矩阵,1 表示陆地、0 表示水。允许把至多一个 0 改成 1,问改完之后最大的那座岛(四连通的 1 组成的连通块)能有多大。
「至多一个」这三个字包含两层意思:可以改一个,也可以一个都不改。后者对应网格里本来就没有 0 的情形,这时答案就是 $n^2$;如果只统计「改某个 0 之后」的收益,全是 1 的输入会得到 0,是最典型的漏解。
「四连通」明确了邻接关系只有上下左右,不含对角线,方向数组必须只写四个。
关键的隐含信息是:把某个 0 改成 1 之后,新岛的大小等于「这个格子自身的 1」加上「它四个邻居所属的互不相同的岛的面积之和」。之所以强调互不相同,是因为两个邻居很可能属于同一座岛——比如一个 L 形的岛把某个 0 夹在中间——此时那座岛的面积只能算一次。
规模上 $n \le 500$,格子数是 $2.5 \times 10^5$。这个量级允许对每个格子做常数次操作,但不允许对每个 0 都重新跑一遍连通块搜索:那会是 $O(n^4)$,也就是 $6 \times 10^{10}$,远远超时。
边界要单独想清楚三种输入:全是 1(没有可改的 0)、全是 0(改哪个都只能得到大小为 1 的岛)、以及某个 0 的多个邻居属于同一座岛。
解法:染色 + 枚举翻转
核心思路
暴力做法是照定义演:枚举每一个 0,把它临时改成 1,然后跑一遍完整的连通块搜索求最大岛,再改回去。单次搜索 $O(n^2)$,候选 0 最多 $n^2$ 个,总代价 $O(n^4)$,在 $n = 500$ 时彻底不可行。
瓶颈非常明显:每次翻转之后我们都把整张图重新算了一遍,但实际上绝大部分岛的形状和面积根本没变。翻转只在被改的那个格子周围产生局部影响——它把自己的若干个邻岛粘成一块,其余岛纹丝不动。
于是思路转向:把「与翻转无关的静态信息」预先算好,让每次翻转的收益能在常数时间内查出来。这个静态信息就是「每个陆地格子属于哪座岛」和「每座岛有多大」。
具体做法是给每座岛染一个唯一编号。编号直接写回
grid本身,省掉一个额外数组;但编号必须从 2 开始,因为 0 和 1 已经被原始语义占用了——如果从 1 开始编号,就再也分不清「这个格子已经染过色」和「这个格子还没处理」。染色用一次深度优先搜索完成,顺手统计每座岛的格子数,存进一张「编号到面积」的表。染完之后,
grid的语义已经改变,不变量变成:每个原本为 1 的格子现在存着它所属岛的编号(一个不小于 2 的整数),每个原本为 0 的格子仍然是 0;且area[id]恒等于编号为id的那座岛的真实格子数。有了这两条,枚举阶段就只剩查表:对每个仍为 0 的格子,看它上下左右四个方向,收集邻居格子上的编号(跳过越界和值为 0 的),用一个集合去重,把去重后各编号对应的面积加起来,再加上被翻转格子自身的 1,就是把这个 0 改成 1 之后新岛的大小。
最后还要把「一个都不改」这条路也纳入比较:先用所有岛的面积最大值给答案打底,再让每个候选翻转去刷新它。这样全是 1 的输入自然得到 $n^2$,不需要任何特判。
解题步骤
- 第一轮扫描全图,遇到值恰好为 1 的格子就以它为起点做一次深度优先搜索,把整座岛的格子都改写成当前编号
id,并返回这座岛的面积记进表里,随后id自增。为什么起始编号取 2:0 表示水、1 表示「尚未染色的陆地」,编号必须避开这两个值,否则递归会分不清该不该继续。- 深度优先搜索的终止条件写成「越界,或者格子的值不等于 1」。为什么是「不等于 1」而不是「等于 0」:已经染过色的格子值大于等于 2,用「等于 0」判断会让它们被反复递归,直接死循环。
- 用所有岛的面积最大值初始化答案。为什么这一步不能省:它覆盖了「一个 0 都没有、无需翻转」的情形;跳过它的话,全 1 的输入会得到 0。
- 第二轮扫描全图,只处理值仍为 0 的格子。对每个这样的格子,准备一个空集合和一个初值为 1 的累加器。为什么累加器从 1 起:被翻转的格子自己也变成了陆地,要算进新岛。
- 依次查看四个方向的邻居,越界的跳过,值不大于 1 的跳过(也就是水),剩下的编号尝试放进集合;只有放进去成功(说明这个编号是第一次见)才把对应面积累加。为什么必须去重:一座 L 形或 U 形的岛可能从两个甚至三个方向包住同一个 0,不去重会把它的面积重复计入。
- 用累加结果刷新答案,扫完返回。
- 以
grid = [[1,0],[0,1]]走一遍:第一轮,$(0,0)$ 的值为 1,以id = 2染色,这座岛只有它自己,area[2] = 1,grid变成[[2,0],[0,1]],id变为 3。继续扫到 $(1,1)$ 值为 1,以id = 3染色,area[3] = 1,grid变成[[2,0],[0,3]],id变为 4。答案先被岛面积最大值打底为 1。- 第二轮,$(0,1)$ 的值为 0:向上越界跳过;向下是 $(1,1)$,值为 3,集合里没有,加入并累加,累加器从 1 变成 2;向左是 $(0,0)$,值为 2,集合里没有,加入并累加,累加器变成 3;向右越界跳过。答案刷新为 3。再看 $(1,0)$:向上是 $(0,0)$ 编号 2,累加器变成 2;向下越界;向左越界;向右是 $(1,1)$ 编号 3,累加器变成 3。答案仍是 3。返回 $3$,对应把任意一个 0 改成 1 后两座单格岛被连成一条长度为 3 的岛。
- 再看一个需要去重的例子
grid = [[1,1],[1,0]]:第一轮把左上三个格子染成同一座岛,area[2] = 3,答案打底为 3。第二轮处理 $(1,1)$:向上是 $(0,1)$ 编号 2,累加器从 1 变成 4;向左是 $(1,0)$ 编号同样是 2,集合里已经有了,跳过不再累加。答案是 4,正确。如果漏掉去重,这里会算成 $1 + 3 + 3 = 7$。- 代码末尾那句「答案为 0 就返回 $n^2$」在本题约束下其实触发不到:$n \ge 1$ 时,要么存在陆地使打底值至少为 1,要么全是水使某个 0 的翻转结果至少为 1。它是一层防御性兜底,保留无害。
代码实现
// 对每个 0 格子,查看四邻的岛屿编号,累加不同岛屿的面积再加 1。
class Solution {
private int n;
private int[][] grid;
private int id = 2;
public int largestIsland(int[][] grid) {
this.n = grid.length;
this.grid = grid;
Map<Integer, Integer> area = new HashMap<>();
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 1) {
int a = dfs(i, j, id);
area.put(id, a);
id++;
}
}
}
int best = 0;
for (int val : area.values()) {
best = Math.max(best, val);
}
int[] dr = {-1, 1, 0, 0};
int[] dc = {0, 0, -1, 1};
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] != 0) {
continue;
}
Set<Integer> seen = new HashSet<>();
int sum = 1;
for (int k = 0; k < 4; k++) {
int ni = i + dr[k];
int nj = j + dc[k];
if (ni < 0 || ni >= n || nj < 0 || nj >= n) {
continue;
}
int idx = grid[ni][nj];
if (idx > 1 && seen.add(idx)) {
sum += area.get(idx);
}
}
best = Math.max(best, sum);
}
}
if (best == 0) {
return n * n;
}
return best;
}
private int dfs(int r, int c, int idx) {
if (r < 0 || r >= n || c < 0 || c >= n || grid[r][c] != 1) {
return 0;
}
grid[r][c] = idx;
int res = 1;
res += dfs(r - 1, c, idx);
res += dfs(r + 1, c, idx);
res += dfs(r, c - 1, idx);
res += dfs(r, c + 1, idx);
return res;
}
}
// 对每个 0 格子,查看四邻的岛屿编号,累加不同岛屿的面积再加 1。
func largestIsland(grid [][]int) int {
n := len(grid)
id := 2
area := make(map[int]int)
var dfs func(r int, c int, idx int) int
dfs = func(r int, c int, idx int) int {
if r < 0 || r >= n || c < 0 || c >= n || grid[r][c] != 1 {
return 0
}
grid[r][c] = idx
res := 1
res += dfs(r-1, c, idx)
res += dfs(r+1, c, idx)
res += dfs(r, c-1, idx)
res += dfs(r, c+1, idx)
return res
}
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
if grid[i][j] == 1 {
a := dfs(i, j, id)
area[id] = a
id++
}
}
}
best := 0
for _, v := range area {
if v > best {
best = v
}
}
dirs := make([][2]int, 0, 4)
dirs = append(dirs, [2]int{-1, 0}, [2]int{1, 0}, [2]int{0, -1}, [2]int{0, 1})
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
if grid[i][j] != 0 {
continue
}
seen := make(map[int]bool)
sum := 1
for _, d := range dirs {
ni := i + d[0]
nj := j + d[1]
if ni < 0 || ni >= n || nj < 0 || nj >= n {
continue
}
idx := grid[ni][nj]
if idx > 1 && !seen[idx] {
seen[idx] = true
sum += area[idx]
}
}
if sum > best {
best = sum
}
}
}
if best == 0 {
return n * n
}
return best
}
复杂度分析
- 时间复杂度:$O(n^2)$,其中 $n$ 是网格边长。染色阶段每个格子最多被递归进入常数次(进入后立刻被改写成编号,不会再次满足「值为 1」的条件),总代价与格子数同阶;枚举阶段每个 0 格子只看四个固定方向,集合最多装 4 个元素,单格是常数级。两个阶段相加仍是格子数的线性倍。
- 空间复杂度:$O(n^2)$。面积表最坏情况下有约 $n^2/2$ 项(棋盘格状分布时岛的数量达到上限);深度优先搜索的递归栈在蛇形长岛上可达 $O(n^2)$ 层;编号直接写回
grid不额外占空间。
关键点总结
- 「枚举一次修改、求修改后的最优值」这类题的通用套路是:先把与修改无关的静态结构预处理好,再让每个候选修改只做常数级查询。判断能不能这么做的依据是「一次修改的影响是否局部」——本题的翻转只会粘合它的四个邻岛,其余部分完全不动。
- 把连通块编号直接写回原网格是很实用的技巧,省掉一个同尺寸数组。前提是编号要避开原有取值,所以从 2 开始;对应地,搜索的终止条件必须写成「值不等于 1」而不是「值等于 0」。
- 邻居去重是本题唯一的算法性陷阱。同一座岛可以从多个方向贴着同一个 0,不去重会把它的面积重复计入。只用四个方向、集合最多四个元素,所以去重的开销可以忽略。
- 「至多一次操作」必须包含「零次操作」这条分支。用所有岛的最大面积给答案打底,是把它并入主流程最干净的写法,比在末尾特判全 1 更不容易漏。
- 面试视角:并查集是完全等价的替代方案——建图时把相邻的 1 合并,用带大小的并查集,枚举时收集四个邻居的根节点去重。两种写法都要能说出来;如果面试官强调 $n = 500$ 时深度优先搜索可能递归两万五千层导致栈溢出,并查集或改成广度优先搜索就是标准应对。
易错点总结
- 错误写法:累加邻居面积时不做去重。用例
grid = [[1,1],[1,0]]中 $(1,1)$ 的上邻和左邻同属编号 2 的那座岛,不去重会算成 $1 + 3 + 3 = 7$,正确答案是 4。- 错误写法:岛的编号从 1 开始。染完色的格子值仍是 1,外层扫描会把它当成「还没处理的陆地」再染一次,同一座岛被切成若干块,面积表全错。
- 错误写法:深度优先搜索的终止条件写成
grid[r][c] == 0才返回。值为 2 及以上的已染色格子不满足这个条件,会被无限递归下去直到栈溢出。- 错误写法:只统计翻转每个 0 的收益,不用岛面积最大值打底。用例
grid = [[1,1],[1,1]]里一个 0 都没有,第二轮循环一次也不执行,答案会是 0 而不是 4。- 错误写法:累加器从 0 开始而不是 1。被翻转的格子自身没被算进去,用例
grid = [[0,0],[0,0]]的正确答案是 1,这么写会返回 0。- 错误写法:枚举阶段判断邻居是否为陆地时写成
grid[ni][nj] == 1。染色之后网格里已经没有值为 1 的格子,这个条件恒不成立,所有翻转的收益都会退化成 1。- 错误写法:方向数组写了八个方向。题目定义的是四连通,对角相邻的两座岛并不会因为中间那个 0 被翻转而连通,用例
grid = [[1,0],[0,1]]会被算成把两座岛都接上,虽然本例答案巧合正确,但在更大的斜向布局上会严重偏大。- 错误写法:先枚举 0 格子再做染色。枚举阶段读到的还是原始的 0/1,根本没有编号可查,逻辑无法成立。
- 错误写法:面积表用定长数组存却按岛的数量估算大小。棋盘格状的输入里岛的数量能达到约 $n^2/2$,编号会一直涨到那个量级,数组开小了必然越界;用哈希表则不必操心上界。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 200. 岛屿数量 | 中等 | 只需数出连通块个数,遍历一遍标记已访问即可,不需要给岛编号也不需要记录面积 |
| 695. 岛屿的最大面积 | 中等 | 恰好是本题染色阶段的独立版本,求的是原图中最大岛的面积,没有「翻转一格」这一层枚举 |
| 305. 岛屿数量 II | 困难 | 陆地是逐个动态加入的,必须用带路径压缩的并查集在线维护连通块数量,深度优先搜索的静态染色思路在这里失效 |