1.定义
并查集是一种维护集合的数据结构,它的名字中“并”、“査”、“集”分别取自Union(合并)、Find(査找)、Set(集合)这3个单词。也就是说,并查集支持下面两个操作:
①合并:合并两个集合。
②査找:判断两个元素是否在一个集合。
那么并査集是用什么实现的呢?其实就是用一个数组:
int father[N];
其中fahter[i]表示元素i的父亲结点,而父亲结点本身也是这个集合内的元素(1≤i≤N)。例如father[1]=2就表示元素1的父亲结点是元素2,以这种父系关系来表示元素所属的集合。另外,如果 father[i]=i,则说明元素i是该集合的根结点,但对同一个集合来说只存在一个根结点,且将其作为所属集合的标识。
如下图所示,father数组的情况如下:

2.基本操作
总体来说,并查集的使用需要先初始化father数组,然后再根据需要进行查找或合并的操作。
(1).初始化
一开始,每个元素都是独立的一个集合,因此需要令所有 father[i]等于i:
for(int i=1;i<=N;i++){
father[i] = i; //令father[i]为-1也可,此处father[i]=i为例
}
(2).查找
由于规定同一个集合中只存在一个根结点,因此查找操作就是对给定的结点寻找其根结点的过程。实现的方式可以是递推或是递归,但是其思路都是一样的,即反复寻找父亲结点,直到找到根结点(即father[i]=i的结点)先来看递推的代码:
//finderFather函数返回元素x所在集合的根结点
int finderFather(int x){
while(x != father[x]){ //如果不是根结点,继续循环
x = father[x]; //获得自己的父亲结点
}
return x;
}
以上图为例,要查找元素4的根结点是谁,应按照上面的递推方法,流程如下:
①x=4,father[4]=2,因此4!= father[4],于是继续查;
②x=2,father[2]=1,因此2!= father[2],于是继续查;
③x=1,father[1]=1,因此1= father[1],找到根结点,返回1。
当然,这个过程也可以用递归来实现:
int finderFather(int x){
if(x == father[x])
return x; //如果找到根结点,则返回结点编号x
else
return finderFather(father[x]); //否则,递归判断x的父亲结点是否是根结点
}
3.合并
合并是指把两个集合合并成一个集合,题目中一般给出两个元素,要求把这两个元素所在的集合合并。具体实现上一般是先判断两个元素是否属于同一个集合,只有当两个元素属于不同集合时才合并,而合并的过程一般是把其中一个集合的根结点的父亲指向另一个集合的根结点。
于是思路就比较清晰了,主要分为以下两步:
①对于给定的两个元素a、b,判断它们是否属于同一集合。可以调用上面的查找函数,对这两个元素a、b分别查找根结点,然后再判断其根结点是否相同。
②合并两个集合:在①中已经获得了两个元素的根结点faA与faB,因此只需要把其中的父亲结点指向另一个结点。例如可以令 father[faA]=faB,当然反过来令 father[faB]=faA也是可以的,两者没有区别。
以下图为例,把元素4和元素6合并

过程如下:
①判断元素4和元素6是否属于同一个集合:元素4所在集合的根结点是1,元素6所在集合的根结点是5,因此它们不属于同一个集合。
②合并两个集合:令 father[5]=1,即把元素5的父亲设为元素1于是有了合并后的集合,如图下图所示。

现在可以写出合并的代码了:
void Union(int a, int b){
int faA = findFather(a); //查找a的根结点,记为faA
int faB = findFather(a); //查找a的根结点,记为faB
if(faA!=faB){ //如果不属于同一个集合
father[faA] = faB; //合并它们
}
}
这里需要注意的是,很多初学者会直接把其中一个元素的父亲设为另一个元素,即直接令 father[a]=b来进行合并,这并不能实现将集合合并的效果。例如,将上面例子中的 father[4]设为6,或是把 father[6]设为4,就不能实现集合合并的效果,如下图所示。

因此,初学者使用上面给出的Union函数来进行合并操作。
最后说明并査集的一个性质。在合并的过程中,只对两个不同的集合进行合并,如果两个元素在相同的集合中,那么就不会对它们进行操作。这就保证了在同一个集合中一定不会产生环,即并査集产生的每一个集合都是一棵树。
4.路径压缩
上面讲解的并查集查找函数是没有经过优化的,在极端情况下效率较低。现在来考虑一种情况,即题目给出的元素数量很多并且形成一条链,那么这个查找函数的效率就会非常低如下图所示,总共有10^5个元素形成一条链。那么假设要进行10^5次査询,且每次查询都查询最后面的结点的根结点,那么每次都要花费10^5的计算量查找,这显然无法承受。

那应该如何去优化查询操作呢?由于finderFather函数的目的就是查找根结点,例如下面这个例子:
father[1] = 1;
father[2] = 1;
father[3] = 2;
father[4] = 3;
因此,如果只是为了査找根结点,那么完全可以想办法把操作等价地变成:

对应的图形变化如下:

这样相当于把当前查询结点的路径上的所有结点的父亲都指向根结点,查找的时候就不需要一直回溯去找父亲了,查询的复杂度可以降为O(1)。
那么,如何实现这种转换呢?回忆之前查找函数finderFather的查找过程,可以知道是从给定结点不断获得其父亲结点而最终到达根结点的。因此转换的过程可以概括为如下两个步骤:
①按原先的写法获得x的根结点r。
②重新从x开始走一遍寻找根结点的过程,把路径上经过的所有结点的父亲全部改为根结点r。
于是可以写出代码:
int findFather(int x){
//由于x在下面的while中会变成根结点,因此先把原来的x保存一下
int a = x;
while(x != father[x]){ //寻找根结点
x = father[x];
}
//到这里,x存放的是根结点,下面就把路径上的所有的结点father都改为根结点
while(a != father[a]){
int z= a; //因为a要被father[a]覆盖,所以先保存a的值,以修改father[a]
a = father[a]; //a回溯父亲结点
father[z] = x; //将原先的根结点a的父结点改为根结点
}
return x; //返回根结点
}
这样就可以在查找时把寻找根结点的路径压缩了。由于涉及一些复杂的数学推导,读者可以把路径压缩后的并查集查找函数均推效率认为是一个几乎为O(1)的操作。而喜欢递归的读者,,也可以采用下面的递归写法:
int findFather(int x){
if(x == father[x])
return x; //如果找到根结点,则返回结点编号x
else{
int F = findFather(father[x]); //提柜寻找father[x]的根结点F
father[x] = F; //将根结点F赋值给father[x]
return F; //返回根结点
}
}
来源:CSDN
作者:李歘歘
链接:https://blog.csdn.net/qq_42410605/article/details/104184420