算法学习笔记(18)——并查集(Disjoint-Set)
并查集(Disjoint-Set)
并查集(Disjoint-Set)是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。详细地说,并查集包括如下两个基本操作:
- Get,查询一个元素属于哪个集合。
- Merge,把两个集合合并成一个大集合。
为了具体实现并查集这种数据结构,我们首先需要定义集合的表示方法。在并查集中,我们采用“代表元”法,即为每个集合选择一个固定的元素,作为整个集合的“代表”。
其次,我们需要定义归属关系的表示方法。第一种思路是维护一个数组 \(f\) ,用 \(f[x]\) 保存元素 \(x\) 所在集合的“代表”。这种方法可以快速查询元素的归属集合,但在合并时需要修改大量元素的 \(f\) 值,效率很低。第二种思路是使用一个树形结构存储每个集合,树上的每个节点都是一个元素,树根是集合的代表元素。整个并查集实际上是一个森林(若干棵树)。我们仍然可以维护一个数组 \(p\) 来记录这个森林,用 \(p[x]\) 保存 \(x\) 的父节点。特别地,令树根的 \(p\) 值为它自己。这样一来,在合并两个集合时,只需连接两个树根(令其中一个树根为另一个树根的子节点,即 \(p[root_1] = root_2\) )。不过在查询元素的归属时,需要从该元素开始通过 \(p\) 存储的值不断递归访问父节点,直至达到树根。为了提高查询效率,并查集引入了路径压缩与按秩合并两种思想。由于按秩合并的优化效果并不明显,本文仅讨论路径压缩这一优化策略。
我们注意到,第一种思路(直接用数组 f 保存代表)的查询效率很高,第二种思路的合并效率很高,所以不妨将两者结合起来。实际上,我们只关心每个集合对应的“树形结构”的根节点是什么,并不关心这棵树的具体形态,这意味着下面两棵树是等价的。
因此,我们可以在每次执行 Get 操作的同时,把访问过的每个节点(也就是查询过的元素的全部祖先)都直接指向树根,即把上图中左边那棵树变成右边那棵。这种优化方法被称为路径压缩。采用路径压缩优化的并查集,每次 Get 操作的均摊复杂度为 \(O(\log n)\)。
模板代码:
- 并查集的存储:使用一个数组 p 保存父节点(根的父节点设为自己)。
int p[N];
- 并查集的初始化:设有n个元素,起初所有元素各自构成一个独立的集合,即有 n 棵 1 个点的树。
for (int i = 1; i <= n; i ++ ) p[i] = i;
- 并查集的 Get 操作:若 x 是树根,则 x 就是集合代表,否则递归访问 p[x] 直至根节点。
int get(int x) { if (p[x] != x) p[x] = find(p[x]); return p[x]; }
- 并查集的 Merge 操作:合并元素 x 和元素 y 所在的集合,等价于让 x 的树根作为 y 的树根的子节点。
void merge(int x, int y) { p[find(x)] = find(y); }
#include <iostream>
using namespace std;
const int N = 1e5 + 10;
int n, m;
int p[N];
int get(int x)
{
if (p[x] != x) p[x] = get(p[x]);
return p[x];
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i ++ ) p[i] = i;
while (m -- ) {
char op;
int a, b;
cin >> op >> a >> b;
if (op == 'M') p[get(a)] = get(b);
else
if (get(a) == get(b)) puts("Yes");
else puts("No");
}
return 0;
}