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)的一个划分.我们考虑对每一个划分集合(S_i),选出一个代表(root_i),则刚开始时,有
显然,此时有(root_i=x_i).
( ext{FIND})操作
定义:对于某个(xin S_i),( ext{FIND}(x)=root_i),即(x)所在集合的代表.现在来考虑快速求( ext{FIND})的算法.
我们把每个集合想作一棵树,则(root)就是该集合的根节点.对于每个(x),维护(father)数组,定义为
显然,(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)即可.
比如,对于两个集合:
此时有
若( ext{QUERY}(4,6)),则有:
因(root_4=root_6),所以元素(4,6)属于同一个集合.
若( ext{MERGE}(3,11)),则有:
直接令(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算法中有很大应用.