并查集
想象你在一个万人的社交网络里,不断有人添加好友、建立联系。每时每刻都有人在问:"我和某个人是不是在同一个朋友圈里?"
朋友圈的关系是传递的——如果 A 和 B 是好友,B 和 C 是好友,那么 A 和 C 也在同一个圈子里。这本质上是一个 动态连通性(Dynamic Connectivity) 问题:元素不断合并成集合,同时需要快速查询两个元素是否属于同一个集合。
暴力做法?每次查询遍历整个图——$O(n)$ 甚至更差。但如果我告诉你,存在一种数据结构,可以让每次操作的时间复杂度降到 近乎常数,你会不会觉得不可思议?
这就是并查集(Union-Find),也叫不相交集合(Disjoint Set Union, DSU)。它只提供两个操作,却足以解决一类非常广泛的问题。
并查集的核心表示是森林(Forest)——每一棵树代表一个集合,树的根节点就是这个集合的"代表元"。
具体实现非常朴素:用一个数组 parent[],其中 parent[i] 表示节点 i 的父节点。如果 parent[i] == i,那么 i 就是根。
| 操作 | 含义 | 实现思路 |
|---|---|---|
Find(x) | 找到 x 所属集合的根 | 沿着 parent 链一直往上走,直到遇到根 |
Union(x, y) | 合并 x 和 y 所在的集合 | 找到两棵树的根,把一棵挂到另一棵下面 |
来写一个最朴素的版本:
int parent[MAXN];
// 初始化:每个元素自成一个集合
void init(int n) {
for (int i = 0; i < n; i++)
parent[i] = i;
}
// 查找根
int find(int x) {
while (parent[x] != x)
x = parent[x];
return x;
}
// 合并
void unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx != ry)
parent[rx] = ry; // 把 x 的根挂到 y 的根下面
}
Find 操作变成 $O(n)$。我们需要优化。思路非常直觉:在查找的过程中,顺手把沿途经过的所有节点都直接接到根上。就像你每次问路,不仅自己记住了怎么走,还把沿途所有路标都改成了直达终点的方向。
这只需在 Find 里加一行递归:
int find(int x) {
if (parent[x] != x)
parent[x] = find(parent[x]); // 路径压缩
return parent[x];
}
每一次 Find 调用后,从查询节点到根的路径上的所有节点,都会变成根的直接子节点。树的形态从一条长链"压扁"成了一个几乎只有两层的矮树。
为什么不能直接把所有节点接到根上?
路径压缩是"懒"的——只在 Find 被调用时才压缩沿途路径。这样做的好处是不需要额外遍历整棵树,均摊代价极低。实际上,单次 Find 的均摊时间复杂度只有 $O(\alpha(n))$,其中 $\alpha$ 是反阿克曼函数。
路径压缩解决了"查询后树变矮"的问题,但第一次查询之前呢?如果初始时树就已经很高了,第一次 Find 还是慢。
按秩合并(Union by Rank)的思路是:在合并两棵树时,总是把较矮的树挂到较高的树下面。这样树的高度永远不会无意义地增长。
用 rank[] 数组记录每棵根节点树的高度上界:
int parent[MAXN], rnk[MAXN];
void init(int n) {
for (int i = 0; i < n; i++) {
parent[i] = i;
rnk[i] = 0; // 初始高度为 0
}
}
void unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return; // 已经在同一集合
if (rnk[rx] < rnk[ry]) // x 的树更矮
parent[rx] = ry; // 把矮树挂到高树下
else if (rnk[rx] > rnk[ry])
parent[ry] = rx;
else {
parent[ry] = rx; // 等高时任选一个
rnk[rx]++; // 高度 +1
}
}
当路径压缩 + 按秩合并同时使用时,$m$ 次操作(Find 和 Union 的混合)在 $n$ 个元素上的总时间复杂度为:
$O(m \cdot \alpha(n))$
其中 $\alpha(n)$ 是反阿克曼函数(Inverse Ackermann Function)。这个函数增长得极其缓慢——对于任何实际可能遇到的 $n$(哪怕 $n = 2^{2^{2^{65536}}}$),$\alpha(n)$ 都不会超过 4。
可以认为,在实际应用中,并查集的每次操作就是 $O(1)$ 的。
| 优化策略 | 单次 Find 最坏 | m 次操作总复杂度 |
|---|---|---|
| 无优化 | $O(n)$ | $O(mn)$ |
| 仅路径压缩 | $O(\log n)$ | $O(m \log n)$ |
| 仅按秩合并 | $O(\log n)$ | $O(m \log n)$ |
| 两者结合 | $O(\alpha(n))$ | $O(m \cdot \alpha(n))$ |
阿克曼函数与反函数
阿克曼函数 $A(k, k)$ 是一个增长极快的递归函数。其反函数 $\alpha(n) = \min\{k : A(k, k) \geq n\}$ 增长极慢。$\alpha(1) = 1$,$\alpha(2) = 2$,$\alpha(3) = 3$,而 $\alpha(4)$ 已经大到需要用塔式幂(tower of powers)来表示了。所以 $\alpha(n) \leq 4$ 对所有实际 $n$ 成立。
标准并查集只关心"在不在同一个集合"。但如果我们在 parent 的边上附加一些额外信息呢?
带权并查集(Weighted Union-Find)在每条边 $(x, \text{parent}[x])$ 上存储一个权重。在路径压缩时,权重需要按规则累加;在合并时,需要根据关系计算新边的权重。
经典应用是食物链问题(POJ 1182):有 A、B、C 三种动物,A 吃 B,B 吃 C,C 吃 A。给出若干条"X 吃 Y"或"X 和 Y 同类"的陈述,判断哪些是谎言。
// parent[x]: x 的父节点
// weight[x]: x 到 parent[x] 的关系权重 (0=同类, 1=被吃, 2=吃)
int parent[MAXN], weight[MAXN];
int find(int x) {
if (parent[x] != x) {
int root = find(parent[x]);
weight[x] = (weight[x] + weight[parent[x]]) % 3; // 路径压缩时累加权重
parent[x] = root;
}
return parent[x];
}
关键在于合并时的关系推导——已知 $x$ 和 $y$ 的关系,以及它们到各自根的关系,要推出两棵树的根之间的关系。这需要一点模运算的推导。
下面是一个完整的、带路径压缩和按秩合并的并查集模板,适用于大多数竞赛场景:
#include <vector>
using namespace std;
struct DSU {
vector<int> parent, rnk, sz;
DSU(int n) : parent(n), rnk(n, 0), sz(n, 1) {
for (int i = 0; i < n; i++)
parent[i] = i;
}
int find(int x) {
if (parent[x] != x)
parent[x] = find(parent[x]);
return parent[x];
}
bool unite(int x, int y) {
x = find(x), y = find(y);
if (x == y) return false; // 已经在同一集合
if (rnk[x] < rnk[y]) swap(x, y);
parent[y] = x; // y 挂到 x 下面
sz[x] += sz[y]; // 维护集合大小
if (rnk[x] == rnk[y]) rnk[x]++;
return true;
}
bool connected(int x, int y) {
return find(x) == find(y);
}
int size(int x) {
return sz[find(x)];
}
};
用法示例——判断图中的连通分量数:
int countComponents(int n, vector<pair<int,int>>& edges) {
DSU dsu(n);
int components = n;
for (auto [u, v] : edges) {
if (dsu.unite(u, v)) // 成功合并说明减少了一个连通分量
components--;
}
return components;
}
并查集不是万能的,但在以下场景中它是首选:
| 场景 | 问题描述 | 并查集的作用 |
|---|---|---|
| 连通分量 | 无向图中有多少连通块? | 每条边做 Union,统计根的个数 |
| 最小生成树 | Kruskal 算法 | 判断边的两端是否已连通,避免成环 |
| 等价类划分 | 哪些元素"等价"? | 等价关系 → Union,查询 → Find |
| 动态连通性 | 实时查询"两点是否连通" | 在线处理 Union 和 Query |
| 食物链/关系推理 | 带有传递关系的推理 | 带权并查集 |
| 网格连通 | 矩阵中的岛屿计数 | 将二维坐标映射为一维,做 Union |
经典题目推荐
并查集是一个极其优雅的数据结构——两个操作,几行代码,却能解决一大类连通性问题。它的精髓在于两个优化策略的配合:路径压缩让树变矮,按秩合并防止树变高,两者结合达到了近乎常数的均摊复杂度。
记住这个判断法则:当题目中涉及"是否在同一个组"、"是否连通"、"等价关系"、"合并集合"这些关键词时,优先考虑并查集。
参考来源
- Robert Sedgewick, Kevin Wayne — Algorithms, 4th Edition, Chapter 1.5: Union-Find
- Thomas H. Cormen et al. — Introduction to Algorithms, 3rd Edition, Chapter 21: Data Structures for Disjoint Sets
- Bernard Chazelle — The Disjoint Set Union Complexity is Near-Linear, 2004 — $O(m \cdot \alpha(m, n))$ 复杂度的严格证明