浅谈并查集

Part1:什么是并查集

引入

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

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

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

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

集合代表

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

\[U=\bigcup_{i=1}^n S_i \]

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

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

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

\(\text{FIND}\)操作

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

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

\[\begin{cases} father[x]=\text{在集合树上}x\text{的父结点},x\ne root,\\ father[x]=x,x=root \end{cases} \]

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

\(\text{FIND}(x):\)
\(\mathbf{if}\ x=father[x]:\)
\(\quad \mathbf{return}\ x\)
\(\mathbf{return}\ \text{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]);
}

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

比如,对于两个集合:

此时有

\[\mathbf{SET1}: \begin{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}: \begin{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} \]

\(\text{QUERY}(4,6)\),则有:

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

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

\(\text{MERGE}(3,11)\),则有:

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

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

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

复杂度分析

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

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

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

Part2:路径压缩

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

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

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

通俗地说,有:

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

算法如下:

\(\text{FIND}(x):\)
\(\mathbf{if}\ x=father[x]:\)
\(\quad \mathbf{return}\ x\)
\(father[x]\leftarrow\text{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)\)\(\text{Ackmann}(n,n)\)的反函数.该函数增长极其缓慢,应用中可基本认为是常数.

Part3:启发式合并

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

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

\(\text{MERGE}(x,y):\)
\(root_x\leftarrow \text{FIND}(x)\)
\(root_y\leftarrow \text{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]]\)即可,算法如下:

\(\text{FIND}(x):\)
\(\mathbf{if}\ x=father[x]:\)
\(\quad \mathbf{return}\ x\)
\(f\leftarrow father[x]\)
\(father[x]\leftarrow \text{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\n",query(x,y));
	}
}

Part6:扩展域

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

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

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

\(\text{MERGE}(x,y)\)
\(\text{MERGE}(x+n,y+n)\)
\(\text{MERGE}(x+2n,y+2n)\)

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

\(\text{MERGE}(x+n,y)\)
\(\text{MERGE}(x,y+2n)\)
\(\text{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\n",ans);
}

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

本文完

posted @ 2019-08-06 18:27  Anverking  阅读(358)  评论(0编辑  收藏  举报