一种简单而有趣的数据结构——并查集

来源:互联网 发布:我朝太宗 知乎 编辑:程序博客网 时间:2024/06/06 04:00
 

一种简单而有趣的数据结构——并查集

作者:goal00001111(高粱)

 

一个秘密生物武器落到某地区,导致当地村民丧失部分记忆,只认得自己最熟悉的人,而忘记自己是哪个村子的人了。大家汇集到一个广场,寻找自己同村的亲人。很不幸你就是其中的一员,记忆中只认得你的爸爸,妈妈还有妹妹了,然后爸爸又认出了叔叔,叔叔找到了婶婶,这样你们的家庭成员就逐渐扩大起来。

       寻亲工作仍在继续,这时候迎面走来了一个年轻的小伙子,看上去很面熟,但是你已经想不起他是谁了。他会不会是你失散的弟弟?属不属于你的家族?真是一个难题!

假如他和你家族中的某个人相识,就可以归入你的家族;否则他就不是你的亲戚。为了判断这个年轻的小伙子到底是不是家族成员,你该怎么办呢?

让我们来回顾整个过程:最开始的你孑然一身,然后遇见了爸爸;爸爸找到了叔叔;叔叔又认出了婶婶。。。。。。家族规模在扩大,这个时候你遇见了那个很可能是你弟弟的人,如何判断他是不是你的弟弟呢?

你很容易想到:把爸爸,叔叔和婶婶都叫到一起来,问问他们认不认识他,如果有一个人认识他,他就可以回到家庭温暖的怀抱。

如果家族人员很少,召开类似的家庭会议是很容易的,可是如果家族规模扩大,有多达成千上万的人呢?判断两个人是否属于同一个家族还真不是一件容易的事情啊!

计算机擅长处理大规模的数据,前提是程序员为它编制一套完美的程序。现在就让我们设计程序来指挥计算机帮助我们判断众人之中的两位是否属于同一家族吧。

先让我们用与机器语言比较接近的一种语言——数学语言来描述这个问题:

假设有n个人(用1,2,3,..,n等正整数来表示他们的序号),用一个数对来表示两人属于同一家族,如数对(23)表示2号成员和3号成员属于同一家族;(28)表示2号成员和8号成员属于同一家族。我们因此可以推导出3号和8号也属于同一家族。

现在给出m个数对,和任意两个序号ij1<=i<j<=n),要求判断ij是否属于同一家族。

例如n = 4m = 33个数对分别是(12),(13)和(34),请判断i(=2)j(=4)是否属于同一家族。

当然我们现在是一眼就可以看出答案的,但是当n=10000m=10000的时候呢?你的眼睛还有这么厉害吗?

一个比较常见,也容易想到的方法是建立一个人脉关系网,朋友的朋友就是我的朋友,族人的族人就是我的族人,打个招呼大家就都认识啦。人脉关系网我们可以用一个图来表示,每个人都是一个结点,关系数对就表示一条边。这样在判断两个人是否属于同一家族时,只要从其中一人出发,遍历这个图,看看能否找到另一个人就行了。关于图的存储方式我们可以用邻接表来实现——结点太多,创建一个稀疏的邻接矩阵是不现实的。而遍历方式的话,广度优先搜索算法是一个不错的选择。

这个方法我自认为是不错的,时间复杂度和空间复杂度也都还好,但是图论是一个复杂的东西,深搜广搜更是搞得人头都大了,能不能不用它啊?

当然可以!

让我们再仔细分析这个问题,它其实包含两个操作:一是根据给定的关系数对,将两个人归并到同一家族;二是判断两个人是否属于同一家族。如果我们转换一下思路,对原问题稍作改动,给每个家族都设置一个族长,就可以把原来的两个操作转化为:一是将两个人归并到同一个族长门下;二是判断两个人是否在同一族长门下。

这样我们就可以利用树这种简单的数据结构了:一棵树就代表一个家族,树的根结点就是族长大人,其他结点都是族长的后代(辈分好像有点乱,呵呵)。我们为每个结点都设置一个父亲结点,因为儿子的数量不确定,就不设置儿子结点了。很明显族长他老人家的父亲结点就是死去的先人了。

我们可以用数组或链表来表示这颗树,为简洁起见,我这里用数组father[]表示,有兴趣的同学可以自己实现链表的算法。数组的下标就是该成员的序号,而数组的值用来存储其父结点的序号。

很明显,最初大家都是孤身一人,自己就是本族的族长,族长的父亲结点是死去的先人,设为0(成员序号从1开始数起,一直到n)。这样先做一个初始化的工作,设所有的数组元素值均为0

依次给出3个数对(12),(13)和(34),让我们来看看这里面发生了什么事情。

首先12相遇,我们设定数对中的第一个成员当族长,于是1号成了这个家族的族长,而2号的父亲结点就由先人伯伯变成了1号,即father[1]=0father[2]=1

       接下来1又碰到了31号先找到族长——就是自己,请求他与3号认亲。按照默认的规则,3号加入1号所在的家族,1号成了3号的族长。

最后34相遇,3号也必须先找到自己的族长1号大人,可以想象,4号是怎样加入到这个家族的。

就这样家族成员扩大到4个,数组元素的值依次为:father[1]=0father[2]=1father[3]=1father[4]=1。我们于是知道了24是同一家族的成员。

这样我们就简单地模拟了一个家族的成长过程,基本上就是两个操作:寻找族长和并入家族。

c++语言描述这两个基本操作:(注意所有的数组元素值均初始化为0

/*

函数名称:FindFather

函数功能:寻找家族成员pos的族长大人

输入变量:int father[]:存储了家族树信息的整型数组

          int pos 家族成员(即数组元素)的序号,1 <= pos <= nn表示家族最大成员数量

输出变量:无

返回值:int :家族成员pos的族长大人的序号。若pos本身是族长,则返回自身 

*/

intFindFather(int father[], int pos)

{

    if (father[pos] == 0)

        return pos;

       

    return FindFather(father, father[pos]);

}

 

/*

函数名称:Unite

函数功能:将成员posIposJ合并到同一个家族

输入变量:int father[]:存储了家族树信息的整型数组

          int posI, posJ 家族成员posIposJ的序号(即数组元素下标)

输出变量:无

返回值:bool :合并是否成功信息。如果posIposJ是同一个族长门下,不必合并,返回false,否则合并成功返回true

*/

bool Unite(intfather[], int posI, int posJ)

{

    //首先各自去寻找自己的族长

    int fI = FindFather(father, posI);

    int fJ = FindFather(father, posJ);

   

    if (fI == fJ) //如果是同一个族长门下,不必合并,即合并失败

        return false;

    else

        father[fJ] = fI; //否则fI当族长:谁让posI站在posJ的前面呢!

       

    return true;

}

 

其实这样一个有趣的家族树结构在数据结构领域有一个好听的名字——并查集,顾名思义它就是一个拥有合并和查找两种基本操作的集合。

并查集这种数据结构是用来研究等价类的,那么什么叫等价类呢?这里给出一个数学上的定义:给定一个集合S 和在S 上的一个等价关系≡,则S 中的一个元素a 的等价类是在S 中等价于a 的所有元素的子集 [a] = { x S | xa }

也就是说,等价类是一个对象(或成员)的集合,在此集合中所有对象应该满足等价关系。那么什么是等价关系呢?所谓等价关系,是说如果集合S中的关系R是自反、对称、传递的,则称它为一个等价关系。

自反:对于任一对象xxx,即x等于自身;

对称:对于任意两个对象xy,若xy,则yx

传递:对于任意三个对象xyz,若xyyz xz

等价关系的例子很多,例如“相等”(=)就是一种等价关系,它满足上述的三个特性。我们前面所讲的家族也是一个等价类,家族各成员之间也满足等价关系。

一般的,若R 是集合S上的一个等价关系,则由这个等价关系可产生这个集合的唯一划分,即可以按RS划分为若干不相交的子集,S1 S2 S3 S4 S5 ……. 它们的并集为S,这些子集称为SR等价类。

我们可以利用等价关系把集合S划分成若干个等价类,这就是等价类划分问题:给定集合S及一系列形如“x等价于y”的等价性条件,要求给出S的符合所列等价性条件的等价类划分。

例如,给定集合 S =12,…,7},及等价性条件:12563414,对集合S作等价类划分如下:

首先将S的每一个元素看成一个等价类。然后顺序地处理所给的等价性条件。每次处理一个等价性条件,所得到的相应等价类列表如下:

12 {1,2}{3}{4}{5}{6}{7};

56 {1,2}{3}{4}{5,6}{7};

34 {1,2}{3,4}{5,6}{7};

14 {1,2,3,4}{5,6}{7}。

最终所得到的集合S的等价类划分为:{1,2,3,4}{5,6}{7}。

这和我们构造各个家族的过程很相似吧!

也许扯得有点远了,让我们再次回到并查集,回顾一下它的两个基本操作,看看是否有哪些值得改进的地方。首先分析寻找族长的操作,说实话这个操作很繁琐,离族长近的成员还好,很容易就能见到族长;但如果这个成员处在家族的最底层的话,要想见到族长就必须通过父亲结点层层转达,其最差时间复杂度高达O(M)(其中M表示该家族的成员数量)——这倒是和现实相符合!

如果每个成员都能够直接归族长大人领导就好了,也就是说将每个成员的父亲结点都设置成族长序号。但这样需要遍历整个数组,为每个成员寻找族长,然后将该成员的父亲结点重新赋值为族长序号。

       工程量可真够大的!

       既然查找族长的速度那么慢,而且查找族长的操作又这么常见,我们能否在查找族长的同时顺便做点其他事情呢?也就是说在查找家族成员pos的族长的同时,把pos及其所有祖先的父亲结点值都改为族长序号。

完全可行!我们把这个算法叫做路径压缩算法,也就是寻找族长的同时,顺带将朝圣途中的所有结点都领到族长门前,这就压缩了寻找族长的路径,以后再要找族长就方便多了。

c++代码如下:

/*

函数名称:FindFatherAndReducePath

函数功能:查找族长并压缩路径:找到族长后,将所途经的前辈结点均指向族长

输入变量:int father[]:存储了家族树信息的整型数组

          int pos 家族成员pos的序号(即数组元素下标)

输出变量:无

返回值:int :序号pos的族长大人的序号。若pos本身是族长,则返回自身

*/

int FindFatherAndReducePath(int father[], int pos)

{

       if (father[pos] <= 0)

        return pos;

    //若自己不是族长,则找到族长后,将所途经的结点均指向族长   

return father[pos] =FindFatherAndReducePath(father, father[pos]);

}

这样虽然在查找族长的时候多做了一些工作,但今后向族长大人报告就方便多了,这点付出是不是很值得呢!

再看看合并家族的操作。我们前面在合并两个家族的时候是按照成员在数对中出现的位置来确定谁当族长的——有点像世袭制,太不公平!实际上这种方式计算机也是不喜欢的,因为把一个大家族树作为小家族树的子树,在为底层叶子结点寻找根时需要花费更多的时间。

那怎么办?

让我们到达尔文的理论中寻找答案吧,“优胜劣汰,适者生存”!我们只要引入简单的竞争机制就可以解决这个难题:在合并两棵家族树时,总是让较小的树成为较大树的子树,也就是谁所在的家族成员多,谁就有资格当族长。我们把这种方法叫做按大小求并(union-by-size)。

我们改变一下数组元素值的含义:非族长成员的father[pos]仍用来存储其父亲结点的序号,但根结点的值,即族长的父亲结点不再是死去的先人,而是本族成员的数量——为了不与成员的序号相混淆,我们这里用其相反数(即负数)表示,该数越小,其绝对值就越大,表示该家族的成员数量就越多。

很明显,最初大家都是孤身一人,自己就是本族的族长,族长的父亲结点是本族成员的数量。此时家族成员数量为1,则在数组中的值为-1,以后每加入一个成员,族长的父亲结点值就减1;如果是另外一个家族加入本家族,则本族族长的父亲结点值为两个族长的父亲结点值之和。这样先做一个初始化的工作,设所有的数组元素值均为-1

同样地,我们来模拟一下这个过程:

依次给出3个数对(12),(13)和(34),我们来看看这里面发生了什么事情。

首先12相遇,2号成员仗着人高马大强迫1号同意他当了族长,这样1号的父亲结点就由-1变成了2,即father[1]=2,而2号的父亲结点则自减1变成-2,即father[2]=-2

       接下来1又碰到了31号不敢私自做主,先找到自己的族长2号大人,请求他与3号认亲。又是争夺族长的的战斗,在1号的帮助下,2号力挫3号,继续霸占族长地位。

       最后34相遇,3号也必须先找到自己的族长2号大人,可以想象,4号是怎样臣服在2号脚下的。

       就这样家族成员扩大到4个,数组元素的值依次为:father[1]=2father[2]=-4father[3]=2father[4]=2。我们也很容易知道24是同一家族的成员。

       继续给出数对(57),(68),(79),(89)和(910),稍作分析我们就可以知道另一个大家族诞生了,而且族长是7号大人。

       现在,很不凑巧的,给出了数对(38),两位小弟都赶紧回去找自己的族长。两位老大27相遇了,谁当族长?当然是实力说明一切!7号以微弱的优势取胜。

数组的值变成:father[1]=2father[2]=7father[3]=2father[4]=2father[5]=7father[6]=8father[7]=-10father[8]=7father[9]=7father[10]=7

从此38是一家了。

注意:6号的父亲结点不是7号,而是8号,因为它是先拜在8号门下,然后跟随8号一起加入这个大家族的。

       明白了吗?一个大家族的产生就两个基本操作:寻找族长和争当族长。

让我们用c++语言来描述争当族长的过程:(注意所有的数组元素值均初始化为-1

/*

函数名称:UnionBySize

函数功能:按大小求并:将成员posIposJ合并到同一个家族

输入变量:int father[]:存储了家族树信息的整型数组

          int posI, posJ 家族成员posIposJ的序号(即数组元素下标)

输出变量:无

返回值:bool :合并是否成功信息。如果posIposJ是同一个族长门下,不必合并,返回false,否则合并成功返回true

*/

bool UnionBySize(int father[], int posI, int posJ)

{

    //首先各自去寻找自己的族长

    int fI = FindFatherAndReducePath(father,posI);

    int fJ = FindFatherAndReducePath(father,posJ);

   

    if (fI == fJ) //如果是同一个族长门下,不必合并,即合并失败

        return false;

    else if (father[fI] < father[fJ])

    {//如果族长fI的实力比fJ强,即|fI|>|fJ|,则fI当族长,并修改father[fI]father[fJ]

        father[fI] += father[fJ];

        father[fJ] = fI;

    }

    else              //否则fJ当族长

    {

        father[fJ] += father[fI];

        father[fI] = fJ;

    }

   

    return true;

}

 

这样寻找族长和合并家族的操作都得到了改进,判断两个人是否在同一个族长门下的操作也就异常简单了:

/*

函数名称:SameFamily

函数功能:判断成员posIposJ是否属于同一家族

输入变量:int father[]:存储了家族树信息的整型数组

          int posI, posJ 家族成员posIposJ的序号(即数组元素下标)

输出变量:无

返回值:若成员posIposJ属于同一家族,返回ture,否则返回false

*/

boolSameFamily(int father[], int posI, int posJ)

{

    return FindFatherAndReducePath(father,posI) ==

           FindFatherAndReducePath(father,posJ);

}

 

到这里,并查集的基本操作就全部介绍完了,很简单吧?呵呵!不过你可别小看这个简单的数据结构和这些简单的操作,它们的作用可大了。下面我给出一些NOIP题目,请读者们看看如何运用并查集来解决吧。

 

 

题目1P1034家族

描述 Description

若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。

规定:xy是亲戚,yz是亲戚,那么xz也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。

输入格式 Input Format      

第一行:三个整数n,m,p,(n<=5000,m<=5000,p<=5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。

以下m行:每行两个数MiMj1<=MiMj<=N,表示AiBi具有亲戚关系。

接下来p行:每行两个数PiPj,询问PiPj是否具有亲戚关系。

输出格式 Output Format    

P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。

样例输入 Sample Input      

   6 5 3

   1 2

   1 5

   3 4

   5 2

   1 3

   1 4

   2 3

   5 6

样例输出 Sample Output    

   Yes

Yes

No

 

题目2:银河英雄传说(NOI2002)

描述 Description

公元五八○一年,地球居民迁移至金牛座α第二行星,在那里发表银河联邦创立宣言,同年改元为宇宙历元年,并开始向银河系深处拓展。

宇宙历七九九年,银河系的两大军事集团在巴米利恩星域爆发战争。泰山压顶集团派宇宙舰队司令莱因哈特率领十万余艘战舰出征,气吞山河集团点名将杨威利组织麾下三万艘战舰迎敌。

杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成30000列,每列依次编号为1, 2,, 30000。之后,他把自己的战舰也依次编号为1, 2, , 30000,让第i号战舰处于第i(i = 1, 2, , 30000),形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为M i j,含义为让第i号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第j号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。

然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。

在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:C i j。该指令意思是,询问电脑,杨威利的第i号战舰与第j号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。

作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。

最终的决战已经展开,银河的历史又翻过了一页……

输入格式 Input Format      

1行有1个整数T1<=T<=500,000),表示总共有T条指令。

以下有T行,每行有一条指令。指令有两种格式:

1.  M i j ij是两个整数(1<=i, j<=30000),表示指令涉及的战舰编号。该指令是莱因哈特窃听到的杨威利发布的舰队调动指令,并且保证第i号战舰与第j号战舰不在同一列。

2.  C i j ij是两个整数(1<=i, j<=30000),表示指令涉及的战舰编号。该指令是莱因哈特发布的询问指令。

输出格式 Output Format    

你的程序应当依次对输入的每一条指令进行分析和处理:

如果是杨威利发布的舰队调动指令,则表示舰队排列发生了变化,你的程序要注意到这一点,但是不要输出任何信息;

如果是莱因哈特发布的询问指令,你的程序要输出一行,仅包含一个整数,表示在同一列上,第i号战舰与第j号战舰之间布置的战舰数目。如果第i号战舰与第j号战舰当前不在同一列上,则输出-1

样例输入 Sample Input      

4

M 2 3

C 1 2

M 2 4

C 4 2

样例输出 Sample Output    

-1

1

 

参考文献:

1.《数据结构(用面向对象方法与C++描述),清华大学出版社,殷人昆,陶永雷,谢若阳,盛绚华编著;

2.《数据结构与算法分析——C语言描述》,机械工业出版社,(美)Mark Allen Weiss 著,冯舜玺 译。

 

附录:

1.《并查集UFSet类》:http://blog.csdn.net/goal00001111/archive/2008/12/24/3595862.aspx

2.P1034家族解题报告》:

http://blog.csdn.net/goal00001111/archive/2008/12/24/3595877.aspx

3.《银河英雄传说(NOI2002)解题报告》:

http://blog.csdn.net/goal00001111/archive/2008/12/24/3595890.aspx

 

 

原创粉丝点击