9.6.1 ACM-ICPC 数据结构:并查集
并查集简介
并查集(Union-Find 或 Disjoint Set Union)是一种用于管理不相交集合的数据结构,主要支持两种操作:合并(Union) 和 查找(Find)。它在解决连通性问题、图论问题(如最小生成树的 Kruskal 算法)以及其他需要动态连通性维护的场景中有着广泛应用。
并查集的核心思想是通过树结构表示集合中的元素,并通过路径压缩和按秩合并等优化手段提升操作效率。本文将介绍并查集的基本概念、操作及优化方法,并结合实际例子进行说明。
并查集的基本操作
- 查找(Find):查找元素所属的集合。通过在树结构中查找元素的根节点,我们可以确定该元素所属的集合。
- 合并(Union):将两个不同的集合合并为一个集合。该操作将两个树的根节点连接起来,形成一个新的树。
示例
假设有以下几个集合:
- {1}
- {2}
- {3}
- {4}
执行 Union(1, 2)
后,集合变为 {1, 2} 和 {3}, {4}。此时如果查询 Find(1)
或 Find(2)
,返回的结果都应该是它们的公共根节点。
并查集的优化策略
在基本的并查集操作中,每次 Find
操作都需要遍历树,直到找到根节点,这在最坏情况下可能会退化为线性时间复杂度。为了提高效率,我们可以采用以下两种优化策略:
-
路径压缩(Path Compression): 在执行
Find
操作时,通过将路径上所有节点直接指向根节点,从而大幅减少后续操作的路径长度。路径压缩的作用是在进行查找时“压缩”树的高度,从而加速未来的查找操作。 -
按秩合并(Union by Rank): 在执行
Union
操作时,将较小的树合并到较大的树上,从而避免树的高度过大。这里的“秩”可以理解为树的高度或节点数量,通过按秩合并可以有效控制树的高度。
代码示例
以下是使用路径压缩和按秩合并优化的并查集实现:
class UnionFind {
public:
vector<int> parent, rank;
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 0);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int u) {
if (parent[u] != u) {
parent[u] = find(parent[u]); // 路径压缩
}
return parent[u];
}
void unionSets(int u, int v) {
int rootU = find(u);
int rootV = find(v);
if (rootU != rootV) {
if (rank[rootU] > rank[rootV]) {
parent[rootV] = rootU;
} else if (rank[rootU] < rank[rootV]) {
parent[rootU] = rootV;
} else {
parent[rootV] = rootU;
rank[rootU]++;
}
}
}
};
并查集的应用
- Kruskal 算法:并查集在 Kruskal 最小生成树算法中用于判断边的两个端点是否属于同一集合。每次加入一条边时,通过
Find
操作检查两个端点是否连通,如果不连通则通过Union
操作合并两个集合。 - 动态连通性问题:在图论问题中,需要不断地查询和更新节点间的连通性,并查集可以高效地支持这一操作。
- 等价类问题:并查集可以用于处理等价类问题,将具有某种等价关系的元素划分为若干个不相交的集合。
个人见解与总结
并查集作为一种经典的数据结构,在解决图论和集合操作问题中有着不可替代的作用。通过路径压缩和按秩合并优化后的并查集,其时间复杂度接近于常数时间,能够高效地处理大量动态操作。对于学习算法竞赛或从事算法开发的人员来说,掌握并查集的实现与优化是非常必要的。
在实际应用中,并查集的概念非常直观,但要理解和实现其中的优化策略,需要对树结构的操作有较为深入的理解。通过反复练习和实际应用,可以更好地掌握这一强大的数据结构。
并查集:高级操作与应用
引入
并查集(Union-Find)是一种用于管理元素所属集合的数据结构,其核心思想是通过树形结构表示集合中的元素,并通过两种基本操作来维护集合的合并与查询:
- 合并(Union):合并两个元素所属的集合,将对应的树合并。
- 查询(Find):查询某个元素所属的集合,查找树的根节点。
并查集在很多算法竞赛和图论问题中都有广泛应用,特别是在处理连通性问题时极为高效。虽然并查集原本不支持低复杂度的集合分离操作,但通过一些扩展,可以实现如删除、移动元素等功能,甚至可以结合其他数据结构实现可持久化并查集。
并查集的初始化
初始状态下,每个元素都在一个独立的集合中,表示为一棵只有根节点的树。为了方便后续操作,我们通常将每个节点的父节点初始化为它自己。
struct dsu {
vector<size_t> pa;
explicit dsu(size_t size) : pa(size) {
iota(pa.begin(), pa.end(), 0); // 初始化每个元素为自身的父节点
}
};
查询操作(Find)
Find
操作通过查找元素所属集合的根节点来实现。具体方法是不断访问当前节点的父节点,直到找到根节点。这个操作的实现很直观,但在深度较大的树中可能会导致效率低下。
size_t dsu::find(size_t x) {
return pa[x] == x ? x : find(pa[x]); // 递归查找根节点
}
路径压缩
路径压缩是一种优化策略。在执行 Find
操作时,将路径上所有访问到的节点直接连接到根节点,从而减少后续查询的路径长度。这种优化大大提升了查询效率,使得后续的 Find
操作接近常数时间复杂度。
size_t dsu::find(size_t x) {
return pa[x] == x ? x : pa[x] = find(pa[x]); // 路径压缩
}
合并操作(Union)
Union
操作将两个不同的集合合并为一个集合,具体实现为将两个集合的根节点相连。最简单的方式是直接将一个集合的根节点指向另一个集合的根节点。
void dsu::unite(size_t x, size_t y) {
pa[find(x)] = find(y); // 合并两个集合
}
启发式合并(Union by Rank)
合并操作时选择哪棵树的根节点作为新树的根节点,直接影响未来操作的效率。启发式合并策略建议将节点较少或深度较小的树合并到另一棵树,以减少树的高度。这种策略称为“按秩合并”,有效防止树的退化。
struct dsu {
vector<size_t> pa, size;
explicit dsu(size_t size_) : pa(size_), size(size_, 1) {
iota(pa.begin(), pa.end(), 0); // 初始化父节点和每棵树的大小
}
void unite(size_t x, size_t y) {
x = find(x), y = find(y);
if (x == y) return;
if (size[x] < size[y]) swap(x, y);
pa[y] = x;
size[x] += size[y];
}
};
删除操作(Erase)
在某些场景下,我们需要从并查集中删除元素。删除操作可以通过将该节点的父节点设为它自己来实现。为了确保删除的元素是叶子节点,我们可以预先为每个节点制作副本,并将副本作为父节点。
struct dsu {
vector<size_t> pa, size;
explicit dsu(size_t size_) : pa(size_ * 2), size(size_ * 2, 1) {
iota(pa.begin(), pa.begin() + size_, size_); // 初始化副本
iota(pa.begin() + size_, pa.end(), size_);
}
void erase(size_t x) {
--size[find(x)]; // 更新集合大小
pa[x] = x; // 将删除的元素父节点设为自己
}
};
移动操作(Move)
移动操作与删除操作类似,通过将元素的父节点设为目标集合的根节点,实现元素从一个集合移动到另一个集合。
void dsu::move(size_t x, size_t y) {
auto fx = find(x), fy = find(y);
if (fx == fy) return; // 如果元素已经在目标集合,直接返回
pa[x] = fy; // 移动元素
--size[fx], ++size[fy]; // 更新集合大小
}
并查集的复杂度
通过路径压缩和启发式合并的优化,并查集的每个操作的平均时间复杂度为 O(α(n))O(\alpha(n))O(α(n)),其中 α\alphaα 是阿克曼函数的反函数,它的增长极其缓慢,因此在实际应用中可以认为并查集的操作几乎是常数时间的。
带权并查集
除了基本的合并和查询操作,带权并查集还能在集合的边上定义权值,通过路径压缩时的运算来解决更多复杂的问题。例如,在经典的“食物链”问题中,我们可以在并查集上维护模 3 意义下的加法群。
例题:UVa11987 Almost Union-Find
在此题目中,我们需要实现类似并查集的数据结构,支持以下操作:
- 合并两个元素所属集合;
- 移动单个元素;
- 查询某个元素所属集合的大小及元素和。
#include <bits/stdc++.h>
using namespace std;
struct dsu {
vector<size_t> pa, size, sum;
explicit dsu(size_t size_)
: pa(size_ * 2), size(size_ * 2, 1), sum(size_ * 2) {
// size 与 sum 的前半段没有使用,简化下标计算
iota(pa.begin(), pa.begin() + size_, size_); // 初始化父节点
iota(pa.begin() + size_, pa.end(), size_); // 初始化父节点副本
iota(sum.begin() + size_, sum.end(), 0); // 初始化元素和
}
void unite(size_t x, size_t y) {
x = find(x), y = find(y);
if (x == y) return; // 如果两个元素已经在同一集合,直接返回
if (size[x] < size[y]) swap(x, y); // 按大小合并,保持树平衡
pa[y] = x; // 合并
size[x] += size[y]; // 更新合并后集合的大小
sum[x] += sum[y]; // 更新合并后集合的元素和
}
void move(size_t x, size_t y) {
auto fx = find(x), fy = find(y);
if (fx == fy) return; // 如果元素已经在目标集合,直接返回
pa[x] = fy; // 移动元素
--size[fx], ++size[fy]; // 更新集合大小
sum[fx] -= x, sum[fy] += x; // 更新元素和
}
size_t find(size_t x) {
return pa[x] == x ? x : pa[x] = find(pa[x]); // 路径压缩
}
};
int main() {
size_t n, m, op, x, y;
while (cin >> n >> m) {
dsu dsu(n + 1); // 初始化并查集,元素范围是 1..n
while (m--) {
cin >> op;
switch (op) {
case 1:
cin >> x >> y;
dsu.unite(x, y); // 合并两个元素所在的集合
break;
case 2:
cin >> x >> y;
dsu.move(x, y); // 移动元素
break;
case 3:
cin >> x;
x = dsu.find(x); // 查找元素所在的集合
cout << dsu.size[x] << ' ' << dsu.sum[x] << '\n'; // 输出集合大小和元素和
break;
default:
assert(false); // 不可达代码,防止非法操作
}
}
}
}
总结
并查集是一种高效且强大的数据结构,尤其在解决动态集合管理和连通性问题时极为出色。通过路径压缩和按秩合并的优化策略,使得并查集的操作非常高效。进一步扩展并查集,还可以实现删除、移动操作,以及带权并查集等高级功能。掌握并查集的使用和优化,是提升算法竞赛和实际编程能力的关键步骤。