简述
在计算机科学中,并查集 是一种树形的数据结构,用于处理不交集的合并(union)及查询(find)问题。
并查集 对于查询两个节点的 连接状态 非常高效。对于两个节点是否相连,也可以通过求解 查询路径 来解决, 也就是说如果两个点的连接路径都求出来了,自然也就知道两个点是否相连了,但是如果仅仅想知道两个点是否相连,使用 路径问题 来处理效率会低一些,并查集 就是一个很好的选择。
这里的话,简单点来说,用基础版的并查集,你可以快速判断某两个点是否在一个集合里,那么进阶版的并查集就有拓展域并查集和边带权并查集两种。因为并查集能动态的维护传递性和连通性
例题分析
这道题就很明显可以用并查集来做,因为题目本质就是通过一些点的关系来维护一些集合,最后只要统计出多少个集合即可。
代码实现思路
并查集的代码很简单,主要的就两个函数:
一个是查询,那么查询的是什么呢?
就是当前这个点的集合的老大,祖宗。
另一个是合并。合并的是什么?
当你查询两个点时,如果他们在不同的集合,那么这时候你就要进行合并,也就是把一个集合合并到另一个集合中。
注意点:
- 初始化的时候,会用到一个st[]数组,而这个数组就代表最开始每个店都是一个集合,后面经过题目给出点的关系进行合并,由于一开始每个点都是一个集合,那么我们就可以st[i] = i 来进行初始化。
- 最后我们只需要遍历这个st[]数组,只要st[i] = i,我们就可以知道当前这个i就是本集合的老大祖宗,那么就为一个集合,像st[i] != i的这些点,我们就可以认为他们是小弟。
代码实现
# include <iostream>
# include <vector>
# include <string>
using namespace std;
const int N = 1e5+10;
int a[N];
void init(const int& n){
for(int i=1;i<=n;++i){
a[i] = i;
}
return ;
}
int find(int x){
return a[x]==x?x:find(a[x]);
}
void merge(int x,int y){
int t1 = find(x);
int t2 = find(y);
if(t1!=t2){
a[t2] = t1;
}
return ;
}
int main(void)
{
int t,n,m,x,y;
cin>>t;
while(t--){
cin>>n>>m;
init(n);
for(int i=1;i<=m;++i){
cin>>x>>y;
merge(x,y);
}
int ans = 0;
for(int i=1;i<=n;++i){
if(a[i]==i){
++ans;
}
}
cout<<ans<<endl;
}
return 0;
}
这里是没有进行优化的,时间复杂度为O(n),接下来就讲讲优化方法
并查集的优化
第一种是在合并的优化,称为按秩合并。
在合并元素x,y的时候,我们先去他们的老大,也就是根节点,然后合并它们,优化的步骤就在这里,我们可以再用一个数组height[]数组,来标记每个点的高度,那么两个根节点的高度就有对应的值,我们就把高度较小的集合合并到较大的集合上,就能减少树的高度。
int height[N];
void init(int n){
for(int i=1;i<=n;++i){
a[i] = i;
height[i] = 0;
}
}
void merge(int x,int y){
int t1 = find(x);
int t2 = find(y);
if(height[t1]==height[t2]){
height[t1 ] = height[t2]+1;
a[t2] = t1;
}else{
if(height[t1]<height[t2]) a[t1] = t2;
else a[t2] = t1;
}
}
第二种就是路径压缩
find()函数递归回溯的时候,我们可以把整个搜索路径上的元素,也就是一开始的那个点到根节点,都可以把father[] 更改为根节点,这样我们就实现了路径压缩,如图:
int find(int x){
return a[x]==x?x:a[x]=find(a[x]);
}
我们可以同时用以上两种优化(效果最好),也可以只用第二种路径压缩优化
注:你会发现上面的代码都是用递归写法,那如果我们担心爆栈,那么就可以写非递归的版本
//非递归版本
int find(int x){
int r = x;
while(a[r]!=r) r = a[r]; //找到根节点
int i=x,j;
while(i!=r){
j = a[i]; //用临时变量j来记录
a[i] = r; //把路径上元素的集改为根节点
i = j;
}
return r;
}
进阶版的并查集--------边带权以及拓展域
“边带权”并查集
并查集实际上是由若干棵树构成的森林,我们可以在树中的每条边上记录一个权值,即维护一个数组d,用d[x]保存节点x到父节点fa[x]之前的边权。在每次路径压缩后,每个访问过的节点都会直接指向树根,如果我们同时更新这些节点的d值,就可以利用路径压缩的过程来统计每个节点到树根之间的路径上的一些信息。这就是所谓“边带权” 的并查集。
例题一:
有一个划分为N列的星际战场,各列依次编号为1,2,…,N。
有N艘战舰,也依次编号为1,2,…,N,其中第i号战舰处于第i列。
有T条指令,每条指令格式为以下两种之一:
1、M i j,表示让第i号战舰所在列的全部战舰保持原有顺序,接在第j号战舰所在列的尾部。
2、C i j,表示询问第i号战舰与第j号战舰当前是否处于同一列中,如果在同一列中,它们之间间隔了多少艘战舰。
现在需要你编写一个程序,处理一系列的指令。
输入格式
第一行包含整数T,表示共有T条指令。
接下来T行,每行一个指令,指令有两种形式:M i j或C i j。
其中M和C为大写字母表示指令类型,i和j为整数,表示指令涉及的战舰编号。
输出格式
你的程序应当依次对输入的每一条指令进行分析和处理:
如果是M i j形式,则表示舰队排列发生了变化,你的程序要注意到这一点,但是不要输出任何信息;
如果是C i j形式,你的程序要输出一行,仅包含一个整数,表示在同一列上,第i号战舰与第j号战舰之间布置的战舰数目,如果第i号战舰与第j号战舰当前不在同一列上,则输出-1。
数据范围
N≤30000,T≤500000
思路:边带权的并查集,说到底就是在每个节点上附带一些信息,这道题来说,附带的信息就是自己到根节点的距离(也就是自己的前面有多少架飞机)。然后在你合并的时候,也需要更新之前是根节点的信息。通过这些信息,尽管我们把它们合并了,但是我们仍然可以知道,他们与根节点的关系或者信息。
边带权的并查集,一般都是记录自己与根节点的关系或者信息*
# include <bits/stdc++.h>
using namespace std;
int fa[30005],a[30005],d[30005],size[30005];
void inio() //初始化
{
for(int i =1;i<=30002;++i){
fa[i] = i;
size[i] = 1;
d[i] = 0;
}
}
int get(int x)
{
if(x==fa[x])
return fa[x];
int root = get(fa[x]); //这里应该是先递归,回溯的时候更新信息,切记!
d[x]+=d[fa[x]];
return fa[x] = root;
}
void union_a(int x,int y) //两个老大进行合并的时候,记得也要更新被合并老大的信息
{
x = get(x),y = get(y);
fa[x] = y;
d[x] = size[y];
size[y]+=size[x];
return ;
}
int main(void)
{
int t;
cin>>t;
inio();
while(t--){
char s;
int x,y;
cin>>s>>x>>y;
if(s=='M'){
if(x==y)
continue;
union_a(x,y);
}
else{
int x1 = get(x);
int y1 = get(y);
if(x1==y1){
cout<<abs(d[x]-d[y])-1<<endl;
}
else
cout<<"-1"<<endl;
}
}
return 0;
}
例题二
动物王国中有三类动物A,B,C,这三类动物的食物链构成了有趣的环形。
A吃B, B吃C,C吃A。
现有N个动物,以1-N编号。
每个动物都是A,B,C中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这N个动物所构成的食物链关系进行描述:
第一种说法是”1 X Y”,表示X和Y是同类。
第二种说法是”2 X Y”,表示X吃Y。
此人对N个动物,用上述两种说法,一句接一句地说出K句话,这K句话有的是真的,有的是假的。
当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
1) 当前的话与前面的某些真的话冲突,就是假话;
2) 当前的话中X或Y比N大,就是假话;
3) 当前的话表示X吃X,就是假话。
你的任务是根据给定的N和K句话,输出假话的总数。
输入格式
第一行是两个整数N和K,以一个空格分隔。
以下K行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中D表示说法的种类。
若D=1,则表示X和Y是同类。
若D=2,则表示X吃Y。
输出格式
只有一个整数,表示假话的数目。
数据范围
1≤N≤50000,
0≤K≤100000
思路:这里的话跟上道题基本一样,通过自己到祖宗的距离,然后对于题目中所要求的关系取模,这里的主要是3,因为有a,b,c三类,a吃b,b吃c,c吃a。
#include <iostream>
using namespace std;
const int N = 50010;
int n, m;
int p[N], d[N];
int find(int x)
{
if (p[x] != x)
{
int t = find(p[x]);
d[x] += d[p[x]];
p[x] = t;
}
return p[x];
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i ++ ) p[i] = i;
int res = 0;
while (m -- )
{
int t, x, y;
scanf("%d%d%d", &t, &x, &y);
if (x > n || y > n) res ++ ;
else
{
int px = find(x), py = find(y);
if (t == 1)
{
if (px == py && (d[x] - d[y]) % 3) res ++ ;
//if(px==py && !(d[x]%3==d[y]%3)) ++res;
else if (px != py)
{
p[px] = py;
d[px] = (d[y] - d[x]);
}
}
else
{
if (px == py && (d[x] - d[y] - 1) % 3) res ++ ;
else if (px != py)
{
p[px] = py;
d[px] = (d[y] + 1 - d[x]);
}
}
}
}
printf("%d\n", res);
return 0;
}
“扩展域”并查集
图片来自别的博客上,请点击!
还是上面那道题,只不过我们用拓展域来做。
思路:我们主要是要开三个拓展的域,也就是天敌域,同类域,以及捕食域.
- 如果x,y是同类,但是x的捕食域有y,那么错误
- 如果x,y是同类,但是x的天敌域有y,那么错误
- 如果x是y的天敌,但是x的同类域中有y,那么错误
- 如果x是y的天敌,但是x的天敌域中有y,那么错误
//这里我们将三个域,分别转化为了n(同类域),n+n(捕食域),n+n+n(天敌域).
#include <bits/stdc++.h>
using namespace std;
int fa[200000];
int n,m,k,x,y,ans;
int get(int x)
{
if(x==fa[x])
return x;
return fa[x]=get(fa[x]);
}
void merge(int x,int y)
{
fa[get(x)]=get(y);
}
int main()
{
cin>>n>>m;
for(int i=1;i<=3*n;i++)
fa[i]=i;
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&k,&x,&y);
if(x>n || y>n)
ans++;
else if(k==1)
{
if(get(x)==get(y+n) || get(x)==get(y+n+n)) //如果x,y是同类,但是x是y的捕食中的动物,或者x是y天敌中的动物,那么错误.
ans++;
else
{
merge(x,y);
merge(x+n,y+n);
merge(x+n+n,y+n+n);
}
}
else
{
if(x==y || get(x)==get(y) || get(x)==get(y+n)) //x就是y,或者他们是同类,再或者是y的同类中有x
ans++;//都是假话
else
{
merge(x,y+n+n);//y的捕食域加入x
merge(x+n,y);//x的天敌域加入y
merge(x+n+n,y+n);//x的捕食域是y的同类域.
}
}
}
cout<<ans<<endl;
}
//x是同类域.
//x+n是捕食域
//x+n+n是天敌域