并查集,在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。这一类问题近几年来反复出现在信息学的国际国内赛题中。其特点是看似并不复杂,但数据量极大,若用正常的数据结构来描述的话,往往在空间上过大,计算机无法承受;即使在空间上勉强通过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题需要的结果,只能用并查集来描述。
并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。 ---------百度百科
并查集
可以这样子理解,就是黑帮火拼黑吃黑的过程,每一个黑帮只有一位老大(根),小弟 x 不用知道他帮派的老大,只需知道它的老大是谁(pr[x]=?),顺腾摸瓜找到它最大的老大,小弟 y 的最大老大与 x 的最大老大的话,x 与 y 就是同一个帮派的(在同一个集合中),火拼中,老大 x 失败了,认了 老大 y 为它的老大,(pr[x]=y)老大 x的小弟们就理所当然的成了y的小弟了,这就是我们后面提到的集合合并了
两种操作
合并(Union):把两个不相交的集合合并为一个集合。
查询(Find):查询两个元素是否在同一个集合中。
初始化
int fa[MAXN];
inline void init(int n)
{
for (int i = 1; i <= n; ++i)
fa[i] = i;}
假如有编号为1, 2, 3, ..., n的n个元素,我们用一个数组fa[]来存储每个元素的父节点(因为每个元素有且只有一个父节点,所以这是可行的)。一开始,我们先将它们的父节点设为自己
查询
int find(int x)
{
if(fa[x] == x)
return x;
else
return find(fa[x]);
}
我们通过递归一层一层的访问父节点,直到遇到根节点,也就是帮派的老大了,集合的代表
合并
inline void merge(int i, int j)
{
fa[find(i)] = find(j);
}
合并比较简单,只需要让一位老大(根元素)认另外一位老大做老大就好了,这里可以做一个并查集的优化,下面会讲到的,看一道题理解下,
洛谷1611 嫌疑人
严重急性呼吸系统综合症 (SARS) 是一种病因不明的非典型肺炎,于 2003 年 3 月中旬被确认为全球威胁。为了尽量减少向他人传播,最好的策略是将嫌疑人与他人分开。
在 Not-Spreading-Your-Sickness University (NSYSU),有很多学生团体。同一个小组的学生经常互相交流,一个学生可以加入几个小组。为了防止SARS的可能传播,南中大收集了所有学生团体的成员名单,并在其标准操作程序(SOP)中制定了以下规则。
一旦群组中的某个成员成为嫌疑人,则该群组中的所有成员都是嫌疑人。
然而,他们发现当一个学生被认定为嫌疑人时,要找出所有的嫌疑人并不容易。你的工作是编写一个程序来找出所有的嫌疑人。
输入文件包含几种情况。每个测试用例以一行中的两个整数 n 和 m 开头,其中 n 是学生数,m 是组数。您可以假设 0 < n <= 30000 和 0 <= m <= 500。每个学生都由 0 到 n-1 之间的唯一整数编号,并且最初学生 0 在所有情况下都被识别为嫌疑人。此行后面是 m 个组的成员列表,每个组一行。每行以一个整数 k 开头,代表组中成员的数量。在成员数量之后,有 k 个整数代表该组中的学生。一行中的所有整数至少用一个空格隔开。
n = 0 和 m = 0 的情况表示输入结束,不需要处理。
输入
输入文件包含几种情况。每个测试用例以一行中的两个整数 n 和 m 开头,其中 n 是学生数,m 是组数。您可以假设 0 < n <= 30000 和 0 <= m <= 500。每个学生都由 0 到 n-1 之间的唯一整数编号,并且最初学生 0 在所有情况下都被识别为嫌疑人。此行后面是 m 个组的成员列表,每个组一行。每行以一个整数 k 开头,代表组中成员的数量。在成员数量之后,有 k 个整数代表该组中的学生。一行中的所有整数至少用一个空格隔开。
n = 0 和 m = 0 的情况表示输入结束,不需要处理。
输出
对于每种情况,在一行中输出嫌疑人的数量。
#include <iostream>
#include <cstring>
#include <cstdio>
#include <cstdlib>
const int N=30005;
int fa[N];
int rink[N];
using namespace std;
int find(int x){
while(x!=fa[x]){
x=fa[x];
}
return x;
}
int main(){
int n,m;
while(~scanf("%d%d",&n,&m)){
if(n==0&&m==0)break;
for(int i=0;i<n;i++){
fa[i]=i;
rink[i]=0;
}
if(m==0){
printf("1\n");
continue;
}
int a,b,k;
while(m--){
scanf("%d",&k);
k--;
scanf("%d",&a);
while(k--){
scanf("%d",&b);
a=find(a);
b=find(b);
if(rink[a]>rink[b]){
fa[b]=a;
}
else
{
fa[a] = b;
if(rink[a] == rink[b])
rink[b]++;
a = b;
}
}
}
int ans = 0, x = find(0);
for(int i=0; i<n; i++)
{
if(find(i) == x) ans++; //这里不能写fa[i]==x,会出错,要再更新一次根节点才行
}
printf("%d\n", ans);
}
return 0;
}
对于上述的合并,我们可以优化下
假设有1000这样的元素,按照上述的方法我们需要遍历1000次才能找的他的跟元素,我们可以从建立集合的时候避免这种,我们记录每个集合的深度
这两个集合我们让谁并入谁,这是一个问题,我们上面提出了当集合足够深时,寻找跟节点的难度,我们可以在创建集合时,选择创建深度不变或者是选择深度改变最小的方式
如上的节点,我们选择根节点 R1 并入 R2 ,R2 的深度变成了5,如果是 R2 并入 R1 ,R1 的深度却不变,
用数组rank,记录深度
void merge(int a,int b){
a=find(a);//寻找a的根节点
b=find(b);
if(a==b)return ;//同一个集合
if(rank[a]>rank[b]){
p[b]=a;//
}else{
p[a]=b;
if(rank[a]==rank[b])rank[b]++;
}//如果是深度相同,
}
路径压缩
这样可能会形成一条长长的链,随着链越来越长,我们想要从底部找到根节点会变得越来越难。
怎么解决呢?我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步。
基于循环的路径压缩操作
写法很容易实现:x 元素不再选择原来的父亲节点,而是直接选择父亲节点的父亲节点来做为自己新的一个父亲节点,查询的层次确实减少了,效率提高了不少。
int find(int p){
assert(p>=0&&p<count);//防止数组越界
while(p != parent[p]){//如果p元素的父亲指针指向的不是自己,说明p并不是集合中的根元素,还需要一直向上查找和路径压缩
//在find查询中嵌入一个路径压缩操作
parent[p]=parent[parent[p]];
//p元素不再选择原来的父亲节点,而是直接选择父亲节点的父亲节点来做为自己新的一个父亲节点
//这样的操作使得树的层数被压缩了
p=parent[p];//p压缩完毕后且p并不是根节点,p变成p新的父节点继续进行查找和压缩的同时操作
}
return p;//经过while循环后,p=parent[p],一定是一个根节点,且不能够再进行压缩了,我们返回即可
}
基于递归的路径压缩操作
这样的压缩操作使得集合树只有两层。所有的节点都指向根节点,这种情况下,我们搜索任何节点的根节点都最多只需要一步就能够完成,因此,我们能不能设计另外一个算法,把集合树压缩成上面的两层形状呢?答案是可以的。现在让我们具体的来讨论如何实现:
因为find操作返回的是集合的根节点,因此我们只需要集合中所有的非根节点的父亲指针都指向这个根节点就好了,我们完全可以用递归的方法去实现
int find(int x)
{
if(x == fa[x])
return x;
else{
fa[x] = find(fa[x]); //父节点设为根节点
return fa[x]; //返回父节点
}
}
这样的路径压缩效率同样也是很高的,虽然递归的使用会增加额外的开销,但是我们认为是值得的。