一、情景引入:刘备的人马管理问题
东汉末年,刘备起兵后,陆续有各路人马前来投奔。这些人马有的是单枪匹马,有的是成群结队,但每队人马都有一个头领。
初始状态:
- 关羽带着 5 个人来投奔
- 张飞带着 8 个人来投奔
- 赵云单人前来
- 黄忠带着 3 个人来投奔
text
关羽队:关羽 → 周仓、关平、廖化、王甫、赵累
张飞队:张飞 → 张苞、关兴、吴班、马岱、魏延、陈式、马忠、鄂焕
赵云队:赵云(单人)
黄忠队:黄忠 → 严颜、陈到、刘封
为了便于管理,刘备只记录每队的头领名字,每个头领自己管理手下。
1.1 问题一:如何判断两个士兵是否一队?
有一天,周仓和关平打架了,刘备想知道他们是不是一队的:
- 问周仓:你的头儿是谁?→ 关羽
- 问关平:你的头儿是谁?→ 关羽
- 结论:都是关羽的人,是一队的,关起来一起教育!
但如果周仓和魏延打架:
- 问周仓:你的头儿是谁?→ 关羽
- 问魏延:你的头儿是谁?→ 张飞
- 结论:不是一队的,各自队长负责处理
这个 "找头儿" 的过程,就是并查集的 Find 操作。
1.2 问题二:如何合并两队人马?
后来刘备有空了,决定整编队伍。他想把关羽队和张飞队合并成一个大队。
合并规则:
- 两队合并后只能有一个队长
- 刘备选关羽当新队长(因为关羽资历更深)
- 原张飞队的所有人(包括张飞)都改为认关羽为头儿
text
合并前:
关羽队:关羽 → 周仓、关平、廖化、王甫、赵累
张飞队:张飞 → 张苞、关兴、吴班、马岱、魏延、陈式、马忠、鄂焕
合并后:
关羽大队:关羽 → 周仓、关平、廖化、王甫、赵累、张飞、张苞、关兴、...
这个 "整编队伍" 的过程,就是并查集的 Union 操作。
1.3 数据结构的本质
仔细观察这个过程:
- 每个士兵 指向 自己的头儿
- 头儿 指向 自己(或更大的头儿)
- 最终形成一棵 树:头儿是根节点,士兵是子节点
text
关羽(根)
/ | \ \
周仓 关平 廖化 张飞(原队长,现在也是队员)
/ | \
张苞 关兴 魏延
这就是并查集的核心思想!
二、并查集的定义与操作
2.1 什么是并查集?
并查集(Disjoint Set / Union-Find) 是一种用于处理 不相交集合 的数据结构。
名词解释:
- Disjoint(不相交):所有集合之间没有公共元素
- Union(合并):将两个集合合并为一个
- Find(查找):找到某个元素所属集合的代表元素
2.2 核心操作
| 操作 | 功能 | 示例 |
|---|---|---|
MakeSet(x) |
初始化,将 x 作为单独的集合 | 每个士兵初始是独立的队伍 |
Find(x) |
查找 x 所属集合的代表元素 | 找到 x 的头儿是谁 |
Union(x, y) |
合并 x 和 y 所在的两个集合 | 把两队人马合并 |
2.3 主要应用场景
- 判断图的连通性:两个节点是否连通?
- 检测环:在无向图中加边时,是否会形成环?
- 最小生成树:Kruskal 算法的核心数据结构
- 社交网络:判断两个人是否在同一个朋友圈
- 等价类划分:将具有传递关系的元素分组
三、实现方式一:链表实现
3.1 数据结构设计
每个集合用一个 链表 表示:
- 头节点(代表元素):作为集合的标志
- 链表节点:存储集合中的其他元素
- 尾节点指针:方便快速合并
text
集合 A:[关羽] → [周仓] → [关平] → [廖化] → NULL
↑ ↑
head tail
集合 B:[张飞] → [魏延] → [张苞] → NULL
↑ ↑
head tail
3.2 基本操作实现
cpp
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
// 链表节点
struct Node {
string name; // 元素名称
Node* next; // 指向下一个元素
Node* representative; // 指向代表元素(集合的头)
Node(string n) : name(n), next(nullptr), representative(this) {}
};
// 集合信息
struct SetInfo {
Node* head; // 链表头(代表元素)
Node* tail; // 链表尾(方便合并)
int size; // 集合大小
SetInfo(Node* node) : head(node), tail(node), size(1) {}
};
class DisjointSetList {
private:
unordered_map<string, Node*> elements; // 元素名 → 节点
unordered_map<Node*, SetInfo*> setInfoMap; // 代表元素 → 集合信息
public:
// 初始化:创建单元素集合
void MakeSet(string name) {
if (elements.find(name) != elements.end()) {
return; // 已存在
}
Node* node = new Node(name);
elements[name] = node;
setInfoMap[node] = new SetInfo(node);
cout << "创建独立队伍:" << name << endl;
}
// 查找:返回代表元素
string Find(string name) {
if (elements.find(name) == elements.end()) {
return ""; // 元素不存在
}
Node* node = elements[name];
return node->representative->name;
}
// 合并:加权合并启发式(小集合并入大集合)
void Union(string name1, string name2) {
string rep1 = Find(name1);
string rep2 = Find(name2);
if (rep1.empty() || rep2.empty()) {
cout << "元素不存在!" << endl;
return;
}
if (rep1 == rep2) {
cout << name1 << " 和 " << name2 << " 已经在同一队" << endl;
return;
}
Node* head1 = elements[rep1];
Node* head2 = elements[rep2];
SetInfo* set1 = setInfoMap[head1];
SetInfo* set2 = setInfoMap[head2];
// 加权合并:小集合并入大集合
if (set1->size < set2->size) {
swap(head1, head2);
swap(set1, set2);
swap(rep1, rep2);
}
cout << "合并队伍:" << rep2 << " 队并入 " << rep1 << " 队" << endl;
// 将 set2 的所有节点接到 set1 尾部
set1->tail->next = head2;
set1->tail = set2->tail;
// 更新 set2 所有节点的代表元素
Node* current = head2;
while (current != nullptr) {
current->representative = head1;
set1->size++;
current = current->next;
}
// 删除 set2 的信息
setInfoMap.erase(head2);
delete set2;
}
// 打印集合
void PrintSet(string name) {
string rep = Find(name);
if (rep.empty()) {
cout << name << " 不存在" << endl;
return;
}
Node* head = elements[rep];
cout << rep << " 队:";
Node* current = head;
while (current != nullptr) {
cout << current->name;
if (current->next != nullptr) cout << " → ";
current = current->next;
}
cout << endl;
}
};
3.3 测试代码
cpp
int main() {
DisjointSetList dsl;
// 初始化各路人马
cout << "=== 初始化 ===" << endl;
dsl.MakeSet("关羽");
dsl.MakeSet("周仓");
dsl.MakeSet("关平");
dsl.MakeSet("张飞");
dsl.MakeSet("魏延");
dsl.MakeSet("赵云");
// 组建初始队伍
cout << "\n=== 组建队伍 ===" << endl;
dsl.Union("关羽", "周仓");
dsl.Union("关羽", "关平");
dsl.Union("张飞", "魏延");
// 打印队伍
cout << "\n=== 当前队伍 ===" << endl;
dsl.PrintSet("关羽");
dsl.PrintSet("张飞");
dsl.PrintSet("赵云");
// 查询
cout << "\n=== 查询头领 ===" << endl;
cout << "周仓的头领:" << dsl.Find("周仓") << endl;
cout << "魏延的头领:" << dsl.Find("魏延") << endl;
// 大合并
cout << "\n=== 大整编 ===" << endl;
dsl.Union("关羽", "张飞");
dsl.PrintSet("关羽");
// 赵云加入
cout << "\n=== 赵云加入 ===" << endl;
dsl.Union("赵云", "关羽");
dsl.PrintSet("关羽");
return 0;
}
3.4 运行结果
text
=== 初始化 ===
创建独立队伍:关羽
创建独立队伍:周仓
创建独立队伍:关平
创建独立队伍:张飞
创建独立队伍:魏延
创建独立队伍:赵云
=== 组建队伍 ===
合并队伍:周仓 队并入 关羽 队
合并队伍:关平 队并入 关羽 队
合并队伍:魏延 队并入 张飞 队
=== 当前队伍 ===
关羽 队:关羽 → 周仓 → 关平
张飞 队:张飞 → 魏延
赵云 队:赵云
=== 查询头领 ===
周仓的头领:关羽
魏延的头领:张飞
=== 大整编 ===
合并队伍:张飞 队并入 关羽 队
关羽 队:关羽 → 周仓 → 关平 → 张飞 → 魏延
=== 赵云加入 ===
合并队伍:赵云 队并入 关羽 队
关羽 队:关羽 → 周仓 → 关平 → 张飞 → 魏延 → 赵云
3.5 链表实现的优化:加权合并
朴素合并的问题 :
如果总是让第二个集合并入第一个集合,可能导致:
- 大集合并入小集合 → 需要更新很多节点的
representative指针
加权合并启发式(Weighted Union):
- 始终让小集合并入大集合
- 这样需要更新的节点数更少
时间复杂度分析:
| 操作 | 朴素实现 | 加权合并 |
|---|---|---|
MakeSet |
O(1) | O(1) |
Find |
O(1) | O(1) |
Union |
O(n) | O(min(n₁, n₂)) |
| m 次操作总时间 | O(mn) | O(m + n log n) |
证明 :使用加权合并时,一个元素被更新 representative 指针的次数最多为 O(log n),因为每次合并后所在集合的大小至少翻倍。
四、实现方式二:树形结构(重点)
4.1 从链表到树的优化
链表实现虽然直观,但有两个问题:
- Find 虽然是 O(1),但需要额外空间存储 representative 指针
- Union 需要遍历整个链表更新指针
树形结构的优势:
- 每个节点只需存储 父节点指针
- Find 操作:沿着父指针一路向上找根
- Union 操作:直接修改一个根的父指针
text
链表表示:
[关羽] → [周仓] → [关平]
↑ ↑ ↑
每个节点都要记住关羽是代表
树形表示:
关羽
/ \
周仓 关平
只需记住父节点即可
4.2 数据结构设计
cpp
class DisjointSetTree {
private:
unordered_map<string, string> parent; // 元素 → 父节点
unordered_map<string, int> rank; // 元素 → 秩(树的高度估计)
public:
// 初始化
void MakeSet(string name) {
if (parent.find(name) != parent.end()) {
return;
}
parent[name] = name; // 初始时自己是自己的父节点
rank[name] = 0; // 初始秩为 0
}
// 查找(带路径压缩)
string Find(string name) {
if (parent.find(name) == parent.end()) {
return "";
}
// 路径压缩:递归查找根节点,并将路径上所有节点直接连到根
if (parent[name] != name) {
parent[name] = Find(parent[name]);
}
return parent[name];
}
// 合并(按秩合并)
void Union(string name1, string name2) {
string root1 = Find(name1);
string root2 = Find(name2);
if (root1.empty() || root2.empty()) {
cout << "元素不存在!" << endl;
return;
}
if (root1 == root2) {
cout << name1 << " 和 " << name2 << " 已在同一队" << endl;
return;
}
// 按秩合并:矮树接到高树下
if (rank[root1] < rank[root2]) {
parent[root1] = root2;
cout << root1 << " 队并入 " << root2 << " 队" << endl;
} else if (rank[root1] > rank[root2]) {
parent[root2] = root1;
cout << root2 << " 队并入 " << root1 << " 队" << endl;
} else {
parent[root2] = root1;
rank[root1]++;
cout << root2 << " 队并入 " << root1 << " 队(秩+1)" << endl;
}
}
// 判断是否在同一集合
bool Connected(string name1, string name2) {
string root1 = Find(name1);
string root2 = Find(name2);
return !root1.empty() && !root2.empty() && root1 == root2;
}
};
4.3 优化一:按秩合并(Union by Rank)
核心思想 :将 矮的树 接到 高的树 下面,避免树越来越高。
秩(Rank):树的高度的一个上界估计值。
text
不优化的合并:
A B A
| | → / \
C D C B
|
D
树高从 2 变成 3
按秩合并:
A B B
| | → / \
C D A D
|
C
树高保持为 3
时间复杂度:单独使用按秩合并,Find 操作的时间复杂度为 O(log n)。
4.4 优化二:路径压缩(Path Compression)
核心思想 :Find 时,将路径上所有节点 直接连到根节点。
text
查找前:
关羽
/
张飞
/
魏延
/
张苞
查找 张苞 时,路径:张苞 → 魏延 → 张飞 → 关羽
查找后(路径压缩):
关羽
/ | \
张飞 魏延 张苞
所有节点直接连到根,下次查找更快!
实现代码:
cpp
string Find(string name) {
if (parent[name] != name) {
parent[name] = Find(parent[name]); // 递归压缩
}
return parent[name];
}
时间复杂度 :路径压缩 + 按秩合并,均摊时间复杂度接近 O(α(n)),其中 α 是反阿克曼函数,增长极慢,实际可认为是常数。
4.5 完整测试
cpp
int main() {
DisjointSetTree dst;
// 初始化
cout << "=== 初始化 ===" << endl;
vector<string> names = {"关羽", "周仓", "关平", "张飞", "魏延", "赵云", "黄忠"};
for (const string& name : names) {
dst.MakeSet(name);
}
// 组建队伍
cout << "\n=== 组建队伍 ===" << endl;
dst.Union("关羽", "周仓");
dst.Union("关羽", "关平");
dst.Union("张飞", "魏延");
// 查询
cout << "\n=== 查询头领 ===" << endl;
cout << "周仓的头领:" << dst.Find("周仓") << endl;
cout << "魏延的头领:" << dst.Find("魏延") << endl;
cout << "赵云的头领:" << dst.Find("赵云") << endl;
// 判断连通性
cout << "\n=== 判断是否一队 ===" << endl;
cout << "周仓和关平:" << (dst.Connected("周仓", "关平") ? "一队" : "不同队") << endl;
cout << "周仓和魏延:" << (dst.Connected("周仓", "魏延") ? "一队" : "不同队") << endl;
// 大合并
cout << "\n=== 大整编 ===" << endl;
dst.Union("关羽", "张飞");
dst.Union("赵云", "黄忠");
dst.Union("关羽", "赵云");
// 最终状态
cout << "\n=== 最终所有人的头领 ===" << endl;
for (const string& name : names) {
cout << name << " → " << dst.Find(name) << endl;
}
return 0;
}
运行结果:
text
=== 初始化 ===
=== 组建队伍 ===
周仓 队并入 关羽 队
关平 队并入 关羽 队(秩+1)
魏延 队并入 张飞 队
=== 查询头领 ===
周仓的头领:关羽
魏延的头领:张飞
赵云的头领:赵云
=== 判断是否一队 ===
周仓和关平:一队
周仓和魏延:不同队
=== 大整编 ===
张飞 队并入 关羽 队
黄忠 队并入 赵云 队
赵云 队并入 关羽 队
=== 最终所有人的头领 ===
关羽 → 关羽
周仓 → 关羽
关平 → 关羽
张飞 → 关羽
魏延 → 关羽
赵云 → 关羽
黄忠 → 关羽
五、实战应用:Kruskal 最小生成树
5.1 问题背景
给定一个加权无向图,找到一棵生成树,使得所有边的权重和最小。
Kruskal 算法思路:
- 将所有边按权重从小到大排序
- 依次考察每条边:
- 如果边的两个端点不在同一集合 → 加入这条边,合并两个集合
- 否则跳过(会形成环)
- 重复直到有 n-1 条边
并查集的作用:快速判断两个节点是否连通,避免形成环。
5.2 代码实现
cpp
struct Edge {
string u, v;
int weight;
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
vector<Edge> Kruskal(vector<Edge>& edges, vector<string>& vertices) {
DisjointSetTree dst;
// 初始化并查集
for (const string& v : vertices) {
dst.MakeSet(v);
}
// 排序边
sort(edges.begin(), edges.end());
vector<Edge> mst; // 最小生成树的边
for (const Edge& e : edges) {
// 如果两个端点不在同一集合,加入这条边
if (!dst.Connected(e.u, e.v)) {
mst.push_back(e);
dst.Union(e.u, e.v);
cout << "加入边:" << e.u << " - " << e.v << " (权重 " << e.weight << ")" << endl;
}
}
return mst;
}
int main() {
vector<Edge> edges = {
{"成都", "重庆", 3},
{"成都", "武汉", 7},
{"重庆", "武汉", 5},
{"重庆", "长沙", 6},
{"武汉", "长沙", 4},
{"武汉", "广州", 8},
{"长沙", "广州", 2}
};
cout << "=== Kruskal 最小生成树算法 ===" << endl;
vector<Edge> mst = Kruskal(edges, cities);
int totalWeight = 0;
cout << "\n最小生成树的边:" << endl;
for (const Edge& e : mst) {
cout << e.u << " - " << e.v << " (权重 " << e.weight << ")" << endl;
totalWeight += e.weight;
}
cout << "总权重:" << totalWeight << endl;
return 0;
}
运行结果:
text
=== Kruskal 最小生成树算法 ===
加入边:长沙 - 广州 (权重 2)
加入边:成都 - 重庆 (权重 3)
加入边:武汉 - 长沙 (权重 4)
加入边:重庆 - 武汉 (权重 5)
最小生成树的边:
长沙 - 广州 (权重 2)
成都 - 重庆 (权重 3)
武汉 - 长沙 (权重 4)
重庆 - 武汉 (权重 5)
总权重:14
算法分析:
- 排序:O(E log E)
- 并查集操作:O(E · α(V))
- 总时间复杂度:O(E log E)
六、两种实现方式对比
6.1 链表 vs 树形结构
| 特性 | 链表实现 | 树形结构(优化后) |
|---|---|---|
| 空间复杂度 | O(n),需要额外存储 representative | O(n),只需父指针 |
| MakeSet | O(1) | O(1) |
| Find(无优化) | O(1) | O(树高) |
| Find(优化后) | O(1) | O(α(n)) ≈ O(1) |
| Union(无优化) | O(n) | O(树高) |
| Union(优化后) | O(log n) | O(α(n)) ≈ O(1) |
| 实现复杂度 | 较复杂 | 简洁 |
| 实际应用 | 较少使用 | 主流选择 |
6.2 为什么树形结构更优?
- 空间效率:只需一个父指针数组
- 时间效率:路径压缩 + 按秩合并后,均摊复杂度接近常数
- 代码简洁:核心代码不超过 30 行
- 灵活性:容易扩展(如支持撤销操作)
七、并查集的高级技巧
7.1 记录集合大小
在某些应用中,我们需要知道每个集合的大小。
cpp
class DisjointSetWithSize {
private:
unordered_map<string, string> parent;
unordered_map<string, int> size; // 集合大小
public:
void MakeSet(string name) {
parent[name] = name;
size[name] = 1;
}
string Find(string name) {
if (parent[name] != name) {
parent[name] = Find(parent[name]);
}
return parent[name];
}
void Union(string name1, string name2) {
string root1 = Find(name1);
string root2 = Find(name2);
if (root1 == root2) return;
// 小树并入大树
if (size[root1] < size[root2]) {
parent[root1] = root2;
size[root2] += size[root1];
} else {
parent[root2] = root1;
size[root1] += size[root2];
}
}
int GetSize(string name) {
return size[Find(name)];
}
};
应用:社交网络中找最大朋友圈。
7.2 带权并查集
在某些问题中,需要维护元素之间的相对关系(如距离、差值)。
示例:判断亲戚关系的代沟
cpp
class WeightedDisjointSet {
private:
unordered_map<string, string> parent;
unordered_map<string, int> weight; // 到父节点的权值(代差)
public:
void MakeSet(string name) {
parent[name] = name;
weight[name] = 0;
}
string Find(string name) {
if (parent[name] != name) {
string originalParent = parent[name];
parent[name] = Find(parent[name]);
weight[name] += weight[originalParent]; // 路径压缩时累加权值
}
return parent[name];
}
void Union(string name1, string name2, int diff) {
string root1 = Find(name1);
string root2 = Find(name2);
if (root1 == root2) return;
parent[root2] = root1;
weight[root2] = weight[name1] - weight[name2] + diff;
}
int GetDifference(string name1, string name2) {
if (Find(name1) != Find(name2)) {
return -1; // 不在同一集合
}
return weight[name2] - weight[name1];
}
};
应用场景:
- 化学方程式配平
- 差分约束系统
- 区间合并问题
7.3 支持删除操作
标准并查集不支持删除,但可以通过以下方式实现:
方法一:懒惰删除(标记删除)
cpp
unordered_map<string, bool> deleted;
string Find(string name) {
if (deleted[name]) return ""; // 已删除
// ... 正常查找逻辑
}
方法二:重建并查集(适用于删除操作较少的场景)
八、经典例题解析
8.1 LeetCode 547: 省份数量
问题:给定 n 个城市的连接关系矩阵,求有多少个省份(连通分量)。
cpp
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
vector<int> parent(n);
// 初始化
for (int i = 0; i < n; i++) {
parent[i] = i;
}
// 合并连通的城市
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (isConnected[i][j] == 1) {
unionSet(parent, i, j);
}
}
}
// 统计根节点数量
unordered_set<int> roots;
for (int i = 0; i < n; i++) {
roots.insert(find(parent, i));
}
return roots.size();
}
private:
int find(vector<int>& parent, int x) {
if (parent[x] != x) {
parent[x] = find(parent, parent[x]);
}
return parent[x];
}
void unionSet(vector<int>& parent, int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
};
8.2 LeetCode 200: 岛屿数量
问题:给定二维网格,'1' 表示陆地,'0' 表示水,求岛屿数量。
cpp
class Solution {
public:
int numIslands(vector<vector<char>>& grid) {
if (grid.empty()) return 0;
int m = grid.size();
int n = grid[0].size();
DisjointSet ds(m * n);
int waterCount = 0;
// 将二维坐标映射到一维
auto getIndex = [&](int i, int j) { return i * n + j; };
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '0') {
waterCount++;
continue;
}
int index = getIndex(i, j);
// 向右合并
if (j + 1 < n && grid[i][j + 1] == '1') {
ds.unionSet(index, getIndex(i, j + 1));
}
// 向下合并
if (i + 1 < m && grid[i + 1][j] == '1') {
ds.unionSet(index, getIndex(i + 1, j));
}
}
}
return ds.getCount() - waterCount;
}
private:
class DisjointSet {
private:
vector<int> parent;
int count; // 集合数量
public:
DisjointSet(int n) : parent(n), count(n) {
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 unionSet(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
count--; // 集合数量减一
}
}
int getCount() { return count; }
};
};
8.3 LeetCode 684: 冗余连接
问题:给定一个无向图,找出一条可以删除的边,使得删除后图变成一棵树。
cpp
class Solution {
public:
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
vector<int> parent(n + 1);
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
for (const auto& edge : edges) {
int u = edge[0], v = edge[1];
// 如果两个节点已经连通,这条边就是冗余的
if (find(parent, u) == find(parent, v)) {
return edge;
}
unionSet(parent, u, v);
}
return {};
}
private:
int find(vector<int>& parent, int x) {
if (parent[x] != x) {
parent[x] = find(parent, parent[x]);
}
return parent[x];
}
void unionSet(vector<int>& parent, int x, int y) {
parent[find(parent, x)] = find(parent, y);
}
};
九、性能分析与证明
9.1 反阿克曼函数
阿克曼函数 A(m, n):增长极快的函数
text
A(0, n) = n + 1
A(m, 0) = A(m-1, 1)
A(m, n) = A(m-1, A(m, n-1))
增长速度:
- A(1, n) = 2n
- A(2, n) = 2^n
- A(3, n) = 2(2(...^2)) (n 个 2)
- A(4, 3) > 10^19728 (比宇宙中原子数还多)
反阿克曼函数 α(n):阿克曼函数的反函数
text
α(n) = min{k : A(k, k) ≥ n}
实际意义:
- α(10^80) ≈ 4(宇宙原子数)
- 对于任何实际问题,α(n) ≤ 5
9.2 时间复杂度证明(简述)
定理(Tarjan & van Leeuwen, 1984):
- 使用路径压缩 + 按秩合并
- m 次操作(包括 n 次 MakeSet)的总时间:O(m · α(n))
- 均摊每次操作:O(α(n))
直觉理解:
- 按秩合并保证树高 ≤ log n
- 路径压缩使得后续查找更快
- 两者结合产生协同效应
十、实现技巧总结
10.1 数组实现(推荐用于整数索引)
cpp
class UnionFind {
private:
vector<int> parent;
vector<int> rank;
public:
UnionFind(int n) : parent(n), rank(n, 0) {
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 unionSet(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return false;
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
return true;
}
bool connected(int x, int y) {
return find(x) == find(y);
}
};
10.2 哈希表实现(适用于字符串等复杂键)
cpp
class UnionFindMap {
private:
unordered_map<string, string> parent;
unordered_map<string, int> rank;
public:
void makeSet(string x) {
if (parent.find(x) == parent.end()) {
parent[x] = x;
rank[x] = 0;
}
}
string find(string x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
bool unionSet(string x, string y) {
string rootX = find(x);
string rootY = find(y);
if (rootX == rootY) return false;
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
return true;
}
};
十一、常见误区与注意事项
11.1 误区一:路径压缩破坏按秩合并
错误认知:路径压缩会改变树高,使得 rank 失效。
正确理解:
- rank 是树高的 上界估计,不是精确值
- 路径压缩只会让树变矮,不影响优化效果
- 两者结合不会产生冲突
11.2 误区二:先 Union 再 Find 会更慢
错误认知:Union 操作会让树变高,后续 Find 变慢。
正确理解:
- 按秩合并控制树高
- 路径压缩在 Find 时自动优化
- 越用越快,而不是越用越慢
11.3 注意事项
- 初始化:所有元素必须先 MakeSet
- 边界检查:Find 前确认元素存在
- 集合数量:需要额外维护计数器
- 不支持删除:标准并查集不支持拆分操作
- 非递归实现:对于极深的树,可能栈溢出
非递归 Find 实现:
cpp
int find(int x) {
int root = x;
while (parent[root] != root) {
root = parent[root];
}
// 路径压缩
while (parent[x] != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
十二、核心总结
12.1 并查集的本质
并查集是一种 用树形结构维护集合划分 的数据结构:
- 每棵树代表一个集合
- 根节点是集合的代表元素
- 通过父指针连接节点
12.2 核心操作
| 操作 | 作用 | 优化策略 | 时间复杂度 |
|---|---|---|---|
| MakeSet | 初始化单元素集合 | - | O(1) |
| Find | 查找代表元素 | 路径压缩 | O(α(n)) |
| Union | 合并两个集合 | 按秩合并 | O(α(n)) |
12.3 优化技巧
- 路径压缩:Find 时将路径上所有节点直接连到根
- 按秩合并:矮树并入高树
- 按大小合并:小集合并入大集合(链表实现)
12.4 应用场景
- ✅ 连通性判断:两个元素是否在同一集合
- ✅ 动态连通性:动态添加边,查询连通性
- ✅ 最小生成树:Kruskal 算法核心
- ✅ 等价类划分:按关系分组
- ❌ 不支持删除:无法拆分集合
- ❌ 不支持查询元素:无法遍历集合中的所有元素
12.5 形象记忆
刘备的人马管理:
- 初始状态:每个人都是独立队伍(MakeSet)
- 查队长:沿着指挥链向上找(Find)
- 整编:两队合并,选一个队长(Union)
- 路径压缩:查完后直接记住最高领导
- 按秩合并:小队并入大队,减少层级
完整代码仓库:建议将上述所有代码整理成模板,便于刷题时快速调用。
进阶学习:
- 可持久化并查集(支持历史版本查询)
- 动态并查集(支持删除操作)
- 并查集在竞赛中的技巧应用
练习建议:
- LeetCode 标签搜索 "Union Find"
- 力扣题单:并查集专题(约 50 题)
- 《算法竞赛进阶指南》并查集章节
掌握并查集,图论问题解一半!