题目描述

✅ 305. 岛屿数量 II

题意分析

一个 m × n 的网格初始全是水,按顺序把指定位置变成陆地,每次操作后都要返回当前岛屿数量。岛屿只按上下左右四个方向连接,对角接触不算连通。

操作只会增加陆地,不会拆开已有岛屿,因此可以持续维护连通块。重复添加已经是陆地的格子不改变岛数,但仍要为这次操作输出一个答案。

解法:并查集动态合并陆地

核心思路

[!blue]

用并查集表示陆地所属的岛屿,把坐标 (row, col) 映射成一维编号 row * n + col。parent 保存集合关系,find 找到代表整个岛的根;active 单独记录这个位置是否已经从水变成陆地。

新格子出现时,先把它当作一个独立岛,岛数加一。随后检查四周已经激活的陆地:若新格子所在集合与邻居集合的根不同,就合并两个岛,岛数减一;若根已经相同,说明本来就在同一岛内,不需要再减。

只在合并成功时减一很关键。新格子可能同时接触同一座岛的多个位置,第一个邻居已经把这座岛合并进来,后续邻居不能再次扣除。因此最终变化是“新增一个岛,再减去实际连接到的不同旧岛数量”。

rank 记录树高的上界,合并时把秩较小的根挂到较大的根下;两者相同则任选一个根,并将它的秩加一。find 找到根后,把查找途中的节点直接连接到根,缩短后续查找路径。这些操作只改变集合的表示,不改变连通关系。虽然代码预先为全部网格位置建立父节点,但未激活的水格始终不计入岛数,也不能参与合并。

解题步骤

  1. 建立大小为 m * n 的并查集和全为假的激活表,岛数初始化为 0。
  2. 对每次添加计算编号。如果该位置已激活,直接追加当前岛数,继续下一次操作。
  3. 将新位置激活,岛数加一;此前水格没有参与合并,所以它仍是独立集合。
  4. 检查四个邻居,跳过越界位置和水格。对有效陆地调用 union,仅当返回成功时将岛数减一。
  5. 四个方向处理完后记录当前岛数,最终返回每次操作对应的结果列表。

代码实现

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
}

复杂度分析

设添加操作数为 q。

  • 时间复杂度:$O(mn+q\alpha(mn))$,初始化全部父节点需要 $O(mn)$;每次操作检查固定四个邻居,并查集合并的均摊开销为反阿克曼函数量级。
  • 空间复杂度:$O(mn)$,用于父节点、秩和激活表;返回结果另占 $O(q)$。

若网格很大而实际添加的位置很少,可以改用哈希映射,只为已经激活的位置创建并查集节点,将存储与初始化规模限制到实际陆地数量。那是针对稀疏网格的优化;当前数组实现初始化了全部位置,复杂度中的 mn 项不能省略。

关键点总结

[!green]

  • 岛数统计的是已激活陆地的连通块,不能直接使用预分配的全部集合数量。
  • 新陆地先加一,每次合并两个不同集合才减一,正好维护连通块数量。
  • 重复添加与重复连接到同一岛是两种不同的重复,都必须避免重复计数。

易错点总结

[!yellow]

  • 编号公式乘的是列数 n,不能误乘行数 m。
  • 已激活位置再次添加时不能加一,也不能漏掉这次操作对应的输出。
  • 邻居必须同时满足没有越界、已经是陆地,才能尝试合并。
  • 看到一个陆地邻居就减一会重复扣除同一座岛;应以两根不同、合并成功为准。

相似题目

题目 难度 关联与区别
200. 岛屿数量 中等 原题一次给出完整网格,本题逐次新增陆地,需要动态合并相邻连通块。
684. 冗余连接 中等 同样用并查集判断新连接是否真正合并两个分量,重复连接不能重复减少分量数。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2021/20838108
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!