zoukankan      html  css  js  c++  java
  • [并查集]的基本操作

    【摘要】

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

    这一类问题近几年来反复出现在信息学的国际国内赛题中,其特点是看似并不复杂,但数据量极大,若用图的数据结构来表示关系的话过于“奢侈”了,往往在空间上过大,计算机无法承受;即使在空间上勉强通过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题需要的结果,这就是为什么常考的原因。其实这只是一个对分离集合(并查集)操作的问题。而并查集通过树形结构,将不必要的计算去除,减少了连通图的空间,优化了时间,达到了更高的效率。

    【关键词】树型结构、并集、查集、优化

    【并查集定义】并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。

    【关键操作】

    初始化:把每个点所在集合初始化为其自身。

    (这一步必不可少,是最关键的操作之一,这会直接影响到整串代码的正确)

    查找:查找元素所在的集合,即根节点。

    (用递归或循环不停查找当前节点的父节点,直到该节点的父节点等于自己为止)

    合并:将两个元素所在的集合合并为一个集合。

    (通常来说,合并之前,应先判断两个元素是否属于同一集合,这可用上面的查找操作实现。)

    【关键代码】(c++语言)

    初始化:

    for(i=1;i<=n;i++)fath[i]=i;//将自己的父节点赋值为本身

    查找/判断集合:

    递归:

    int find(int x){
    if(fath[x]!=x){
        return find(fath[x]);
    }
    return x;
    }

    非递归:

    int find(int x){
    while(fath[x]!=x){
        x=fath[x];
    }
    return x;
    }
    

      

    【例题】亲戚

    问题描述:

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

    输入格式:

    输入由两部分组成。

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

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

    输出格式:

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

    输入样例:

      10 7 3

      2 4

      5 7

      1 3

      8 9

      1 2

      5 6

      2 3

      3 4

      7 10

      8 9

    输出样例:

      Yes

      No

    Yes

    【题目解析】

    如果使用常规思路就是连通图的算法,用Floyed算法通过第三路径连通连两个节点(亲戚)再查找的确可行,但本题因时间空间要求对于它来说太严格,用连通图的思想一定会超过时空限制,需要寻找新的思路。我们可以给每个人建立一个集合,集合的元素值有他自己,表示最开始时他不知道任何人是它的亲戚。以后每次给出一个亲戚关系a, b,则a和他的亲戚与b和他的亲戚就互为亲戚了,将a所在集合与b所在集合合并。对于样例数据的操作全过程如下:

    输入关系 分离集合

    初始状态

    (2,4) {1} {2,4} {3} {5} {6} {7} {8} {9}

    (5,7) {1} {2,4} {3} {5,7} {6} {8} {9}

    (1,3) {1,3} {2,4} {5,7} {6} {8} {9}

    (8,9) {1,3} {2,4} {5,7} {6} {8,9}

    (1,2) {1,2,3,4} {5,7} {6} {8,9}

    (5,6) {1,2,3,4} {5,6,7} {8,9}

    (2,3) {1,2,3,4} {5,6,7} {8,9}

    最后我们得到3个集合{1,2,3,4}, {5,6,7}, {8,9},于是判断两个人是否亲戚的问题就变成判断两个数是否在同一个集合中的问题。如此一来,需要的数据结构就没有图结构那样庞大了。

    【并查集实现】

    #include<cstdio>
    #include<cstring>
    using namespace std;
    int n,m,p,i,t1,t2,fath[5001];
    bool ans[5001];                     //用于储存答案
    int find(int x){
        if(fath[x]!=x)return find(fath[x]){  //如果x的父节点不是它本身就继续找根节点
        	fath[x]=find(fath[x]);
    	}
    	return fath[x];                  //如果是就返回根节点的值
    }
    int main(){
        scanf("%d%d%d",&n,&m,&p);
        memset(ans,0,p);              //初始化答案
        for(i=1;i<=n;i++){             //初始化,使一个集合根节点为本身
        	fath[i]=i;
    	}
        for(i=1;i<=m;i++){
            scanf("%d%d",&t1,&t2);
            t1=find(t1);
            t2=find(t2);
            if(t1!=t2){                 //如果不在同一集合中就合并集合
            	fath[t2]=t1;
    		}
        }
        for(i=1;i<=p;i++){
            scanf("%d%d",&t1,&t2);
            if(find(t1)==find(t2)){     //查找根节点
            	ans[i]=1;             //记录答案
    		}
        }
        for(i=1;i<=p;i++){            //输出答案
        	if(ans[i])printf("Yes
    ");
    		else printf("No
    ");
    	}
        return 0;
    }
    

      

    思路解析:

    并查集的思想,就是亲戚就合并集合(树),对于两个亲戚关系的人的集合进行合并的操作,将一个人的所属树挂在另一个人所属的树下面,然后对于两个判断的人就找他们的根节点的人是否是同一个ok了。虽然得出的是正确答案,但这依然超过了时空限制。因此算法需要再优化,如下程序。

    【并查集优化】

    #include<cstdio>
    #include<cstring>
    using namespace std;
    int n,m,p,i,t1,t2,fath[5001];
    bool ans[5001];                     //用于储存答案
    int find(int x){                       //路径优压缩优化
        if(fath[x]!=x){                   //如果x的父节点不是它本身就继续找根节点
        	fath[x]=find(fath[x]);
    	}
    	return fath[x];                  //如果是就返回根节点的值
    }
    int main(){
        scanf("%d%d%d",&n,&m,&p);
        memset(ans,0,p);              //初始化答案
        for(i=1;i<=n;i++){             //初始化,使一个集合根节点为本身
        	fath[i]=i;
    	}
        for(i=1;i<=m;i++){
            scanf("%d%d",&t1,&t2);
            t1=find(t1);
            t2=find(t2);
            if(t1!=t2){                 //如果不在同一集合中就合并集合
            	fath[t2]=t1;
    		}
        }
        for(i=1;i<=p;i++){
            scanf("%d%d",&t1,&t2);
            if(find(t1)==find(t2)){     //查找根节点
            	ans[i]=1;             //记录答案
    		}
        }
        for(i=1;i<=p;i++){            //输出答案
        	if(ans[i]){
        		printf("Yes
    ");
    		}
    		else printf("No
    ");
    	}
        return 0;
    }
    

      

    思路解析:

    对于之前的程序,此处最大的优化就是在寻找根节点的函数上,函数直接在递归过程中顺便将其合并的集合的子结点直接指向了根节点,这样的路径压缩非常简单而有效,可以减少查找时的递归层数,大大减少了计算的时间。

    【优化关键代码】

     递归:

    int find(int x){
    if(fath[x]!=x){//如果不是根节点就继续寻找
        fath[x]=find(fath[x]);//将子节点直接指向根节点
    }
    return x;//如果已寻找到根节点就返回根节点
    }
    

      

    非递归:

    int find(int x){
    int father,now=x,t=x;
    while(fath[now]!=now){//寻找到根节点
        now=fath[now];
    }
    father=fath[now];//储存根节点
    while(fath[x]!=x){//将遍历到的元素直接指向它的根节点
        x=fath[x];
        fath[t]=father;
        t=x;
    }
    return father;//返回根节点的值
    }
    

      

    对于这种优化思路,另一种方式也能实现,如下。

    【以另一种方式优化】

    #include<stdio.h>
    using namespace std;
    int n,m,q,i,t1,t2,fath[5001];
    bool ans[5001];                          //用于储存答案
    void change(int a){                       //合并两棵树
    	int i;
    	for(i=1;i<=n;i++){                    //查找每一个节点
    		if(fath[i]==a&&fath[i]!=fath[t1]){
    			fath[i]=fath[t1];               //合并节点
    		}
    	}
    	return ;
    }
    int main(){
    	scanf("%d%d%d",&n,&m,&q);
    	for(i=1;i<=n;i++){                    //初始化集合
    		fath[i]=i;
    	}
    	for(i=1;i<=m;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]!=fath[t2]){
    			change(fath[t2]);             //并集
    			fath[fath[t2]]=fath[t1];
    		}
    	}
    	for(i=1;i<=q;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]==fath[t2]){            //只需看它们的父节点是否相同就行了
    			ans[i]=1;                    //储存答案
    		}
    		else ans[i]=0;
    	}
    	for(i=1;i<=q;i++){                   //输出答案
    		if(ans[i])printf("Yes
    ");
    		else printf("No
    ");
    	}
    	return 0;
    }
    

      

    思路解析:

    本思路就是将集合看成只有一层的树,每次输入两个亲戚的关系后就将一棵树合并到另一棵,此处就是直接将一棵树直接指向另一棵的根节点,这样每一个叶节点的父节点就是它所属树的根节点,查找起来十分方便,虽然没有【并查集优化快】,但也减少了查找的时间。这种思路可以再优化,就是将每一棵树的子节点数量储存下来,这样在并集的时候就能减少时间,如下。

    【以另一种方式优化的优化】

      

    #include<stdio.h>
    using namespace std;
    int n,m,q,i,t1,t2,fath[5001],num[5001];       //num数组用于记录每一集合的成员个数
    bool ans[5001];                               //用于储存答案
    void change(int a){
    	int i;
    	for(i=1;i<=n;i++){
    		if(fath[i]==a&&fath[i]!=fath[t1]){      //合并集合
    			fath[i]=fath[t1];
    		}
    	}
    	return ;
    }
    int main(){
    	scanf("%d%d%d",&n,&m,&q);
    	for(i=1;i<=n;i++){                       //初始化元素的根节点
    		fath[i]=i;
    		num[i]=1;
    	}
    	for(i=1;i<=m;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]!=fath[t2]){
    			if(num[fath[t1]]<num[fath[t2]]){  //判断集合成员个数多少
    				t1=t1^t2;t2=t1^t2;t1=t1^t2; //位运算交换两个数的值
    			}                               //等同于swap(t1,t2)
    			num[fath[t1]]+=num[fath[t2]];   //合并后的成员个数
    			change(fath[t2]);
    			fath[fath[t2]]=fath[t1];
    		}
    	}
    	for(i=1;i<=q;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]==fath[t2]){               //收集答案
    			ans[i]=1;
    		}
    		else ans[i]=0;
    	}
    	for(i=1;i<=q;i++){                       //输出答案
    		if(ans[i]){
    			printf("Yes
    ");
    		}
    		else printf("No
    ");
    	}
    	return 0;
    }
    

      

    思路解析:

    本思路就是将集合中成员个数记录下来,在输入两个成员关系时,将成员所属集合成员少的合并到集合成员较多的集合中去,这样在并集时可以优化计算次数,但由于增加了交换的步骤使其抵消了原来的时间,反而时间复杂度增多了,下面提供一种本优化思路时间复杂度最低的也是最快的一个程序。

      

    #include<stdio.h>
    using namespace std;
    int n,m,q,i,t1,t2,fath[5001];
    bool ans[5001];                          //用于储存答案
    void change(int a){                       //合并两棵树
    	int i;
    	for(i=1;i<=n;i++){                    //查找每一个节点
    		if(fath[i]==a&&fath[i]!=fath[t1]){
    			fath[i]=fath[t1];               //合并节点
    		}
    	}
    	return ;
    }
    int main(){
    	scanf("%d%d%d",&n,&m,&q);
    	for(i=1;i<=n;i++){                    //初始化元素的根节点
    		fath[i]=i;
    	}
    	for(i=1;i<=m;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]!=fath[t2]){
    			if(fath[t1]==t1&&fath[t2]!=t2){//仅判断是否有自己的集合
    				t1=t1^t2;t2=t1^t2;t1=t1^t2;
    			}
    			change(fath[t2]);             //并集
    			fath[fath[t2]]=fath[t1];
    		}
    	}
    	for(i=1;i<=q;i++){
    		scanf("%d%d",&t1,&t2);
    		if(fath[t1]==fath[t2])ans[i]=1     //只需它们的父节点是否相同再储存答案
    		else ans[i]=0;
    	}
    	for(i=1;i<=q;i++){                   //输出答案
    		if(ans[i])	printf("Yes
    ");
    		else printf("No
    ");
    	}
    	return 0;
    }
    

      

    【总结】

    并查集就是在一些有N个元素的集合应用问题中,输入某两个元素在一个集合中,合并集合,并对于输入的有两个元素的询问判断它们是否在一个集合中的问题。

    如果这种关系用图来表示空间和时间就都一定会超过限制,但是用树形结构表示时空复杂度就要简化很多,一开始将每一个元素判定在自己的集合中,然后对于输入数据合并集合(如【并查集实现】)。信息学奥赛中给的时空限制都很苛刻,一般都需要路径压缩优化来减化时空才能通过(如【并查集优化】),这就需要在合并时直接将一个集合的所有结点直接指向另一个集合的根节点,这样查找时就可以减少递归层数,从而做到优化时间和空间。

    如果用【以另一种方式优化】做这道题,虽然通过了提交,但这样在合并集合时仍然有点费时间,应此改进后的思路【并查集优化】,就是在在寻找根节点时顺便就把子节点指向了根节点,虽然不是所有的子节点都指向了,但的确是最优解。

  • 相关阅读:
    176. Second Highest Salary
    175. Combine Two Tables
    172. Factorial Trailing Zeroes
    171. Excel Sheet Column Number
    169. Majority Element
    168. Excel Sheet Column Title
    167. Two Sum II
    160. Intersection of Two Linked Lists
    个人博客记录
    <meta>标签
  • 原文地址:https://www.cnblogs.com/TbIblog/p/11190016.html
Copyright © 2011-2022 走看看