简单の暑假总结——并查集

本文详细介绍了并查集的建、查、合操作,包括优化路径压缩和启发式合并,并扩展至带权并查集和扩展域并查集。通过实例分析和Eg1-Eg3讲解了如何在实际问题中运用这些技术,涉及BOI2003团伙问题的解决方案。
摘要由CSDN通过智能技术生成

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 例题分析

Eg1_【模板】并查集

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 带权并查集使用方法及例题分析

对于不同的题目,对其边权的处理也有所不同,举个栗子:

Eg_2 [NOI2002] 银河英雄传说

蒟蒻来教大家手切 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 例题分析

Eg_3 [BOI2003]团伙

所以啥是 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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值