信息学赛培 | 04 并查集理论与实战详解

时间:2022-11-15 07:11:40



导读

信息学能够有助于孩子未来工作发展,提升孩子的综合能力。


前面两节课我们讲解了树的基本理论和实战,今天我们要学习一个基于树结构的知识——并查集,我们将学习什么是并查集,并查集的重要操作及其实现,然后讲解并查集的应用。


1 并查集理论

讲个故事。


大家都参加过喜宴,喜宴上,举办方会邀请到很多亲朋好友。而且很多参与宴会的人都是有亲缘关系的。但是因为大家在一起时间不久或者没有在一起生活过,我们可能不知道这个人是否和我们有亲缘关系。


但是我们都知道我们自己的父母,我们的父母也知道他们的父母。我们和周围的人都去找自己的祖先,如果祖先是同一个人,那么,说明我们是有亲缘关系的。


这就是我们今天要讲的并查集。

1 什么是并查集

并查集(union-find set)是一种用于分离集合操作的抽象数据类型,是一种树形的数据结构。


并查集包含三个字:


并:并查集支持合并操作
查:并查集支持查询操作
集:并查集处理的是集合之间的关系


通过上面三个字的解读,我们能知道并查集支持两个重要操作:


合并操作:将两个集合合并成为一个集合;
查询操作:查询两个节点是否同属于一个集合或某个节点属于哪个集合。


2 并查集的用途

并查集支持两个重要操作,合并查询。合并之后的两个集合会同属于一个集合,所以我们可以使用并查集进行合并,将未来会同属于一个集合的内容进行合并。并查集合并的目的是为了后续判断两个节点是否同属于一个集合或者某个节点到底属于哪个集合。


比如,我们可以判断两个人是否为同一个阵营。我们在后面的应用中来具体看并查集的用途。

2 并查集实战

我们先来自己实现一下并查集吧!


并查集我们并不用定义独特的数据结构,只需要一个数组存放其父节点就可以了。

1 并查集的初始化

做并查集的合并和查询操作,首先要有并查集。我们首先要初始化并查集。并查集初始化的时候,每个节点都是独立的个体。这个时候,每个节点都算是森林中的不同的树的根节点,所以其父节点指向其自身。




我们先定义并查集的结构体,包含两部分,一部分是并查集中节点的个数,一部分是每个节点的父节点:


struct Set{
int fa[100];
int n;
};


初始化的时候,我们要初始化每个节点,所以我们要知道有多少个节点,每个节点是独立的,可以看作是根节点,所以其父节点指向自己:


int initSet(Set &S, int num) {
S.n=num;
for(int i = 0; i < n; i++)
S.fa[i] = i; // 父节点指向自己是根节点
return 0;
}


2 并查集的查询

有了并查集,我们就可以进行查询操作了。


查询操作就是不断找父节点。直到找到根节点。找到根节点就说明查询节点属于根节点这个集合。


当我们找两个节点的根节点之后,我们就可以判断,如果两个节点的根节点是同一个节点,这说明两个节点是同一个集合,否则说明不是同一个集合。


我们通过递归的方式,不断找父节点,直到父节点指向自己(根节点)。


int find(Set S, int x) {
// 寻找x的祖先
if(S.fa[x] == x) // 如果x是祖先则返回
return x;
else
return find(S, S.fa[x]); // 如果不是则递归找父节点
}


3 并查集的合并

当我们可以找并查集中每个节点的祖先节点,我们就可以进行合并操作了。


并查集我们不在乎节点在树中的层次。例如我们不在乎两个人具体的亲戚关系是什么,我们只在乎他们是不是亲戚。


所以,如果有两个节点具有亲缘关系,我们可以让任意一个的父节点指向另外一个。不会影响我们最终的结果。


我们找到两个节点的祖先节点,然后需要将两个节点的祖先节点设为同一个,最简单的方式就是将一个祖先的父节点设为另一个祖先:


int unionSet(Set &S, int x, int y) {
// x 与 y 所在集合合并
x = find(S,x);
y = find(S,y);
S.fa[x] = y; // 把 x 的祖先变成 y 的祖先的子节点
return y;
}


然后我们可以写一下测试代码,我们设初始并查集有10个节点,然后我们进行三次合并操作,并判断两个节点是否属于同一个集合:


示例数据:
10 3 //表示并查集有10个节点,并做三次合并操作,下面三行是要合并的节点
1 2
5 6
1 5
2 6 //判断这两个节点是否为同一个集合。


代码如下:


int main(){
Set S;
int n,m,x,y;
cin>>n>>m;

initSet(S, n);
for(int i=0;i<m;i++){
cin>>x>>y;
unionSet(S,x,y);
}
cin>>x>>y;

if(find(S,x) == find(S, y)) cout<<"x和y是一个集合"<<endl;
else cout<<"x和y不是一个集合"<<endl;

return 0;
}


执行结果如下:


信息学赛培 | 04 并查集理论与实战详解


4 并查集路径压缩

我们在递归查找的时候,每次都能找到祖先节点,我们前面也提到,我们只在乎两个节点是不是同一个集合(是否有亲缘关系),不在乎他们之间是否有层次关系(不在乎具体的亲戚关系)。所以我们为了简化查找过程,可以将查找路径进行压缩。我们每找到一次祖先节点,我们就将该节点的父节点指向该祖先节点,这样之后查找的复杂度就降为1。图示如下:


信息学赛培 | 04 并查集理论与实战详解


代码如下:


int find(Set S, int x) {
if (x != S.fa[x]) // x不是自身的父亲,即x不是该集合的代表
S.fa[x] = find(S, S.fa[x]); //每找一次父节点就赋值一次
return S.fa[x];
}


5 代码简化

在竞赛中,我们的原则是在对的基础上,越简单越好,很多用并查集的场景,并不需要n,也就是说并不需要直到并查集中元素的个数。所以我们直接用数组即可。


定义与初始化函数简化如下:



#include<iostream>
using namespace std;

int fa[100];

int initSet(int n) {
for(int i = 0; i < n; i++)
fa[i] = i;
return 0;
}


查询函数简化如下:


int find(int x) {
if (x != fa[x])
fa[x] = find(fa[x]);
return fa[x];
}


合并函数简化如下:


int unionSet(int x, int y) {
x = find(x);
y = find(y);
fa[x] = y;
return y;
}


3 并查集应用

我们看一道并查集的应用题目吧!

1 团伙(group)

【问题描述】


在某城市里住着n个人,任何两个认识的人不是朋友就是敌人,而且满足:


1、我朋友的朋友是我的朋友;
2、我敌人的敌人是我的朋友;


所有是朋友的人组成一个团伙。告诉你关于这n个人的m条信息,即某两个人是朋友,或者某两个人是敌人,请你编写一个程序,计算出这个城市最多可能有多少个团伙?


【输入格式】
第1行为n和m,1<n<1000,1<=m<=100 000;  
以下m行,每行为p x y,
p的值为0或1,p为0时,表示x和y是朋友,
p为1时,表示x和y是敌人。
【输出格式】
一个整数,表示这n个人最多可能有几个团伙。
【输入样例】  
6 4  
1 1 4  
0 3 5  
0 4 6  
1 1 2
【输出样例】  
3


【题目分析】


这里相比普通的并查集,还要考虑敌人的敌人是朋友,这样,我们可以给每个用户设置一个“假想敌”。如果其他用户和这个用户是敌人,那其他用户和这个用户的假想敌是朋友。这样就转换成了我们前面的并查集。


那么有1-n个用户,对应n+1到n+n我们可以设置为对应的假想敌,即第i个用户的假想敌是n+i。如果两个用户a和b是朋友,那我们合并a和b就可以了。如果a和b是敌人,那么我们合并a和b的假想敌与a的假想敌和b。


全部代码如下:


#include<iostream>
using namespace std;
int n,m,cnt;
int a,b,c;
int fa[15000];
int vis[15000];

int initSet(int n) {
for(int i = 1; i <= 2*n; i++) //不仅要存每个人,还要存每个人的敌人
fa[i] = i;
return 0;
}

int find(int x) {
if (x != fa[x])
fa[x] = find(fa[x]);
return fa[x];
}

void unionSet(int x, int y) {
x = find(x);
y = find(y);
fa[x] = y;
}

int main()
{
cin>>n>>m;
initSet(n);
for(int i=1;i<=m;i++){
cin>>a>>b>>c;
if(a){ // b和c是敌人
unionSet(b,c+n); //b和c的敌人是朋友
unionSet(b+n,c); //c和b的敌人是朋友
}
else unionSet(b,c);
}

int t;
for(int i=1;i<=n;i++){
t=find(i);
if(!vis[t]){
vis[t]=1;
cnt++;
}
}
cout<<cnt<<endl;
return 0;
}


6 作业

本节课的作业,就是复习上面的所有知识,并完成下面的题目!

1 【NOIP2017提高组A】奶酪

【题目描述】


现有一块大奶酪,它的高度为h,它的长度和宽度我们可以认为是无限大的,奶酪中间有许多半径相同的球形空洞。我们可以在这块奶酪中建立空间坐标系, 在坐标系中,奶酪的下表面为 z = 0,奶酪的上表面为 z = h。


现在, 奶酪的下表面有一只小老鼠 Jerry, 它知道奶酪中所有空洞的球心所在的坐标。如果两个空洞相切或是相交,则 Jerry 可以从其中一个空洞跑到另一个空洞,特别地,如果一个空洞与下表面相切或是相交, Jerry 则可以从奶酪下表面跑进空洞; 如果一个空洞与上表面相切或是相交, Jerry 则可以从空洞跑到奶酪上表面。


位于奶酪下表面的 Jerry 想知道, 在不破坏奶酪的情况下,能否利用已有的空洞跑到奶酪的上表面去?空间内两点  、   的距离公式如下: 

【输入描述】


每个输入文件包含多组数据。
输入文件的第一行,包含一个正整数 T,代表该输入文件中所含的数据组数。
接下来是 T 组数据,每组数据的格式如下:
第一行包含三个正整数 n, h 和 r, 两个数之间以一个空格分开,分别代表奶酪中空洞的数量,奶酪的高度和空洞的半径。
接下来的 n 行,每行包含三个整数 x, y, z, 两个数之间以一个空格分开, 表示空洞球心坐标为 (x,y,z)。


【输出描述】


输出文件包含 T 行,分别对应 T 组数据的答案,在第 i 组数据中:
(1)如果 Jerry 能从下表面跑到上表面,则输出“Yes”,
(2)如果不能,则输出“No”(均不包含引号)。


【示例】


【输入】
3
2 4 1
0 0 1
0 0 3
2 5 1
0 0 1
0 0 4
2 5 2
0 0 2
2 0 4
【输出】
Yes
No
Yes




AI与区块链技术

信息学赛培 | 04 并查集理论与实战详解

长按二维码关注