算法笔记:并查集

专题:并查集

内容来源:《挑战程序设计竞赛》(第2版)

一、引入

        在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪哪个集合中。

        该问题看似并不复杂,但数据量极大,若用正常的数据结构来描述的话,往往超过了空间的限制,计算机无法承受;而且复杂度较高,实现过程较复杂。因此,只能采用一种特殊数据结构——并查集来描述。

二、定义

        并查集是一种用于分离集合操作(管理元素分组情况)的抽象数据类型。它所处理的是“集合”之间的关系,即动态地维护和处理集合元素之间复杂的关系。

        当给出两个元素的一个无序对(a,b)时,需要快速“合并”a和b分别所在的集合,这其间需要反复“查找”某元素所在的集合。“并”、“查”和“集”三字由此而来。在这种数据类型中,n个不同的元素被分为若干组。每组是一个集合,这种集合叫做分离集合。

三、结构

        使用树形结构实现,不过不是二叉树。

        集合中的每个元素对应一个节点,每个集合对应一棵树。在并查集中,哪个节点是哪个节点的父亲以及树的形状等信息无需多加关注,整体组成一个树形结构才是重要的。

四、基本操作

        并查集支持查找一个元素所属的集合 以及 两个元素各自所属的集合的合并 两种操作。

1.      初始化:

        使用n个节点表示n个元素,最开始时没有边。

2.      合并:

        相当于将两个集合合并为一个集合(即求并集的过程),假定在此操作前两个集合是分离的。

3.      查询:

        查询两个节点是否属于同一集合(即他们是否有相同的根节点),我们需要沿着树向上走,来查询包含这个元素的树的根是谁。如果两个节点走到了同一个根,则可说明它们属于同一集合。

举例:

算法笔记:并查集_第1张图片

五、优化

1. 当树形结构发生“退化”,即趋于线性结构时,对并查集的基本操作的复杂度就会非常高。因此,有必要优化存储结构,想办法避免“退化”的发生。

2. 考虑高度合并的方法:对每棵树,记录树的高度rank;合并时,如果两棵树的rank不同,那么从rank小的向rank大的连边。

3. 并查集的路径压缩(一个重要且典型的方法)

        (1)路径压缩实际上是在找完根结点之后,在递归回来的时候顺便把路径上元素的父亲指针都指向根结点

        (2)以上图为例,我们在“合并5和3”的时候,不是简单地将5的父亲指向3,而是直接指向根节点1,如图:

算法笔记:并查集_第2张图片

        (3)在使用这种简化的方法时,为简便起见,即使树的高度发生了变化,也不修改rank的值。

4. 复杂度分析:O(ɑ(n)),是一个”均摊复杂度”,比O(log(n))还要快。

六、代码实现(以数组实现为例)

#include 
#define maxn 100005
int N,M,Q;          //建立含N个元素的集合(编号1~N),进行M次合并、Q次查找
int r1,r2,x,y;      //将x和y节点所属集合合并
int parent[maxn];   //parent[i]表示元素i的父亲节点
int rank[maxn];     //树的高度
void Init(int n)    //初始化并查集
{
    int i;
    for(i=1;i<=n;i++)//初始状态:每个节点自身为根节点,且高度为0
    {
        parent[i]=i;
        rank[i]=0;
    }
}
/*
int Find(int x)     //查询树的根(非递归实现)
{
    while(parent[x]!=x)
        x=parent[x];
    return x;       //找到返回根节点
}
*/
int Find(int x)     //查询树的根(递归实现)
{
    if(parent[x]==x)//x是根节点,找到,直接返回
        return x;
    else            //x不是根节点,则从x的父亲节点开始,继续查找
        return parent[x]=Find(parent[x]);
}
void Union(int x,int y) //合并节点x和y所属的集合
{
    x=Find(x);  //首先查找x和y节点所属的根节点
    y=Find(y);
    if(x==y)    //x和y节点同属一个根节点,则直接返回
        return;
    if(rank[x]

/*测试输入:

5 4 5

1 2

1 3

5 4

3 5

1

2

3

4

5

*/

七、典例

1. 亲戚

        或许你并不知道,你的某个朋友是你的亲戚。他可能是你的曾祖父的外公的女婿的外甥女的表姐的孙子。如果能得到完整的家谱,判断两个人是否亲戚应该是可行的,但如果两个人的最近公共祖先与他们相隔好几代,使得家谱十分庞大,那么检验亲戚关系实非人力所能及。在这种情况下,最好的帮手就是计算机。为了将问题简化,你将得到一些亲戚关系的信息,如Marry和Tom是亲戚,Tom和Ben是亲戚,等等。从这些信息中,你可以推出Marry和Ben是亲戚。请写一个程序,对于我们的关于亲戚关系的提问,以最快的速度给出答案。

输入:

        输入由两部分组成。

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

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

输出:

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

样例输入:

       10 7

  2 4

  5 7

  1 3

  8 9

  1 2

  5 6

  2 3

  3

  3 4

  7 10

  8 9

样例输出:

  Yes

  No

  Yes

【分析】并查集基础+入门

#include 
#define maxn 20010
int N,M,Q;
int a,b,c,d;
struct People
{
    int parent;
    int rank;
} p[maxn];
void Init(int n)
{
    int i;
    for(i=1;i<=n;i++)
    {
        p[i].parent=i;
        p[i].rank=0;
    }
}
int Find(int n)
{
    if(p[n].parent==n)
        return n;
    return p[n].parent=Find(p[n].parent);
}
void Union(int x,int y)
{
    x=Find(x);
    y=Find(y);
    if(x==y)
        return;
    if(p[x].rank

2. Ubiquitous Religions(POJ 2524)

        There are so manydifferent religions in the world today that it is difficult to keep track ofthem all. You are interested in finding out how many different religionsstudents in your university believe in.

        You know that thereare n students in your university (0 < n <= 50000). It is infeasible foryou to ask every student their religious beliefs. Furthermore, many studentsare not comfortable expressing their beliefs. One way to avoid these problemsis to ask m (0 <= m <= n(n-1)/2) pairs of students and ask them whetherthey believe in the same religion (e.g. they may know if they both attend thesame church). From this data, you may not know what each person believes in,but you can get an idea of the upper bound of how many different religions canbe possibly represented on campus. You may assume that each student subscribes toat most one religion.

Input

        The input consists ofa number of cases. Each case starts with a line specifying the integers n andm. The next m lines each consists of two integers i and j, specifying thatstudents i and j believe in the same religion. The students are numbered 1 ton. The end of input is specified by a line in which n = m = 0.

Output

        For each test case,print on a single line the case number (starting with 1) followed by themaximum number of different religions that the students in the universitybelieve in.

 

Sample Input

10 9

1 2

1 3

1 4

1 5

1 6

1 7

1 8

1 9

1 10

10 4

2 3

4 5

4 8

5 8

0 0

Sample Output

Case 1: 1

Case 2: 7

 

Hint

Huge input, scanf is recommended.

【分析】并查集连通块计数问题。信仰统一宗教的同学属于同一连通块,连通块总数即最大不同宗教信仰数。

#include 
const int maxn=50010;
int n,m;
int x,y,ans;       //ans记录不同宗教信仰数
struct Student
{
    int root;      //记录根节点
    int rank;      //记录"树高"
} stu[maxn];
void Init(int n)
{
    int i;
    ans=n;
    for(i=1;i<=n;i++)
    {
        stu[i].root=i;
        stu[i].rank=0;
    }
}
int Findroot(int n)
{
    if(stu[n].root==n)
        return n;
    return stu[n].root=Findroot(stu[n].root);
}
void Union(int x,int y)
{
    x=Findroot(x);
    y=Findroot(y);
    if(x==y)
        return;
    ans--;      //当两个学生的宗教信仰不同时(对应的根节点不同),ans--
    if(stu[x].rank

3. 格子游戏

        Alice和Bob玩了一个古老的游戏:首先画一个n * n的点阵(如下图,此时n = 3)。

算法笔记:并查集_第3张图片

        接着,他们两个轮流在相邻的点之间画上红边和蓝边:直到围成一个封闭的圈(面积不必为1)为止,“封圈”的那个人就是赢家。

        因为棋盘实在是太大了(n <= 200),他们的游戏实在是太长了!他们甚至在游戏中都不知道谁赢得了游戏。于是请你写一个程序,帮助他们计算他们是否结束了游戏?

输入:

  输入数据第一行为两个整数n和m。m表示一共画了m条线。以后m行,每行首先有两个数字(x, y),代表了画线的起点坐标,接着用空格隔开一个字符,假如字符是"D ",则是向下连一条边,如果是"R "就是向右连一条边。输入数据不会有重复的边且保证正确。

输出:

        输出一行:在第几步的时候结束。假如m步之后也没有结束,则输出一行“draw”。

 

样例输入:

  3 5

  1 1 D

  1 1 R

  1 2 D

  2 1 R

  2 2 D

样例输出:

  4

【分析】难点:构造点阵、“封圈”的判断。

#include 
const int maxn=210;
int n,m;
int x,y;     //笔的起点坐标
char dir;    //笔的方向(D/R)
struct node  //构造点阵
{
    int x;   //点的横坐标
    int y;   //点的纵坐标
} f[maxn][maxn],k1,k2;
void Init(node f[][maxn])//初始化点阵(并查集)
{
    int i,j;
    for(i=1;i<=n;i++)
    {
        for(j=1;j<=n;j++)
        {
            f[i][j].x=i;
            f[i][j].y=j;
        }
    }
}
node Findroot(node k)    //找根节点
{
    if(f[k.x][k.y].x==k.x && f[k.x][k.y].y==k.y)
        return k;
    return f[k.x][k.y]=Findroot(f[k.x][k.y]);
}
int main()
{
    int i;
    int suc=0,ret;       //suc-游戏结束标记 ret-游戏在第ret步结束
    scanf("%d %d",&n,&m);
    Init(f);
    for(i=0;i

4. 食物链(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),输出假话的总数。

输入:

        第一行是两个整数N和K,以一个空格分隔。

        以下K行,每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中D表示说法的种类。

        若D=1,则表示X和Y是同类;

        若D=2,则表示X吃Y。

输出:

只有一个整数,表示假话的数目。

 

样例输入:

100 7

1 101 1

2 1 2

2 2 3

2 3 3

1 1 3

2 3 1

1 5 5

样例输出:

3

【分析】带权并查集问题。

        由于N和K很大,所以必须高效地维护动物之间的关系,并快速判断是否产生矛盾。

        并查集是维护“属于同一组”的数据结构,但是在本题中,并不只有属于同一类的信息,还有捕食关系的存在。因此需要做好信息的高效维护。

        这里,对每只动物i创建3个元素i-A  i-B  i-C,并用这3个元素建立并查集,该并查集维护如下信息:

        (1)i-x表示“i属于种类x”

        (2)并查集里的每一个组表示组内所有元素代表的情况都同时发生或不发生

        例如:如果i-A和j-B在同一个组内,就表示如果i属于种类A那么j一定属于种类B,如果j属于种类B那么i一定属于种类A。因此,对于每一条信息,进行如下操作(这样对x和y进行了全面讨论,即x和y可能属于A,B,C三类中的某一类):

        (1)第一种信息:x和y属于同一类:合并x-A和y-A,x-B和y-B,x-C和y-C

        (2)第二种信息:x吃y:合并x-A和y-B,x-B和y-C,x-C和y-A

        不过在合并之前,需要先判断合并是否会产生矛盾。例如在第一种信息的情况下,需要检查比如x-A和y-B或者y-C是否在同一组等信息。

#include 
const int maxn=50010;
int N,K;
int D,X,Y;
int ans=0;    //ans记录错误信息条数
struct Animal
{
    int pre;
    int rank;
} a[3*maxn];
void Init(int n)
{
    int i;
    for(i=1;i<=n;i++)
    {
        a[i].pre=i;
        a[i].rank=0;
    }
}
int Findpre(int n)
{
    if(a[n].pre==n)
        return n;
    return a[n].pre=Findpre(a[n].pre);
}
void Union(int x,int y)
{
    x=Findpre(x);
    y=Findpre(y);
    if(x==y)
        return;
    if(a[x].rankN || Y<1 || Y>N)//输入编号不正确的情况,错误信息条数+1,不再向下进行
        {
            ans++;
            continue;
        }
        if(D==1)     //"X和Y属于同一类"的信息
        {
            if(is_Same(X,Y+N) || is_Same(X,Y+2*N))  //此时X和Y属于不同类,信息有误
                ans++;
            else     //同类关系成立
            {
                Union(X,Y);
                Union(X+N,Y+N);
                Union(X+2*N,Y+2*N);
            }
        }
        else         //"X吃Y"的信息
        {
            if(is_Same(X,Y) || is_Same(X,Y+2*N))  //此时X和Y不是捕食关系,信息有误
                ans++;
            else     //捕食关系成立,即"A吃B  B吃C  C吃A"
            {
                Union(X,Y+N);
                Union(X+N,Y+2*N);
                Union(X+2*N,Y);
            }
        }
    }
    printf("%d\n",ans);
    return 0;
}

=======================================拓展部分======================================

1. 求无向图的连通分量

       求无向图连通分量是个非常常用的算法。通过并查集可以使得空间上省去对边的保存,同时时间效率又是很高的。

  需要特别指出的是,如果用链表来实现的话,最后任何在同一个集合(即连通块)中的元素,其代表指针的值都是相等的。而采用有根树来实现的话,算法结束后,留下的依然是树的关系,因此如果希望每个元素都指向它的根的话,还需要对每个节点进行一次find操作,这样每个节点的父节点都是代表此集合的节点。在某些统计问题中,往往需要这样做。

2. Kruskal最小生成树算法

       此经典算法的思想是将树上的边按照边权排序,然后从小到大分析每一条边,如果选到一条边e=(v1,v2),且v1和v2不在一个连通块中,就将e作为最小生成树的一条边,否则忽略e。这其中明显就包含了并查集的算法。Kruskal算法也只有在结合了并查集后才能说是个高效的算法。


你可能感兴趣的:(算法笔记系列)