并查集(Disjoint Set Union,DSU)是一种高效处理元素分组与合并问题的数据结构。它支持两种核心操作:查找(Find)某个元素所属的集合,以及合并(Union)两个集合。凭借路径压缩与按秩合并两大优化策略,并查集的时间复杂度接近常数级,是图论中判断连通性、生成最小生成树(Kruskal算法)以及社交网络分析的核心工具。本文将用 Java 从零实现一个完整的并查集,并深入讲解两种优化策略的原理与效果。
一、为什么需要并查集
想象一个社交网络平台,用户之间可以添加好友关系。系统需要快速回答以下问题:
- 用户 A 和用户 B 是否属于同一个朋友圈?
- 将用户 C 和用户 D 的朋友圈合并为一个整体。
如果用暴力方法维护邻接表并每次执行 BFS/DFS 判断连通性,时间复杂度为 O(V+E)。而并查集可以在近乎 O(1) 的时间内完成查询与合并。
| 操作 | 暴力方法 | 并查集(优化后) |
|---|---|---|
| 查询是否同一集合 | O(V+E) | O(α(n)) ≈ O(1) |
| 合并两个集合 | O(V+E) | O(α(n)) ≈ O(1) |
其中 α(n) 是阿克曼函数的反函数,增长极其缓慢,对于任何实际数据规模都小于 5。
二、核心思想:用树表示集合
并查集的核心思想是:每个集合用一棵树表示,树的根节点作为该集合的唯一标识。每个节点只需记录其父节点,根节点的父节点指向自身。
2.1 数组表示法
使用一个整型数组 parent[] 即可实现:
parent[i] = i:表示 i 是根节点(集合代表)parent[i] = j:表示 i 的父节点是 j
/**
* 并查集基础结构
* 使用一维数组 parent 记录每个节点的父节点
*/
public class UnionFind {
private int[] parent; // parent[i] 表示节点 i 的父节点
private int count; // 当前连通分量的数量
/**
* 初始化并查集,每个节点自成一个集合
* @param n 节点总数(编号 0 ~ n-1)
*/
public UnionFind(int n) {
parent = new int[n];
count = n;
// 初始时,每个节点的父节点都是自己
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
}
三、基础操作:Find 与 Union
3.1 查找操作(Find)
查找某个元素所属集合的根节点。通过不断向上追溯父节点,直到找到父节点指向自身的根节点。
/**
* 查找节点 x 所属集合的根节点(代表元素)
* 时间复杂度:O(h),h 为树的高度
* @param x 待查找的节点
* @return 根节点编号
*/
public int find(int x) {
// 如果父节点不是自己,继续向上查找
while (parent[x] != x) {
x = parent[x];
}
return x;
}
3.2 合并操作(Union)
将两个元素所在的集合合并。先分别找到两个元素的根节点,然后将其中一个根节点的父节点指向另一个根节点。
/**
* 合并节点 x 和节点 y 所在的集合
* 时间复杂度:O(h)
* @param x 第一个节点
* @param y 第二个节点
*/
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
// 如果已经在同一个集合,无需合并
if (rootX == rootY) return;
// 将 rootX 的根指向 rootY
parent[rootX] = rootY;
count--; // 连通分量减少一个
}
3.3 判断是否连通
/**
* 判断节点 x 和节点 y 是否在同一个集合中
* @return true 表示连通,false 表示不连通
*/
public boolean connected(int x, int y) {
return find(x) == find(y);
}
/**
* 获取当前连通分量的数量
*/
public int getCount() {
return count;
}
四、优化策略一:路径压缩(Path Compression)
4.1 问题分析
基础版本的 Union 操作可能产生退化的树结构。例如,每次都把一棵树的根接到另一棵树的根上,如果操作不当,树的高度可能达到 O(n),导致 Find 操作退化。
4.2 路径压缩原理
路径压缩的核心思想是:在 Find 操作中,将查找路径上的所有节点直接挂到根节点下。这样下次再访问这些节点时,只需一步即可到达根节点。
/**
* 查找并压缩路径
* 递归版本:在返回过程中,将路径上每个节点的父节点直接设为根节点
* 时间复杂度:O(α(n))
* @param x 待查找的节点
* @return 根节点编号
*/
public int find(int x) {
if (parent[x] != x) {
// 递归查找根节点,并将当前节点的父节点直接指向根节点
parent[x] = find(parent[x]);
}
return parent[x];
}
4.3 路径压缩效果
路径压缩使树的高度急剧降低。经过多次查询后,几乎所有节点都会直接连接到根节点,形成扁平化的星形结构,大幅提升后续操作效率。
五、优化策略二:按秩合并(Union by Rank)
5.1 问题分析
即使有了路径压缩,如果合并时总是随意选择根节点,仍然可能产生较高的树。理想情况是将较矮的树合并到较高的树下,以控制整体高度。
5.2 按秩合并原理
为每棵树维护一个秩(rank),表示树的高度的上界。合并时,总是将秩较小的树的根节点挂到秩较大的树的根节点下。
- 如果两棵树秩不同:秩小的挂到秩大的下面,合并后秩不变
- 如果两棵树秩相同:任选一棵作为根,其秩加 1
public class UnionFind {
private int[] parent; // 父节点数组
private int[] rank; // rank[i] 表示以 i 为根的树的高度上界
private int count;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1; // 初始时每棵树只有一个节点,高度为1
}
}
/**
* 带路径压缩的查找
*/
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
/**
* 按秩合并
* 将秩较小的树合并到秩较大的树下
*/
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
// 将秩较小的树挂到秩较大的树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
// 秩相等时,任选一棵作为根,秩加1
parent[rootY] = rootX;
rank[rootX]++;
}
count--;
}
}
六、完整Java实现
以下是集成了路径压缩与按秩合并的完整并查集实现,包含一个实际应用场景:判断无向图中的连通分量数量。
/**
* 并查集完整实现(Java)
* 支持:路径压缩、按秩合并、连通性查询、连通分量统计
* 时间复杂度:O(α(n)),α 为阿克曼函数反函数
*/
public class UnionFind {
// ==================== 成员变量 ====================
private int[] parent; // 父节点数组
private int[] rank; // 秩数组,表示树高的上界
private int count; // 当前连通分量数量
// ==================== 构造函数 ====================
/**
* 初始化并查集
* @param n 元素个数,编号从 0 到 n-1
*/
public UnionFind(int n) {
if (n <= 0) {
throw new IllegalArgumentException("元素数量必须大于0");
}
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i; // 每个元素初始时父节点指向自己
rank[i] = 1; // 初始秩为1(单个节点)
}
}
// ==================== 核心操作 ====================
/**
* 查找元素 x 所属集合的根节点
* 同时执行路径压缩,将 x 到根路径上的所有节点直接挂到根下
* @param x 待查找元素
* @return 根节点编号
*/
public int find(int x) {
if (x < 0 || x >= parent.length) {
throw new IllegalArgumentException("元素编号越界");
}
// 递归路径压缩
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
/**
* 合并元素 x 和元素 y 所在的集合
* 使用按秩合并策略,将矮树合并到高树下
* @param x 第一个元素
* @param y 第二个元素
*/
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
// 已在同一集合,无需合并
if (rootX == rootY) {
return;
}
// 按秩合并:矮树挂到高树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
// 秩相等时,rootX 作为新根,秩加1
parent[rootY] = rootX;
rank[rootX]++;
}
count--; // 合并后连通分量减1
}
/**
* 判断元素 x 和元素 y 是否在同一个集合中
* @return true 表示连通,false 表示不连通
*/
public boolean connected(int x, int y) {
return find(x) == find(y);
}
/**
* 获取当前连通分量的数量
*/
public int getCount() {
return count;
}
/**
* 获取指定集合的大小(遍历查找,效率较低,仅用于调试)
* @param root 集合根节点
* @return 集合中元素数量
*/
public int getSetSize(int root) {
int size = 0;
for (int i = 0; i < parent.length; i++) {
if (find(i) == root) {
size++;
}
}
return size;
}
// ==================== 测试主程序 ====================
public static void main(String[] args) {
// 场景:10个用户,逐步建立好友关系,观察朋友圈变化
int n = 10;
UnionFind uf = new UnionFind(n);
System.out.println("初始状态:" + uf.getCount() + " 个连通分量");
// 建立好友关系(合并集合)
int[][] friendships = {
{0, 1}, {1, 2}, {3, 4}, {5, 6}, {6, 7}, {8, 9},
{2, 3}, {7, 8}
};
System.out.println("\n逐步合并好友关系:");
for (int[] f : friendships) {
int a = f[0], b = f[1];
boolean wasConnected = uf.connected(a, b);
uf.union(a, b);
System.out.printf(" 合并 %d 和 %d: 之前%s连通, 剩余 %d 个朋友圈%n",
a, b, wasConnected ? "已" : "未", uf.getCount());
}
// 查询连通性
System.out.println("\n连通性查询结果:");
System.out.println(" 0 和 4 是否连通?" + uf.connected(0, 4));
System.out.println(" 0 和 9 是否连通?" + uf.connected(0, 9));
System.out.println(" 5 和 9 是否连通?" + uf.connected(5, 9));
// 最终状态
System.out.println("\n最终连通分量数量:" + uf.getCount());
// 验证路径压缩效果:再次查询时parent数组已被扁平化
System.out.println("\n路径压缩后,各节点根节点:");
for (int i = 0; i < n; i++) {
System.out.printf(" 节点 %d -> 根节点 %d%n", i, uf.find(i));
}
}
}
七、应用场景:朋友圈合并问题
LeetCode 经典题目 547. 省份数量 是并查集的典型应用。给定一个 n×n 的矩阵 isConnected,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,求省份(连通分量)的总数。
/**
* 使用并查集解决省份数量问题
* @param isConnected 邻接矩阵
* @return 省份数量
*/
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();
}
八、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 初始化 | O(n) | O(n) |
| 查找(路径压缩) | O(α(n)) ≈ O(1) | O(1) |
| 合并(按秩合并) | O(α(n)) ≈ O(1) | O(1) |
| 查询连通性 | O(α(n)) ≈ O(1) | O(1) |
其中 α(n) 是阿克曼函数的反函数,在宇宙原子数量级别的数据范围内,其值不超过 4。因此并查集的操作可以视为均摊常数时间。
九、总结
并查集以其简洁的结构和惊人的效率,成为处理动态连通性问题的首选数据结构。通过本文的学习,你掌握了:
- 数组表示法:用父节点数组高效表示森林结构
- 路径压缩:在查找时将节点直接挂到根下,扁平化树结构
- 按秩合并:合并时矮树挂高树,控制树高增长
- 实际应用:连通分量统计、Kruskal最小生成树、社交网络分析等
这两种优化策略可以单独使用,也可以同时使用。实际应用中,路径压缩 + 按秩合并的组合是最优配置,能够在理论和实践中都达到近乎常数的时间复杂度。