浅谈并查集

并查集

这是个什么东西呢?肯定OI大佬都知道。并就是指合并,查就是指查询,集就是集合,这种数据结构可以动态维护很多不重叠的集合

那么并查集其实是一种树形结构,它的每个节点都是一个元素,那么根节点就是这棵树(这个集合)的代表元素,我们可以用树根来表示这个集合。我们用一个f数组来记录并查集(整个并查集应该是一个森林),f[x] 就表示 x 的父节点,那么对于根节点(集合代表)就把 f[x] 赋为自己就行了,那么初始化,就是把每个节点的父节点赋为自己(即单独为一个集合)

那么在实际应用中,如果把 f[x1] = x2 的话,因为这是一个树形结构,我们的查找应该查找它的根节点,如果深度很大,那么查询的时间复杂度就会相当高,那么在合并的时候,就有两种优化方法,一个是 路径压缩 ,一个是 按秩合并。对于第二者,用得很少,主要讲解路径压缩

我们想,因为我们的并查集只关心这颗树的根节点(根节点代表这颗树,即这个集合),那么对于它的子树有哪些儿子我们并不关心,我们就可以在合并的时候,把所有的儿子都指向根节点。对于下面这个图,两颗树是等价的(不知道为什么图有点小),这样的话,每次查询操作的均摊复杂度为 O(logN)

并查集的初始化
for(int i=1;i<=n;i++) f[i]=i;

并查集的查询
int find(int x){
	if(f[x]==x) return x;    //emmm..很多人喜欢写return f[x],个人习惯吧 
	return f[x]=find(f[x]);  //路径压缩的思想 
} 

并查集的合并(因为是之前写的程序,我的题目的代码里面就叫做hb)
void merge(int x,int y){
	f[find(x)]=find(y);  //合并两个集合,其实就是让x集合的根节点作为y集合根节点的儿子(反过来也行) 
} 

那么这个就是并查集的一些思想和实现操作了,可能我讲得不太清楚,读者可以在其他大佬的博客上面或者在一些书上面自学一下(能力不足啊。。。),那么接下来就是一些例题了

P3367【模板】并查集

P2814 家谱

P2256 一中校运会之百米跑

这三道都是非常纯正的模板题,后面两道是对字符串的操作,可以用STL的map水一水(不知道的可以看看我之前的文章),但是最好自己学学哈希,后面会说的

P1955 程序自动分析

这道题还是很不错的,我做了挺久,让我明白了什么叫做 玄学优化+运气优化 。首先是题意,给你一些操作,包括两种,两个数字相等或者是不等,我们需要去判定这些所有的操作是否正确。思路还是比较好想的,对于相等的操作,我们把两个数字合并到一起,对于不等的操作,如果这两个数字在一个集合中(即他们两个相等),说明这个就是错误的

思路非常简单,但是有点坑的东西就是,它数字的范围是在 1e9 以内,直接用int,long long之类的会直接炸掉,这里就需要用到离散化(哈希的重要性)。我们可以将用到的数字离散化,这样就不需要达到这么大的数据。但是我自己用的是map,因为STL的一些操作其实并不是特别优,所以这道题我TLE的一个点,但是一波玄学操作之后,它过了(没开O2),在之后我又交了一次,还是TLE了,但是哈希我自己学得不好,这里不讲解(见谅见谅)

#include<bits/stdc++.h>
using namespace std;
inline void read(int &v) {
	register int num;
	char a;
	bool flag=0;
	while((a=getchar())<48||a>57)flag^=!(a^'-');
	num=a^48;
	while((a=getchar())>=48&&a<=57)num=(num<<3)+(num<<1)+(a^48);
	v=flag? -num:num;
	return;
}//一波快读优化,然而并没有什么用,但是在输入数据多的时候用处还是很大的 
bool ok; 
int tot,x,y,e,i; //尽量把能声明的变量都先声明了,时间复杂度偶尔会快一点(够玄学吧) 
const int MAXN=1e6+5;
int t,n;
map<int,int> a;  //用来离散化 
int f[3*MAXN];  //并查集father数组 
struct node {
	int l,r,e;
} b[3*MAXN];  //存储是否相等 
int find(int x) {
	if(f[x]==x) return x;
	return f[x]=find(f[x]);
}
void hb(int x,int y) {
	f[find(x)]=find(y);
}
bool cmp(node x,node y){
	return x.e>y.e;
}  //记得排序,直接把相等的先处理完,我最开始没弄这个只有30分的样子。。 
int main() {
	read(t);
	while(t--) {
		read(n);
		tot=0;  //用来离散化 
		for(i=1; i<=n; ++i) {
			read(x),read(y),read(e);
			if(a[x]==0) {
				a[x]=++tot;
			} //如果当前的数没有用过,用tot代表它来执行之后的操作 
			if(a[y]==0) {
				a[y]=++tot;
			} //同上,如果这里没有这个if判断的话,会出错(map的一个关键字只能对应唯一一个值) 
			b[i].l=a[x],b[i].r=a[y],b[i].e=e; //放到结构体中,结构体比开几个数组也会优一点(依然玄学) 
		}
		sort(b+1,b+1+n,cmp); //按e从大到小排序,就相当于先处理相等的操作 
		for(i=1; i<=tot; ++i) f[i]=i;  //并查集初始化 
		ok=false; //用来判断最后的答案 
		for(i=1; i<=n; ++i) {  
			if(b[i].e==1) hb(b[i].l,b[i].r); //如果相等直接合并 
			else{
				if(find(b[i].l)==find(b[i].r)){ //如果在不等的操作中,发现之前他们已经相等了,说明是错误的 
					ok=true;
					break; //判断一下直接退出 
				}
			}
		}
		if(ok==false) puts("YES");
		else puts("NO");
		a.clear();  //map记得清空,不然也会出错 
	}
	//程序只有90分,TLE一个点,如果满分的话可以看看题解学一下用哈希等操作离散化 
	return 0;
}

种类并查集

算是并查集的一个扩展吧。它是用来干啥的呢?在之前的并查集中,我们其实只针对了把所有的朋友分到一起,而这个玩意,其实是用来针对“敌人的敌人就是我的朋友”这类问题的(这里没有提到说朋友的敌人也是敌人哦,还是要结合题目具体分析)

比如维护敌人的敌人是我朋友,我们只需要开一个 2n 大小的数组就行了,1 ~ n存储朋友关系,n+1 ~ 2n存储敌人关系,那么对于敌人的敌人应该怎么表示呢?
假如a和b是敌人,a和c是敌人,我们把a+n和b合并,再把a+n和c合并(注意这里的根节点一定是b或者c,之后会讲),这样b和c就处于同一个集合中了,而那个a+n只是用来过渡,可以自己手模一下

P1892 团伙

P1525 关押罪犯

关押罪犯这道题,给你一些罪犯,有若干对罪犯在同一座监狱时会发生冲突,这个冲突的值就是他们之间的怨气值,我们要把这些罪犯放到两个监狱中,求他们发生冲突的最大怨气值最小

首先想到贪心,我们要使最大怨气值最小,那我们肯定要对数据排个序啊,怨气值越大的两个罪犯,我们肯定希望把他们分开,再去考虑后面的罪犯,那么这样贪心就解决了。至于具体的对于罪犯的处理,直接看程序吧,我自己感觉还是讲得比较清楚吧...

#include <bits/stdc++.h>
using namespace std;
int n,m,fa[400010];
struct node {
	int u,v,w;
} a[400010];
inline bool cmp(node x,node y) {
	return x.w>y.w;
}
inline int find(int x) {
	if(fa[x]==x) return x;
	return fa[x]=find(fa[x]);
}
int main() {
	scanf("%d%d",&n,&m);
	for(register int i=1;i<=2*n;i++) fa[i]=i;
	for(register int i=1;i<=m;i++) {
		scanf("%d%d%d",&a[i].u,&a[i].v,&a[i].w);
	}
	sort(a+1,a+1+m,cmp);
	for(register int i=1;i<=m;i++) {
		if(find(a[i].u)==find(a[i].v)) {
			printf("%d",a[i].w);
			return 0;
		} //如果两个罪犯在同一个集合内(同一个监狱中),直接输出最大值(排序之后保证第一个值为最大值) 
		if(find(a[i].u+n)==a[i].u) fa[a[i].u+n]=a[i].v; //如果他没有敌人,就把此时的敌人作为他的敌人 
		else if(find(a[i].u+n)!=a[i].u) fa[find(a[i].u+n)]=find(a[i].v); 
		//如果已经有敌人了,把之前的敌人和现在的敌人合并在一起,把他们当做朋友
		//这里可能有点绕,他们俩是朋友,但是并不影响他们也可能是敌人的关系
		//因为之前的判断,他们在同一座监狱的话,怨气值肯定比之前的要小,那就可以把他们合并在一起 
		if(find(a[i].v+n)==a[i].v) fa[a[i].v+n]=a[i].u;
		else if(find(a[i].v+n)!=a[i].v) fa[find(a[i].v+n)]=find(a[i].u);
		//两个人都要处理 
	}
	puts("0"); //不发生冲突那肯定最好嘛 
	return 0;
}//这程序是我同桌的,我会把她的博客挂在后面

P2024 食物链

不用多说这道题多经典了吧,学过并查集应该都会接触到这道题,毕竟思维难度挺高。我们之前讲得种类并查集是开的2n数组,维护敌人和朋友两种关系。而这道题应该开3n数组,是因为要维护 同类,猎物,天敌 三种关系

如果我们说 x会吃y ,那么 x吃的应该是y的同类,而 x的同类其实也是y的天敌。那么如果我们说 x吃y,y吃z,那么就有z吃x(因为题意是这样的,只有三个种类),我们应该合并的是①x的猎物和y②x和y的天敌③x的天敌和y的猎物(即x的天敌和z)

这里可能有点绕,读者注意理解一下。那么判断一句话是否是假的,看下面

x与y是同类矛盾的情况:
1.x的猎物和y在同一集合,即x吃y
2.x和y的猎物在同一集合,即y吃x

x吃y的矛盾情况:
1.x和y在同一集合,即x与y同类
2.x和y的猎物在同一集合,即y吃x 

那么直接看程序吧,1 ~ n维护同类,n+1 ~ 2n维护猎物,2n+1 ~ 3n维护天敌

#include<bits/stdc++.h>
using namespace std;
const int MAXN=50005;
int n,k,ans,f[MAXN*3];
int find(int x){
	if(f[x]==x) return x;
	else return f[x]=find(f[x]);
}
void hb(int x,int y){
	f[find(x)]=find(y);
}
int main(){
	scanf("%d%d",&n,&k);
	for(register int i=1;i<=3*n;i++) f[i]=i;
	while(k--){
		int op,x,y;
		scanf("%d%d%d",&op,&x,&y);
		if(x>n||y>n){
			ans++;
			continue;
		}  //最睿智的判断,超过n了 
		if(op==1){ //x和y是同类 
			if(find(x+n)==find(y)||find(x)==find(y+n)) ans++; //如果x吃y或y吃x,是假话 
			else{
				f[find(x)]=find(y);
				f[find(x+n)]=find(y+n);
				f[find(x+2*n)]=f[find(y+2*n)];
			} //x和y是同类,说明x和y的猎物也同类,x和y的天敌也同类 
		}else{ //x吃y 
			if(find(x)==find(y)||find(x)==find(y+n)) ans++; //如果x和y同类或y吃x,是假话 
			else{
				f[find(x+n)]=find(y);
				f[find(x+2*n)]=find(y+n);
				f[find(x)]=f[find(y+2*n)];
			} //x吃y,说明y是x的猎物,y的猎物是x的天敌,x是y的天敌 
		}
	}
	printf("%d",ans);
	return 0;
}

带权并查集

咕完了回来了。带权并查集还是很好理解的,对于普通的并查集,我们要维护的只是集合与集合之间的关系,当然还有元素和根节点的关系。那么回到最初的地方,并查集是一个树形结构,那么树形结构肯定会有边,那么肯定就有权值,无论是边权还是点的权值,具体题目具体分析,那么有权值的并查集,就是带权并查集

那么对于带权并查集,对于父节点和点与根节点的处理,其实是一样的,只是对于权值的处理,我们需要变化一下。这里其实我们不止需要处理边权,也需要处理一个集合的大小,为什么呢?(这里的例子还没涉及到边权,但是需要考虑边权),在不同题中,我们要维护的东西是不一样的,所以不同题中查询和合并的操作并不相同,这里只根据一道题分析

对于两个集合,我们把它们的元素编号(在各自的元素中编号) 
集合1: 1 2 3 4
 编号: 0 1 2 3
 
集合2: 5 6
 编号: 0 1 

合并集合1和集合2
集合3: 1 2 3 4 5 6
 编号: 0 1 2 3 4 5

那我们要保存的就有两个东西,一个是当前节点在集合中的位置(也可以理解为节点到父节点的距离,权值),以及集合的大小。再回归到并查集的本质,我们不用处理其它节点,我们同样也只需要处理当前节点到根节点的各种关系即可

那么这样看来,我们的查询操作和合并操作肯定也会发生改变,我这里就把代码发出来,应该还是讲得比较详细,不懂得可以自己手模一下,就很好理解了

int f[30005],size[30005],d[30005]; 
//f和之前一样,表示父节点;size表示当前子树的大小,d表示当前元素到集合中父节点的距离 
int find(int x){
	if(f[x]==x) return x; //相同的操作,不多说 
        int root=find(f[x]); //总之先把父亲安顿好,避免一些操作更新了父节点
	d[x]+=d[f[x]]; //维护d数组,否则在路径压缩的时候会出错
	return f[x]=root; //相同操作 
}
int fx,fy;
void merge(int x,int y){
	fx=find(x),fy=find(y); //先把根节点找出来 
	f[fx]=fy; //合并两个集合 
	d[fx]+=size[fy]; //我这个集合的根节点到另外一个集合的根节点的距离=我到自己集合根节点的距离+另外一个集合的大小 
	size[fy]+=size[fx]; //合并的新的集合的大小也需要更新一下 
}

P1196 银河英雄传说

因为带权并查集的题我接触的不多,这里就只放这一道题吧,因为这道题比较模板。思路不多说,该合并就合并,但是注意的是,题中是将i接在j的后面。那么对于查询操作,就是 abs(d [ j ] - d [ i ])- 1,-1是因为不包含两个端点,是之间的距离,那么这道题就迎刃而解了

#include<bits/stdc++.h>
using namespace std;
int t; 
char op;
int l,r;
int f[30005],size[30005],d[30005]; 
int find(int x){
	if(f[x]==x) return x; 
        int root=find(f[x]);
	d[x]+=d[f[x]]; 
	return f[x]=root;
}
int fx,fy;
void merge(int x,int y){
	fx=find(x),fy=find(y); 
	f[fx]=fy; 
	d[fx]+=size[fy]; 
	size[fy]+=size[fx]; 
}
int main(){
	for(register int i=1;i<=30000;i++){
		f[i]=i,size[i]=1,d[i]=0;
		//自己单独为一个集合,父节点是自己,子树大小就是1,到父节点的距离是0 
	}
	scanf("%d",&t);
	while(t--){
		cin>>op;
		scanf("%d%d",&l,&r);
		if(op=='M'){
			merge(l,r);
		}else{
			if(find(l)==find(r)) printf("%d
",abs(d[l]-d[r])-1);
			else cout<<-1<<endl;
		}
	}
	return 0;
} 

那么大概并查集也就讲了一些基础一点的东西了,但其实还有一些更多的应用,例如在最小生成树算法中的Kruskal算法,还有一些可持续化并查集等的高端操作,我太蒻了,还没研究或者说搞清楚,之后应该会写的,所以就先讲到这里吧,有不对的地方可以评论或者在QQ上找我,希望对你们的学习有些许帮助吧

大佬的博客

蒟蒻自己的博客

原文地址:https://www.cnblogs.com/Poetic-Rain/p/13155209.html