并查集
并查集(Disjoint Set Union,简称 DSU 或 Union-Find)是一种非常精巧且高效的数据结构,主要用于处理不相交集合的合并及查询问题。
它的核心功能可以用一句话概括:快速判断两个元素是否属于同一个集合,以及将两个不同的集合合并。
以下是 C++ 并查集的深度详解,包含原理、图解、代码模板及核心优化。
1. 核心概念与形象比喻
为了理解并查集,我们可以使用“帮派”或“朋友圈”的比喻:
- 元素(Element): 具体的某个人。
- 集合(Set): 一个帮派。
- 代表元(Representative/Root): 帮派的“老大”。
并查集维护了一个森林(若干棵树),每一棵树代表一个集合。
- 树根(Root): 就是这个集合的“老大”。
- 父节点指针: 每个节点都只知道自己的上级(父节点)是谁。
三大操作
- 初始化 (Init): 开始时,每个人都是独立的,自己是自己的老大。
- 查询 (Find): 查找某个人的“最终大BOSS”是谁。如果两个人的大BOSS相同,说明他们在同一个帮派。
- 合并 (Union): 两个帮派合并。通常是让其中一个帮派的老大,成为另一个帮派老大的下属。
2. 核心操作与代码实现
2.1 初始化 (Initialization)
我们通常使用一个数组 parent (或 fa),其中 parent[i] 表示元素 $i$ 的父节点。
初始时,parent[i] = i,表示自己是自己的根节点。
#include <vector>
#include <numeric> // for std::iota
class DSU {
private:
std::vector<int> parent;
public:
DSU(int n) {
parent.resize(n + 1); // 假设节点从 0 到 n 或 1 到 n
// 初始化:每个人的父节点是自己
// std::iota 填充 0, 1, 2... 也可以用循环
for (int i = 0; i <= n; ++i) {
parent[i] = i;
}
}
};
2.2 查询 (Find) - 核心优化:路径压缩
这是并查集最关键的一步。
- 普通查找: 沿着父节点一路向上找,直到找到根节点(
parent[x] == x)。 - 路径压缩 (Path Compression): 在查找的过程中,直接把沿途经过的所有节点的父节点,全部改为指向根节点。
为什么需要路径压缩?
如果不压缩,树可能会退化成一条长链,查找效率变成 $O(n)$。压缩后,树的高度几乎是常数,下次再查该路径上的节点时,一步就能找到根。
int find(int x) {
if (parent[x] == x) {
return x; // 找到根节点
}
// 递归查找,并将结果赋值给 parent[x] (路径压缩)
return parent[x] = find(parent[x]);
}
2.3 合并 (Union)
将元素 $x$ 和元素 $y$ 所在的集合合并。
- 找到 $x$ 的根 $rootX$。
- 找到 $y$ 的根 $rootY$。
- 如果 $rootX \neq rootY$,则令
parent[rootX] = rootY(即让 $X$ 的帮派归顺 $Y$)。
void unite(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY; // 合并
}
}
// 辅助功能:判断是否相连
bool isConnected(int x, int y) {
return find(x) == find(y);
}
3. 进阶优化:按秩合并 (Union by Rank/Size)
虽然路径压缩已经足够高效(能满足绝大多数算法竞赛和工程需求),但为了理论上的极致,我们可以结合按秩合并。
- 原理: 合并时,总是将“树高较小”或“节点数较少”的树,挂在“树高较大”或“节点数较多”的树下。
- 目的: 防止树的高度增加过快。
加入按秩合并后的完整 C++ 模板:
#include <vector>
class DSU {
private:
std::vector<int> parent;
std::vector<int> rank; // 用于记录树的高度(大致高度)
public:
DSU(int n) {
parent.resize(n + 1);
rank.resize(n + 1, 1); // 初始高度都为 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];
}
void unite(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
// 按秩合并:小树挂在大树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootX] = rootY;
rank[rootY] += 1; // 只有两棵树高度相等时,合并后高度才加 1
}
}
}
bool isConnected(int x, int y) {
return find(x) == find(y);
}
};
4. 时间复杂度分析
并查集的时间复杂度非常惊人。
如果同时使用路径压缩和按秩合并,单次操作的平均时间复杂度为:$$O(\alpha(n))$$
其中 $\alpha$ 是阿克曼函数 (Ackermann Function) 的反函数。
- 它的增长极其缓慢。
- 对于宇宙中所有实际可见的 $n$(例如 $10^{100}$),$\alpha(n) < 4$。
- 因此,我们可以认为并查集的操作时间复杂度是近乎常数的。
5. 实战应用场景
并查集主要解决两类问题:
- 图的连通性问题: 比如“判断两点之间是否存在路径”、“计算岛屿数量”、“朋友圈个数”。
- 图的最小生成树 (MST): Kruskal 算法的核心就是并查集。
示例:计算连通分量个数 (省份数量)
假设有 n 个城市,isConnected[i][j] = 1 表示直接相连。求有多少个独立的省份。
int findCircleNum(std::vector<std::vector<int>>& isConnected) {
int n = isConnected.size();
DSU dsu(n);
int count = n; // 初始假设有 n 个独立省份
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (isConnected[i][j] == 1) {
// 如果两城相连,且原本不在一个集合,则合并,总数减一
if (dsu.find(i) != dsu.find(j)) {
dsu.unite(i, j);
count--;
}
}
}
}
return count;
}
6. 总结
| 特性 | 说明 |
|---|---|
| 主要功能 | 集合合并、查询元素所属集合 |
| 核心技巧 | 路径压缩 (必须掌握)、按秩合并 (选学) |
| 空间复杂度 | $O(n)$ |
| 时间复杂度 | $O(\alpha(n))$ (近乎常数) |
| 典型应用 | 最小生成树(Kruskal)、连通性检测、最近公共祖先(Tarjan) |
浙公网安备 33010602011771号