zoukankan      html  css  js  c++  java
  • 浅谈并查集

    Part1:什么是并查集

    引入

    考虑(n)个元素,(x_1,x_2,dots,x_n),它们分别属于不同的集合,现在要维护这两种操作:

    ( ext{MERGE}(x,y)),合并两个元素(x,y)所在的集合;
    ( ext{QUERY}(x,y)),询问两个元素(x,y)是否属于同一个集合.

    初始时,每个元素自己构成一个集合.保证集合任意时刻两两不相交.

    显然,我们可以用朴素算法,则( ext{MERGE})操作需要(O(n))时间,( ext{QUERY})操作也要(O(n))时间,那么,有没有更好的算法呢?

    集合代表

    令全集(U={x_1,x_2,dots,x_n}),设当前集合的状态为

    [U=igcup_{i=1}^n S_i ]

    (U)的一个划分.我们考虑对每一个划分集合(S_i),选出一个代表(root_i),则刚开始时,有

    [U=igcup_{i=1}^n{x_i},S_i={x_i} ]

    显然,此时有(root_i=x_i).

    ( ext{FIND})操作

    定义:对于某个(xin S_i),( ext{FIND}(x)=root_i),即(x)所在集合的代表.现在来考虑快速求( ext{FIND})的算法.

    我们把每个集合想作一棵树,则(root)就是该集合的根节点.对于每个(x),维护(father)数组,定义为

    [egin{cases} father[x]= ext{在集合树上}x ext{的父结点},x e root,\ father[x]=x,x=root end{cases} ]

    显然,(FIND)操作可以实现如下:

    ( ext{FIND}(x):)
    (mathbf{if} x=father[x]:)
    (quad mathbf{return} x)
    (mathbf{return} ext{FIND}(father[x]))

    C++实现如下:

    inline void init(int n)//初始化有n个元素的集合
    {
        for(int i=1;i<=n;++i)
            father[i]=0;
    }
    inline int find(int x)//找x所在集合的代表
    {
        return x==father[x]?x:find(father[x]);
    }
    

    对于( ext{MERGE})操作,我们只要令(root_yleftarrow ext{FIND}(y),root_xleftarrow ext{FIND}(x)),再令(father[root_y]leftarrow root_x)(或(father[root_x]leftarrow root_y)也可)即可.
    对于( ext{QUERY})操作,我们只要令(root_yleftarrow ext{FIND}(y),root_xleftarrow ext{FIND}(x)),在判断是否有(root_x=root_y)即可.

    比如,对于两个集合:

    此时有

    [mathbf{SET1}: egin{array} {| c | | c | c |} x&root[x]&father[x]\ 1&1&1\ 2&1&1\ 3&1&1\ 4&1&1\ 5&1&2\ 6&1&2\ end{array} mathbf{SET2}: egin{array} {| c | | c | c |} x&root[x]&father[x]\ 7&7&7\ 8&7&7\ 9&7&7\ 10&7&9\ 11&7&9\ end{array} ]

    ( ext{QUERY}(4,6)),则有:

    [root_4= ext{FIND}(4)= ext{FIND}(father[4])= ext{FIND}(1)=1;\ root_6= ext{FIND}(6)= ext{FIND}(father[6])= ext{FIND}(2)= ext{FIND}(father[2])= ext{FIND}(1)=1; ]

    (root_4=root_6),所以元素(4,6)属于同一个集合.

    ( ext{MERGE}(3,11)),则有:

    [root_3= ext{FIND}(3)= ext{FIND}(father[3])= ext{FIND}(1)=1;\ root_{11}= ext{FIND}(11)= ext{FIND}(father[11])= ext{FIND}(9)= ext{FIND}(father[9])= ext{FIND}(7)=7; ]

    直接令(father[7]leftarrow 11),则整棵树更新如下:

    两个集合就成功合并了.我们把这种维护不相交集合的树形数据结构叫做并查集(disjoint union set).

    复杂度分析

    显然,算法的复杂度等于( ext{FIND})操作的复杂度,易知其复杂度是均摊(O(deep))的,其中(deep)是集合树的深度.在优秀情况下,( ext{FIND})可近似认为是(O(log n))级别的,但是如果我们不停( ext{MERGE})两个集合,集合树就会退化为链,此时的复杂度就会退化为(O(n)).如:

    我们调用( ext{MERGE}(2,4), ext{MERGE}(3,5)),则:

    这样树就退化成了链,复杂度就退化成了(O(n)).

    Part2:路径压缩

    我们通过上述例子可以知道,暴力上跳(father)数组很容易导致树结构退化.这时,我们就要引入路径压缩.

    回忆( ext{FIND})操作的过程,我们实际上访问了(x)(root_x)的整条链.事实上,这条链上除(root)结点外的父子关系对最终结果没有影响.所以,我们可以考虑这样一种算法:对于该链上的所有结点(x),当(x e root_x)时,直接令(father[x]leftarrow root_x).这样就可以保持树的深度在常数左右.比如,对于前面的两个集合:

    调用( ext{FIND}(5)),则链上对于结点({1,2,5}),直接令(father[2]=father[5]=father[1]=1),树就变成:

    通俗地说,有:

    我爸爸的爸爸就是我爸爸,我爸爸的爸爸的爸爸也是我爸爸.

    算法如下:

    ( ext{FIND}(x):)
    (mathbf{if} x=father[x]:)
    (quad mathbf{return} x)
    (father[x]leftarrow ext{FIND}(father[x]))
    (mathbf{return} father[x])

    C++实现如下:

    inline int find(int x)
    {
        return x==father[x]?x:father[x]=find(father[x]);
    }
    

    我们把这种算法成为并查集的路径压缩.可以证明,路径压缩的复杂度是均摊(O(alpha(n)))的,其中(alpha(n))( ext{Ackmann}(n,n))的反函数.该函数增长极其缓慢,应用中可基本认为是常数.

    Part3:启发式合并

    尽管路径压缩的复杂度很低,但是由于( ext{MERGE})操作的"直接连",会导致均摊复杂度退化为(O(log n))级别.

    直观上来说,对于两个集合,我们显然觉得把小集合合并到大集合的复杂度较低.事实也是如此.我们对于每个集合维护一个(size)数组,(size[x]= ext{以}x ext{为根的子树的结点个数}).在路径压缩时,只要令(size[x]leftarrow size[father[x]])即可.在( ext{MERGE})操作时,只需比较两个集合(root)(size)大小,将(size)较小的集合连到较大的集合,然后在更新大集合的(size)即可.刚开始时,(forall x,size[x]=1).算法如下:

    ( ext{MERGE}(x,y):)
    (root_xleftarrow ext{FIND}(x))
    (root_yleftarrow ext{FIND}(y))
    (mathbf{if} root_x=root_y:)
    (quad mathbf{return})
    (mathbf{if} size[root_x]<size[root_y]:)
    (quad father[root_x]leftarrow root_y)
    (quad size[root_y]leftarrow size[root_y]+size[root_x])
    (mathbf{else}:)
    (quad father[root_y]leftarrow root_x)
    (quad size[root_x]leftarrow size[root_x]+size[root_y])

    C++实现如下:

    inline int find(int x)//路径压缩
    {
        if(x==father[x])
            return x;
    
        father[x]=find(father[x]);
        siz[x]=siz[father[x]];//更新size
        return father[x];
    }
    
    inline void merge(int x,int y)//合并
    {
        int rx=find(x),ry=find(y);
    
        if(rx==ry)
            return;
    
        if(siz[rx]<siz[ry])
            father[rx]=ry,
            siz[ry]+=siz[rx];
        else
            father[ry]=rx,
            siz[rx]+=siz[ry];
    }
    

    启发式合并后,并查集的均摊复杂度为(O(alpha(n))).

    Part4:带权并查集

    考虑维护一个数组:(dis[x]),表示(x)(root)的距离,即(x)的深度.我们只要在路径压缩时更新令(dis[x]leftarrow dis[father[x]])即可,算法如下:

    ( ext{FIND}(x):)
    (mathbf{if} x=father[x]:)
    (quad mathbf{return} x)
    (fleftarrow father[x])
    (father[x]leftarrow ext{FIND}(father[x]))
    (dis[x]leftarrow dis[x]+dis[f])
    (siz[x]leftarrow siz[father[x]])

    C++实现如下:

    inline void init(int n)//初始化
    {
        for(int i=1;i<=n;++i)   
            father[i]=i,
            dis[i]=0,
            siz[i]=1;
    }
    
    inline int find(int x)
    {
        if(x==father[x])
            return x;
    
        int f=father[x];
    
        father[x]=find(fahter[x]);
        dis[x]+=dis[f];
        siz[x]=siz[father[x]];
    }
    

    Part5:简单习题

    LG P3367【模板】并查集

    模板题,C++实现如下:

    const int Maxn=1e4+7;
    
    int n,m;
    int father[Maxn];
    
    inline void init(int n)
    {
        for(int i=1;i<=n;++i)
            father[i]=i;
    }
    
    inline int find(int x)
    {
        return x==father[x]?x:father[x]=find(father[x]);
    }
    
    inline void merge(int x,int y)
    {
        int rx=find(x),ry=find(y);
    
        if(rx==ry)
            return;
        
        father[rx]=ry;
    }
    
    int main()
    {
        scanf("%d%d",&n,&m);
        init(n);
    
        while(m--)
        {
            int opt,x,y;
            scanf("%d%d%d",&opt,&x,&y);
    
            if(opt==1)
                merge(x,y);
            else
                puts(find(x)==find(y)?"Y":"N");
        }
    }
    

    LG P1955 [NOI2015]程序自动分析

    现将数据离散化,然后对于将所有等式排在不等式前面,对于每个等式,合并所约束的变量;对于不等式,若两个约束变量已在同一集合中,则这组约束不可实现.否则可实现.C++实现如下:

    const int Maxn=1000007;
    
    int f[Maxn],dic[Maxn*3],t,n,tot;
    
    struct Equal
    {
    	int x,y,e;
    }a[Maxn];
    
    class cmp
    {
    	public:
    		inline bool operator()(const Equal& a,const Equal& b)const//排序
    		{
    			return a.e>b.e;
    		}
    };
    
    inline void init(int s)
    {
    	for(int i=1;i<=s;++i)
    		f[i]=i;
    }
    
    inline int find(int x)
    {
    	return x==f[x]?x:f[x]=find(f[x]);
    }
    
    inline void discrete()//离散化
    {
    	sort(dic,dic+tot);
    	int r=unique(dic,dic+tot)-dic;
    	
    	for(int i=1;i<=n;++i)
    		a[i].x=lower_bound(dic,dic+r,a[i].x)-dic,
    		a[i].y=lower_bound(dic,dic+r,a[i].y)-dic;
    	
    	init(r);
    }
    
    int main()
    {
    	scanf("%d",&t);
    	
    	while(t--)
    	{
    		memset(f,0,sizeof(f));
    		memset(a,0,sizeof(a));
    		memset(dic,0,sizeof(dic));
    		tot=0;
    		
    		scanf("%d",&n);
    		
    		for(int i=1;i<=n;++i)
    		{
    			scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].e);
    			dic[tot++]=a[i].x;
    			dic[tot++]=a[i].y;
    		} 
    		
    		--tot;
    		
    		discrete();
    		
    		sort(a+1,a+n+1,cmp());
    		
    		int flag=1;
    		
    		for(int i=1;i<=n;++i)
    		{
    			int rx=find(a[i].x),ry=find(a[i].y);
    			
    			if(a[i].e)
    			{
    				f[rx]=ry;
    				continue;
    			}
    			if(rx==ry)
    			{
    				flag=0;
    				puts("NO");
    				break;
    			}
    		}
    		
    		if(flag)
    			puts("YES");
    	}
    }
    
    

    LG P1196 [NOI2002]银河英雄传说

    考虑用带权并查集,对于每个点,分别记录所属链的头结点,该点到头结点的距离以及它所在集合的大小.

    每次合并将(y)接在(x)的尾部,改变(y)头的权值和所属链的头结点,同时改变(x)的尾节点.

    注意:每次查找的时候也要维护每个节点的权值.

    每次查询时计算两点的权值差.C++实现如下:

    const int Maxn=3e4+7;
    
    int father[Maxn],siz[Maxn],dis[Maxn],n;
    int x,y;
    
    inline int find(int x)
    {
    	if(x!=father[x])
    	{
    		int f=father[x];
    		father[x]=get_father(father[x]);
    		siz[x]+=siz[f];
    		dis[x]=dis[father[x]];
    	}
    	
    	return father[x];
    }
    
    inline void merge(int x,int y)
    {
    	int fx=find(x),fy=find(y);
    	
    	if(fx!=fy)
    		father[fx]=fy,
    		siz[fx]=siz[fy]+dis[fy],
    		dis[fy]+=dis[fx],
    		dis[fx]=dis[fy];
    }
    
    inline int query(int x,int y)
    {
    	int fx=find(x),fy=find(y);
    	
    	if(fx!=fy)
    		return -1;
    	else
    		return abs(siz[x]-siz[y])-1;
    }
    
    inline int abs(int x)
    {
    	return x<0?-x:x;
    }
    
    int main()
    {
    	scanf("%d",&n);
    	
    	for(int i=1;i<=30000;++i)
    		father[i]=i,
    		dis[i]=1;
    	
    	for(int i=1;i<=n;++i)
    	{
    		char c;
    		cin>>c>>x>>y;
    		
    		if(c=='M')
    			merge(x,y);
    		
    		if(c=='C')
    			printf("%d
    ",query(x,y));
    	}
    }
    

    Part6:扩展域

    我们来看LG P2024 [NOI2001]食物链这道题.

    因为题目告诉我们每三种动物构成一条食物链,我们可以将每种动物分成三部分,即同类(self),捕食(eat),天敌(enemy),那我们不妨将并查集数组开大三倍,作为并查集的扩展域.

    即本身对应第一倍,猎物对应第二倍,天敌对应第三倍
    例如,如果是同类,就合并他们本身,他们的敌人,他们的猎物.算法如下:

    ( ext{MERGE}(x,y))
    ( ext{MERGE}(x+n,y+n))
    ( ext{MERGE}(x+2n,y+2n))

    如果(x)(y),说明(x)(y)的天敌,那(x)的天敌就是(y)捕食的物种,也就是(x)(y),(y)(z),(z)(x):

    ( ext{MERGE}(x+n,y))
    ( ext{MERGE}(x,y+2n))
    ( ext{MERGE}(x+2n,y+n))

    每次先判断是不是假话,也就是看一下是否已经被合并过,并且之前合并的关系与当前关系是否冲突,然后就可以按照题目所给出的关系进行合并.

    在做这道题之前不妨先做一下这道题:LG P1892 [BOI2003]团伙.

    食物链是这道题运用的反集思想的扩展(食物链用的是三倍空间,团伙用的是二倍),做完这道题再来做食物链可能更好理解.

    Part7:并查集求环

    由于并查集能维护父子关系,所以我们也可以将它运用到图论中,比如这道题LG P2661 信息传递,对于一个环,势必有一个点的父亲是他的子孙节点,如果发现将要成为自己父亲的节点是自己几代之后的子孙,这就说明有环出现了,用边带权并查集维护儿子是哪一代就可以求出环的大小,就可以进一步求最大环,最小环之类的东西.当然这只是并查集思路,这类题目还有另一种解法---Tarjan.C++实现如下:

    const int Maxn=2e5+7;
    
    int father[Maxn],dis[Maxn],n,ans,last;
    
    inline void init(int n)
    {
        for(int i=1;i<=n;++i)
            father[i]=i,
            dis[i]=0;
    }
    
    inline int find(int x)
    {
        if(father[x]!=x) 
        {
            int f=father[x];
            father[x]=find(f[x]); 
            dis[x]+=dis[f]; 
        }
    
        return father[x];
    }
    
    inline void merge(int x,int y)
    {
        int rx=find(x),ry=find(y);
        if(rx!=ry)
            father[rx]=ry,
            dis[x]=dis[y]+1;//若不相连,则连接两点,更新父节点和路径长.
        else
            ans=min(ans,dis[x]+dis[y]+1); //若已连接,则更新最小环长度.
    }
    
    int main()
    {
        scanf("%d",&n);
        init(n);
        ans=0x3f3f3f3f;
    
        for(int i=1,t;i<=n;++i)
            scanf("%d",&t),
            merge(i,t);                    //检查当前两点是否已有边相连接。 
    
        printf("%d
    ",ans);
    }
    

    如果理解了,可尝试这道题->LG P2921 [USACO08DEC]在农场万圣节Trick or Treat on the Farm.并查集求环在最小生成树的Kruskal算法中有很大应用.

    本文完

    The man who follow the shadow is just the shadow itself.
  • 相关阅读:
    模拟出栈
    全排列 next_permutation 用法
    区间覆盖
    BFS GPLT L2-016 愿天下有情人都是失散多年的兄妹
    GPLT L2-014 列车调度
    图的联通分量个数统计(判断图是否联通)
    堆排序 GPLT L2-012 关于堆的判断
    牛客挑战赛 30 A 小G数数
    由树的中后序遍历求树的前层序遍历
    【HDOJ4699】Editor(对顶栈,模拟)
  • 原文地址:https://www.cnblogs.com/Anverking/p/oi-duset.html
Copyright © 2011-2022 走看看