5.1 并查集
并查集是一种精巧且实用的数据结构,主要用于处理一些不相交集合的合并问题。
如果还记得紫书十一章内容的同学,这个概念肯定不是第一次见了,在讲解Kruskal算法时就已经简单介绍过这种数据结构:其中图上的结点分割成一个一个不相交的点集合,并查集将一个集合中的点用一种类似于链表(递归)的方法存储着,集合中的点不存在次序关系,只存在是否属于某个“帮派”的属性,优势就在于两个集合的合并就像链表的插入一样非常之快。当时也提到了并查集可能遇到的特殊情况:树退化成一条长链子从而降低查找速度。解决的方法是在遍历的同时,将遍历过的结点都改成树根的子节点,就可以增加下次查找的效率。
Kruskal算法:
int cmp(const int i,const int j){
return w[i]<w[j];}//w表示权重
int find(int x){
return p[x]==x?x:p[x]=find(p[x]);}//返回集合编号
//但如果只为了得到集合(树)的编号(根),不需要有赋值的操作:p[x]=find(p[x])
//赋值的操作就是前面提到的对特殊情况的优化:将遍历过的点都改成树根的子节点
int Kruskal(){
int ans=0; for (int i=0;i<n;i++) p[i]=i;//建立并查集
for (int i=0;i<m;i++) r[i]=i;//给每条边配上序号,排序后将第i小的边的序号保存在r[i]
//排序的关键字是对象的代号,而不是对象本身,这被称为间接排序
sort(r,r+m,cmp);//进行排序,u[i]和v[i]分别表示编号为i的结点的两个端点的序号
for (int i=0;i<m;i++){
int x=find(u[r[i]]),y=find(v[r[i]]);
if (x!=y) {
ans+=w[r[i]]; p[x]=y;}//ans+=w[r[i]]相当于加上边的权重,这个很好理解
//p[x]=y相当于将v结点所在树接到u结点的根上(因为不考虑树的形态,只要保证连通即可)
} return ans;
}
这里我们接着黑书的内容从基本的操作更加细致的了解并查集。
并查集操作的简单实现
首先我们先设定一个简单的背景:在一个城市中有n个人,他们组成不同的“帮派”,给出一些人之间的关系,例如:1号,2号是朋友,1号,3号是朋友,那么他们都属于一个帮派。现在的问题是,在这些关系下,有多少帮派,没人属于哪个帮派。
分析:简单来说,n个结点的图,给定m条有向边,问你有多少个连通分量,每个结点属于哪个分量。
这里就用并查集,将n个结点划分为不相交的集合,然后用一个集合中的某个结点来代表所在集合。
并查集的初始化
定义数组s[i]是以节点i为元素的并查集,在开始还没有处理朋友与朋友之间的关系时,每个人都属于一个独立的集,并且以元素i的值表示它的集合s[i]。
并查集的合并
例如加入第一个朋友关系(1,2),就是将朋友1所在的“帮派”与朋友2所在的“帮派”进行合并。递归查找到结点1所在的集为1,结点2所在的集为2,将结点1的集1改成集2(改哪个没什么实际影响)。
加入第二个朋友关系(1,3),递归查找到结点1所在的集为2,结点2的集为2,结点3所在的集为3,将元素2的集2合并到集3,此时结点1,2,3属于一个集。
这里的合并并不是一个很好的合并,因为大家可以发现一个集合只有3个元素,但是链的长度已经达到了3,树退化成了长链,查找的复杂度会很高。
并查集的查找
在前面的操作中就已经需要查找操作了:查找元素的集是一个递归的过程,例如我们查找上面的并查集中结点1的所在集。
此时s[1]不等于1,说明1不是结点1的所在集,对s[1]=2进行查找,s[s[1]]=s[2]=3,此时s[2]不等于2,继续向下查找,s[3]=3,说明3是1的所在集。
可以发现像上面这种退化成长链的情况会影响查找的速度,所以在实际操作的过程中还需要很多的优化
统计有多少个集
如果s[i]=i,说明这是一个根结点,根结点的数量即是集的数量。
看一下并查集在实际问题中的运用。
hdu 1213 “How Many Tables”
有n个人一起吃饭,有些人互相认识,认识的人想坐一桌,例如A认识B,B认识C,那么A,B,C就会坐同一桌。
问需要给出多少张桌子。
分析:就是我们拿来做例子的帮派问题。
#include<iostream>
using namespace std;
int s[1050];
void init_set(int n){
for (int i=1;i<=n;i++) s[i]=i;}//初始化并查集
int find_set(int x){
return x==s[x]?x:find_set(s[x]);}//查找并查集,s[x]=x时,说明为集合的根节点
void union_set(int x,int y){
x=find_set(x); y=find_set(y);//合并并查集,找到两个集的编号
if (x!=y) s[x]=s[y];//如果不是同一个集,将其中一个集代表元素的s值改成另一个集合的代表元素即可
}
int main(){
int t,n,m,x,y; cin>>t;
while (t--){
cin>>n>>m; init_set(n);//初始化并查集
for (int i=1;i<=m;i++){
cin>>x>>y; union_set(x,y);}//合并两个朋友的所在"帮派"
int ans=0; for (int i=1;i<=n;i++) if (s[i]==i) ans++;//统计集的个数
cout<<ans<<endl;
} return 0;
}
并查集操作的优化
以上都是最简单的操作,在实际的使用中往往性能比较差,下面介绍一些优化的方法。
合并的优化
给集合对应的树添加高度的属性,将高度较小的树加入到高度较大的树的根节点下。
int height[maxn];//表示集i对应树的高度
void init_set(int n){
//初始化并查集,树的高度初始化为0
for (int i=1;i<=n;i++) {
s[i]=i; height[i]=0;}
}//并查集合并
void union_set(int x,int y){
x=find_set(x); y=find_set(y);
if (height[x]==height[y]){
height[x]=height[x]+1; s[y]=x;}//两个树一样高,无法用一个树覆盖另一个树,合并后树的高度+1
else if (height[x]<height[y]) s[x]=y;//y的树比较高,用y的树来覆盖x树,高度保持为y树的高度
else s[y]=x;//x的树比较高,用x树来覆盖y树,高度保持为x树的高度
}
查询的优化
这个优化我们在Kruskal算法中就见到过了,就是在查询搜索的过程中,将路径上的结点的前一个结点全部改成根节点。
这种优化方式降低了树的深度,提高了下次查询的效率。
代码的话比较简单,加一个赋值即可:
//并查集查询
int find_set(int x){
return s[x]==x?x:s[x]=find_set(s[x]);}
整体的模板如下:
int height[maxn];//表示集i对应树的高度
void init_set(int n){
//初始化并查集,树的高度初始化为0
for (int i=1;i<=n;i++) {
s[i]=i; height[i]=0;}
}//并查集查询
int find_set(int x){
return s[x]==x?x:s[x]=find_set(s[x]);}
//并查集合并
void union_set(int x,int y){
x=find_set(x); y=find_set(y);
if (height[x]==height[y]){
height[x]=height[x]+1; s[y]=x;}//两个树一样高,无法用一个树覆盖另一个树,合并后树的高度+1
else if (height[x]<height[y]) s[x]=y;//y的树比较高,用y的树来覆盖x树,高度保持为y树的高度
else s[y]=x;//x的树比较高,用x树来覆盖y树,高度保持为x树的高度
}
习题
poj 2524 “Ubiquitous Religious”
直接看分析
分析:没有看题,看了一下数据感觉就是前面说的问题,改了一下直接过了,没啥好说的。
#include<iostream>
using namespace std;
int height[50010],s[50010];//表示集i对应树的高度
void init_set(int n){
//初始化并查集,树的高度初始化为0
for (int i=1;i<=n;i++) {
s[i]=i; height[i]=0;}
}//并查集查询
int find_set(int x){
return s[x]==x?x:s[x]=find_set(s[x]);}
//并查集合并
void union_set(int