数据结构之并查集

来源:互联网 发布:淘宝订单批量导出 编辑:程序博客网 时间:2024/05/22 06:34

本文是我学习并查集的整个心路历程,若是还没学过或是想温习的朋友可以阅读整片博客,共同学习。希望你们能指出我的错误,谢谢。

注:由于是学习过程,关于算法的思考偏多,于是废话偏多,请合理,耐心阅读。


引言

并查集是树型的一种数据结构,其实把它当做算法更为合适。它常常用于处理集合与集合之间的合并与查找,故称并查集

接下来我以具体问题分析为什么用并查集:

(此题目出自“并查集_百度百科”)

问题一:Description若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。 规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。Input第一行:三个整数n,m,p,(n< =5000,m< =5000,p< =5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。 以下m行:每行两个数Mi,Mj,1< =Mi,Mj< =N,表示Mi和Mj具有亲戚关系。 接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。OutputP行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。

首先看到这道题,难免会想到图论,图论中就有关系矩阵,可明确表示各个元素之间的关系。

但是,如果使用关系矩阵来表示这道题,首先得有一个[ 5000 × 5000 ] 的矩阵来存储他们之间的关系,且不说内存是不是炸了,光是遍历整个关系矩阵花费的时间都是巨大的。

可能你会说:“不需要这么多的空间来存储,有很多人他们之间是没有关系的,那么便不需要存储。即是只需要存储有关系的人,把有关系的人当做一个集合。”

试想题目中的这句话:

如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。

如果我们把每个人看成一个点,把是亲戚的两个点用直线连起来。

假设 x1,x2,x3 是 x 的亲戚,y1,y2 是 y 的亲戚,并且 x 和 y 是亲戚,那么我可以用下面的图1来表示他们。

                                               图1

这不就是树状结构吗?


普通并查集

通过引言,我们想到了利用树状结构去解决“集合与集合之间关系的合并与查找”的问题。

但是,怎么样才能用用代码表示图1中的关系呢?

此时,我们定义一个存储根节点的数组 root , 那么有:

root [ x1 ]  = x , root [ x2 ]  = x ,root [ x3 ]  = x ,root [ y1 ]  = y ,root [ x2 ]  = y ,root [ x ] = y

所谓根节点就是和此节点有关系的节点,再思考以下问题:

x1 给我说,他有一个亲戚 z,于是 root [ z ] = x1。z 又给我说,他也有一个亲戚 z1,又 root [ z1 ] = z。 z1 还给我说。。。。。。直到 z5555

现在,倘若有个人想知道 z1111 与 z55555 是否是亲戚关系,那怎么办?

我需要通过 root [ z1000000 ] 一步一步的往上推,结果无非两种:1. 一步步推的时候,推出了他们两的关系;2. 把整个关系分支(树的分支)全部便利也没找到他们的关系。

以上两种结果,都会消耗大量的时间,有没有什么办法缩短时间呢?

于是我们引出了 路径压缩

状态压缩前我们需要有一个准备:初始化 root 数组

//初始化,在我们还未给出各节点的关系之前,我们先定义每个节点的根节点是他自己void init(){    for(int i = 1; i < MAX; i++)  //MAX是root数组的大小        root[i]= i;}

//递归实现int Find(int n){    if(n == root[n]) return n;    else    {        root[n] = Find(root[n]);  //改变所指根节点        return root[n];    }}
路径压缩的过程完美的体现了下面这句话

如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。
x1 是 x 的亲戚,root [ x1 ] = x。z 是 x1 的亲戚,如此表示 root [ z ] = x,也能够很清晰的表示 x1 与 z 之间的关系。如图2所示:

                       图2

经过路径压缩之后,有人再想知道 z5555 与 z1000000 之间的关系的话,就简单多了,比较他们两的 root 就可以了。

那么有关集合的查找我们已经做到了。

//非递归实现int Find(int n){    if(n == root[n]) return n;    else    {        int temp = root[n];        while(n != root[n])  //找到最终的根节点            n = root[n];        while(temp != n)  //路径压缩        {            int tt = root[temp];            root[temp] = n;            temp = tt;        }        return n;    }}

现在让我们来看看集合与集合的合并。

由于我们在之前状态压缩时把整个集合都“”在根节点上了,于是只需要改变根节点就可以改变整个集合的关系。

假设 x 与 y 有关系,此时我只需 root [ root [ x ] ] = root [ y ]。过程如图3。

                                                图3

int root1 = Find(mi), root2 = Find(mj);root[root1] = root2;
于是关于集合的合并我们也完成了,整个并查集也就完成了,剩下的就是利用并查集了。

/*问题一代码*/
#include<iostream>using namespace std;const int MAX = 5010;  // n 最大值int root[MAX];void init()  //初始化{for(int i = 0; i < MAX; i++)root[i] = i;}int Find(int n)  //查找函数{if(n == root[n]) return n;else{int temp = root[n];while(n != root[n])n = root[n];while(temp != n)  //路径压缩{int tt = root[temp];root[temp] = n;temp = n;}return n;}}int main(){int n, m, p;cin>>n>>m>>p;int mi, mj, pi, pj;init();while(m--){cin>>mi>>mj;int root1 = Find(mi), root2 = Find(mj);  //集合合并root[root1] = root2;}while(p--){cin>>pi>>pj;int root1 = Find(mi), root2 = Find(mj);if(root1 == root2) cout<<"Yes"<<endl;   //集合查询else cout<<"No"<<endl;}return 0;}

普通并查集题目:POJ 2236、POJ 1611 、 HDU 1213 。


带权并查集

普通并查集,自然是不够的,我们需要进阶才行。

(此题 POJ 1182

问题二:动物王国中有三类动物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(1 <= N <= 50,000)和K句话(0 <= K <= 100,000),输出假话的总数。 Input第一行是两个整数N和K,以一个空格分隔。 以下K行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中D表示说法的种类。 若D=1,则表示X和Y是同类。 若D=2,则表示X吃Y。Output只有一个整数,表示假话的数目。

第一次看这个问题想到的是用三个数组A,B,C,每一类动物都放到相应的数组里面去,每次遍历数组就知道每个元素之间的关系了,但是N,K太大,会超时。。。

首先明确并查集是整理集合与集合之间的关系,那么就这道题来说,有三类动物,各类动物之间有自己的关系,那么利用并查集来做可以吗?

于是引出了带权并查集

相比较问题一,问题二不再是单纯的两种关系了,变成了三种,那么仅仅是利用 root 整形数组来存储是不足以表示他们之间关系的了。

对于这样的三种关系,我们可以通过其他方法来解决:

const int MAX = 50010;struct node{    int father;   //根节点    int relation;   //与根节点的关系};node root[MAX];
每一个元素有其对应的节点 node,节点 node 存储了他的根节点 father(与问题一想法一致),还有一个 relation 表示与根节点的关系。

在这里,relation 有三个值 :

0 ,与根节点同类;

1,吃根节点;

2,被根节点吃。

假设 a,b,c 是三只动物,并规定 a 吃 b,a 吃 c。

那么我们可以 root [ b ] . father = a,root [ b ] .relation = 2;root [ c ]. father = a,root [ c ] .relation = 2。

我们还可以这样写 root [ b ] . father = c,root [ b ] . relation = 0;root [ a ]. father = c,root [ a ]. relation = 1。等等。。。很多写法

观察上述例子发现,在根节点不同的情况下,对应的关系也可能不一样!于是,我们在进行并查集路径压缩时,需要更新与根节点的关系。

更新与根节点的关系需要自己动手画一下图,列一下表,准确比对一下。详见图4。

                                                图4

//初始化数组void init(int n){    for(int i = 1; i <= n; i++)    {        root[i].father = i;  //一开始每个节点的根节点是自己        root[i].relation = 0;  //每个节点与自己是同类    }}
//递归实现int Find(int n){    if(n == root[n].father) return n;    else    {        int temp = root[n].father;  //保存原根节点        root[n].father = Find(root[n].father);  //状态压缩        root[n].relation = (root[n].relation + root[temp].relation) % 3;  //详见图4        return root[n].father;    }}
当然,代码中的 ‘%3’ 是我们画图找出来的规律,你也可以写多个 if 条件判断。

完成了集合与集合之间的查询,接着抿抿集合与集合之间的合并。

假设 x 与 y 有关系,此时不能像问题一一样把 x,y 的两个根节点“连接“起来,root [ x ] . father = root [ y ]. father。因为,你并不知道 x 的根节点与 y 的根节点之间的关系,需要去推导出来

你可以画图,列表,各种操作去推导公式,我在这给出我的想法:

假设 root1 = root [ x ] . father,root2 = root [ y ] . father。且 root [ root1 ]. father= root2,求 root [ root1 ]. relation。

试想我想知道 root1 与 root2 的关系,应该通过两步:

① 找到 root1 与 y 的关系;

② 通过①所得,还有 y 与 root2 的关系去建立 root1 与 root2 的关系。

(我新规定一个记法方便你们阅读:n 与 m 的关系我这样记 { n,m }。)

第①步恰好就是上面的集合与集合的查询,所以 { y,root1 } = ( { y ,x } + { x,root1 } ) % 3。

第②步画图,列表可得关系,详见图5:

                                                     图5

于是我们可以得到 { y,root1 }  =( { y,root2 } + { root1 ,root2 } ) % 3。

联立上述两个等式可得 { root1,root2 } = ( { y,x } + { x,root1 } - { y,root2 } )% 3。

公式有瑕疵,可能会出现负值,于是最终公式是:{ root1,root2 } = ( { y,x } + { x,root1 } - { y,root2 }  + 3)% 3

合并我们就完成了。剩下就是利用并查集查找了。

/*问题二代码*/
#include<iostream>#include<cstring>#include<cstdio>using namespace std;const int MAX = 50010;struct node{    int father;    //0代表与根同类,1代表吃根,2代表被跟吃    int relation;};node root[MAX];//初始化数组void init(int n){    for(int i = 1; i <= n; i++)    {        root[i].father = i;  //一开始每个节点的根节点是自己        root[i].relation = 0;  //每个节点与自己是同类    }}int Find(int n){    if(n == root[n].father) return n;    else    {        int temp = root[n].father;  //保存原根节点        root[n].father = Find(root[n].father);  //路径压缩        root[n].relation = (root[n].relation + root[temp].relation) % 3;  //详见图4        return root[n].father;    }}int main(){    int n, k, d, x, y;    scanf("%d%d", &n, &k);    init(n);    int cnt = 0;    while(k--)    {        scanf("%d%d%d", &d, &x, &y);        if(x > n || y > n)   //超出 n 的范围,错误        {            cnt++;            continue;        }        if(d == 2 && x == y)   //自己吃自己,错误        {            cnt++;            continue;        }        int root1 = Find(x), root2 = Find(y);        if(root1 != root2)        {            root[root1].father = root2;  //合并集合            root[root1].relation = ((d - 1) + root[y].relation - root[x].relation + 3) % 3;          }        else  //与前面话有矛盾,错误        {            if(d == 1 && root[x].relation != root[y].relation) cnt++;            if(d == 2 && (root[x].relation + d) % 3 != root[y].relation) cnt++;  //详见图6        }    }    printf("%d\n",cnt);    return 0;}
此代码有两处需要注意的地方:

① 输入量很大,用缓冲输入输出( cin )会超时;

② 在判断 n 吃 m 是否正确时,也需要画图,列表比对比对。这里我就不给出图了,自己画一画想想。


带权并查集题目:POJ 2492、POJ 2912、HDU 3038、POJ 1733(后两题的里面元素关系有点不一样,但题目思路一致)


总结

初见并查集是在做最小生成树时,这个算法很奇妙,而且容易理解,希望今后能够掌握并运用好这门算法,感谢你的阅读,若发现错误,请告诉我,谢谢。


原创粉丝点击