【算法学习笔记】并查集
\(0.\) 简介
并查集是一种维护节点之间不相交集合关系的一种树状数据结构。在算法竞赛中较为常用。并查集的基本形态如下
\(1.\) 一些定义
代表元素
代表元素是指代表这个集合的元素,通常由根节点表示。如上图的4号节点就是这个集合的代表元素。
父亲节点
和其他的树状数据结构类似,父亲节点就是某一节点“上方”的节点。在并查集中,边的方向始终指向父亲节点。
\(2.\) 并查集的功能与基本实现方式
基本的并查集一般实现两种操作:合并集合,查找某个节点所在集合的代表元素。
并查集的初始化
一般的我们在初始状态时将每个元素看做独立的集合,即每个元素所在的集合就是自己。
for(rg int i=1;i<=n;i++) fa[i]=i;
上面代码中\(fa[\ ]\)就简单的实现了一个并查集。其中\(fa[i]\)代表编号为\(i\)的元素的父亲节点。
查找代表元素
并查集的实现结构是每个节点有一个指针指向父亲节点,代表元素(根)的指针指向自己。
所以可以递归地查找代表元素
inline int find(int x){ return fa[x]==x?x:find(fa[x]); }
上面的代码就实现了查找代表元素的操作。如果一个节点的父亲是他自己,那么这个节点就是并查集的代表元素,否则往上方跳,直到找到代表元素为止。
合并集合
zcy大神在某个地方讲过
如果两个学校要合并,最简单的方式就是让其中一个学校的校长成为另一个学校的副校长。
所以合并并查集最简单的方式就是讲其中一个集合的代表元素的父亲节点改为另一个集合的代表元素
inline void merge(int x,int y){
rg int xx=find(x),yy=find(y);
fa[xx]=yy;
}
基于基本操作的操作——查询两个元素是否在同一集合
由代表元素的定义可以得到,只要两个元素所在集合的代表元素相同,那么就说明两个元素在同一集合
板子题 洛谷P3367
\(3.\) 并查集的优化
路径压缩
由于有些毒瘤数据可能会让你将并查集建的很奇怪,如下图(加粗的是代表元素)
此时如果不进行一些优化,每次查找时,都会重复的走很多路。因此,程序跑的将会非常慢
由于我们只想要维护元素间的集合关系,而高中数学必修一中又告诉我们集合具有无序性,因此我们在查找的时候可以顺便将每个节点的父亲节点直接指向代表元素所在的节点。
实现方式如下:
inline int find(int x){ return fa[x]==x?x:fa[x]=find(fa[x]); }
这个代码还有一个好处,我们在递归的查找某个集合的代表元素的同时,会将这个元素上方所有节点的父亲节点都挪到代表元素上。
下图就是查找\(8\)号元素实现的效果。
可以看见,我们下次查找这个集合中任意元素的代表元素,只需进行一次递归即可。
按秩合并
我们考虑如下图所示的两个带合并的并查集
我们先定义一个树状数据结构的基本特征——秩
对于不同的算法书对秩的解释不同,这里采用“集合大小”的解释方法。对于这种定义方法,这种合并也被称为“启发式合并”。
启发式合并的基本思想就是将小的合并到大的中,只增加小的结构的查询代价
实现方式:
inline void merge(int x,int y){
rg int xx=find(x),yy=find(y);
if(tag[xx]>tag[yy]) swap(xx,yy);
fa[xx]=yy;
}
板子题 洛谷P3367
\(4.\) 并查集的拓展
扩展域并查集
扩展与并查集简单来说就是开多个空间以维护多种关系
直接上例题吧
例题 NOI2001 食物链
题目描述
动物王国中有三类动物 \(A,B,C\),这三类动物的食物链构成了有趣的环形。\(A\) 吃 \(B\),\(B\) 吃 \(C\),\(C\) 吃 \(A\)。
现有 \(N\) 个动物,以 \(1-N\) 编号。每个动物都是 $ A,B,C $ 中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这 \(N\) 个动物所构成的食物链关系进行描述:
第一种说法是“\(1\ X\ Y\)”,表示 \(X\) 和 \(Y\) 是同类。
第二种说法是“\(2\ X\ Y\)”,表示 \(X\) 吃 \(Y\) 。
此人对 \(N\) 个动物,用上述两种说法,一句接一句地说出 \(K\) 句话,这 \(K\) 句话有的是真的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
• 当前的话与前面的某些真的话冲突,就是假话
• 当前的话中 \(X\) 或 \(Y\) 比 \(N\) 大,就是假话
• 当前的话表示 \(X\) 吃 \(X\),就是假话
你的任务是根据给定的 \(N\) 和 \(K\) 句话,输出假话的总数。
解决办法
开一个三倍空间,分别代表同类(\(A\)区),敌人(\(B\)区),食物(\(C\)区)
\(A,B,C\)三区对应编号如下
对于编号为\(i\)的动物,\(A\)区为\(i\),\(B\)区为\(i+n\),\(C\)区为\(i+2*n\)
初始化时要注意\(A,B,C\)三个区对应编号不同,初始化时要注意对应自己编号
- 冲突的处理方式
由规则可得:
- 对于“同类”的情况,如果给定\(X,Y\)互为敌人时,即为不合法。
对应代码:
if(find(x+n)==find(y)||find(y+n)==find(x)){ ans++;}
- 对于“\(X\)吃\(Y\)”的情况,如果给定\(X,Y\)为同类,或者\(Y\)吃\(X\),即为不合法。
对应代码:
if(find(x)==find(y)||find(x)==find(y+n)){ ans++;}
其他情况直接合并即可。
注意:一定要同时维护三个域的信息,因为这三个域实质上地位是平等的
AC代码:
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <string>
#include <vector>
#include <queue>
#include <stack>
#include <cctype>
#include <iostream>
using namespace std;
#define rg register
#define ll long long
#define ull unsigned long long
inline int read(){
rg int s=0,f=0;
rg char ch=getchar();
while(not isdigit(ch)) f|=(ch=='-'),ch=getchar();
while(isdigit(ch)) s=(s<<1)+(s<<3)+(ch^48),ch=getchar();
return f?-s:s;
}
const int N=5e4+15;
int fa[N*3+5];
int n,k;
int ans;
inline int find(int x){ return fa[x]==x?x:fa[x]=find(fa[x]);}
signed main(){
n=read(),k=read();
for(rg int i=1;i<=n*3;i++){
fa[i]=i;
}
for(rg int i=1;i<=k;i++){
int opt=read(),x=read(),y=read();
if(x>n||y>n){
ans++;
continue;
}
if(opt==1){
if(find(x+n)==find(y)||find(y+n)==find(x)){ ans++;}
else{
fa[find(x)]=find(y);
fa[find(x+n)]=find(y+n);
fa[find(x+n+n)]=find(y+n+n);
}
}else{
if(find(x)==find(y)||find(x)==find(y+n)){ ans++;}
else{
fa[find(x+n)]=find(y);
fa[find(x+n+n)]=find(y+n);
fa[find(x)]=find(y+n+n);
}
}
}
printf("%d",ans);
return 0;
}
边带权并查集
边带权并查集可以维护节点到根的信息,通过路径压缩完成信息的统计
例题 NOI2002 银河英雄传说
公元五八○一年,地球居民迁至金牛座\(\alpha\)第二行星,在那里发表银河联邦创立宣言,同年改元为宇宙历元年,并开始向银河系深处拓展。
宇宙历七九九年,银河系的两大军事集团在巴米利恩星域爆发战争。泰山压顶集团派宇宙舰队司令莱因哈特率领十万余艘战舰出征,气吞山河集团点名将杨威利组织麾下三万艘战舰迎敌。
杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成\(30000\)列,每列依次编号为\(1, 2, …,30000\)。之后,他把自己的战舰也依次编号为\(1, 2, …, 30000\),让第\(i\)号战舰处于第\(i\)列\((i = 1, 2, …, 30000)\),形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为\(M_{i,j}\) ,含义为第\(i\)号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第\(j\)号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。
然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。
在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:\(C_{i,j}\)。该指令意思是,询问电脑,杨威利的第\(i\)号战舰与第\(j\)号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。
作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。
最终的决战已经展开,银河的历史又翻过了一页……
解决方法
在维护是否在同一列的关系同时维护节点到根的距离
如下图的并查集,我们可以发现每个节点到根节点的距离就是\(dis[i]+dis[fa[i]]\)
所以我们的查找+路径压缩代码就变成了这个鸭子
inline int find(int x){
if( fa[x]==x ) return x;
int root=find(fa[x]);
dis[x]+=dis[fa[x]];
return fa[x]=root;
}
这里需要说明的是,如果不使用路径压缩,距离就会重复统计,导致信息维护有误。所以对于边带权并查集,路径压缩是必须的。
- 关于合并
本题中要求
“整个战舰队列,作为一个整体(头在前尾在后)接至第\(j\)号战舰所在的战舰队列的尾部。”
所以对于每次合并,我们需要额外记录这个集合的大小,并将移动的集合大小加到加入的集合大小中去,而\(dis[root_{\text{移动的集合}}]\)应为\(size_\text{加入的集合}\)
合并部分代码如下
rg int x=read(),y=read(),xx=find(x),yy=find(y);
fa[xx]=yy;
dis[xx]=size[yy];
size[yy]+=size[xx];
- 答案处理
本题中要求查询
“杨威利的第\(i\)号战舰与第\(j\)号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。”
所以 \(ans=\begin{cases}-1&i\text{与}j\text{不在一个集合内}\\ |dis[i]-dis[j]|-1&i\text{与}j\text{在一个集合内}\end{cases}\)
AC代码:
#include<bits/stdc++.h>
using namespace std;
#define rg register
#define ll long long
#define ull unsigned long long
namespace Enterprise{
inline int read(){
rg int s=0,f=0;
rg char ch=getchar();
while(not isdigit(ch)) f|=(ch=='-'),ch=getchar();
while(isdigit(ch)) s=(s<<1)+(s<<3)+(ch^48),ch=getchar();
return f?-s:s;
}
const int N=30015;
int fa[N],dis[N],size[N],t;
inline int _abs(int x) { return x>=0?x:-x; }
inline int find(int x){
if( fa[x]==x ) return x;
int root=find(fa[x]);
dis[x]+=dis[fa[x]];
return fa[x]=root;
}
inline void main(){
for(rg int i=1;i<=30000;i++) fa[i]=i,size[i]=1;
t=read();
for(rg int i=1;i<=t;i++){
char opt=getchar();
while(!isalpha(opt)) opt=getchar();
if(opt=='C'){
rg int x=read(),y=read(),xx=find(x),yy=find(y);
if(xx==yy) printf("%d\n",abs(dis[x]-dis[y])-1);
else printf("-1\n");
}else{
rg int x=read(),y=read(),xx=find(x),yy=find(y);
fa[xx]=yy;
dis[xx]=size[yy];
size[yy]+=size[xx];
}
}
}
}
signed main(){
Enterprise::main();
return 0;
}
\(5.\) 写在最后
并查集作为一种很优秀的数据结构,其拓展应用还有很多,本篇博客仅列举讲解了一些并查集基本的操作。最后还是要好好努力,加油!