并查集 (Union-Find Sets)及其应用

By Fandywang  2007-11-22

并查集:(union-find sets)是一种简单的用途广泛的集合. 并查集是若干个不相交集合,能够实现较快的合并和判断元素所在集合的操作,应用很多。一般采取树形结构来存储并查集,在合并操作时可以利用树的节点树或者利用一个rank数组来存储集合的深度下界--启发式函数,在查找操作时进行路径压缩使后续的查找操作加速。这样优化实现的并查集,空间复杂度为O(N),建立一个集合的时间复杂度为O(1),N次合并M查找的时间复杂度为O(M Alpha(N)),这里Alpha是Ackerman函数的某个反函数,在很大的范围内这个函数的值可以看成是不大于4的,所以并查集的操作可以看作是线性的。

它支持以下三种操作:
  -Union (Root1, Root2) //合并操作;把子集合Root2和子集合Root1合并
.要求:Root1和 Root2互不相交,否则不执行操作
.
  -Find (x) //搜索操作;搜索单元素x所在的集合,并返回该集合的名字--根节点标示
.
  -UFSets (s) //构造函数。将并查集中s个元素初始化为s个只有一个单元素的子集合
.
  -对于并查集来说,每个集合用一棵树表示。

  -集合中每个元素的元素名分别存放在树的结点中,此外,树的每一个结点还有一个指向其双亲结点的指针。
  -设 S1= {0, 6, 7, 8 },S2= { 1, 4, 9 },S3= { 2, 3, 5 }

-为简化讨论,忽略实际的集合名,仅用表示集合的树的根来标识集合。
  -为此,采用树的双亲表示作为集合存储表示。集合元素的编号从0到 n-1。其中 n 是最大元素个数。在双亲表示中,第 i 个数组元素代表包含集合元素 i 的树结点。根结点的双亲为-m,m表示集合中的元素个数。为了区别双亲指针信息( ≥ 0 ),集合元素个数信息用负数表示。   

下标
parent

 

集合S1, S2S3的双亲表示:

                             S1 S2的可能的表示方法

const int DefaultSize = 10;
  class UFSets { //并查集的类定义

  private:
   
int *parent;
   
int size;
  
public:
   
UFSets ( int s = DefaultSize );
   
~UFSets ( ) { delete [ ] parent; }
   UFSets & operator = ( UFSets const & Value );//集合赋值

   void Union ( int Root1, int Root2 );
   
int Find ( int x );
   
void UnionByHeight ( int Root1, int Root2 ); };
   UFSets::UFSets ( int s ) { //构造函数

   size = s;
   
parent = new int [size+1];
   
for ( int i = 0; i <= size; i++ ) parent[i] = -1;
  }

  unsigned int UFSets::Find ( int x ) { //搜索操作
   if ( parent[x] <= 0 ) return x;
   
else return Find ( parent[x] );
  }

  void UFSets::Union ( int Root1, int Root2 ) { //
   parent[Root2] = Root1; //Root2指向Root1
  }

FindUnion操作性能不好。假设最初 n 个元素构成 n 棵树组成的森林,parent[i] = -1。做处理Union(0, 1), Union(1, 2), …, Union(n-2, n-1)后,将产生如图所示的退化的树。

                            

执行一次Union操作所需时间是O(1)n-1Union操作所需时间是O(n)。若再执行Find(0), Find(1), …, Find(n-1), 若被
搜索的元素为i,完成Find(i)操作需要时间为O(i),完成 n 次搜索需要的总时间将达到
              

Union操作的加权规则

  为避免产生退化的树,改进方法是先判断两集合中元素的个数,如果以 i 为根的树中的结点个数少于以 j 为根的树中的结点个数,即parent[i] > parent[j],则让 j 成为 i 的双亲,否则,让i成为j的双亲。此即Union的加权规则。

              parent[0](== -4) < parent[4] (== -3)

 

  void UFSets::WeightedUnion(int Root1, int Root2) {
   //Union的加权规则改进的算法,另外可以使用启发式函数犯法

     //进行压缩---树的高度
   int temp = parent[Root1] + parent[Root2];
   
if ( parent[Root2] < parent[Root1] ) {
    parent[Root1] = Root2; //Root2中结点数多

    parent[Root2] = temp;  //Root1指向Root2
   }
   
else {
    parent[Root2] = Root1; //Root1中结点数多

    parent[Root1] = temp;  //Root2指向Root1
   }
  }

Two:

void Union(int root1, int root2)
{
int a = Find(root1);
int b = Find(root2);

  
if( a == b ) return ;
//
启发式合并:以根节点高度比较大的树的根作为新树的根
if( rank[a] > rank[b] ) pre[b] = a;
else
{
   pre[a] = b;
   if(rank[a] == rank[b]) ++rank[b];
}
}

 

                            使用加权规则得到的树

下面是几到用并查集可以方便解决的问题:

题目亲戚(Relations)

或许你并不知道你的某个朋友是你的亲戚。他可能是你的曾祖父的外公的女婿的外甥的表姐的孙子。如果能得到完整的家谱,判断两个人是否亲戚应该是可行的,但如果两个人的最近公共祖先与他们相隔好几代,使得家谱十分庞大,那么检验亲戚关系实非人力所能及.在这种情况下,最好的帮手就是计算机。

为了将问题简化,你将得到一些亲戚关系的信息,如同Marry和Tom是亲戚,Tom和B en是亲戚,等等。从这些信息中,你可以推出Marry和Ben是亲戚。请写一个程序,对于我们的关心的亲戚关系的提问,以最快的速度给出答案。

参考输入输出格式 输入由两部分组成。

第一部分以N,M开始。N为问题涉及的人的个数(1 ≤ N ≤ 20000)。这些人的编号为1,2,3,…,N。下面有M行(1 ≤ M ≤ 1000000),每行有两个数ai, bi,表示已知ai和bi是亲戚.

第二部分以Q开始。以下Q行有Q个询问(1 ≤ Q ≤ 1 000 000),每行为ci, di,表示询问ci和di是否为亲戚。

对于每个询问ci, di,若ci和di为亲戚,则输出Yes,否则输出No。

样例输入与输出

输入relation.in

10 7

2 4

5 7

1 3

8 9

1 2

5 6

2 3

3

3 4

7 10

8 9

输出relation.out

Yes

No

Yes

如果这道题目不用并查集而只用链表或数组来存储集合那么效率很低肯定超时。

例程:

#include<iostream>

using namespace std;

int N,M,Q;

int pre[20000],rank[20000];

void makeset(int x)

 {

     pre[x]=-1;

     rank[x]=0;

 }

int find(int x)

 {

     int r=x;

     while(pre[r]!=-1)

      r=pre[r];

     while(x!=r)

      {

          int q=pre[x];

          pre[x]=r;

          x=q;

      }

    return r;      

 }    

void unionone(int a,int b)

 {

     int t1=find(a);

     int t2=find(b);

     if(rank[t1]>rank[t2])

       pre[t2]=t1;

    else

       pre[t1]=t2;

    if(rank[t1]==rank[t2])

      rank[t2]++;     

 }       

int main()

 

{

   int i,a,b,c,d;

    while(cin>>N>>M)

     {

         for(i=1;i<=N;i++)

          makeset(i);

        for(i=1;i<=M;i++)

          {

              cin>>a>>b;

              if(find(a)!=find(b))

               unionone(a,b);

          }

        cin>>Q; 

        for(i=1;i<=Q;i++)

         {

             cin>>c>>d;

             if(find(c)==find(d))

              cout<<"YES"<<endl;

             else

              cout<<"NO"<<endl; 

         }           

     }   

    return 0;

}

ZJU1789The Suspects

【问题描述】

Severe acute respiratory syndrome (SARS), an atypical pneumonia of unknown aetiology, was recognized as a global threat in mid-March 2003. To minimize transmission to others, the best strategy is to separate the suspects from others.

In the Not-Spreading-Your-Sickness University (NSYSU), there are many student groups. Students in the same group intercommunicate with each other frequently, and a student may join several groups. To prevent the possible transmissions of SARS, the NSYSU collects the member lists of all student groups, and makes the following rule in their standard operation procedure (SOP).

Once a member in a group is a suspect, all members in the group are suspects.

However, they find that it is not easy to identify all the suspects when a student is recognized as a suspect. Your job is to write a program which finds all the suspects.
Input

The input contains several cases. Each test case begins with two integers n and m in a line, where n is the number of students, and m is the number of groups. You may assume that 0 < n <= 30000 and 0 <= m <= 500. Every student is numbered by a unique integer between 0 and n-1, and initially student 0 is recognized as a suspect in all the cases. This line is followed by m member lists of the groups, one line per group. Each line begins with an integer k by itself representing the number of members in the group. Following the number of members, there are k integers representing the students in this group. All the integers in a line are separated by at least one space.

A case with n = 0 and m = 0 indicates the end of the input, and need not be processed.
Output

For each case, output the number of suspects in one line.
Sample Input

100 4
2 1 2
5 10 13 11 12 14
2 0 1
2 99 2
200 2
1 5
5 1 2 3 4 5
1 0
0 0

Sample Output

4
1
1

【算法分析】

这道题的意思很简单,n个人编号,0n-1,n个人分成m个集合(1个人可以参加不同的集合),求的就是最后所有和0号有关系的集合的

人数.

如果这道题目不用并查集,而只用链表或数组来存储集合,那么效率很低,肯定超时.我们在题目给出的每个集合的人员编号时,进行并查

操作,不过在进行合并操作时,合并的是两个集合的元素个数.最后0号元素所在的集合数目就是所求.

例程:

#include<iostream>

#include<cstdio>

using namespace std;

const int size=30000;

int pre[size],num[size];

int n,m,k;

void makeset(int x)

 {

     pre[x]=-1;

     num[x]=1;

 }

int find(int x)//非递归压缩路径

 {

     int r=x;

     while(pre[r]!=-1)

      r=pre[r];

     while(x!=r)

      {

          int q=pre[x];

          pre[x]=r;

          x=q;

      }

    return r;      

 }    

int unionone(int a,int b)

{

         int t1,t2;

         t1=find(a);

         t2=find(b);

         if(t1==t2) return 0;

         if(num[t2]<=num[t1])

         {

                   pre[t2]=t1;

                   num[t1]+=num[t2];

         }

         else

         {

                   pre[t1]=t2;

                   num[t2]+=num[t1];

         }

         return 0;

}

int main()

 {  

     freopen("in.txt","r",stdin);

     freopen("out.txt","w",stdout);

     int i,j,a,b;

     while(scanf("%d%d",&n,&m))

      {

          if(n==0&&m==0)

           break;

          for(i=0;i<n;i++)

            makeset(i);

          for(i=0;i<m;i++)

           {

               //cin>>k;

               scanf("%d",&k);

               if(k==0) continue;

               //cin>>a;

                scanf("%d",&a);

                a=find(a);

               for(j=1;j<k;j++)

                {

                    //cin>>b;

                    scanf("%d",&b);

                    b=find(b);

                    unionone(a,b);

                }   

           }

         printf("%d/n",num[find(0)]);       

      }   

     return 0;

 }    

银河英雄传说

【问题描述】

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

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

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

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

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

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

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

【输入文件】

输入文件galaxy.in的第一行有一个整数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),表示指令涉及的战舰编号。该指令是莱因哈特发布的询问指令。

【输出文件】

输出文件为galaxy.out。你的程序应当依次对输入的每一条指令进行分析和处理:

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

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

【样例输入】

4

M 2 3

C 1 2

M 2 4

C 4 2

【样例输出】

-1

1

【样例说明】

战舰位置图:表格中阿拉伯数字表示战舰编号

 

第一列

第二列

第三列

第四列

……

初始时

1

2

3

4

……

M 2 3

1

 

3

2

4

……

C 1 2

1号战舰与2号战舰不在同一列,因此输出-1

M 2 4

1

 

 

4

3

2

……

C 4 2

4号战舰与2号战舰之间仅布置了一艘战舰,编号为3,输出1

 

【算法分析】

        同一列的战舰组成一个并查集,在集合中,我们以当前列的第一艘战舰作为集合的代表元.并查集的数据类型采用树型,树的根结点即为集合的代表元.为了查询的效率达到最优,我们进行了路径压缩的优化:首先找到树根,然后将路径上所有结点的父结点改为根,使得树的深度为1.

        问题是,题目不仅要求判别两个结点是否在同一个集合(即两艘战舰是否在同一列),而且还要求计算结点在有序集合的位置(即每一艘战舰相隔列的第一艘战舰几个位置),       我们增加了一个数组behind[x],记录战舰x在列中的相对位置.

        查找一个元素x所在集合的代表元时,先从x沿着父亲节点找到这个集合的代表元root,然后再从x开始一次到root的遍历,累计其间经过的每一个子结点的behind,其和即为behind[i].,如下图所示:

          

       按照题意,合并指令Mxy,含义是让战舰x 所在的整个战舰队列,作为一个整体(头在前,尾在后)接至战舰y所在的战舰队列的尾部,显然两个队列合并成同一列后,其集合代表元为结点y所在的树的根结点fy,x所在的树的根结点fx,合并后,fx的相对位置为合并前y所在集合的结点数, behind[fx]=num[fy],新集合的结点数为原来两个集合结点数的和 num[fy]+=num[fx]. 则如果战舰x和战舰y在同一列,则他们相隔

|behind[x]-behind[y]|-1艘战舰.如下图:

 

例程:

#include<iostream>

#include<cstdio>

#include<cmath>

using namespace std;

const int size=30001;

int pre[size],num[size],behind[size];//behind[x]战舰x在列中的相对位置

int n,m,k;

void makeset(int x)

 {

     pre[x]=-1;

     num[x]=1;

 }

int find(int x)//查找结点x所在树的根结点,并对该树进行路径压缩

 {

     int r=x;

     int j;

     while(pre[r]!=-1)//找出结点x所在树的根结点r

      r=pre[r];

     while(x!=r)

      {

          int q=pre[x];//路径压缩

          pre[x]=r;

          j=q;

          do{//迭代求出路径上每一个子结点相对于r的相对位置

              behind[x]+=behind[j];

              j=pre[j];

            }while (j!=-1);   

          x=q;

      }

    return r;      

 }    

void MoveShip(int a,int b)

{

         int t1,t2;

         t1=find(a);//计算a所在树的根结点t1

         t2=find(b);//计算b所在树的根结点t2

//以下三句的先后顺序很重要

         pre[t1]=t2;//将t1的父结点设为t2

         behind[t1]=num[t2];//计算t1的相对位置为num[t2]

         num[t2]+=num[t1]; //计算新集合的结点数

}

void CheckShip(int x,int y)

 {

     int f1,f2;

     f1=find(x);

     f2=find(y);

     if(f1!=f2)

      cout<<-1<<endl;

     else

      cout<<abs(behind[x]-behind[y])-1<<endl;

 }

int main()

 {

   freopen("galaxy.in","r",stdin);

    freopen("out.txt","w",stdout);

     int n,x,y;

     char ch;

     while(cin>>n)

      {

          for(int i=1;i<size;i++)

           {

               makeset(i);

           }

          memset(behind,0,sizeof(behind));   

          while(n--)

           {

               cin>>ch>>x>>y;

               if(ch=='M')

                 MoveShip(x,y); //处理合并指令

               else if(ch=='C')

                 CheckShip(x,y);  //处理询问指令

           }   

      } 

   return 0;    

 }       

 

 

 

 

 

 

再说一个并查集应用最完美的地方:最小生成树的kruskal算法:

算法基本思想是:
开始把所有节点作为各自的一个单独集合,以为每次从边信息中得到一个最短的边,如果这个边邻接了两个不同集合的节点,就把这两个节点的所属集合结合起来,否则继续搜索。直至所有节点都同属一个集合,就生成了一个最小生成树。

int kruskal(int parent[],int N)
{
int i,j,k,x,y;
int min;
while(k<=N-1) //
产生N-1条边

{
   min=MAX_INT;
   for(i=1;i<=N;i++)
    for(j=1;j<=N;j++)
    {
     if(sign[i][j]==0&&i!=j) //sign[N][N]
是标志节点是否被访问过或已被排除……
     {
      if(arcs[i][j]<min)
 //arcs[N][N]存放边的长度

      {
       min=arcs[i][j];
       x=i;
       y=j;
      }//if
     }//if
    }//for
   if(Find(parent,x)==Find(parent,y)) //
X,Y已经属同一连通分量则不合并排除……
    sign[x][y]=1;
   else
    {
     Union(parent,x,y);
     Sign[x][y]=1;
    }

 

并查集的一些题目和我的相关解题报告:

 

POJ 1611 The Suspects          最基础的并查集

POJ 2524 Ubiquitous Religions 最基本的并查集

POJ 1182 食物链       并查集的拓展

注意: 只有一组数据;

要充分利用题意所给条件:有三类动物A,B,C,这三类动物的食物链

构成了有趣的环形。A吃B, B吃C,C吃A。也就是说:只有三个group

POJ 2492 A Bug's Life 并查集的拓展

法一:深度优先遍历

每次遍历记录下该点是男还是女,只有:男-〉女,女-〉男满足,否则,找到同性恋,结束程序。

法二:二分图匹配

法三:并查集的拓展:和1182很像,只不过这里就有两组,而1182是三组,1611无限制

POJ 1861 Network == zju_1542    并查集+自定义排序+贪心求"最小生成树"

答案不唯一,不过在ZOJ上用QSORT()和SORT()都能过,在POJ上只有SORT()才能过...

POJ 1703 Find them, Catch them 并查集的拓展

这个和POJ 2492 A Bug's Life很像就是把代码稍微修改了一下就AC

注意And of course, at least one of them belongs to Gang Dragon, and the same for Gang Snake. 就是说只有两个组。

POJ 2236 Wireless Network        并查集的应用

需要注意的地方1、并查集2N的范围可以等于10013、从N+1行开始第一个输入的可以是字符串。

POJ 1988 Cube Stacking            并查集很好的应用

1、与 银河英雄传说==NOI2002 Galaxy一样;2、增加了一个数组behind[x],记录战舰x在列中的相对位置;3、详细解题报告见银河英雄传说。

 

提高题目:

POJ 2912 Rochambeau

POJ 1733 Parity game   

POJ 1308 Is It A Tree?

 

 

 

简易版并查集类

/*********************************************************
First Created:2007-2-3  By Liuctic

void MakeSet(int n);     To initial
void Union(int x,int y); To union two elements(union by rank)
                         按秩合并
int FindSet(int x);      Find which set does x belong to(path compression)
                         路径压缩
int size();
void compress_all()      compress all the path. In order to calculate number
                         of group members
*************************************************************************/
class Lset
{
    int *p,*rank;
    int sz;
    void link(int x,int y)
    {
       if(x==y) return;
       if(rank[x]>rank[y]) p[y]=x;
       else p[x]=y;
       if(rank[x]==rank[y]) rank[y]++;
    }
public:
    void MakeSet(int n)
    {
       int i;
       delete[] p;delete[] rank;
       sz=n;
       p=new int[sz];rank= new int[sz];
       for(i=0;i<n;i++) {p[i]=i;rank[i]=0;}
    }
    Lset(int n)
    {
       int i;
       p=new int[n];rank=new int[n];
       sz=n;
       for(i=0;i<n;i++) {p[i]=i;rank[i]=0;}
    }
    ~Lset(){delete[] p; delete[] rank;}
    void Union(int x,int y)
    {
       link(FindSet(x),FindSet(y));
    }
    int FindSet(int x)
    {
       if(x!=p[x]) p[x]=FindSet(p[x]);
       return p[x];
    }
    int size(){return sz;}
    void compress_all()
    {
        int i;
        for(i=0;i<sz;i++) FindSet(i);
    }
};

 
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值