前言
在介绍union-find算法前,先引入一个场景。输入有N个整数对,对于每一个整数对,判断p,q是否是同一个连通分量(直接或间接相连)。如果是,则不对该整数对做处理,进行下一个整数对的判断。如果不是,则使p,q所在的连通分量合并(即把p,q直接或间接相连)。可以参考下图,我们将直接或间接相连的元素组成的集合称为一个连通分量(如图中的0,5,6)。当输入为0和6,通过图我们可以看出0和6是属于同一个连通分量,故不做处理。但如果输入是0和1,我们要做的就是合并0和1所在的连通分量。
这里主要是通过一个简单场景直接引入union-find算法,对于这个场景可能没有很好的描述,望读者见谅。
1. quick-find
我们可以将输入的每一个数都看成是一个数组中的索引,当两个数属于同一连通分量时,各自索引所对应的值是相等的。我们可以通过判断对应索引的值来判断输入的整数p,q是否属于同一连通分量,每次判断只需要访问数组一次,这就是quick-find算法。至于合并两个连通分量,一种简单的做法就是遍历数组,把和q属于同一连通分量的所有索引的值改为和p的值一样即可(把p合并到q的连通分量也一样)。代码如下所示:
/**
* 该类只写出了与算法相关的代码,在于理解算法逻辑,至于其他代码(如测试代码等),若读者有兴趣,自行补充。
**/
public class UnionFind {
private int[] id;
/**
* 模拟已有连通分量。数组初始化,将数组中的索引初始化为对应的值
* @param length
*/
public void init(int length){
id = new int [length];
for (int i : id) {
id[i] = i;
}
}
/**
* 获取p所在连通分量的值
* @param p
* @return
*/
public int find(int p){
return id[p];
}
/**
* 合并两个连通分量
* @param p
* @param q
*/
public void union(int p,int q){
int pVal = find(p);
int qVal = find(q);
// p,q属于同一连通分量,不做处理
if(pVal == qVal){
return;
}
// p,q不是同一连通分量
// 遍历数组
for(int i = 0; i < id.length; i++){
// 合并p,q所属的两个连通分量
if(id[i] == qVal){
id[i] = pVal;
}
}
}
}
以上的方案虽然简单,但是每次合并都需要遍历一遍数组效率太低。我们可以对for循环部分进行优化。有没有一种办法可以在不遍历数组就可以合并两个连通分量。虽然我在上面将每一个连通分量称之为直接或间接相连的元素组成的集合,但通过上图,我们可以发现,其实在合并两个连通分量的时候我们可以在两个连通分量间增加一条连线就可以将两个连通分量合并。这个时候就可以用到另一种数据结构,那就是树。这也是接下来要介绍的另一种算法quick-union的核心。
2. quick-union
quick-union是对合并两个连通分量部分的优化,和quick-find是互补的。quick-union算法使用树来定义每一个连通分量,每一棵树都有一个根节点,可以通过判断p,q是否有相同的根节点判断p,q是否是同一连通分量,即在quick-find以数组的基础上,将同一连通分量的某个索引作为根节点,若p,q是同一连通分量,则p,q必定有相同的根节点。
可参考下图所示:
改进后的quick-union算法如下:
/**
* 获取p所在树的根节点
* @param p
* @return
*/
public int find(int p){
while (p != id[p]) {
p = id[p];
}
return p;
}
/**
* 合并两个连通分量
* @param p
* @param q
*/
public void union(int p,int q){
// 获取p,q所在树的根节点
int pRoot = find(p);
int qRoot = find(q);
// p,q属于同一连通分量,不做处理
if(pRoot == qRoot){
return;
}
// p,q不是同一连通分量
// 将q的根节点的值指向p的根节点,合并p,q所在的树
id[qRoot] = pRoot;
}
3. 加权quick-union
在quick-union算法中,我们合并两棵树的是随机的。即有可能会把小树合并到大树下,也有可能把大树合并到小树下。当大树被合并到小树下时,会使合并后的树太高而增加额外的查找代价。而加权quick-union就是为了避免这种情况,通过记录每棵树的节点数来保证每次都将小树合并到大树下。
改进后的加权quick-union算法如下:
public class WeightedQuickUnion {
private int[] id;
private int [] size; // 每个连通分量(树)的节点数
/**
* 模拟已有连通分量
* @param length
*/
public void init(int length){
// 数组初始化
id = new int [length];
for (int i : id) {
id[i] = i;
}
// 初始化连通分量节点数
size = new int[length];
for (int i : size) {
size[i] = 1;
}
}
/**
* 获取p所在树的根节点
* @param p
* @return
*/
public int find(int p){
while (p != id[p]) {
p = id[p];
}
return p;
}
/**
* 合并两个连通分量
* @param p
* @param q
*/
public void union(int p,int q){
// 获取p,q所在树的根节点
int pRoot = find(p);
int qRoot = find(q);
// p,q属于同一连通分量,不做处理
if(pRoot == qRoot){
return;
}
// p,q不是同一连通分量
// 比较p,q所在树的大小
if (size[pRoot] > size[qRoot]){
// 将q的根节点的值指向p的根节点,合并p,q所在的树
id[qRoot] = pRoot;
// 合并后树的大小
size[pRoot] += size[qRoot];
}else {
id[pRoot] = qRoot;
size[qRoot] += size[pRoot];
}
}
}
4. 路径压缩
对于加权quick-union算法,其实已经很难再进行优化了,但《算法》一书中还是给出了“最优算法”,即对加权quick-union算法进行路径压缩。算法的思想是:在获取根节点的时候,将路径上的每一个节点都直接链接到根节点,得到几乎扁平化的树。这样可以减少之后在获取根节点时的操作开销。
该算法实现很简单,只需要为find()添加一个循环。实现代码如下:
public int find(int p) {
int root = p;
while (root != id[root]){
root = id[root];
}
while (p != root) {
int newp = id[p];
id[p] = root;
p = newp;
}
return root;
}
对于加权union-find算法进行路径压缩,笔者个人认为在树的规模足够大而且查找比较频繁的情况下经过经过路径压缩的加权union-find算法效率还是比较高的。但是对于加权quick-union算法和经过路径压缩的union-find算法两者性能的比较,笔者知识有限,无法得到一个确切的答案。若读者感兴趣,可自行查阅资料。
以上就是笔者在学习union-find算法过程中的一些见解。