返回文章列表
数据结构与算法
算法图论并查集

并查集:原理与 Java 实现

并查集(Union-Find / Disjoint Set Union,DSU)是图论里投入产出比最高的数据结构:不到二十行代码,专门回答一类问题——两个元素是否连通?并且连通关系会动态变化(不断合并)。

一、它解决什么问题

设想两个需求场景:

  • 社交网络里,不断有人加好友,随时要回答"小明和大明是否在同一个朋友圈";
  • 往一张图里一条边一条边地加,每加一条边都要立刻判断"这两个点是否已经连通"。

用 DFS 每次查询都重跑一遍 O(n+m),边一多就崩了。并查集把连通性维护成森林:同一个连通块里的节点挂在同一棵树下,共享一个根节点。判断连通 = 判断根是否相同;合并连通块 = 把一棵树挂到另一棵树下。单次操作摊还下来接近 O(1)。

两个核心动词:

  • find(x):找 x 所在树的根节点(根相同 ⇔ 连通);
  • union(x, y):把 x 和 y 所在的两棵树合并成一棵。

二、朴素实现

用数组 father[i] 存 i 的父节点,根节点指向自己:

int[] father;
 
public void init(int n) {
    father = new int[n];
    for (int i = 0; i < n; i++) father[i] = i;   // 各自为根
}
 
// 朴素 find:一路向上找到根
public int find(int x) {
    while (father[x] != x) x = father[x];
    return x;
}
 
public void union(int x, int y) {
    int rootX = find(x), rootY = find(y);
    if (rootX == rootY) return;                  // 本来就连通
    father[rootX] = rootY;                       // 一棵树挂到另一棵下
}

朴素版的问题:合并太随意,树可能退化成链(每次都把大树挂到小树叶子下面),find 最坏 O(n)。两大优化就是为了让树永远保持矮胖。

三、两大优化

优化一:路径压缩(find 时拍平)

find 走到根之后,顺手把沿途所有节点直接挂到根下——下次再查就是一步到位:

// 递归写法:把 x 的父节点直接改成根,整条链被拍平
public int find(int x) {
    if (father[x] != x) {
        father[x] = find(father[x]);    // 递归返回的就是根
    }
    return father[x];
}
压缩前:0→1→2→3→root          压缩后:0→root
                                     1→root
                                     2→root
                                     3→root

优化二:按秩合并(union 时挑挂的方向)

合并时把矮的树挂到高的树下(按 rank),或把小的树挂到大的树下(按 size,再维护一个计数的数组)。这样合并出的树高度受控:

int[] rank;   // 初始化为 0,rank[i] 近似树高
 
public void union(int x, int y) {
    int rootX = find(x), rootY = find(y);
    if (rootX == rootY) return;
    if (rank[rootX] < rank[rootY]) {
        father[rootX] = rootY;          // 矮挂高
    } else if (rank[rootX] > rank[rootY]) {
        father[rootY] = rootX;
    } else {
        father[rootY] = rootX;          // 等高时任挂一边,高的那棵 rank+1
        rank[rootX]++;
    }
}

两者任选其一已能保证单次操作 O(logn),两个都上则摊还复杂度接近 O(1)(精确值是反阿克曼函数 α(n),可视为常数)。路径压缩实现最简单,面试手写推荐它 + 一个方向随意的 union;工程上两者并用最稳。

四、完整 Java 模板(数组版)

把上面组装成一份可直接抄进题解的模板:

class UnionFind {
    private final int[] father;
    private int count;                       // 连通块个数(按需保留)
 
    public UnionFind(int n) {
        father = new int[n];
        count = n;
        for (int i = 0; i < n; i++) father[i] = i;
    }
 
    public int find(int x) {                 // 路径压缩
        if (father[x] != x) {
            father[x] = find(father[x]);
        }
        return father[x];
    }
 
    public void union(int x, int y) {        // 默认随机方向 + 维护 count
        int rootX = find(x), rootY = find(y);
        if (rootX == rootY) return;
        father[rootX] = rootY;
        count--;                             // 合并成功,连通块 -1
    }
 
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
 
    public int getCount() { return count; }
}

五、实战三例

547. 省份数量

n 个城市,isConnected[i][j] = 1 表示直接相连,省份是城市的极大连通块。把每个矩阵里的 1 当成一次 union,最后数有几个根——直接用模板的 count:

public int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    UnionFind uf = new UnionFind(n);
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {   // 对称矩阵,只看一半
            if (isConnected[i][j] == 1) uf.union(i, j);
        }
    }
    return uf.getCount();
}

684. 冗余连接

给一棵加了"多余一条边"的树,找出这条多余边。逐条加边:如果这条边的两端已经是同根(加它之前就连通),那它就是成环的多余边,直接返回:

public int[] findRedundantConnection(int[][] edges) {
    UnionFind uf = new UnionFind(edges.length + 1);  // 节点从 1 编号
    for (int[] edge : edges) {
        if (uf.connected(edge[0], edge[1])) {
            return edge;        // 加之前已连通 → 这条边导致环
        }
        uf.union(edge[0], edge[1]);
    }
    return new int[0];
}

这个解法能体现并查集的精髓:不需要真把图画出来,连通关系边到边查。685(有向版的冗余连接)在此基础上多一个入度讨论,思路相通。

200. 岛屿数量:并查集 vs DFS/BFS 对照

同一个经典题给三种解法,感受差异:

维度 DFS 淹岛 BFS 并查集
核心动作 扫网格,遇 1 就递归淹掉 扫网格,遇 1 就入队扩散 相邻两个 1 就 union
防重复 直接改 grid visited 矩阵 根节点天然分块,count 维护
复杂度 O(mn) 时间,最坏 O(mn) 递归栈 O(mn) 时间,队列 O(min(m,n)) O(mn·α) 时间,O(mn) 数组
特点 代码最短 无栈溢出风险 可支持"边输入边统计"

并查集版核心:给每个格子编号 id = i * col + j,陆地上扫右、扫下两个方向做 union,水格子单独跳过:

public int numIslands(char[][] grid) {
    int rows = grid.length, cols = grid[0].length;
    UnionFind uf = new UnionFind(rows * cols);
    int water = 0;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            if (grid[i][j] == '0') { water++; continue; }   // 水先记数
            // 只需向右、向下两个方向连,避免重复
            if (j + 1 < cols && grid[i][j + 1] == '1')
                uf.union(i * cols + j, i * cols + j + 1);
            if (i + 1 < rows && grid[i + 1][j] == '1')
                uf.union(i * cols + j, (i + 1) * cols + j);
        }
    }
    return uf.getCount() - water;           // 总块数减去水格子数
}

六、什么时候选并查集?

选并查集的信号:边是动态加入的、只需回答"是否连通/有几个块"、不需要输出路径或遍历顺序。典型:冗余连接判环、动态加边、Kruskal 最小生成树里判断边两端是否已连通。

选 DFS/BFS 的信号:需要遍历过程本身——求路径、求面积/步数、按层处理、方向性约束(如只能往低处流)。连通关系是静态且一次性给全的话,DFS 通常代码更短。

一句话:只要"连通性"不要"过程",优先并查集;要"过程",DFS/BFS。

七、LeetCode 题目清单

题号 题名 一句话考点
547 省份数量 矩阵建图,数连通块个数
684 冗余连接 加边前已连通的边即成环边
685 冗余连接 II 有向树:入度分析 + 并查集
1971 寻找图中是否存在路径 并查集判连通裸题
200 岛屿数量 网格编号后相邻 union
990 等式方程的可满足性 先合并 == 再检查 != 矛盾
128 最长连续序列 值域并查集合并相邻数(哈希表法更常用)

参考