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算法中有很大应用.