1.1 并查集
并查集,顾名思义,它用于处理一些不交集的合并及查询问题。(废话)
一般来讲,某些题目会要求我们不断地将两个集合结合成一个集合,而且还要多次查询某两个元素是否处于同一个集合当中时,如果采用一般的数据结构,一般有两种死法:MLE 和 TLE
这时,我们就可以采用并查集来很好的解决这一问题。
所以,我们首先要学会并查集的板子。
那么,对于一个并查集,我们一般有三种操作:建并查集,查找祖先和合并集合。
1.1.1 建并查集
int fa[100005]; //fa[i] 存储第 i 号元素的父亲或祖先
void make(int num){
for(int i=1;i<=num;i++){
fa[i]=i; //因为一开始,每一个元素都是单独的一个集合,所以自己就是自己的父亲
}
}
对于不同的题目, m a k e make make 函数可能会有所差异,具体题目具体分析。
1.1.2 查找祖先及其优化
讲一个本班真实故事:
班上总有人喜欢认亲,A 认 B 爸爸,B 认 C 大爷……
这样乱认下去,自己的兄弟就成了自己的舅舅,A 有疑问了:我的祖先是谁呢?
可是家庭过于庞大,家谱(是的,我们有家谱)又不完整,所以查家谱是不可能的。
这时,A 就有方法了:
A 先问爸爸 B 的祖先是谁,B 又问大爷 C 的祖先是谁,C 又问……直到得到了准确答案之后,A 就知道了自己的祖先是谁。
并查集的查找祖先的原理和上述故事的思想是一样的:
int find(int num){
if(fa[num]==num){ //当前元素是祖先
return num; //返回祖先
}
return find(fa[num]); //当前元素不是祖先,返回父亲的祖先,一次向上爬,直到找到祖先为止
}
对于不同的题目, f i n d find find 函数可能会有所差异,具体题目具体分析。
关于 f i n d find find 函数的优化:
考虑这样的一个并查集:
假设当前并查集是一个链式结构,而如果我们要查询 10 10 10 号元素的祖先,我们就需要查询所有的点,如果数据范围太大,那么程序会非常的慢。
这时,我们引入第 1 1 1 种优化方法:路径压缩
因为我们在使用 f i n d find find 函数时,只关注所要查询的元素的祖先,而不关注并查集自身的形态。
换言之:下面这幅图和上面这幅图其实是等价的:
代码实现也是非常简单的:
int find(int num){
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]); //将 num 的祖先直接赋值给 fa[num]
}
1.1.3 合并集合及其优化
对于两个集合 M , N M,N M,N ,我们只需要让某个集合的一个元素存在于另一个集合当中,我们就可以合并两个集合。
那么,对于并查集而言,我们只需将其中一个集合的祖先的父亲设为另一个集合的祖父即可。
void build(int x,int y){
if(find(x)!=find(y)){ //两个元素不在同一集合内
fa[find(x)]=find(y); //含义见上
}
}
对于不同的题目, b u i l d build build 函数可能会有所差异,具体题目具体分析。
然而,对于 b u i l d build build 函数,我们依旧有优化方法——启发式合并。
考虑下面两个集合:
我们有两种方式合并这两个集合:
考虑第 1 1 1 种合并方式:如果我们要查询 5 5 5 号元素的祖先,则加上 5 5 5 号元素,我们一共要查询 4 4 4 个元素。
考虑第 2 2 2 种合并方式:如果我们要查询 5 5 5 号元素的祖先,则加上 5 5 5 号元素,我们一共要查询 5 5 5 个元素。
从优化时间复杂度来看,第 1 1 1 种合并方式更优。
我们可以猜想:把深度较小的集合塞进深度较大的集合里面,其时间复杂度更优。
证明?不好意思,我不会……
但是,你可以看看这句话:
一个祖先突然抖了个机灵:「你们家族人比较少,搬家到我们家族里比较方便,我们要是搬过去的话太费事了。」—— OI Wiki
相信大家都能理解了。这里开始解释代码:
由于引入了深度的概念,所以,我们要修改一下 m a k e make make 函数和 b u i l d build build 函数:。
void build(int x,int y){
int a=find(x),b=find(y);
if(a==b){ //x 和 y 处在同一集合中
return ;
}
if(deap[a]<=deap[b]){ //y 所处的集合的深度比 x 所处的集合的深度大
fa[a]=b; //把 x 塞入 y 中
}else{ //x 所处的集合的深度比 y 所处的集合的深度大
fa[b]=a; //把 y 塞入 x 中
}
if(deap[a]==deap[b]){ //x 所处的集合的深度和 y 所处的集合的深度一样
deap[b]++; //合并后必会加深 y 所处的集合的深度,需要加 1 (可以画图证明,也可数学证明)
}
}
int fa[100005],deap[100005]; //deap[i] 存储第 i 号元素所在
void make(int num){
for(int i=1;i<=num;i++){
fa[i]=i,deap[i]=1; //因为一开始,每一个集合只有1个元素,所以深度为1
}
}
当然启发式合并并不常用,一般来讲,有了路径压缩就够了。
1.1.4 例题分析
Just 一道裸题,没啥技术含量。(英语老师:不能裸奔)
#include<cstdio>
int fa[10005];
int n,m,x,y,z;
void make(){ //建并查集
for(int i=1;i<=n;i++){
fa[i]=i;
}
}
int find(int num){ //查找祖先
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]);
}
void build(int x,int y){ //合并集合
fa[find(x)]=find(y);
}
int main(){
scanf("%d%d",&n,&m);
make();
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
if(x==1){ //要求合并
build(y,z);
}else{ //要求查询
if(find(y)==find(z)){ //判断两个元素的祖先是否相同,即两个元素是否在同一集合内
printf("Y\n");
}else{
printf("N\n");
}
}
}
return 0;
}
当然,以上这些仅仅是最最基础的并查集,还有一些变式:
1.2 带权并查集
对于某些并查集,我们不仅需要处理各个元素,还需要对其某两个元素之间的连线所带的权值(边权)进行处理。(我认为应该可以听懂)
所以,我们需要一种带有边权的并查集,即带权并查集。(废话梅开二度)
1.2.1 带权并查集使用方法及例题分析
对于不同的题目,对其边权的处理也有所不同,举个栗子:
蒟蒻来教大家手切 NOI 啦 ![doge]
在此题中,我们需要处理某两个处于统一集合的元素的相对位置,但若直接求解难度大(路径压缩会改变并查集原有的形态),考虑将其简化:
考虑如上图的一个并查集,我们要求
x
x
x 与
y
y
y 的相对位置关系,肉眼观察得:两个元素之间间隔了
2
2
2 个元素。(可是计算机没有肉眼啊!)
由于路径压缩,并查集的形态势必会发生改变,但无论如何,我们都求得到某个元素与其祖先元素的相对位置关系。
那么,我们就可以简化问题了,即求出某个元素与祖先元素的相对位置关系即可。
那么,我们就需要在并查集模板的基础上增加 d e a p deap deap 数组和 s i z e size size 数组, d e a p deap deap 数组用于存储第 i i i 号元素到其祖先元素的相对位置,用于 s i z e size size 数组用于存储第 i i i 号元素所在集合的元素数量。
s i z e size size 数组的具体用处后面有介绍。
首先,是对 d e a p deap deap 数组和 s i z e size size 数组的初始化。
void make(int num){
for(int i=1;i<=30000;i++){ //依题意,30000列战舰都要初始化
fa[i]=i,deap[i]=0,size[i]=1; //每一个元素都是单独的一个集合,所以自己就是自己的祖先,相对位置为0,元素个数为0
}
}
接下来,解释本题的关键:如何更新 d e a p [ i ] deap[\ i\ ] deap[ i ] 里面的值呢?
很容易想到:当我们在进行 b u i l d build build 函数合并集合时,原先集合 M M M 的祖先 m m m 的父亲成了集合 N N N 的祖先 n n n 。自然,这时 d e a p [ m ] deap[\ m\ ] deap[ m ] 应该加上 s i z e [ n ] size[\ n\ ] size[ n ]。(题目所言, m m m 应该接在集合 N N N 的最后一个元素后面,虽然在并查集中并不是这样的一个结构)
void build(int x,int y){
if(find(x)!=find(y)){
deap[find(x)]+=size[find(y)]; //含义见上
size[yy]+=size[xx]; //合并集合后,新的集合的大小是原有两个集合的大小之和
fa[find(x)]=find(y);
}
}
可是,如果只有这样一点修改,是不行的:
假设有三个集合
A
,
B
,
C
A,B,C
A,B,C (元素里面的数字代表当前元素到其祖先元素的相对位置)
先将 A A A 集合塞给 B B B 集合。
按照我们修改的 b u i l d build build 函数,此时,原属于 A A A 集合的 a a a 元素塞进了 B B B 集合,同时 d e a p [ a ] + + deap[\ a\ ]++ deap[ a ]++ 。
再将 B B B 集合塞给 C C C 集合。
那么,按照我们所写的程序,并查集应该长这样。
但是,注意箭头所指的元素 a a a ,此时 d e a p [ a ] deap[\ a\ ] deap[ a ] 应该是 2 2 2 ,而不是 1 1 1 。
因为 b u i l d build build 函数里面的操作只能修改祖先元素,对于祖先元素下的子元素无法修改。
肿么办?
我们将邪恶的目光投向了 find
我们可以想:
一个元素到其祖先元素的相对位置=该元素在合并前到其祖先元素的相对位置+该元素的父亲到其祖先元素的相对位置
一个元素的父亲到其祖先元素的相对位置=该元素的父亲在合并前到其祖先元素的相对位置+该元素的父亲的父亲到其祖先元素的相对位置
一个元素的父亲的父亲到其祖先元素的相对位置=该元素的父亲的父亲在合并前到其祖先元素的相对位置+该元素的父亲的父亲的父亲到其祖先元素的相对位置
…
递归!
那么,我们自然就可以在 f i n d find find 函数里操作了。( f i n d find find 也是一个递归函数)
int find(int num){
if(fa[num]==num){
return num;
}
int roat=find(fa[num]); //先将结果保留
deap[num]+=deap[fa[num]]; //累加 deap ,原因见上
return fa[num]=roat;
}
这样,所有的元素到其祖先元素的相对位置我们都可以及时的更新到了。
下面是完整代码:
#include<cmath>
#include<cstdio>
int fa[30005];
int deap[30005];
int size[30005];
void make(){
for(int i=1;i<=30000;i++){
fa[i]=i,deap[i]=0,size[i]=1;
}
}
int find(int num){
if(fa[num]==num){
return num;
}
int roat=find(fa[num]);
deap[num]+=deap[fa[num]];
return fa[num]=roat;
}
void build(int x,int y){
int xx=find(x),yy=find(y);
if(xx!=yy){
fa[xx]=yy;
deap[xx]+=size[yy];
size[yy]+=size[xx];
}
} //三个有所修改的基本函数,上文已介绍
int main(){
int n,x,y;
char ch;
scanf("%d",&n);
make();
for(int i=1;i<=n;i++){
scanf("\n%c ",&ch); //输入特殊处理
if(ch=='M'){ //合并操作
scanf("%d%d",&x,&y);
build(x,y);
}else{ //查询操作
scanf("%d%d",&x,&y);
if(find(x)==find(y)){ //判断两个元素是否在同一集合内
printf("%d\n",abs(deap[x]-deap[y])-1); //由于我们不知道两个元素之间的先后顺序,所以采用绝对值
//注意,题目所求的是间隔了多少战舰,所以需要减1
}else{
printf("-1\n");
}
}
}
return 0;
}
1.3 扩展域并查集
我们知道,普通并查集里的元素都具有连通性、传递性的关系。比如:亲戚的亲戚是亲戚。
但是,某些关系不具有这种关系,比如:敌人的敌人是朋友,与同一个数不相等的两个数不一定相等。
这时,我们就需要使用扩展域并查集。
1.3.1 扩展域并查集基本使用方法
举个例子:
一共有 3 3 3 个人,其中,第 1 1 1 个人和第 2 2 2 个人是敌人,第 2 2 2 个人和第 3 3 3 个人是朋友。
如果我们直接将 [ 1 , 2 ] , [ 2 , 3 ] [\ 1,2\ ],[\ 2,3\ ] [ 1,2 ],[ 2,3 ] 塞到并查集里面,那么,我们就无法确定他们之间是朋友关系还是敌人关系。
这时,我们就可以开两倍空间大小的并查集,前一半空间处理朋友关系,后一半空间处理敌人关系。
所以,我们在处理数据时,若 [ a , b ] [\ a,b\ ] [ a,b ] 是一组朋友对,则应该直接塞入并查集,若 [ a , b ] [a,b] [a,b] 是一组敌人对,则应该将其中一个人与另一个人的虚拟敌人塞入并查集,即将 [ a , b + n ] [ a + n , b ] [\ a,b+n\ ][\ a+n,b\ ] [ a,b+n ][ a+n,b ] 塞入并查集。
在这个例子中,我们就不能直接将 [ 1 , 2 ] [1,2] [1,2] 塞进去,而是塞入 [ 1 , 2 + 3 ] , [ 1 + 3 , 2 ] [\ 1,2+3\ ],[\ 1+3,2\ ] [ 1,2+3 ],[ 1+3,2 ]。
为什么可以这么做呢?
考虑这样两个敌人对: [ a , b ] [ b , c ] [\ a,b\ ][\ b,c\ ] [ a,b ][ b,c ]
上文已说,我们应该将 [ a , b + n ] [ a + n , b ] [ b + n , c ] [ b , c + n ] [\ a,b+n\ ][\ a+n,b\ ][\ b+n,c\ ][\ b,c+n\ ] [ a,b+n ][ a+n,b ][ b+n,c ][ b,c+n ] 塞入并查集,现在,并查集应该长这样:
但是!
并查集中有一些虚拟的点,我们不需要管它。若将其删除,我们可以得到这样一个并查集:
这样,我们就自动的将
a
,
b
,
c
a,b,c
a,b,c 三个人进行了分类。
所以,遇到要处理不具有传递性关系的元素的集合的合并及查询时,扩展域并查集是不二选择。
1.3.2 例题分析
所以啥是 BOI
这题是一道扩展域并查集的裸题,主要注意一下团伙数量的计算。
团伙数量其实就是集合数量,集合数量就是祖先元素的数量。
所以,我们只需要统计祖先元素的数量即可。
#include<cstdio>
char x;
int fa[2005],n,m,y,z,ans;
bool flag[2005];
void make(){
for(int i=1;i<=n*2;i++){
fa[i]=i;
}
}
int find(int num){
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]);
}
void build(int x,int y){
fa[find(x)]=find(y);
} //以上都是基本操作
int main(){
scanf("%d\n%d",&n,&m);
make();
for(int i=1;i<=m;i++){
scanf("\n%c %d %d",&x,&y,&z); //输入特殊,注意处理
if(x=='F'){ //建立朋友关系
build(y,z);
}else{ //建立敌人关系
build(y,z+n);
build(z,y+n);
}
}
for(int i=1;i<=n;i++){
if(flag[find(i)]==0){ //查找该元素的祖先是否被标记过
ans++; //未被标记,说明是一个新的集合里的元素,答案累加并标记
flag[find(i)]=1;
}
}
printf("%d",ans);
return 0;
}