并查集

浅谈并查集

并查集

并查集(冰茶姬DisjointSetDisjoint-Set)是一种树形的数据结构,它可以处理一些不交集的合并以及查询问题。主要为两种操作:

查找(Find):确定某个元素处于哪个子集;

合并(Merge):将两个子集合并成一个集合。

初始化

1
2
3
4
5
void makeSet(int size) 
{
for (int i = 0; i < size; i++) fa[i] = i; // i就在它本身的集合里
return;
}

查找

通俗地讲一个故事:几个家族进行宴会,但是家族普遍长寿,所以人数众多。由于长时间的分离以及年龄的增长,这些人逐渐忘掉了自己的亲人,只记得自己的爸爸是谁了,而最长者(称为「祖先」)的父亲已经去世,他只知道自己是祖先。为了确定自己是哪个家族,他们想出了一个办法,只要问自己的爸爸是不是祖先,一层一层的向上问,直到问到祖先。如果要判断两人是否在同一家族,只要看两人的祖先是不是同一人就可以了。

在这样的思想下,并查集的查找算法诞生了。

1
2
3
4
5
6
7
8
9
int fa[MAXN];  // 记录某个人的爸爸是谁,特别规定,祖先的爸爸是他自己
int find(int x)
{
// 寻找x的祖先
if (fa[x] == x) // 如果x是祖先则返回
return x;
else
return find(fa[x]); // 如果不是则x的爸爸问x的爷爷
}

路径压缩

一层一层的找父亲效率太低了,所以我们直接把在路径上的每个节点都直接连接到根上,这就是路径压缩。

1
2
3
4
5
6
int find(int x) 
{
if (x != fa[x]) // x不是自身的父亲,即x不是该集合的代表
fa[x] = find(fa[x]); // 查找x的祖先直到找到代表,于是顺手路径压缩
return fa[x];
}

合并

宴会上,一个家族的祖先突然对另一个家族说:我们两个家族交情这么好,不如合成一家好了。另一个家族也欣然接受了。

我们之前说过,并不在意祖先究竟是谁,所以只要其中一个祖先变成另一个祖先的儿子就可以了。

1
2
3
4
5
6
7
void MergeSet(int x, int y) 
{
// x 与 y 所在家族合并
x = find(x);
y = find(y);
fa[x] = y; // 把 x 的祖先变成 y 的祖先的儿子
}

启发式合并

(奇技淫巧)

在合并集合时,无论将哪一个集合连接到另一个集合的下面,都能得到正确的结果。但不同的连接方法存在时间复杂度的差异。

所以合并时利用点数和深度的估价函数来降低时间复杂度

“秩”:树的深度(未路径压缩) / 集合大小 。均摊复杂度 O(logN)O(logN)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//记录并初始化子树的大小为 1
void MergeSet(int x, int y)
{
int x=find(x), y=find(y);
if (x==y) return;
if (size[x] > size[y]) // 保证小的合到大的里
swap(x, y);
fa[x] = y;
size[y] += size[x];
}//按大小合并

int depth[maxn];// 深度
void MergeSet(int x, int y)
{
int x=find(x),y=find(y)
if(depth[x]<depth[y])fa[x]=y;
if(depth[x]>depth[y])fa[y]=x;
if(depth[x]==depth[y])
{
depth[y]++;
fa[x]=y;
}// 深度小的合并到深度大的集合里
}//按秩合并

同时采用 “路径压缩” 和 “按秩合并” 优化的并查集, 每次Get操作复杂度可进一步降低到O(α(N))O(α(N))(一个比对数函数增长还慢的函数,对于N221019729\forall N \leqslant 2^{2^{10^{19729}}},都有α(N)<5\alpha(N)<5,故α(N)\alpha(N),可近似看成一个常数,由著名计算机科学家R.E.Tarjan于1975年发表的论文中给出了证明)。

带权并查集

并查集其实就是一个森林,我们可以在树上的每条边上记录一个权值,维护一个数组dd,用d[x]d[x]保存节点xx到父节点fa[x]fa[x]之间的边权,在路径压缩的同时不断更新dd数组。

1
2
3
4
5
6
7
int find(int x)
{
if(x==fa[x])return x;
int root=find(fa[x]); // 求集合代表
d[x]+=d[fa[x]]; // 边权求和,维护d数组
return fa[x]=root; // 路径压缩
}

并查集的应用

  • 并查集能在一张无向图中维护节点之间的连通性,这是并查集的一个基本用途,实际上,并查集可以动态维护具有传递性的关系
  • 最小生成树算法中的KruskalKruskal和最近公共祖先中的TarjanTarjan算法都是基于并查集的算法。

例题🚀️

这道题呢就是用到了带权并查集,在本题中我们可以把每两号相邻的战舰之间的权值看为11

两号战舰之间的战舰数目,其实就是第ii号战舰的深度与第jj号战舰的深度的差的绝对值-11

并且我们还需要用一个sizesize数组去存每个集合的大小,去更新每个点的深度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include<bits/stdc++.h>
using namespace std;
int T,f[30010],dep[30010],size[30010];
int find(int x)
{
if(x==f[x])return x;
int fn=find(f[x]);
dep[x]+=dep[f[x]];// 更新权值
return f[x]=fn;
}// 查找集合代表
void Union(int x,int y)
{
x=find(x);
y=find(y);
dep[x]+=size[y];
f[x]=y;
size[y]+=size[x];
size[x]=0;// 这一列上已经没有战舰
return;
}// 合并
int main()
{
scanf("%d",&T);
for(int i=1;i<=30000;i++)f[i]=i,size[i]=1;// 初始化每个集合的代表为自己,每一列上只有一艘战舰
while(T!=0)
{
T--;
char a;int b,c;
cin>>a>>b>>c;
if(a=='M')Union(b,c);
if(a=='C')
{
if(find(b)!=find(c))printf("-1\n");// 不在同一列,输出-1
else
printf("%d\n",abs(dep[b]-dep[c])-1);// 计算有多少艘战舰

}
}
return 0;
}

这是一道枚举加并查集,首先我们可以先把每条边按速度从大到小排序然后去枚举最大边和最小边,使速度比最小。

枚举的同时不断加边,用并查集来判断起点和终点是否联通,输出时不要忘记分子分母同除最大公因数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include<bits/stdc++.h>
using namespace std;
const int maxm=5e3+10;
int n,m,s,t,f[510],xl,yl;
double Min=2147483647;
struct node{
int u,v,c;
};// 结构体存边
node edge[maxm];
int _find(int x)
{
if(x==f[x])return x;
return f[x]=_find(f[x]);
}// 查找集合代表
void _union(int x,int y)
{
x=_find(x);
y=_find(y);
f[x]=y;
return;
}// 合并
int cmp(node x,node y)
{
return x.c>y.c;
}// 排序
int gcd(int x,int y)
{
if(y==0)return x;
return gcd(y,x%y);
}// 求最大公因数,辗转相除法
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)f[i]=i;
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&edge[i].u,&edge[i].v,&edge[i].c);
_union(edge[i].u,edge[i].v);
}
scanf("%d%d",&s,&t);
if(_find(s)!=_find(t))
{
printf("IMPOSSIBLE");
return 0;
}// 如果把所有边加入后起点终点不连通,输出IMPOSSIBLE
sort(edge+1,edge+m+1,cmp);
for(int i=1;i<=m;i++)// 枚举最大边
{
for(int j=1;j<=n;j++)f[j]=j;
for(int j=i;j<=m;j++)// 枚举最小边
{
_union(edge[j].u,edge[j].v);
if(_find(s)==_find(t))
{
double tim=(1.0*edge[i].c)/(1.0*edge[j].c);
if(tim<Min)
{
xl=edge[i].c;
yl=edge[j].c;
Min=tim;// 找最小速度比
}
break;
}
}
}
if(gcd(xl,yl)==yl)cout<<xl/yl;
else cout<<xl/gcd(xl,yl)<<'/'<<yl/gcd(xl,yl);// 输出
return 0;
}

题目读起来很简单,只需要先用并查集处理是等于的约束条件,之后在处理不等于的条件,如果不等于的两个数在同一联通块就输出NONO,否则输出YESYES

但是本题的数据范围过大,无法把输入的xx作为数组下标存储,所以我们需要用到离散化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+10;
int t,f[maxn],book[maxn*2];
struct node{
int a,b,e;
};
int Find(int x)
{
if(x==f[x])return x;
return f[x]=Find(f[x]);
}
void Union(int x,int y)
{
x=Find(f[x]);
y=Find(f[y]);
f[x]=y;
return;
}
int cmp(node x,node y)
{
return x.e>y.e;
}
int main()
{
scanf("%d",&t);
while(t!=0)
{
t--;
int n,tot=0;
scanf("%d",&n);
node p[maxn];
for(int i=1;i<=n;i++)
{
scanf("%d%d%d",&p[i].a,&p[i].b,&p[i].e);
book[++tot]=p[i].a;
book[++tot]=p[i].b;
}
sort(book+1,book+tot+1);
int indx=unique(book+1,book+tot+1)-book-1;
for(int i=1;i<=n;i++)
{
p[i].a=lower_bound(book+1,book+indx+1,p[i].a)-book;
p[i].b=lower_bound(book+1,book+indx+1,p[i].b)-book;
}
bool k=true;
for(int i=1;i<=indx;i++)f[i]=i;
sort(p+1,p+n+1,cmp);
for(int i=1;i<=n;i++)
{
if(p[i].e==1)
Union(p[i].a,p[i].b);
else
{
if(Find(p[i].a)==Find(p[i].b))
{
cout<<"NO"<<endl;
k=false;
break;
}
}
}
if(k==true)cout<<"YES"<<endl;
}
return 0;
}

这是一道种类并查集,需要分析清楚AA种群,BB种群,CC种群之间的关系。

首先这三个种群之间的关系只有同类猎物天敌,这三种,所以我们可以开一个三倍的并查集,一倍存同类,二倍存猎物,三倍存天敌,然后不断去判断就好了,具体看代码注释(用到了拓展域的并查集)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e4+10;
int n,k,f[maxn*3],ans;
int Find(int x)
{
if(x==f[x])return x;
return f[x]=Find(f[x]);
}
void Union(int x,int y)
{
f[Find(f[x])]=Find(f[y]);
return;
}
int main()
{
scanf("%d%d",&n,&k);
for(int i=1;i<=n*3;i++)f[i]=i;
for(int i=1;i<=k;i++)
{
int flag,x,y;
scanf("%d%d%d",&flag,&x,&y);
if(x>n||y>n)
{
ans++;continue;
}// 如果不在当前食物链范围内,就是假话
if(flag==1)
{
if(Find(x+n)==Find(y)||Find(x+2*n)==Find(y))
{
ans++;continue;
}// 如果x是y的猎物或天敌,为假话
Union(x,y);Union(x+n,y+n);Union(x+2*n,y+2*n);
// 如果是真,x的同类就是y的同类,x的猎物就是y的猎物,x的天敌就是y的天敌
}
else
{
if(x==y)
{
ans++;continue;
}
if(Find(x)==Find(y)||Find(x)==Find(y+n))
{
ans++;continue;
}// 如果x是y的同类或猎物为假话
Union(x+n,y);Union(x+2*n,y+n);Union(x,y+2*n);
// 如果为真,x的猎物就是y的同类,x的天敌就是y的猎物,x的同类就是y的天敌
}
}
printf("%d",ans);
return 0;
}

如果我们正着按顺序去摧毁,显然在时间复杂度上不允许,所以我们可以去使用逆向思维,把摧毁改为修建再利用并查集判断联通性就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include<bits/stdc++.h>
using namespace std;
const int maxn=4e5+10;
int n,k,m,b[maxn],B[maxn],f[maxn];
int tot,ans[maxn];
vector<int> mp[maxn];
int Find(int x)
{
if(x==f[x])return x;
return f[x]=Find(f[x]);
}
void Union(int x,int y)
{
f[Find(f[x])]=Find(f[y]);
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)f[i]=i;
for(int i=1;i<=m;i++)
{
int x,y;cin>>x>>y;
mp[x].push_back(y);
mp[y].push_back(x);
}// 存双向图
cin>>k;
for(int i=1;i<=k;i++)
{
cin>>b[i];
B[b[i]]=1;
}// 标记是否被摧毁
tot=n-k;// 摧毁后有几个联通块
for(int i=1;i<=n;i++)
{
for(int j=0;j<mp[i].size();j++)
{
if(!B[i]&&!B[mp[i][j]]&&Find(i)!=Find(mp[i][j]))// 如果没有被摧毁合并
{
tot--;// 每减一条边联通块-1
Union(i,mp[i][j]);
}
}
}// 建好摧毁后的联通块
ans[k+1]=tot;
for(int i=k;i>=1;i--)
{
tot++;B[b[i]]=0;// 修建
for(int j=0;j<mp[b[i]].size();j++)
{
if(!B[mp[b[i]][j]]&&Find(b[i])!=Find(mp[b[i]][j]))
{
tot--;
Union(b[i],mp[b[i]][j]);
}
}
ans[i]=tot;
}
for(int i=1;i<=k+1;i++)cout<<ans[i]<<endl;
return 0;
}

可持久化并查集

并查集作为一个数据结构,也是有可持久化版本的。

顾名思义,可持久化并查集=可持久化+并查集=可持久化数组+并查集=主席树+并查集。👀️

首先,因为需要记录历史版本,所以路径压缩显然是不能用的;

其次,为了让并查集的高度尽量保持平衡,我们需要用到按秩合并。(如果并查集退化到一条链的情况下,效率会非常低)

可持久化并查集的操作有以下几种:

  • 回到历史版本(毕竟是可持久化数组);
  • 合并(毕竟是并查集);
  • 查询祖先。

对于第一个操作:

1
root[i]=root[k];

对于第二个操作:其实就是按秩合并;

对于第三个操作:在可持续化数组中查询。

初始建树

1
2
3
4
5
6
7
8
9
10
11
12
13
int build(int l,int r)
{
cnt++;int p=cnt;
if(l==r)
{
t[p].fa=l;
return p;
}
int mid=(l+r)>>1;
t[p].ls=build(l,mid);
t[p].rs=build(mid+1,r);
return p;
}

合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int merge(int now,int l,int r,int fat,int son)
{
cnt++;int p=cnt;
t[p]=t[now];
if(l==r)
{
t[p].fa=fat;
return p;
}
int mid=(l+r)>>1;
if(son<=mid)t[p].ls=merge(t[p].ls,l,mid,fat,son);
else t[p].rs=merge(t[p].rs,mid+1,r,fat,son);
return p;
}

按秩合并的修改深度

1
2
3
4
5
6
7
8
9
10
11
void add(int p,int l,int r,int x)
{
if(l==r)
{
t[p].depth++;
return;
}
int mid=(l+r)>>1;
if(x<=mid)add(t[p].ls,l,mid,x);
else add(t[p].rs,mid+1,r,x);
}

得到元素在当前版本的元素编号

1
2
3
4
5
6
7
int get_indx(int p,int l,int r,int x)
{
if(l==r)return p;
int mid=(l+r)>>1;
if(x<=mid)return get_indx(t[p].ls,l,mid,x);
else return get_indx(t[p].rs,mid+1,r,x);
}

查询祖先

1
2
3
4
5
6
int find(int now,int x)
{
int father=get_indx(now,1,n,x);
if(x==t[father].fa)return father;
return find(now,t[father].fa);
}

最后放一下完整代码吧(QWQ)。

code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include<bits/stdc++.h>
using namespace std;
const int maxm=2e5+10;
int n,m,root[maxm],cnt;
struct TREE{
int ls,rs,fa,depth;
}t[maxm<<5];
int build(int l,int r)
{
cnt++;int p=cnt;
if(l==r)
{
t[p].fa=l;
return p;
}
int mid=(l+r)>>1;
t[p].ls=build(l,mid);
t[p].rs=build(mid+1,r);
return p;
}
void add(int p,int l,int r,int x)
{
if(l==r)
{
t[p].depth++;
return;
}
int mid=(l+r)>>1;
if(x<=mid)add(t[p].ls,l,mid,x);
else add(t[p].rs,mid+1,r,x);
}
int get_indx(int p,int l,int r,int x)
{
if(l==r)return p;
int mid=(l+r)>>1;
if(x<=mid)return get_indx(t[p].ls,l,mid,x);
else return get_indx(t[p].rs,mid+1,r,x);
}
int find(int now,int x)
{
int father=get_indx(now,1,n,x);
if(x==t[father].fa)return father;
return find(now,t[father].fa);
}
int merge(int now,int l,int r,int fat,int son)
{
cnt++;int p=cnt;
t[p]=t[now];
if(l==r)
{
t[p].fa=fat;
return p;
}
int mid=(l+r)>>1;
if(son<=mid)t[p].ls=merge(t[p].ls,l,mid,fat,son);
else t[p].rs=merge(t[p].rs,mid+1,r,fat,son);
return p;
}
int main()
{
scanf("%d%d",&n,&m);
root[0]=build(1,n);
for(int i=1;i<=m;i++)
{
int opt;scanf("%d",&opt);
if(opt==1)
{
int a,b;scanf("%d%d",&a,&b);
root[i]=root[i-1];
int f1=find(root[i],a);
int f2=find(root[i],b);
if(t[f1].fa==t[f2].fa)continue;
if(t[f1].depth>t[f2].depth)swap(f1,f2);
root[i]=merge(root[i-1],1,n,t[f2].fa,t[f1].fa);
if(t[f1].depth==t[f2].depth)add(root[i],1,n,t[f2].fa);
}
if(opt==2)
{
int k;scanf("%d",&k);
root[i]=root[k];
}
if(opt==3)
{
int a,b;scanf("%d%d",&a,&b);
root[i]=root[i-1];
int f1=find(root[i],a);
int f2=find(root[i],b);
if(t[f1].fa==t[f2].fa)printf("1\n");
else printf("0\n");
}
}
return 0;
}

拓展—可持久化带权并查集

可持久化并查集+边带权(逃)。

最后附上我的题单

完结撒花~~(终于写完了)🎉️ 🎉️ 🎉️
PS:

(一些资料和图例参考自OIwiki和算法竞赛进阶指南QwQ~,不喜勿喷)

作者

Jekyll_Y

发布于

2022-06-09

更新于

2023-03-02

许可协议

评论