并查集
并查集 ADT 核心定义
并查集(Disjoint Sets/Union-Find)是一种维护不相交动态集合的数据结构,集合族记为 \(S=\{S_1,S_2,...,S_k\}\),每个集合有一个代表元(leader)
支持三大核心操作:
MakeSet(x):创建仅包含元素 \(x\) 的集合,并将其加入集合族 \(S\)Find(x):返回 \(x\) 所属集合的代表元指针Union(x,y):找到 \(x\) 所属集合 \(S_x\) 和 \(y\) 所属集合 \(S_y\),移除两者并加入其并集
典型应用:计算图的连通分量
链表实现
// 1. 链表元素节点(链表存储集合元素,每个元素指向所属集合)
struct SetNode {
int value; // 元素值
SetNode* next; // 链表下一个节点指针
class DisjointSet* set; // 指向所属的集合对象(用于快速Find)
explicit SetNode(int val) : value(val), next(nullptr), set(nullptr) {}
};
// 2. 并查集集合对象(集合对象含链表头尾指针,维护集合大小)
class DisjointSet {
public:
SetNode* head; // 链表头指针(代表元,链表第一个元素)
SetNode* tail; // 链表尾指针(用于快速追加)
int size; // 集合大小(用于加权合并)
explicit DisjointSet(SetNode* node) : head(node), tail(node), size(1) {
node->set = this; // 元素关联到当前集合
}
};
// 3. 链表实现的并查集管理类
class UnionFindLinkedList {
unordered_map<int, SetNode*> valToNode; // 值到节点的映射,用于快速查找节点
public:
// MakeSet(x)——创建仅含 x 的集合,加入集合族
void MakeSet(int x) {
// 避免重复创建
if (valToNode.find(x) != valToNode.end()) return;
// 1. 创建元素节点
SetNode* newNode = new SetNode(x);
// 2. 创建集合对象(包含该节点)
DisjointSet* newSet = new DisjointSet(newNode);
// 3. 记录值到节点的映射
valToNode[x] = newNode;
}
// Find(x)——返回 x 所属集合的代表元(链表头)
int Find(int x) {
// 检查 x 是否存在
if (valToNode.find(x) == valToNode.end()) {
return -1;
}
// 通过节点的 set 指针直接找到集合,返回表头(代表元)
SetNode* node = valToNode[x];
int leader = node->set->head->value;
return leader;
}
// Union(x,y)(按大小合并)
void UnionWeighted(int x, int y) {
// 1. 查找 x 和 y 所属集合
SetNode* xNode = valToNode[x];
SetNode* yNode = valToNode[y];
if (!xNode || !yNode) {
return;
}
DisjointSet* setX = xNode->set;
DisjointSet* setY = yNode->set;
if (setX == setY) {
return;
}
// 2. 确保 setX 是更大的集合(小集合追加到大链表,减少指针更新次数)
if (setX->size < setY->size) {
swap(setX, setY); // 交换后setX始终更大
}
// 3. 合并操作
setX->tail->next = setY->head;
setX->tail = setY->tail;
setX->size += setY->size;
// 4. 更新小集合 setY 的所有元素指针
SetNode* curr = setY->head;
while (curr) {
curr->set = setX;
curr = curr->next;
}
// delete setY; // 5. 释放setY
}
}
复杂度分析:
MakeSet(x):\(O(1)\)Find(x):\(O(1)\)Union(x,y):\(O(lgn)\)
考虑如下伪代码
MakeSet(x0)
for i := 1 to n
MakeSet(xi)
Union(xi, x0)
如果不使用加权合并,时间复杂度为 \(O(n^2)\),单次平均时间复杂度为 \(O(n)\)
使用加权合并后,时间复杂度优化为 \(O(n)\),单次平均时间复杂度为 \(O(1)\)
平均为 \(O(logn)\),关键证明:每个元素的集合指针最多更新 \(O(logn)\)次,因每次更新后所在集合大小至少翻倍
有根树实现
注意,这里的树不是二叉树
class UnionFindTreeBasic {
unordered_map<int, int> parent; // 节点到父节点的映射(每个节点有父指针)
public:
// MakeSet(x)——创建仅含x的树,x的父是自身(根节点)
void MakeSet(int x) {
if (parent.contains(x)) return;
parent[x] = x; // 根节点的父是自己
}
// Find(x)——迭代找根(避免递归栈溢出)
int Find(int x) {
if (!parent.contains(x)) {
return -1;
}
// 从x向上遍历,直到父节点等于自身(根)
int curr = x;
while (parent[curr] != curr) {
curr = parent[curr];
}
return curr;
}
// Union(x,y)——将x的根挂到y的根下(无优化)
void Union(int x, int y) {
int rootX = Find(x);
int rootY = Find(y);
if (rootX == -1 || rootY == -1 || rootX == rootY) {
return;
}
// 将rootX的父设为rootY(简单合并,可能形成链)
parent[rootX] = rootY;
}
};
考虑到这种情况下树有可能退化为链表,故有以下两种避免成链表的第一层优化:
- 按大小合并
- 按高度合并
优化 1:按大小合并
class UnionFindTreeBySize {
unordered_map<int, int> parent; // 节点到父节点
unordered_map<int, int> size; // 根节点到树的大小(按大小合并需维护树大小)
public:
void MakeSet(int x) {
if (parent.contains(x)) return;
parent[x] = x; // 根节点
size[x] = 1; // 初始树大小为1
}
int Find(int x) {
// 同上
}
// 按大小合并——小棵树挂到大连树根下,减少树深
void Union(int x, int y) {
int rootX = Find(x);
int rootY = Find(y);
if (rootX == -1 || rootY == -1 || rootX == rootY) {
return;
}
// 小棵树挂到大连树根下
if (size[rootX] < size[rootY]) {
parent[rootX] = rootY; // rootX 挂到 rootY 下
size[rootY] += size[rootX]; // 更新 rootY 的树大小
} else {
parent[rootY] = rootX; // rootY 挂到 rootX 下
size[rootX] += size[rootY];
}
}
};
优化 2:按高度合并
class UnionFindTreeByHeight {
unordered_map<int, int> parent; // 节点到父节点
unordered_map<int, int> height; // 根节点到树的高度
public:
void MakeSet(int x) {
if (parent.contains(x)) return;
parent[x] = x; // 根节点
height[x] = 0; // 初始树高为 0(单节点树)
}
int Find(int x) {
// 同上
}
// 按高度合并——矮树挂到高树根下,高度相同才更新高度
void Union(int x, int y) {
int rootX = Find(x);
int rootY = Find(y);
if (rootX == -1 || rootY == -1 || rootX == rootY) {
return;
}
// 矮树挂到高树根下
if (height[rootX] < height[rootY]) {
parent[rootX] = rootY; // rootX 挂到 rootY 下,rootY 高度不变
} else {
parent[rootY] = rootX; // rootY 挂到 rootX 下
// 若高度相同,合并后 rootX 高度 +1
if (height[rootX] == height[rootY]) {
height[rootX]++;
}
}
}
};
这两种优化方式均将 Find 和 Union 的最坏情况时间复杂度优化至 \(O(logn)\)
那么还有无更好的优化方式?
Union 更改父节点的操作非常快,瓶颈还是在于 Find,若能进一步加速 Find 操作将可以得到更好的性能
路径压缩 + 按秩合并
class UnionFindTreeFinal {
unordered_map<int, int> parent; // 节点到父节点
unordered_map<int, int> rank; // 节点到秩(秩是高度的上界,路径压缩后无需维护真实高度)
public:
// MakeSet(x)——根节点父为自身,秩初始化为 0
void MakeSet(int x) {
if (parent.contains(x)) return;
parent[x] = x;
rank[x] = 0;
}
// Find(x)——带路径压缩(扁平化树结构)
// 原来 1→2→3→4
// 压缩后 1→4、2→4、3→4
// 下一次查找就一步到位
int Find(int x) {
if (!parent.contains(x)) {
return -1;
}
// 递归实现路径压缩:找到根后,将当前节点的父直接设为根
if (parent[x] != x) {
parent[x] = Find(parent[x]); // 核心:父节点指向根,扁平化树
}
return parent[x];
}
// Union(x,y)——按秩合并(低秩树挂到高秩树根下)
void Union(int x, int y) {
int rootX = Find(x);
int rootY = Find(y);
if (rootX == -1 || rootY == -1 || rootX == rootY) {
return;
}
// 按秩合并:低秩挂到高秩下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY; // rootX挂到rootY下,rootY秩不变
} else {
parent[rootY] = rootX; // rootY挂到rootX下
// 若秩相等,合并后rootX秩+1
if (rank[rootX] == rank[rootY]) {
rank[rootX]++;
}
}
}
};
使用路径压缩后,发现树高属性不在适用,故引出 “秩” 属性
秩是高度的上界,路径压缩后无需维护真实高度
哈希表与数组
unordered_map<int, int> parent;
unordered_map<int, int> rank;
哈希表灵活性非常大,尤其是 Find 操作对无法找到的特判,但这也拖累了性能
如果对于预先知道操作对象而不需要特判的情况,可以采用 vector 实现,性能会得到大幅提升
class DisjointSets
{
vector<int> parent;
vector<int> rank;
public:
explicit DisjointSets(int n) // 构造函数,初始化容量
{
parent.resize(n);
rank.assign(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];
}
void Union(int x, int y)
{
int xRoot = Find(x);
int yRoot = Find(y);
if (xRoot == yRoot)
{
return;
}
if (rank[xRoot] > rank[yRoot])
{
parent[yRoot] = xRoot;
}
else
{
parent[xRoot] = yRoot;
if (rank[xRoot] == rank[yRoot])
{
rank[yRoot]++;
}
}
}
};
哈希表 VS 动态数组:
- 优先选 vector 的场景:
- 节点是 非负整数,且范围已知、连续(如 0~n-1)
- 追求极致性能(如大规模数据、频繁 Find/Union)
- 节点密度高(几乎所有编号都被使用)
- 优先选 unordered_map 的场景:
- 节点是 稀疏分布(如随机 ID、零散整数)或范围未知
- 节点编号可能是负数、字符串等非连续非负整数
- 无需预先确定节点总数,动态添加场景多
标题:并查集
作者:Zwing
创建于:2026-01-16 19:03:00
更新于:2026-08-07 16:17:52
链接:https://zanytriumph.github.io/posts/并查集.html
版权声明:本文章采用 CC BY-NC-SA 4.0 进行许可