【原创】并查集之扩展域与边带权

【前言】

并查集是一种可以动态维护若干个不重叠的集合,并支持合并于查询的数据结构。
并查集的基本概念很简单,但是这样一种思想的用途十分广泛。
 
个人理解:这是一种很巧妙的,可以很好的处理对象之间关系的数据结构。
 
那么先在这里提一下并查集的适用问题(划重点):
  • 在一张无向图中维护节点之间的连通性或子图之间的连通性(图论优化)
  • 动态维护许多具有传递性的关系(基本特性)
  • 利用路径压缩来统计每个节点到树根之间路径上的一些信息(边带权)
  • 维护具有多重关系的集合(扩展域)
以上基本上就是最高涉及到NOI级别难度的并查集应用了,再高一点,就是与其他算法和数据结构相结合,比如说并查集经常用于维护图论算法。
那么,我们先从简单开始,只是简单地讨论一下并查集的实现方法和特性。毕竟看到这篇文章的各位应该都对并查集有了基础的理解。

【并查集之概念】

在并查集中,我们使用“代表元”的方法体现集合的划分。
意即,我们为每个集合选择一个固定的元素作为整个集合的代表,在查找该集合中某一元素时,我们返回这个集合的代表元即可。
有两种思路实现并查集,一种是数组,一种是树。我们使用静态实现一棵树来表示一个并查集。

【并查集的代码实现(c++)】

具体而言,并查集实际上只有两种操作,一种是将两个集合并起来,一种是查询某个元素在哪个集合。
  1. 初始化
for(int i=1;i<=t;i++) fa[i]=i;

 

  1. Get操作
int get(int x)
{
  if(fa[x]==x) return x;
  get(fa[x]);
}

 

  1. Merge操作
void merge(int x,int y)
{
  fa[x]=y;
}

 

如你所见,十分简洁。

【路径压缩】

这是对并查集进行优化的最常用方法。
由于我们在查询元素时,并不关心这棵树到底长什么样,只要知道它的代表元即可,那我们就可以让树中的所有元素全部指向代表元,压缩访问路径,减少访问时间。
int get(int x)
{
  if(fa[x]==x) return x;
  return fa[x]=get(fa[x]);
}

 

【最小生成树之kruskal】

  • 在一张无向图中维护节点之间的连通性或子图之间的连通性
这个问题的具体情景,我们可以参考最小生成树。
 
> 最小生成树(MST)性质:
给定一张边带权的无向图G=(V,E),n=|V|,m=|E|。由V中全部n个顶点和E中n-1条边构成的无向联通子图被称为G的一棵生成树。边的权值之和最小的生成树被称为G的最小生成树。
 
也就是说,我们要找出一张无向图中带有n-1个点的权值总和最小的子图。
 
  • 定理:任意一颗最小生成树总是包含无向图中权值最小的边。
该定理可用数学反证法证明。
 
Kruskal算法数据结构基于并查集,理论基础基于上述定理。Kruskal算法总是维护无向图的最小生成森林,使得任意时刻的生成子图权值和最小。基于这种思想,我们用并查集解之。
对于已经升序排列的边集,我们每次逐个递增选取一条边加入并查集以维护生成的子图。
标准模板如下:
#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
struct rec{
    int x,y,z;
}edge[500010];
int fa[100010],n,m,ans=0;
bool operator<(rec a,rec b){
    return a.z<b.z;
}
int get(int x){
    if(x==fa[x]) return x;
    else return fa[x]=get(fa[x]);
}
int main()
{
    cin>>n>>m;
    for(int i=1;i<=m;i++)
        scanf("%d%d%d",&edge[i].x,&edge[i].y,&edge[i].z);
    sort(edge+1,edge+m+1);
    for(int i=1;i<=n;i++) fa[i]=i;
    for(int i=1;i<=m;i++){
        int x=get(edge[i].x),y=get(edge[i].y);
        if(x==y) continue;
        fa[x]=y;
        ans+=edge[i].z;
    }
    if(ans) cout<<ans<<endl;
    else cout<<'MST not found'<<endl;
    return 0;
}

 

【边带权的并查集】

边带权并查集,顾名思义,就是各个结点之间带有权值的并查集,在查询和合并时可以维护结点之间的权值。边带权并查集可以用于统计每个节点到树根之间路径上的一些信息。
详细内容我们到具体题目中讲解吧。
 

[NOI2002]银河英雄传说

公元五八○一年,地球居民迁至金牛座α第二行星,在那里发表银河联邦创立宣言,同年改元为宇宙历元年,并开始向银河系深处拓展。
宇宙历七九九年,银河系的两大军事集团在巴米利恩星域爆发战争。泰山压顶集团派宇宙舰队司令莱因哈特率领十万余艘战舰出征,气吞山河集团点名将杨威利组织麾下三万艘战舰迎敌。
杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成30000列,每列依次编号为1,2,…,30000。之后,他把自己的战舰也依次编号为1,2,…,30000,让第i号战舰处于第i列(i=1,2,…,30000),形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为Mi,j​,含义为第i号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第j号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。
然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。
在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:Ci,j​。该指令意思是,询问电脑,杨威利的第i号战舰与第j号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。
作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。
最终的决战已经展开,银河的历史又翻过了一页……
 

解析:

这道题如果想到了用并查集求解,那就还是不复杂的。
对这道题稍加分析,就能发现每一列其实就可以看作一个集合,在移动位置时将某两列合并就可以了。而任意两个战舰之间的战舰个数,可以利用我们上面提到的边带权并查集求解。
 
在没有进行路径压缩时,我们知道对于任意一个战舰x,它的前面那个战舰的编号就是fa[x],它所在集合的代表元就是列首。如果我们假设所有战舰之间的距离为1的话,那么一个战舰到另一个战舰的距离就是该战舰到列首的距离减去另一个战舰到列首的距离的绝对值减去1(距离比战舰个数多1)。我们可以开一个数组d[]来保存这些距离。
 
在进行路径压缩的时候,我们对每个战舰x上对于fa[x]之间的距离进行更新,使得这个距离变为这个战舰x到列首的距离。具体操作见代码:
int get(int x)
{
if(fa[x]==x) return x;
int root=get(fa[x]);
d[x]+=d[fa[x]];
return fa[x]=root;
}

 

也就是说,每次暂存一个战舰的前面那个战舰,再更新这个战舰到它前面那个战舰的距离。
 
接下来是合并。
对我们来说,合并一个集合就相当于将一队战舰接到另一队战舰后面。也就是说,我们只要合并两个集合就行了。接下来,我们还要更新d[]数组。我们使用这样一个策略:构建一个表示每一列战舰长度的数组size[],在每次合并时将被接的那一列战舰的长度加上接上去那一列战舰的长度就OK了。具体操作见代码:
void merge(int x,int y)
{
    int nx=get(x),ny=get(y);
    fa[nx]=ny;
    d[nx]=size[ny];
    size[ny]+=size[nx];
}

 

 
这里要注意一个小问题,size数组初值要赋值为1,由于每列初始只有一艘战舰,而d数组初值要赋值为0,由于每列一开始列首到自己的距离为0。
 
更多细节,就看AC代码吧:
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<queue>
#include<vector>
#include<cstring>
#define N 30010
using namespace std;
int d[N],fa[N],size[N];
int get(int x)
{
  if(fa[x]==x) return x;
  int root=get(fa[x]);
  d[x]+=d[fa[x]];
  return fa[x]=root;
}
void merge(int x,int y)
{
  int nx=get(x),ny=get(y);
  fa[nx]=ny;
  d[nx]=size[ny];
  size[ny]+=size[nx];
}
int main()
{
  int t;
  cin>>t;
  fill(size+1,size+N,1);
  fill(d+1,d+N,0);
  for(int i=1;i<=N;i++) fa[i]=i;
  while(t--)
  {
    char c;
    int x,y;
    cin>>c>>x>>y;
    if(c=='M') merge(x,y);
    else{
    int nx=get(x),ny=get(y);
    if(nx==ny)
    printf("%d\n",abs(d[x]-d[y])-1);
    else cout<<"-1"<<endl;
    }
   }
  return 0;
}

 

 
看到这里,各位想必也对边带权并查集有更深入的理解了,接下来我们谈谈重头戏——扩展域,反正我觉得这玩意挺难的。
 

【扩展域并查集】

扩展域用以维护较抽象的对象之间的逻辑关系,由于有点抽象,所以理解起来有点费劲。
扩展域将数据之间的关系分类,将对象之间不同的关系种类分开讨论,并在这些数据之间建立联系。
而且,这样的关系最好要具有传递性,这样某些对象之间的关系就能相互导出。
这里难以理解的一点主要是,扩展域仅仅维护对象之间的关系,而不关心对象的其他特征。
 
PS:目前为止,我只见到过维护二元关系的扩展域并查集。
详细内容我们看两道例题。
 

P1525 关押罪犯

S城现有两座监狱,一共关押着N名罪犯,编号分别为1−N。他们之间的关系自然也极不和谐。很多罪犯之间甚至积怨已久,如果客观条件具备则随时可能爆发冲突。我们用“怨气值”(一个正整数值)来表示某两名罪犯之间的仇恨程度,怨气值越大,则这两名罪犯之间的积怨越多。如果两名怨气值为c 的罪犯被关押在同一监狱,他们俩之间会发生摩擦,并造成影响力为c的冲突事件。
每年年末,警察局会将本年内监狱中的所有冲突事件按影响力从大到小排成一个列表,然后上报到S 城Z 市长那里。公务繁忙的Z 市长只会去看列表中的第一个事件的影响力,如果影响很坏,他就会考虑撤换警察局长。
在详细考察了N 名罪犯间的矛盾关系后,警察局长觉得压力巨大。他准备将罪犯们在两座监狱内重新分配,以求产生的冲突事件影响力都较小,从而保住自己的乌纱帽。假设只要处于同一监狱内的某两个罪犯间有仇恨,那么他们一定会在每年的某个时候发生摩擦。
那么,应如何分配罪犯,才能使Z 市长看到的那个冲突事件的影响力最小?这个最小值是多少?
 

解析:

这道题可以作为并查集扩展域的入门题目,在做过这道题后,我们会对扩展域有更深刻的理解。
 
假设x和y在同一个监狱里,那么他们必然不会在不同的监狱里;如果x和y在不同的监狱里,那他们就不会在同一个监狱里。
听起来有点绕,我们分析一下:如果有一组关系表明x和y在同一个监狱,那么就可以推导出另一组关系x和y不会在不同的监狱。反之亦然。
 
于是我们可以给一个并查集划分几个不同的种类(或者叫域),用来存放对象之间不同的关系。
说说这道题我的解题思路吧:按照怨气值把较大的两个人分开,直到分不下去(两个罪犯无法避免在同一个监狱里)。
 

算法实现:

1.边权值大到小排序。
2.每次维护并查集:x_self表示罪犯x所在监狱,x_another表示另1个监狱。删一条边就把x_self和y_another合并,y_self和x_another合并。前提条件:分不下去,二者在一个监狱(x_self=y_self||x_another=y_another)。
注意:由于有两个域,因此我们在初始化并查集时一定要给出两个域的空间出来。
 
具体细节见代码:
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cmath>
#include<cstring>
#include<queue>
#include<vector>
#define N 100010
using namespace std;
struct p{
    int x,y,val;
}a[N];
bool operator<(p a,p b){
    return a.val>b.val;
}
int head[N<<1],fa[N<<3],tot,n,m;
int get(int x)
{
    if(fa[x]==x) return x;
    return fa[x]=get(fa[x]);
}
void merge(int x,int y)
{
    x=get(x),y=get(y);
    if(x!=y) fa[x]=y;
}
int main()
{
    scanf("%d%d",&n,&m);
    for(int i=1;i<=2*n;i++) fa[i]=i;
    for(int i=1;i<=m;i++){
        scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].val);
    }
    sort(a+1,a+m+1);
    int ans=0;
    for(int i=1;i<=m;i++){
        int x_self=a[i].x,x_another=a[i].x+n;
        int y_self=a[i].y,y_another=a[i].y+n;
        if(get(x_self)==get(y_self)||get(x_another)==get(y_another)){
            ans=a[i].val;break;
        }
        merge(x_self,y_another);
        merge(x_another,y_self);
    }
    cout<<ans<<endl;
    return 0;
}

 

 
看完这道题目,想必也对扩展域有了基本的认识了,那么我们深入一点,稍稍讨论一道复杂一点的题目。首先要说明一点,下面这道题是不可多得的好题啊,其实上面这道也是啦。
 

P2024 [NOI2001]食物链

动物王国中有三类动物 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 句话有的是真
的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
• 当前的话与前面的某些真的话冲突,就是假话
• 当前的话中 X 或 Y 比 N 大,就是假话
• 当前的话表示 X 吃 X,就是假话
你的任务是根据给定的 N 和 K 句话,输出假话的总数。
 

解析:

如果循序渐进先做完上面的题目再,到这里的话,理解这道题的题意并想到思路应该是不难的。
 
关键点在于这道题给出关系的特殊性,使得题目给出的三种关系之间联系十分紧密,三者之中任意知道两者就可推出另外一个关系。这也使得我们可以用扩展域来解这道题,否则我们就得用边带权并查集了。
 
思路如下:
 扩展域并查集维护三个域:x_self同类,x_enemy天敌,x_eat捕食。
他们之间的关系:
假设有x,y两个动物,
 
如果题目给定x和y是同类,那么合并x_self,y_self和x_enemy,y_enemy和x_eat和y_eat。
前提条件是:x_self与y_eat不在同一个集合,x_eat与y_self不在同一个集合。
 
如果题目给定x吃y,这里我们注意到一点,由于题目给出的食物链关系是环形,因此我们可以由“x吃y”推断出“x的天敌是y的猎物”。
由此得到,合并x_eat,y_self和x_self,y_enemy和x_enemy,y_eat。
前提条件是:x_self和y_self不在一个集合,x_self和y_eat不在一个集合。
具体细节见代码:
 1 #include<cstdio>
 2 #include<algorithm>
 3 #include<queue>
 4 #include<cstring>
 5 #include<iostream>
 6 #define N 50010
 7 using namespace std;
 8 struct node{
 9     int x,y,flag;
10 }g[N<<4];
11 int n,k,fa[N<<4];
12 int get(int x)
13 {
14     if(fa[x]==x) return x;
15     return fa[x]=get(fa[x]);
16 }
17 void merge(int x,int y)
18 {
19     x=get(x),y=get(y);
20     fa[x]=y;
21 }
22 int main()
23 {
24     //freopen("fuc.in","r",stdin);
25     //freopen("fuc.out","w",stdout);
26     int cnt=0;
27     scanf("%d%d",&n,&k);
28     for(int i=1;i<=n*3;i++) fa[i]=i;
29     for(int i=1;i<=k;i++)
30         scanf("%d%d%d",&g[i].flag,&g[i].x,&g[i].y);
31     for(int i=1;i<=k;i++){
32         if(g[i].x>n||g[i].y>n){
33             cnt++;continue;
34         }
35         int x_self=g[i].x,x_enemy=g[i].x+n,x_eat=g[i].x+n+n;
36         int y_self=g[i].y,y_enemy=g[i].y+n,y_eat=g[i].y+n+n;
37         if(g[i].flag==1){
38             if(get(x_eat)==get(y_self)||get(x_self)==get(y_eat)){
39                 cnt++;
40             }
41             else{
42                 merge(x_self,y_self);
43                 merge(x_eat,y_eat);
44                 merge(x_enemy,y_enemy);
45             }
46         }
47         else{
48             if(get(x_self)==get(y_self)||get(y_eat)==get(x_self)){
49                 cnt++;
50             }
51             else{
52                 merge(x_self,y_enemy);
53                 merge(x_eat,y_self);
54                 merge(x_enemy,y_eat);
55             }
56         }
57     }
58     cout<<cnt<<endl;
59     return 0;
60 }

 

 
这道题的确值得好好深入理解,如果这道题的精髓能掌握了,那么距离理解并查集也就更深入了一层,但是如果要真正理解并查集所维护的所谓“关系”的本质,我们还需要学习很多其它知识,就目前我们所看到的,已经勉强足够使用了。
 
【后记】
总而言之,并查集是一个十分重要的数据结构,在很多想都想不到的地方都存在着它的身影,比如说 POJ1733 这道题,如果没有细致而敏锐的观察力,根本想不到这上面。所以,OI无论是对思维的强度还是灵敏度的要求都是极大的,如果看完文章的你能更加体会到这一点,那也足够了。

posted @ 2019-06-06 22:04  DarkValkyrie  阅读(1406)  评论(0编辑  收藏  举报