LeetCode 305. 岛屿数量 II
题目描述
题意分析
给一个初始全是水的 m×n 网格,以及一串操作,每次把某个位置变成陆地。每次操作之后都要报告当前的岛屿数量,最终返回一个与操作序列等长的答案数组。岛屿的定义仍是四连通的陆地连通块。
与静态的岛屿计数最大的区别是「在线」:结果要随着每次操作实时给出,不能等所有陆地都加完再统计。这条约束否决了「每次操作后重新跑一遍 DFS」的做法——那是 $O(k \cdot m \cdot n)$,操作数上万时必然超时。
数据的演化方向是单向的:只加陆地、不删陆地。这是一条极其关键的信号。连通块只会合并、不会分裂,正是并查集擅长的场景(并查集不支持删边,一旦有删除操作就要换成别的结构)。
岛屿数量的变化也因此非常规律:新增一块陆地先让计数加一,随后它每与一个此前不连通的邻居岛屿合并一次,计数就减一。
边界包括:同一个位置可能被重复添加,第二次起不应改变任何计数;新陆地可能同时挨着两个原本属于同一个岛的格子,此时只能算一次合并;网格边缘的邻居要做越界判断。
解法:并查集动态合并陆地
核心思路
先看为什么暴力不行。每加一块陆地就对整个网格重跑一次洪水填充,单次 $O(mn)$,操作数 k 次就是 $O(k \cdot mn)$。但仔细想会发现,一次操作只影响新格子周围最多四个方向的连通关系,重扫全图的工作绝大部分是白做的。
目标因此变成:只用局部信息就把全局的岛屿数量更新对。观察岛屿数量的变化规律——加入一块新陆地时,它自己先算作一个独立的岛,计数加一;然后依次考察四个邻居,若邻居是陆地且与新格子当前尚未连通,就把两者合并,两个岛变成一个,计数减一。若邻居虽是陆地但早已通过别的路径与新格子连通,则不能再减。
「判断两个格子是否已连通」和「把两个连通块合并」正是并查集的两个基本操作,而且都近似常数时间。为了用一维的并查集表示二维网格,把坐标 (row, col) 映射成编号
row * n + col——这个映射是双射,且只依赖列数 n,是网格类并查集的标准写法。还需要一个 active 标记数组,区分「这个格子已经是陆地」和「还是水」。并查集的 parent 数组初始时每个格子都自成一个集合,但那时它们还都是水,不能算作岛;active 让我们只对真正的陆地做合并与计数。
由此得到的不变量是:每次操作处理完毕时,count 恰好等于当前所有 active 格子构成的四连通块个数。加一表示新格子暂时自成一岛,每次成功合并减一表示两个块并成了一个,而
union返回布尔值正是为了区分「真的合并了」与「本来就同属一块」——只有前者才减。重复添加的处理也很自然:若该位置已经 active,说明网格没有任何变化,直接把当前 count 追加进答案并跳过后续逻辑。漏掉这个判断会让 count 凭空多一。
解题步骤
- 初始化容量为
m * n的并查集,parent 指向自身、rank 全 0、active 全 false;准备方向数组、岛屿计数 count 和答案列表。并查集一次性开满整个网格,避免动态扩容。- 逐个处理操作位置,先把 (row, col) 换算成编号
row * n + col。乘的是列数 n,因为一行有 n 个格子,写成row * m + col是最常见的低级错误。- 若该编号已经 active,说明这块陆地此前已经加过,网格状态不变,直接把当前 count 记入答案并处理下一个操作。
- 否则把它标成 active 并让 count 加一,先假设它是一座新的独立岛屿。这个「先加后减」的顺序让后面的合并逻辑变得统一。
- 枚举四个方向的邻居,先做越界判断再取编号。越界判断必须在计算编号之前,否则越界坐标算出的编号可能恰好落在数组范围内(比如 col = -1 时会绕到上一行的末尾),造成看似合法实则错误的连通。
- 若邻居 active 且
union返回 true(说明此前不连通,本次真的合并了),count 减一。用返回值判断而不是先find再比较,是把两步合成一步,也避免了忘记判重。- 每次操作结束把 count 追加进答案。
以
m = 3、n = 3、positions = [[0,0],[0,1],[1,2],[2,1]]走一遍,期望输出[1,1,2,3]。第一次加 (0,0),编号 0。未 active,标记后 count 变成 1。四个邻居中 (-1,0) 与 (0,-1) 越界跳过,(1,0) 编号 3 和 (0,1) 编号 1 都还是水,不合并。答案记 1。
第二次加 (0,1),编号 1。未 active,count 变成 2。邻居 (1,1) 编号 4 是水;(0,2) 编号 2 是水;(0,0) 编号 0 是陆地,
union(1, 0)发现两者根不同,合并成功返回 true,count 减回 1。答案记 1——两块相邻的陆地合成了一座岛。第三次加 (1,2),编号 5。未 active,count 变成 2。邻居 (2,2) 编号 8 是水,(0,2) 编号 2 是水,(1,3) 越界,(1,1) 编号 4 是水,没有任何合并。答案记 2——右侧多出一座孤岛。
第四次加 (2,1),编号 7。未 active,count 变成 3。邻居 (3,1) 越界,(1,1) 编号 4 是水,(2,2) 编号 8 是水,(2,0) 编号 6 是水,同样无合并。答案记 3。
最终返回
[1,1,2,3]。值得单独体会第二步:如果
union不返回布尔值而是无条件让 count 减一,那么当一块新陆地同时挨着两个已经属于同一岛的格子时(例如在[[0,0],[0,1],[1,0],[1,1]]这样的操作序列末尾加 (1,1),它的上邻和左邻早已连通),count 会被多减一次,答案偏小。
代码实现
class Solution {
public List<Integer> numIslands2(int m, int n, int[][] positions) {
UnionFind uf = new UnionFind(m * n);
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
List<Integer> res = new ArrayList<>();
int count = 0;
for (int[] pos : positions) {
int row = pos[0];
int col = pos[1];
int id = row * n + col;
if (uf.active[id]) {
res.add(count);
continue;
}
uf.active[id] = true;
count++;
for (int[] dir : dirs) {
int nextRow = row + dir[0];
int nextCol = col + dir[1];
if (nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n) {
continue;
}
int nextId = nextRow * n + nextCol;
if (uf.active[nextId] && uf.union(id, nextId)) {
count--;
}
}
res.add(count);
}
return res;
}
static class UnionFind {
int[] parent;
int[] rank;
boolean[] active;
UnionFind(int size) {
parent = new int[size];
rank = new int[size];
active = new boolean[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
boolean union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) {
return false;
}
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
return true;
}
}
}
func numIslands2(m int, n int, positions [][]int) []int {
uf := newUnionFind(m * n)
dirs := [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}
res := make([]int, 0, len(positions))
count := 0
for _, pos := range positions {
row, col := pos[0], pos[1]
id := row*n + col
if uf.active[id] {
res = append(res, count)
continue
}
uf.active[id] = true
count++
for _, dir := range dirs {
nextRow := row + dir[0]
nextCol := col + dir[1]
if nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n {
continue
}
nextId := nextRow*n + nextCol
if uf.active[nextId] && uf.union(id, nextId) {
count--
}
}
res = append(res, count)
}
return res
}
type unionFind struct {
parent []int
rank []int
active []bool
}
func newUnionFind(size int) *unionFind {
parent := make([]int, size)
rank := make([]int, size)
active := make([]bool, size)
for i := 0; i < size; i++ {
parent[i] = i
}
return &unionFind{parent: parent, rank: rank, active: active}
}
func (uf *unionFind) find(x int) int {
if uf.parent[x] != x {
uf.parent[x] = uf.find(uf.parent[x])
}
return uf.parent[x]
}
func (uf *unionFind) union(x int, y int) bool {
rootX := uf.find(x)
rootY := uf.find(y)
if rootX == rootY {
return false
}
if uf.rank[rootX] < uf.rank[rootY] {
uf.parent[rootX] = rootY
} else if uf.rank[rootX] > uf.rank[rootY] {
uf.parent[rootY] = rootX
} else {
uf.parent[rootY] = rootX
uf.rank[rootX]++
}
return true
}
复杂度分析
- 时间复杂度:$O(mn + k \cdot \alpha(mn))$,其中 k 是操作数,$\alpha$ 是反阿克曼函数。初始化并查集是 $O(mn)$;每次操作只做常数次(至多四次)合并,路径压缩加按秩合并让单次操作近似常数。
- 空间复杂度:$O(mn)$,parent、rank、active 三个数组都与网格格子数同阶;答案列表额外占 $O(k)$。
关键点总结
- 「动态加边、实时查询连通块个数」是并查集的招牌场景;反过来,一旦题目出现删除操作,并查集就不再适用,要考虑离线倒序处理或改用别的结构。
- 连通块计数的通用维护方式是「新增元素先加一,每次成功合并减一」,其中「成功」二字必须由 union 的返回值判定,无条件减一会在多个邻居同属一块时算错。
- 二维网格转一维编号统一写成
row * 列数 + col,乘的永远是列数;这个约定要固定下来,混用行数是这类题最高频的低级错误。- 越界判断必须在计算编号之前完成,因为负下标或超界坐标算出的编号可能仍落在数组范围内,会造成跨行的虚假连通。
- 并查集本身要写全两个优化:find 里的路径压缩和 union 里的按秩(或按大小)合并,缺一个都可能在极端数据上退化成链。
- 面试视角:面试官通常先让你说清「为什么不能每次重跑 DFS」,再让你手写并查集模板(这道题的真正考点就是能否白板默写出带两种优化的并查集)。追问点常有三个——重复添加怎么处理、一次操作最多减几次、如果要支持删除陆地该怎么办(答案是离线倒序,把删除变成添加)。
易错点总结
- 错误写法:编号写成
row * m + col→ 用例m = 1, n = 3, positions = [[0,2]],编号算成 2 碰巧正确,但m = 3, n = 1, positions = [[2,0]]会算成 6 而数组长度只有 3,直接越界异常。- 错误写法:漏掉「该位置已是陆地」的判断 → 用例
m = 1, n = 1, positions = [[0,0],[0,0]],第二次重复添加又让 count 加一,返回[1,2],正确答案是[1,1]。- 错误写法:合并时无条件
count--而不看 union 的返回值 → 用例m = 2, n = 2, positions = [[0,0],[0,1],[1,0],[1,1]],最后加入的 (1,1) 有两个邻居且它们早已连通,count 被多减一次,返回末位 0,正确答案是 1。- 错误写法:先算
nextId再做越界判断 → 用例m = 2, n = 2, positions = [[1,0]],左邻 (1,-1) 算出编号 1,恰好落在数组内,会与实际不相邻的格子建立虚假连通。- 错误写法:忘记检查邻居是否 active,对所有相邻编号都做 union → 用例
m = 2, n = 2, positions = [[0,0]],把还是水的格子并了进来,之后这些水格变成陆地时不再触发合并,count 全程偏小。- 错误写法:并查集初始化时忘记
parent[i] = i→ 用例任意,所有节点的根都是 0,第一次 union 就返回 false,count 只增不减,答案全部偏大。- 错误写法:find 不做路径压缩 → 用例是长链式的添加顺序(如一整列自上而下逐格添加),树高退化成 $O(mn)$,每次查询都要走满一条链,在大网格上超时。
- 错误写法:union 里直接
parent[x] = y而不是操作两者的根 → 用例positions = [[0,0],[0,1],[0,2]],已有的连通信息被覆盖,之前并进来的成员被割裂出去,count 出错。- 错误写法:按秩合并时更新了错误的 rank,例如在
rank[rootX] < rank[rootY]分支里也执行rank[rootX]++→ 秩失去意义,树可能退化,虽然结果仍对但性能不再有保证。- 错误写法:active 标记在合并之后才置位 → 用例
m = 1, n = 2, positions = [[0,0],[0,1]],处理 (0,1) 时它自己还不是 active,若合并逻辑里也检查了自身的 active 就会跳过合并,返回[1,2],正确答案是[1,1]。- 错误写法:答案在重复添加的分支里忘记
res.add(count)就 continue → 用例positions = [[0,0],[0,0]],返回的数组只有一个元素,长度与操作数不符,直接判错。- 错误写法:每次操作后重新跑一遍 DFS 统计岛屿 → 用例是 $10^4$ 次操作的大网格,单次 $O(mn)$ 累积成上亿次访问,超时。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 200. 岛屿数量 | 中等 | 静态一次性统计,用洪水填充比并查集更直接 |
| 547. 省份数量 | 中等 | 输入是邻接矩阵,合并对象是节点而非网格坐标 |
| 130. 被围绕的区域 | 中等 | 需要引入一个虚拟节点代表边界,把「是否连到外部」并进来 |
| 684. 冗余连接 | 中等 | 靠 union 返回 false 来定位第一条成环的边 |
| 721. 账户合并 | 中等 | 元素是字符串,需要额外的映射把邮箱编号化并在最后归组排序 |
| 990. 等式方程的可满足性 | 中等 | 要分两趟处理,先合并所有等式再逐条校验不等式 |
| 128. 最长连续序列 | 中等 | 并查集需同时维护集合大小,也可用哈希集合做线性扫描 |
| 1319. 连通网络的操作次数 | 中等 | 答案由连通块数减一给出,还要先判断线缆是否够用 |
| 839. 相似字符串组 | 困难 | 边由两两比较隐式生成,建图本身就是 $O(n^2 \cdot len)$ |