关于并查集

关于并查集

目录

  • 概述
  • 实现方法
  • 思想
  • 优化
  • 路径压缩
  • 按秩合并(启发式合并)
  • 边带权
  • 扩展域
  • 关于删除

Part 1 概述

并查集是一种管理元素所属关系的数据结构。它支持两种操作:

  1. 并:即合并两个集合
  2. 查:即查询元素属于哪个集合

通过并查集,我们可以快速便捷地管理元素的所属关系。但是并查集也有一些不足:

  1. 不支持删除
  2. 只能管理所属关系

可以发现,并查集应该是一片森林形式。如图:

图源:OI-Wiki

OI-wiki IMG

Part 2 思想

已经说过,并查集是一种管理元素所属关系的结构。

那么我们要怎么表示某个元素属于哪个集合呢?

我们按照一般思路,一般是给集合取名字,或者标序号。
但是实际在计算机里使用发现并不方便。

于是并查集使用了这样的方法:
使用这个集合中的某一个元素来代表这个集合。注意,代表元素的值有可能是相等的,但是他们的编号不同(下标)。

所以我们令fai表示i的前驱,于是i的前驱的前驱的前驱...就是它所在集合的代表。

假如我们要判断两个元素是否归属同一个集合,那么只要判断他们的代表相不相等。

并查集还有一个操作,合并集合。这个操作就比较简单,我们只要把其中一个集合的代表换成另一个集合的就行了。

可以发现,随着不断合并集合,并查集会像上面那片森林一样。

那此时就有个问题,为什么我们不用fai直接表示i所在集合的代表呢?这样查询起来不就只要O(1)了吗?

因为并查集还支持合并集合,如果直接存代表就导致我们需要在合并时一一修改,浪费很多时间,而查找的时间花费其实可以进行优化,我们后面再说。

Part 3 实现方法

  1. 开数组fa[N],用于存储i的前驱
  2. 实现两种操作:
  • get操作

get操作是一个递归函数,从调用元素一直递归向上找到代表

int get(int x){
	return (x==fa[x]?x:get(fa[x]));
}
  • merge操作

merge操作是直接把所选两个元素的代表设为同一个。

void merge(int x,int y){
	fa[get(x)]=get(y);
	return;
}

注意 一般情况下,数组fa需要初始化,保证fai=i,意义是最开始每个数自己构成一个集合,自己是他的代表。

Part 4 优化

  • 路径压缩

这个是应用最多的一个优化方法,而且十分好写。只要:

int get(int x){
	return (x==fa[x]?x:fa[x]=get(fa[x]));
}

其实只是多加了一个赋值,把从x向上路径上的每一个点的前驱都直接指向代表。
如图:

如果执行get(5),那么就可以转化为

发现了吗,现在如果去执行get(4)、get(3)、get(5)只要一次就行了。

这就是路径压缩的优化策略。

  • 按秩合并

其实按秩合并属于启发式合并,但不止有这一种启发式合并的方法。

首先,什么是秩?秩就是这个元素所在集合的树的高度。

我们在合并时,将秩小的树挂在秩大的那棵树上。

为什么它可以优化?如果要详细说明,那么需要一个非常长的数学证明,推荐大家自行oi-wiki。简单来说,那么就是如果这样连接,那么下面查找的时间复杂度会较低。

为为为为什么呢?因为,我们连接之后,设秩小的那个中有一个点A,设秩大的那个中有一个点B。假如按秩合并后,还要get(A),那么就只要向上走到原来A的代表,就可以直接跳至最上面。如果不这样做,那么就可能要先向上走到原来B的代表从而浪费更多时间。

当然,还有另外一种启发式合并的方法。我们只要将秩的定义替换为“这个元素所在集合中的元素数量”。这样也能提升速度。

// 按节点数合并法
struct dsu {
  vector<size_t> pa, size;

  explicit dsu(size_t size_) : pa(size_), size(size_, 1) {
    iota(pa.begin(), pa.end(), 0);
  }

  void unite(size_t x, size_t y) {
    x = find(x), y = find(y);
    if (x == y) return;
    if (size[x] < size[y]) swap(x, y);
    pa[y] = x;
    size[x] += size[y];
  }
};

Part 5 边带权的并查集

我们说,普通并查集只能维护所属关系,它在路径压缩过程中丢失了图的形态特征,假如我们既想要知道所属关系,还想知道图上的一些信息,那么我们可以给图上每一条边加上一个权。这个权可以代表很多意义,那么我们来看一个例子。

  • 银河英雄传说

题目背景
公元 58015801 年,地球居民迁至金牛座 \alphaα 第二行星,在那里发表银河联邦创立宣言,同年改元为宇宙历元年,并开始向银河系深处拓展。

宇宙历 799799 年,银河系的两大军事集团在巴米利恩星域爆发战争。泰山压顶集团派宇宙舰队司令莱因哈特率领十万余艘战舰出征,气吞山河集团点名将杨威利组织麾下三万艘战舰迎敌。

题目描述
杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成 30000 列,每列依次编号为 1,2,,30000。之后,他把自己的战舰也依次编号为 1,2,,30000,让第 ii 号战舰处于第 ii 列,形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为 M i j,含义为第 ii 号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第 jj 号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。

然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。

在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:C i j。该指令意思是,询问电脑,杨威利的第 ii 号战舰与第 jj 号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。

作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。

也就是说,现在不仅需要知道两艘战舰的所属关系,还要知道他们之间有多少战舰。那么我们就可以将一条边的权赋值为1,然后在压缩时将权进行合并。

代码:

#include<bits/stdc++.h>
using namespace std;
const int SIZE=30005;
int t,fa[SIZE],d[SIZE],size[SIZE];
void InIt(){
	for(int i=1;i<=SIZE;i++){
		fa[i]=i;
		d[i]=0;
		size[i]=1;
	}
	return;
}
int Get(int x){
	if(fa[x]==x) return x;
	int r=Get(fa[x]);
	d[x]+=d[fa[x]];
//	printf("Update d[%d]=%d\n",x,d[x]);
//	就像前缀和那样,依次加上fa的权值,求到x到root的距离 
	fa[x]=r;
	return fa[x];
}
void Merge(int x,int y){
	x=Get(x);
	y=Get(y);
	fa[x]=y;
	d[x]=size[y];
//	连到y列的末尾,权值是y列原有多少战舰 
	size[y]+=size[x];
	
//	连接之后,增加y列长度,即集合大小 
	return;
}
int main(){
	InIt();
	cin>>t;
	for(int i=1;i<=t;i++){
		int x,y;
		char ch;
		cin>>ch>>x>>y;
		if(ch=='M'){
			Merge(x,y);
		}
		else{
			if(Get(x)==Get(y)){
				cout<<abs(d[x]-d[y])-1<<endl;
			}
			else cout<<"-1\n";
		}
	}
//	for(int i=1;i<=4;i++)
//	{
//		printf("#%d>\n ROOT %d\n SIZE %d\n FATHER %d\n DREEGE %d\n",i,Get(i),size[i],fa[i],d[i]);
//	}
	return 0;
}

Part 6 扩展域的并查集

这种并查集将一个物品拆分成几个。有什么好处?这样可以精确刻画每个元素之间属性的相同点,从而求出一些具有传递性的属性关系。

  • 例 Parity Game

Alice 和 Bob 在玩一个游戏:他写一个由 0 和 1 组成的序列。Alice 选其中的一段(比如第 3 位到第 5 位),问他这段里面有奇数个 1 还是偶数个1。Bob 回答你的问题,然后 Alice 继续问。Alice 要检查 Bob 的答案,指出在 Bob 的第几个回答一定有问题。有问题的意思就是存在一个 01 序列满足这个回答前的所有回答,而且不存在序列满足这个回答前的所有回答及这个回答。

我们发现,首先,这个题目要判断一段区间内的和。自然想到前缀和。

又通过前缀和的计算公式ij=sumjsumi1得到,最终区间和的奇偶性是与sumi1sumj的奇偶性有关的。具体的是:

  • 如果 区间[i,j]为 odd可以转化为sumi1sumj奇偶性不同
  • 如果 区间[i,j]为 even可以转化为sumi1sumj奇偶性相同

这样,就把区间的问题变成了单点的问题。而且我们发现这里面的sumi的奇偶性是具有传递性的,即

  • 如果sumi=sumj,sumj=sumksumi=sumk
  • 如果sumisumj,sumj=sumksumisumk
  • 如果sumisumj,sumjsumksumi=sumk

所以,我们只要将一个sumi拆分成i_same和i_diff,这样就可以刻画奇偶的相同与不同。

代码:

#include<bits/stdc++.h>
using namespace std;
const int SIZE=5006;
int fa[SIZE*4],n,m;
int tmp[SIZE*4],cnt;
struct question
{
	int l,r;
	bool ans;
}s[SIZE*2];
void ReadandDiscrete()
{
	cin>>n>>m;
	tmp[++cnt]=0;
	for(int i=1;i<=m;i++)
	{
		string a;
		cin>>s[i].l>>s[i].r>>a;
		if(a=="odd") s[i].ans=1;
		else s[i].ans=0;
		tmp[++cnt]=s[i].l;
		tmp[++cnt]=s[i].r;
	}
	sort(tmp+1,tmp+cnt+1);
	int aft=(unique(tmp+1,tmp+cnt+1)-tmp)-1;
	cnt=aft;
	return;
}
int Query(int x)
{
	return lower_bound(tmp+1,tmp+1+cnt,x)-tmp;
}
void InIt()
{
	for(int i=0;i<=cnt*2+3;i++) fa[i]=i;
	return;
}
int Get(int x)
{
	return (fa[x]==x?x:(fa[x]=Get(fa[x])));
}
void Merge(int x,int y)
{
	fa[Get(x)]=Get(y);
	return;
}
int main(){
	ReadandDiscrete();
	InIt();
	for(int i=1;i<=m;i++)
	{
		int x_odd=Query(s[i].l-1);
		int x_even=x_odd+cnt;
		int y_odd=Query(s[i].r);
		int y_even=y_odd+cnt;
	//	printf("Question #%d:\n x_odd=%d x_even=%d\n y_odd=%d y_even=%d\n",i,x_odd,x_even,y_odd,y_even);
	//	printf(" Get>\n  x_odd %d\n  x_even %d\n  y_odd %d\n  y_even %d\n",Get(x_odd),Get(x_even),Get(y_odd),Get(y_even));
		if(!s[i].ans)
		{
	//		printf("F1\n");
			if(Get(x_odd)==Get(y_even))
			{
				cout<<i-1;
				return 0;
			}
			else
			{
				Merge(x_odd,y_odd);
				Merge(x_even,y_even);
			}
		}
		else
		{
	//		printf("F2\n");
			if(Get(x_odd)==Get(y_odd))
			{
				cout<<i-1;
				return 0;
			}
			else
			{
				Merge(x_odd,y_even);
				Merge(x_even,y_odd);
			}
		}
	//	printf("After-Get>\n  x_odd %d\n  x_even %d\n  y_odd %d\n  y_even %d\n",Get(x_odd),Get(x_even),Get(y_odd),Get(y_even));
	}
	cout<<m;
	return 0;
}

另外要注意的是,由于本题数据范围大而区间端点少,所以需要离散化。这里不属于主题,请自行oi-wiki

Part 7 其他操作?

其实如果通过空间换时间的方法,是可以实现并查集的删除、移动高效操作的。但由于几乎用不到,这里放一个OI-wiki的链接吧

但是有的题可能会要求删除,比如这个题:

  • 星球大战

很久以前,在一个遥远的星系,一个黑暗的帝国靠着它的超级武器统治着整个星系。

某一天,凭着一个偶然的机遇,一支反抗军摧毁了帝国的超级武器,并攻下了星系中几乎所有的星球。这些星球通过特殊的以太隧道互相直接或间接地连接。

但好景不长,很快帝国又重新造出了他的超级武器。凭借这超级武器的力量,帝国开始有计划地摧毁反抗军占领的星球。由于星球的不断被摧毁,两个星球之间的通讯通道也开始不可靠起来。

现在,反抗军首领交给你一个任务:给出原来两个星球之间的以太隧道连通情况以及帝国打击的星球顺序,以尽量快的速度求出每一次打击之后反抗军占据的星球的连通块的个数。(如果两个星球可以通过现存的以太通道直接或间接地连通,则这两个星球在同一个连通块中)。

我们看到这个就犯难了,“打击”是一个删除操作,并查集怎么删除呢?莫非每次都dfs一遍?假如他是加边就好了...

对了,为什么不能反向思考,从打击后的状况往前推,不就是加边了吗?是的,我们从后往前推,遇到打击就重新统计加上该点的答案,最后倒序输出答案即可。

当然还有一点,我们每次不能直接统计答案,否则时间消耗太大。我们可以发现:

  • 假如加边之前某一个边所连两点不属于一个块,那么连上后就少了一个块
  • 如果加边之前还属于一个块,那么没有变化

因此可以直接推出答案。

代码:

#include<bits/stdc++.h>
using namespace std;
const int M=2e5+2;
int n,m,k,fa[M*2];
int boom[M*2],head[M*2],ans[M*2],tot;
bool has[M*2];
struct edge
{
	int from,to,next;
}s[M*2];
void Add(int x,int y)
{
	s[++tot].from=x;
	s[tot].to=y;
//	printf("Add edge %d->%d Head[x]==%d\n",x,y,head[x]);
	s[tot].next=head[x];
	head[x]=tot;
}
void InIt()
{
	for(int i=1;i<=n;i++) fa[i]=i;
}
int Get(int x)
{
	return (fa[x]==x?x:(fa[x]=Get(fa[x])));
}
void Merge(int x,int y)
{
	fa[Get(x)]=Get(y);
}
int main()//0-index,输入时加1 
{
	cin>>n>>m;
	InIt();
	memset(has,true,sizeof has);
	for(int i=1;i<=m;i++)
	{
		int u,v;
		cin>>u>>v;
		u++;
		v++;
		Add(u,v);
		Add(v,u);
	}
	cin>>k;
	for(int i=1;i<=k;i++)
	{
		cin>>boom[i];
		boom[i]++;
		has[boom[i]]=false;
	}
	int cnt=n-k;
	for(int i=1;i<=n;i++)
	{
		if(!has[i]) continue;
		for(int j=head[i];j;j=s[j].next)
		{
			if(!has[s[j].to]) continue;
			if(Get(i)==Get(s[j].to)) continue;
			Merge(i,s[j].to);
			cnt--;
		}
	}
	ans[k+1]=cnt;
	for(int i=k;i>=1;i--)
	{
		has[boom[i]]=true;
		cnt++;
		for(int j=head[boom[i]];j;j=s[j].next)
		{
			if(!has[s[j].to]) continue;
			if(Get(boom[i])==Get(s[j].to)) continue;
			Merge(boom[i],s[j].to);
			cnt--;
		}
		ans[i]=cnt;
	}
	for(int i=1;i<=k+1;i++) cout<<ans[i]<<endl;
	return 0;
}


EOF

感谢观看。QwQ

posted @   haozexu  阅读(35)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示