并查集(Disjoint Set Union)详解:原理、优化与应用
并查集(也称为不相交集合数据结构 ,英文 Disjoint Set Union 或 Union-Find)是一种非常高效的数据结构,主要用于处理动态连通性问题。它支持两种核心操作:
- 查询(Find):判断两个元素是否属于同一个集合(即是否连通)。
- 合并(Union):将两个集合合并成一个。
并查集在算法竞赛(如 OI、ACM)和实际工程中应用广泛,例如最小生成树(Kruskal 算法)、图的连通分量统计、网格连通问题等。其最大特点是操作几乎达到 O(1) 时间复杂度(摊销后)。
本文将从基本原理入手,逐步介绍实现、优化技巧,并附上完整代码示例。
1. 并查集的基本原理
并查集用一个森林 (多棵树)来表示多个不相交的集合。每棵树代表一个集合,树的根节点作为该集合的代表元。
- 用一个数组
parent[]表示每个节点的父节点。 - 初始时,每个元素都是独立的集合:
parent[i] = i。 - 查找根节点(Find 操作):从某个节点向上追溯,直到找到父节点为自己本身的节点(根)。
- 合并(Union 操作):找到两个元素的根节点,如果不同,则将一棵树的根指向另一棵树的根。
简单实现(无优化)
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int parent[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] == x) return x;
return find(parent[x]); // 递归查找根
}
void union_sets(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx != ry) {
parent[rx] = ry; // 将 rx 的根指向 ry 的根
}
}
这种实现最坏情况下树会退化成一条链(高度为 n),每次 Find 操作可能需要 O(n) 时间。
2. 优化一:路径压缩(Path Compression)
路径压缩的核心思想是:在查找根节点的过程中,将路径上的所有节点直接指向根节点,从而压扁树的高度。
修改 Find 函数:
cpp
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归压缩路径
}
return parent[x];
}
效果示意:
- 未压缩:1 → 2 → 3 → 4(根)
- 压缩后:1、2、3 都直接指向 4
路径压缩后,树的高度会大幅降低,后续查询极快。
3. 优化二:按秩合并(Union by Rank)
单纯路径压缩虽然优秀,但合并时如果总是随意连接,可能导致树偏斜。按秩合并通过维护一个 rank[] 数组记录每棵树的"秩"(近似高度)来优化。
- 初始时,所有秩为 1(或 0)。
- 合并时,总是将秩小的树挂到秩大的树上。
- 如果秩相等,任选一个挂接,并将胜出方的秩 +1。
cpp
int rank[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i;
rank[i] = 1; // 初始秩为 1
}
}
void union_sets(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx == ry) return;
// 将秩小的合并到秩大的分支上
if (rank[rx] < rank[ry]) {
parent[rx] = ry;
} else {
parent[ry] = rx;
if (rank[rx] == rank[ry]) {
rank[rx]++;
}
}
}
路径压缩 + 按秩合并 是标准优化组合,摊销时间复杂度为 O(α(n)),其中 α(n) 是反阿克曼函数,增长极慢,实际可视为常数。
4. 完整优化版代码
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int parent[N];
int rank[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i;
rank[i] = 1;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
void union_sets(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx == ry) return;
if (rank[rx] < rank[ry]) {
swap(rx, ry); // 保证 rx 的秩更大
}
parent[ry] = rx;
if (rank[rx] == rank[ry]) {
rank[rx]++;
}
}
int main() {
// 示例使用
init(10);
union_sets(1, 2);
union_sets(3, 4);
union_sets(2, 4);
cout << (find(1) == find(4) ? "连通" : "不连通") << endl; // 输出:连通
return 0;
}
5. 常见应用场景
- 图的连通分量统计:遍历所有边进行 Union,最后统计根节点数量。
- Kruskal 最小生成树:按边权排序,Union 时判断是否形成环。
- 网格/迷宫连通问题:如 LeetCode 的"朋友圈"、"被围绕的区域"等。
- 在线判断连通性:动态添加边,实时查询是否连通。
6. 扩展:按大小合并(Union by Size)
另一种优化是维护每个集合的大小 size[],合并时将小集合挂到大集合上。效果与按秩合并类似,有时更优(尤其在需要统计集合大小时)。
cpp
int size[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i;
size[i] = 1;
}
}
void union_sets(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return;
if (size[rx] < size[ry]) swap(rx, ry);
parent[ry] = rx;
size[rx] += size[ry];
}
总结
并查集是算法竞赛中的"神器"之一,实现简单却效率极高。记住两大优化:
- 路径压缩:让 Find 更快。
- 按秩/大小合并:防止树退化。
掌握了并查集,很多图论和连通性问题都会迎刃而解。建议多刷相关题目(如 HDU 1232 畅通工程、POJ 2236 等)来加深理解。