并查集是一种树形的数据结构,顾名思义,它用于处理一些不交集的 合并 及 查询 问题。
它支持两种操作:
- 查找(find):确定某个元素处于哪个子集;
- 合并(join):将两个子集合并成一个集合。
*也就是说,不支持集合的分离、删除。
查找(find)
几个家族进行宴会,但是家族普遍长寿,所以人数众多。由于长时间的分离以及年龄的增长,这些人逐渐忘掉了自己的亲人,只记得自己的爸爸是谁了,而最长者(称为「祖先」)的父亲已经去世,他只知道自己是祖先。为了确定自己是哪个家族,他们想出了一个办法,只要问自己的爸爸是不是祖先,一层一层的向上问,直到问到祖先。如果要判断两人是否在同一家族,只要看两人的祖先是不是同一人就可以了。来自oi_wiki
int fa[MAXN]; //记录某个人的爸爸是谁,特别规定,祖先的爸爸是他自己
int find(int x) {
//寻找x的祖先
if (fa[x] == x) //如果x是祖先则返回
return x;
else
return find(fa[x]); //如果不是则x的爸爸问x的爷爷
}
合并(jion or union )
宴会上,一个家族的祖先突然对另一个家族说:我们两个家族交情这么好,不如合成一家好了。另一个家族也欣然接受了。
我们之前说过,并不在意祖先究竟是谁,所以只要其中一个祖先变成另一个祖先的儿子就可以了。(来自oi_wiki)
void unionSet(int x, int y) {
// x与y所在家族合并
x = find(x);
y = find(y);
if (x == y) //原本就在一个家族里就不管了
return;
fa[x] = y; //把x的祖先变成y的祖先的儿子
}
路径压缩
这样的确可以达成目的,但是显然效率实在太低。为什么呢?因为我们使用了太多没用的信息,我的祖先是谁与我父亲是谁没什么关系,这样一层一层找太浪费时间,不如我直接当祖先的儿子,问一次就可以出结果了。甚至祖先是谁都无所谓,只要这个人可以代表我们家族就能得到想要的效果。 把在路径上的每个节点都直接连接到根上 ,这就是路径压缩。
此处给出一种 C++ 的参考实现:
int find(int x) {
if (x != fa[x]) // x不是自身的父亲,即x不是该集合的代表
fa[x] = find(fa[x]); //查找x的祖先直到找到代表,于是顺手路径压缩
return fa[x];
}
简易写法:
int find(int x) {
if (x != fa[x]) // x不是自身的父亲,即x不是该集合的代表
return fa[x]=find(fa[x]);
}
以下内容我也不会直接复制到,出处在下面
启发式合并(按秩合并)
一个祖先突然抖了个机灵:「你们家族人比较少,搬家到我们家族里比较方便,我们要是搬过去的话太费事了。」
由于需要我们支持的只有集合的合并、查询操作,当我们需要将两个集合合二为一时,无论将哪一个集合连接到另一个集合的下面,都能得到正确的结果。但不同的连接方法存在时间复杂度的差异。具体来说,如果我们将一棵点数与深度都较小的集合树连接到一棵更大的集合树下,显然相比于另一种连接方案,其期望复杂度更优(也会带来更优的最坏复杂度)。
当然,我们不总能遇到恰好如上所述的集合————点数与深度都更小。鉴于点数与深度这两个特征都很容易维护,我们常常从中择一,作为估价函数。而无论选择哪一个,时间复杂度都为 (Theta (malpha(m,n))),具体的证明可参见 References 中引用的论文。
在算法竞赛的实际代码中,即便不使用启发式合并,代码也往往能够在规定时间内完成任务。在 Tarjan 的论文[1]中,证明了不使用启发式合并、只使用路径压缩的最坏时间复杂度是 (Theta (m log n))。在姚期智的论文[2]中,证明了不使用启发式合并、只使用路径压缩,在平均情况下,时间复杂度依然是 (Theta (malpha(m,n)))。
此处给出一种 C++ 的参考实现,其选择深度作为估价函数:
int size[N]; //记录子树的大小
void unionSet(int x, int y) {
int xx = find(x), yy = find(y);
if (xx == yy) return;
if (size[xx] > size[yy]) //保证小的合到大的里
swap(xx, yy);
fa[xx] = yy;
size[yy] += size[xx];
}
例题
P1536 村村通
题目链接:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cmath>
#include<queue>
#include<stack>
#include<vector>
#include<map>
#include<string>
#include<cstring>
#define ll long long int
using namespace std;
const int maxn=999999999;
const int minn=-999999999;
inline int read() {
char c = getchar(); int x = 0, f = 1;
while(c < '0' || c > '9') {if(c == '-') f = -1; c = getchar();}
while(c >= '0' && c <= '9') x = x * 10 + c - '0', c = getchar();
return x * f;
}
int pre[1000001];
int find(int x)
{
if(pre[x]!=x)
{
pre[x]=find(pre[x]);
}
return pre[x];
}
void join(int x,int y)
{
int a=find(x);
int b=find(y);
if(a!=b)
{
pre[a]=b;
}
}
int n,m;
int calc()
{
int js=0;
for(int i=1;i<=n;++i)
{
if(pre[i]==i)
{
js++;
}
}
return js;
}
int main()
{
while(cin>>n>>m&&n!=0)
{
for(int i=1;i<=n;++i) pre[i]=i;
for(int i=1;i<=m;++i)
{
int u,v,w;
cin>>u>>v;
join(u,v);
}
cout<<calc()-1<<"
";
}
return 0;
}
参考:
以上内容来自oi_wiki
- [1]Tarjan, R. E., & Van Leeuwen, J. (1984). Worst-case analysis of set union algorithms. Journal of the ACM (JACM), 31(2), 245-281. ResearchGate PDF
- [2]Yao, A. C. (1985). On the expected performance of path compression algorithms. SIAM Journal on Computing, 14(1), 129-133.
-# [3]知乎回答:是否在并查集中真的有二分路径压缩优化? 这个挺好玩的可以看看,原来知乎不只是用来颓废的