数据结构---并查集解析

前言

"集合的力量在于它的整体性,只有当所有元素相互关联,才能发挥出最大的价值。"

        而这里要讲的并查集正如上文所言,它被用来处理一些不相交集合的合并查询问题(即所谓的并、查),或者通俗的讲,它可以快速的判断两个元素是否在同一个集合内,并且同样快速的将两个集合完成合并,而且并不局限于集合,它也可以解决树中同样的问题,事实上,并查集本身就是一种树形数据结构

目录

前言

目录

正文

---并查集的作用及概述

<查找(find)>

<合并(union)>

---并查集的实现

<查找(find)>

<合并(union)>

模板题(亲戚)

---其他并查集

带权并查集

点带权并查集

选学霸

边带权并查集

信息传递

堆箱子(综合)

种类并查集

食物链


正文

---并查集的作用及概述

<查找(find)>

        如上文所言,并查集用来处理一些不相交集合的合并及查询问题,但是这样将可能不够具体,更具体一些,引入一个案例:

        比如说我们现在有两个家族,小a,小b,小c位于第一个家族,小d,小e,小f位于第二个家族,现在我们要在趋近于O(1)的时间复杂度下判断任意两个人是否在同一个家族,那么很容易想到dfs深搜完成查找,但是这样时间复杂度会达到O(n),显然不符合题意,那么怎么办呢?此处就要引出这里要讲的并查集了,对于两个有关系的人,根据并查集的思想,我们在他们之间”连一条边“,在这些相互连通的人里面,随便找出一个人作为这个家族的“祖宗”,然后我们如果要找判断两个人是否在同一个家族,就只需要判断他们的祖宗是否相同了,如下图:

        但是又会出现一个问题,如果这两个家族的人之间都是呈直线关系呢?如下图:

         显然的,在这个时候,并查集查询的时间复杂度被退化到了O(n),那么该怎么办呢?这里,我们给出一种路径压缩的方法,对于每个人,既然我们只需要知道他的最高祖宗是谁,那么他与其他顶点是什么关系就不重要了,所以,为了时间复杂度考虑,我们可以直接把这条关系链连到祖宗身上,直接查找祖宗,如下图:

        那么目前的时间复杂度就被优化到了阿克曼函数的反函数级别,而且,这个函数的变化率极慢,基本无限趋近于O(1),但是这个过程怎么实现呢?我们可以考虑在第一次查找时,把所有过程中经过的人的关系链都连到他们的最高祖宗身上,这样就能实现这个效果了。

<合并(union)>

        经过了上文对于查找过程的概述,大家应该对于合并过程都较为了解了,其实,上文的说法又可能引起大家的误导,预处理时对于两个人之间,因为他们并没有诸如父子关系一类的说法,所以并不能在他们之间直接连边,这个时候的操作其实就是两个家族之间的合并操作,将两个家族合并成同一个家族,又能使其中所有人都相连,那么我们相当于直接把它们两个家族的最高祖宗之间连一条边就可以了,而且谁是新的家族的最高祖宗也并不重要,因为合并后里面所有人的最高祖宗一定是相同的,如图:

---并查集的实现

        其实放到并查集中,上文中为了更具象化所使用的家族就是集合,而家族里的人就相当于集合里的元素,而最高祖宗就是代表元。

        同时,由于上文已经将过程叙述的十分详细了,所以此处在讲解一些细节之后就直接贴代码了。

PS:

正如比尔·盖茨说的:

靠代码行数来衡量开发进度,就像是凭重量来衡量飞机制造的进度。

                                                                                                                ——比尔·盖茨

所以,并查集的代码其实很短T_T 

(下文的pre[x]表示x的父结点)

<查找(find)>

        在查找过程中,其实就是一层层的往上递归,但是为了做到上文中所提到的路径压缩的效果,我们考虑一下在找到代表元后往回递归的过程中,把返回时经过的每个结点的父节点都改为已经查找完的代表元即可,代码如下:

int Find(int x){
    if(pre[x]==x) return x;
    return pre[x]=Find(pre[x]);
}
//最好不要用find做函数名,容易函数名冲突导致编译错误

<合并(union)>

        合并过程也很简单,对于任意两个元素所在的集合,如果要合并,我们只需要找到两个顶点所在的代表元,然后如果他们不相同,让他们中任意一个的父节点改为另一个就可以了。

        但是,还要注意初始化问题,对于任意一个元素,最开始他的父节点一定指向自己。

void Union(int x,int y){
    int a=Find(x);
    int b=Find(y);
    if(a==b){
        return;
    }
    pre[a]=b;
}
//初始化
for(int i=1;i<=n;i++){
    pre[i]=i;
}

模板题(亲戚)

题目链接:亲戚 - 洛谷

题面概括:

        给定n组关系,其中x_{i}y_{i}表示x和y之间有亲戚关系,然后给出多组询问,判断给出的两个数之间是否有亲戚关系。

思路:

        纯模板题,直接套用上面给出的代码即可。

代码:

#include<bits/stdc++.h>
using namespace std;
int n,m,p;
int pre[5005];
int Find(int x){
    if(pre[x]==x) return x;
    return pre[x]=Find(pre[x]);
}
void Union(int x,int y){
    int a=Find(x);
    int b=Find(y);
    if(a==b){
        return;
    }
    pre[a]=b;
}
int main(){
	cin>>n>>m>>p;
	for(int i=1;i<=n;i++){
	    pre[i]=i;
	}
	while(m--){
	    int x,y;
	    cin>>x>>y;
	    Union(x,y);
	 }
	while(p--){
	    int x,y;
	    cin>>x>>y;
	    if(Find(x)==Find(y)){
	        cout<<"Yes"<<"\n";
	    }else{
	        cout<<"No"<<"\n";
	    }
	}
	return 0;
}

---其他并查集

"Think out of the box"---不但要掌握基础算法和数据结构,还要灵活的运用它们。

         正如上文所言和大部分算法及数据结构一样,并查集也有很种不同的形式,下面将为大家介绍三种不同的并查集。

带权并查集

点带权并查集

        顾名思义,这种并查集就是各点上都有相应的权值的并查集,它可以用来处理很多寻常并查集处理不了的问题,而点的权值如何赋则要视题目而定,下面给出一道例题:

选学霸

题目链接:选学霸 - 洛谷

题面概括:

        给定n对学霸,他们中有些人的实力相同。

        规定:要选出最接近m个数量的学霸,并且相同实力的学霸必须同时被选或同时落选,给出最接近的数量。

思路:

        对于这个题来说,由于题面涉及到了实力(权值),所以很容易想到点带权并查集,我们把相同实力的学霸的实力赋为一个相同的值,然后记录下每个值所对应的学霸数量,最后就相当于处理一个背包问题,对于某组学霸选或不选,dp得到与m差最小的值即可。

代码:

#include<bits/stdc++.h>
using namespace std;
int n,m,k;
int dp[40005];
int pre[20005],num[20005],sum[20005];
int Find(int x){
    if(pre[x]==x) return x;
    return pre[x]=Find(pre[x]);
}
void Union(int x,int y){
    int a=Find(x);
    int b=Find(y);
    if(a==b){
        return;
    }
    pre[a]=b;
    num[b]+=num[a];
}
int main(){
    int minn=0x3f3f3f3f;
	cin>>n>>m>>k;
	for(int i=1;i<=n;i++){
	    pre[i]=i;
	    num[i]=1;
	}
	for(int i=1;i<=k;i++){
	    int x,y;
	    cin>>x>>y;
	    Union(x,y);
	}
	int cnt=0;
	for(int i=1;i<=n;i++){
	    if(i==pre[i]){
	    	sum[++cnt]=num[i];
		}
	}
    for(int i=1;i<=n;i++){
        for(int j=2*m;j>=sum[i];j--){
            dp[j]=max(dp[j],dp[j-sum[i]]+sum[i]);
        }
    }
    int ans=0;
    for(int i=1;i<=2*m;i++){
        if(minn>abs(dp[i]-m)){
            minn=abs(dp[i]-m);
            ans=i;
        }
    }
    cout<<dp[ans];
	return 0;
}
边带权并查集

        同样故名思义,边带权并查集就是在边上有权值的并查集,但是一般来说,不会直接将权值赋在边上,而是通过点上的权值来表示出边上的权值,这种并查集一般用来解决环问题或距离问题,下面来看一道例题:

信息传递

题目链接:[NOIP2015 提高组] 信息传递 - 洛谷

题面概括:

         有n个同学,每个同学会将生日传给固定的传递对象,求最少经过多少轮这个同学会接收到自己的生日。

思路:

        稍微理解一下题意就能知道,这道题就是要判断最短的环的长度,思考一下并查集的意义,如果两个点在同一个集合内并且中间有边,他们之间一定会构成一个环,所以我们考虑使用边带权并查集,记录下来每个点到代表元(根节点)的距离,环长就为两个点到根节点的距离+1,即

                                                        dis[x]+dis[y]+1

         同时我们知道,合并过程中的两个点之间是一定有连边的,所以我们在Union函数中求结果就可以了,

        但要注意,由于路径压缩的原因,每个点到根节点的距离要在find中就用递推处理好。

代码:

#include<bits/stdc++.h>
using namespace std;
int n,m,k,ans=0x3f3f3f3f;
int pre[200005],dis[200005],num[200005];
int Find(int x){
    if(pre[x]==x) return x;
    int root=Find(pre[x]);
    dis[x]+=dis[pre[x]];
    return pre[x]=root;
}
void Union(int x,int y){
    int a=Find(x);
    int b=Find(y);
    if(a==b) ans=min(ans,dis[x]+dis[y]+1);
    else{	
    	pre[a]=b;
    	dis[x]=dis[y]+1;
    }
}
int main(){
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){
        pre[i]=i;
    }
    for(int i=1;i<=n;i++){
        cin>>num[i];
        Union(i,num[i]);
    }
    
    cout<<ans;
	return 0;
}
堆箱子(综合)

题目链接:暂无

题面概括:

        共有n个箱子,起初排成一排,下面给定两种操作:

  1. M,x,y:将 x 所在的那一堆箱子,放置到 y 所在的那一堆箱子的上面,如果 x 和 y 本来就在同一堆,则忽略这次操作
  2. C x :询问 x 箱子的下面有多少个箱子 

思路:

        这道题我们可以发现,单纯使用点带权或边带权已经无法解决问题了,所以我们考虑把两种并查集结合起来,通过点的权值维护边的权值,其中,我们令

点权:num[x]表示x这堆箱子的个数

边权:dis[x]表示x下箱子的个数

        合并时将上面的那堆箱子的边权+下面那堆箱子根节点的点权,下面那堆箱子的根节点的点权+上面那堆箱子的根节点的点权,上面那堆箱子的根节点指向下面那堆箱子的根节点即可完成一次合并操作。

        最后对于每组询问查询输出即可。

代码:

#include<bits/stdc++.h>
using namespace std;
int n,m,k;
int pre[200005],dis[200005],num[200005];
int Find(int x){
    if(pre[x]==x) return x;
    int root=Find(pre[x]);
    dis[x]+=dis[pre[x]];
    return pre[x]=root;
}
void Union(int x,int y){
    int a=Find(x);
    int b=Find(y);
    if(a==b) return;	
	pre[a]=b;
    dis[a]=num[b];
    num[b]+=num[a];
}
int main(){
    int n,m;
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        pre[i]=i;
        num[i]=1;
    }
    for(int i=1;i<=m;i++){
    	char op;
    	cin>>op;
    	if(op=='M'){
    		int x,y;
    		cin>>x>>y;
    		Union(x,y);
		}else{
			int x;
			cin>>x;
			Find(x);
			cout<<dis[x]<<"\n";
		}
	} 
	return 0;
}

种类并查集

(终于到了最后一个了...)

        种类并查集所处理的一般是集合元素之间逻辑关系复杂的题目,其使用多倍的数组,一般来说,a[x],a[x+n]...,a[x+yn]之间都有一些关系,在查找时可以直接通过这些关系来查找,下面来看一道例题:

食物链

题目链接:[NOI2001] 食物链 - 洛谷

题面概括:

        共有n个动物,给出k句话,有两种情况,分别为:

  • 第一种说法是 1 X Y,表示 X 和 Y 是同类。
  • 第二种说法是2 X Y,表示 X 吃 Y。

        且当一句话满足下列三条之一时,这句话就是假话,否则就是真话。

  • 当前的话与前面的某些真的话冲突,就是假话;
  • 当前的话中 X 或 Y 比 N 大,就是假话;
  • 当前的话表示 X 吃 X,就是假话。

        输出假话的 总数。

思路:

        这种复杂的关系很明显要用种类并查集来解决,我们可以令:

pre[x]表示x本身

pre[x+n]表示被x吃的动物

pre[x+2n]表示吃x的动物

然后根据题意对此题进行模拟即可,如果是真话则改变pre数组中的值,否则则累加假话数量。

代码:

#include<bits/stdc++.h> 
using namespace std;
int n,k;
int a,x,y;
int ans;
int pre[300010];
int Find(int x){
    if(pre[x]==x) return x;
    return pre[x]=Find(pre[x]);
}
void merge(int x,int y){
    int a=Find(x),b=Find(y);
    if(a!=b){
        pre[a]=b;
    }
}
int main(){
    cin>>n>>k;
    for(int i=1;i<=3*n;i++){
        pre[i]=i;
    }
    for(int i=1;i<=k;i++){
        cin>>a>>x>>y;
        if(x>n||y>n){
            ans++;
        }
        else{
            if(a==1){
                if(Find(x+n)==Find(y)||Find(x+2*n)==Find(y)){ 
                    ans++;
                }
                else{ 
                    merge(x,y);  
                    merge(x+n,y+n);
                    merge(x+2*n,y+2*n);
                }
            }
            else{
                if(Find(x)==Find(y)||Find(x+2*n)==Find(y)){ 
                    ans++;
                }
                else{ 
                    merge(x,y+2*n);
                    merge(y,x+n); 
                    merge(x+2*n,y+n); 
                }
            }
        }
    }
    cout<<ans;
    return 0;
}

 

  • 24
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值