本文参考自《算法笔记》并查集篇
1 并查集的定义
什么是并查集?并查集可以理解为是一种维护数据集合的结构。名字中并查集分别取自于单词并(union合并),查(find查找),集(set集合)。一个并查集需要有一下两个功能:
- 合并:合并两个集合
- 查找:判断两个元素是否在一个集合里
并查集主要是用一个数组来实现的,也就是一个father[N]
,我们定义father[i]
表示i的父亲结点。父亲结点也是存在于这个集合当中。同时,如果father[i] == i
的话,我们也说father[i]
是i的根结点。一个集合里面同属的集合里面只有一个根结点。我们可以看看下面的示例
father[1] = 1; //结点1的父亲结点是1,同时也说1号是这个集合里面的根结点
father[2] = 1;//结点2的父结点是1
father[3] = 2; //结点3的父结点是2
father[4] = 2;//结点4的父结点是2
father[5] = 5; //结点5的父亲结点是5,同时也说5是这个集合里面的根结点
father[6] = 6; //结点6的父结点是5
在上面中,根结点同属1的我们视为一个集合,根结点为5的,我们视为一个集合,这样我们就得到了两个集合。
2 并查集的基本操作
首先我们对并查集需要进行初始化的操作,然后才能根据需要进行合并或者查找的操作。
2.1初始化
一开始每个元素都是一个集合,所以需要对数组进行father[i] = i
的操作
for(int i = 1; i <= N; i++){
father[i] = i;
}
2.2 查找
由于我们说过了同一个集合只有一个根结点,因此查找就是在一个集合中查找到根结点的操作。实现方式可以是递归或者递推,思路都是一样,就是反复寻找父亲结点。
先来看递推的代码:
//findFather函数返回元素x所在集合的根结点
int findFather(int x){
while(x != father[x]){
x = father[x];
}
return x;
}
我们以图1为例,按照上面的递推方法,走一下查找元素4的根结点的流程:
- x = 4 ,father[x] = 2,因为 4 != father[4],所以继续查;
- x = 2,father[x] = 1,因为 2 != father[2],所以继续查;
- x = 1,father[x] = 1,因为 1 == father[1],所以找到根结点,返回1。
再来看递归的代码:
int findFather(int x){
if(x == father[x]) return x; //如果找到根结点,直接返回x
else return findFather(father[x]); //否则,递归判断x的父亲结点是不是根结点
}
2.3 合并
合并就是将两个集合合并成一个集合,比如说题目中一般给出两个元素说这两个是一个集合,那么就需要将其合并。具体实现上一般是先判断两个元素是否属于同一个集合,只有当两个元素属于不同集合才能合并。而合并的过程就是一个元素的根结点的父亲结点指向另一个元素的根结点。
具体思路主要是以下两个步骤
- 对于给定的两个元素,判断是否属于一个集合,可以调用上面的查找元素,分别查找其的根结点,然后判断根结点是否相同。
- 合并两个集合,在步骤1中我们已经找到两个元素的根结点,如果两个根结点不相同的话,我们将元素a的根结点fa,元素b的根结点fb,令father[fa] = fb,当然反过来也是可以的,father[fb] = fa。这样子就合并了两个集合。
比如我们合并元素4和6的两个集合,找到它们的根结点,然后进行合并。如图1.0操作后变图1.1
具体实现代码如下
void union(int a,int b){
int faA = findFather(a); //查找a的根结点,记为faA
int faB = findFather(b); //查找b的根结点,记为faB
if(faA != faB){
father[faA] = faB; //进行合并,反过来也是一样的
}
}
合并的过程,只是对两个不同的集合进行合并,如果两个元素在相同的集合中,那么就不会对它们进行操作。这就保证了同一个集合中一定不会产生环,即并查集每一个集合都是一棵树。
3 路径压缩
像上面的查找函数是没有经过优化的,在极端的情况下效率极低。比如,题目给出的元素很多形成一条链,那么这个查找的函数效率就会非常低。如图1.2所示,总共有10^5个元素形成一条链,那么假设要进行10^5次查询,且每次查询都要查询最后面的结点的根结点,那么每次都要花费10^5的计算量查找,这显然无法接受。
那么该如何优化呢?
我们以下面这个为例子
father[1] = 1;
father[2] = 1;
father[3] = 2;
father[4] = 2;
由于是查找根结点,所以我们可在查找的过程中,等价操作于
father[1] = 1;
father[2] = 1;
father[3] = 1;
father[4] = 1;
- 按照原先的写法获得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 = faher[a]; //回溯父亲结点
faher[z] = x; //将原先的结点a的父亲改成根结点x
}
return x; //返回根结点
}
(使用递归的方式)
int findFather(int v){
if(v == father[v]) return v; //找到根结点
else{
int F = findFather(father[v]); //递归寻找fatehr[v] 的根结点的F
father[v] = F; //将根结点F赋给father[v]
return F; //返回根结点F
}
}
并查集的基本使用方式就这些,具体的变形还需要具体情况来视。下面给出一道题目可以练练手
leetcode990.等式方程的可满足性