貌似合并的时候还要考虑将高度低的合并到高的,我就没考虑了。。。
1. 图的动态连通性问题⭐⭐⭐⭐⭐
并查集算法,主要是解决图论中「动态连通性」问题的。 这个算法本身不难,能不能应用出来主要是看你抽象问题的能力,是否能够把原始问题抽象成一个有关图论的问题。(很少考裸的并查集,大都是要自己进行抽象的!)
简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
现在我们的 Union-Find 算法主要需要实现这两个 API:
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}
这里所说的 「连通」 是一种等价关系,也就是说具有如下三个性质:
- 自反性:节点
p
和p
是连通的。 - 对称性:如果节点
p
和q
连通,那么q
和p
也连通。 - 传递性:如果节点
p
和q
连通,q
和r
连通,那么p
和r
也连通。
比如说之前那幅图,0~9 任意两个不同的点都不连通,调用connected
都会返回 false
,连通分量为 10 个。
如果现在调用union(0, 1)
,那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2)
,这时 0,1,2 都被连通,调用connected(0, 2)
也会返回 true
,连通分量变为 8 个。
判断这种 「等价关系」 非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
这样,你应该大概明白什么是动态连通性了,Union-Find 算法的关键就在于union
和connected
函数的效率。那么用什么模型来表示这幅图的连通状态呢?用什么数据结构来实现代码呢?
我们使用森林来表示图的动态连通性,用数组(是静态的数组,很巧妙)来具体实现这个森林。
2. 并查集的定义
并查集是一种维护集合的数据结构,它的名字中“并”、“查”、“集”分别取自Union(合并)、Find(查找)、 Set(集合) 这3个单词。也就是说,并查集支持下面两个操作:
- 合并:合并两个集合。
- 查找:判断两个元素是否在一个集合。
那么并查集是用什么实现的呢?其实就是用一个数组:
int father[N];
其中father[i]
表示元素i
的父亲结点。另外,如果father[i]=i
,则说明元素i
是该集合的根结点,但 对同一个集合来说只存在一个根结点,且将其作为所属集合的标识。
举个例子,下面给出了图9-37的father数组情况。
father[1] = 1;
father[2] = 1;
father[3] = 2;
father[4] = 2;
father[5] = 5;
father[6] = 5;
上面的这个例子应该还要修改3,4的father
数组里的值为1
3. 并查集的基本操作
并查集的基本操作顺序包括:
- 初始化
- 查找
- 合并
3.1 初始化
一开始,每个元素都是一个独立的集合,因此需要令所有father[i]
等于i
:
for(int i=0;i<=N;i++){
father[i] = i; //令father[i]为-1也可,此处以father[i]=i为例
}
3.2 查找
由于规定同一个集合只存在一个根结点,因此查找操作就是对给定的结点寻找其根结点的过程。实现的方式可以是递归或者递推,但是思路都是一样的,即反复寻找父亲结点,直到找到根结点(即father[i] == i
的结点)。
递推代码:
int findFather(int x){
while(x != father[x]){
x = father[x];
}
return x;
}
递归代码:
int findFather(int x){
if(x == father[x]){
return x;
}else{
return findFather(father[x]);
}
}
3.3 合并
合并是指把两个集合合并成一个集合,题目中一般给出两个元素,要求把这两个元素所在的集合合并。具体实现上一般是先判断两个元素是否属于同一个集合,只有当两个元素属于不同集合时才合并,而合并过程一般是把其中一个集合的根结点的父亲指向另一个集合的根结点。
于是思路就比较清晰了,主要分为以下两步:
- 对于给定的两个元素a、b,判断它们是否属于同一集合。
- 合并两个集合:在1中已经获得了两个元素的根结点
faA
与faB
,因此只需要把其中一个的父亲结点指向另一个结点。
代码如下:
void Union(int a,int b){
int faA = findFather(a);
int faB = findFather(b);
if(faA != faB){
father[faA] = faB;
}
}
4. 路径压缩⭐⭐⭐⭐⭐
上面讲解的并查集的查找函数findFather()
是没有经过优化的,在极端情况下效率较低。现在来考虑一种情况,即题目给出的元素数量很多并且形成了一条链,那么这个查找函数的效率就会非常低。
如图9-40所示,总共有
1
0
5
10^5
105个元素形成一条链,那么假设要进行
1
0
5
10^5
105次查询,且每次从查询都查询最后面的结点的根结点,那么每次都要花费
1
0
5
10^5
105的计算量查找,这显然无法承受。
那应该如何去优化查询操作呢?
由于findFather()
函数的目的就是查找根结点。因此,如果只是为了查找根结点,那么完全可以想办法把树等价为:
这样相当于把当前查询结点的路径上的所有结点的父亲都指向根结点,查找时就不用一直回溯去找父亲了,查询的复杂度可以降为
O
(
1
)
O(1)
O(1)。
那么,如何实现这种转换呢?
回忆之前查找函数findFather()
的查找过程,可以知道是从给定结点不断获得其父亲结点而最终到达根结点的。
因此转换的过程可以概括为如下两个步骤:
- 按原先的写法获得
x
的根结点r
。 - 重新从x开始走一遍寻找根结点的过程,把路径上经过的所有结点的父亲全部改为根结点
r
。
于是可以写出代码:
int findFather(int x){
//由于x在下面的while中会变成根结点,因此先把原先的x保存一下
int a = x;
while(x != father[x]){
x = father[x];
}
while(a != father[a]){
int z = a;
a = father[a];
father[z] = x;
}
return x;
}
这样就可以在查找时把寻找根结点的路径压缩了。
由于涉及一些复杂的数学推导,读者可以把路径压缩后的并查集查找函数均摊效率认为是一个几乎为
O
(
1
)
O(1)
O(1) 的操作。下面是递归的写法:
int findFather(int x){
if(x == father[x]){
return x;
}else{
int F = findFather(x);
father[x] = F;
return F;
}
}
5. 应用场景
5.1 图的连通性问题——最大的用途⭐⭐⭐⭐⭐
求图的连通性和图的连通分量个数~~
5.2 DFS的代替方案
很多使用 DFS 深度优先算法解决的问题,也可以用 Union-Find 算法解决。
比如LeetCode 第 130 题,被围绕的区域:给你一个 M×N
的二维矩阵,其中包含字符 X
和 O
,让你找到矩阵中四面被 X
围住的 O
,并且把它们替换成 X
。
说实话,Union-Find 算法解决这个简单的问题有点杀鸡用牛刀,它可以解决更复杂,更具有技巧性的问题,主要思路是适时增加虚拟节点,想办法让元素「分门别类」,建立动态连通关系。
5.3 判断合法等式⭐⭐⭐⭐⭐
这个解题思想十分地重要!!!
6. 题型训练
- 【PAT A1107】Social Clusters
- LeetCode 684. Redundant Connection
- LeetCode 399. Evaluate Division
- LeetCode 200. Number of Islands
- LeetCode 130. Surrounded Regions
- ⭐LeetCode 128. Longest Consecutive Sequence
- PAT A1114 Family Property
- PAT A1118 Birds in Forest
- 【裸的并查集】畅通工程
- 【并查集模板题】连通图
- ⭐⭐⭐⭐⭐【并查集+树】Is It A Tree?
- LeetCode 1319. Number of Operations to Make Network Connected