本文是“漫谈算法”系列的第一篇,算是整理一下所学习的数据结构和算法,既便于加深理解,再者也算共享知识,该系列所有的算法都是用Java语言描述的,首先就从动态连通性和并查集(Union-Find)说起。
动态连通性是一个基础的计算性问题,广泛存在于多种应用中,从物理化学的渗透到通信网络的连通等,我们可以将问题抽象为:一个包含n个点的网络,点之间存在连接,给出任意两个点,判断两点之间是否存在连接?其中一种解决方法就是并查集,此问题中,并查集将相互连接的点归并到同一个集合中,要判断两个点之间是否存在连接即是判断两个点是否属于同一集合。
图1. 判断两点是否存在连接
设计算法的首要任务就是精确地定义问题,找出解决问题所需的基本操作抽象为一份API,此问题中,设计的API如下所示。我们将n个节点映射为0到n-1的整数,UF初始化所有节点;若点p和点q在两个集合中,union()操作则会将两个集合归并。find()操作会返回点所在集合的标识符;connected()操作则判断点p和点q是否属于同一集合。整个API的核心操作即是union()和find()。
public abstract class UF {
//初始化
public UF(int n) { }
//返回点p所属集合的标识符
public int find(int p);
//连接点p和点q
public void union(int p,int q);
//若p,q存在于同一个集合中,则返回true
public boolean connected(int p,int q);
}
首先来看一种基本实现Quick-Find算法,一开始,我们有n个互不相连的点,每个点自身为一个集合,我们用一个数组id[]来表示每个点所属集合的标识符,初始化id[i]为i;因此,find()操作直接返回点所属集合的标识符id[p],而union(p,q)操作首先要检查两个点是否属于同一集合,若不是,为了将两个点所属集合归并,则需要遍历所有点,将所有与p同集合的点归并到q所在集合,具体实现如下:
public class QuickFindUF {
private int[] id; //id[i]表示i所属集合的标识符
//初始化
public QuickFindUF(int n) {
id = new int[n];
for(int i = 0; i < n ; i++)
id[i] = i; //初始时每个点自成一集合
}
//查找p集合的根结点
public int find(int p){
return id[p];
}
//连接两点
public void union(int p,int q){
int rootp = find(p);
int rootq = find(q);
if(rootp == rootq) return; //同一集合返回
//将p集合所有结点的根结点id[p]改为rootq
for(int i = 0; i < id.length; i++)
if(find(i) == rootp)
id[i] = rootq;
}
//判断p,q是否连接
public boolean connected(int p,int q){
return id[p] == id[q];
}
}
Quick-Find算法find()操作显然很快,每次只需要访问一次id数组,但对于每一次union()操作,都需要扫描整个id数组,其复杂度为O(n)。因此我们需要优化union()操作的速度,即Quick-Union,该算法中节点之间以树的方式形成连接,每一个集合的标识符设置为树的根节点,id[i]表示为节点i的父节点的下标,因此节点所属集合的标识符或根节点x:id[x]==x。find()操作则沿着p节点向上找到根节点,union()操作仅仅只需将q所在树设为p所在树的子树,即能完成集合归并。具体实现如下:
public class QuickUnionUF {
privateint[] id; //id[]集合
//查找p集合的根结点
public int find(int p){
while(p != id[p])
p = id[p];
return p;
}
//连接两点
public void union(int p,int q){
int rootp = find(p);
int rootq = find(q);
if(rootp == rootq) return;
//将p集合的根结点rootp的父节点设为rootq
id[rootp] = rootp;
}
//其他操作不变
}
算法union()操作无需遍历所有节点,find()操作只需向上遍历即能找到根节点,两个操作的复杂度为O(树的高度),但极端情况下出现n个节点构成n层高的树,使得调用find()的复杂度变成O(n)。因此,可以继续优化合并,简单来说,就是每次将较矮的树作为子树连接到较高的树形成类似一种平衡二叉树的结构,判断哪棵树较矮有by_size和by_height两种做法,这里使用由树节点的个数判断,该方法称为加权Quick-Union,用sz数组记录树的节点个数,具体实现如下:
public class WeightedQuickUnionUF {
privateint[] id; //id数组
privateint[] sz; //sz[i]表示i所在集合节点的数目
//初始化
public WeightedQuickUnionUF(int n) {
id = new int[n];
sz = new int[n];
for(int i = 0; i < n ; i++){
id[i] = i;
sz[i] = 1;
}
}
//查找p集合的根结点
public int find(intp){
while(p != id[p])
p = id[p];
return p;
}
//连接两点
public void union(int p,int q){
int rootp = find(p);
int rootq = find(q);
if(rootp == rootq) return;
//将结点数目少的集合的根结点的父节点设为数目多的集合
if(sz[rootp] < sz[rootq]){
id[rootp] = rootp;
sz[rootq] += sz[rootp];
}
else{
id[rootq] = rootp;
sz[rootp] += sz[rootq];
}
}
}
对于加权Quick-Union算法,最坏情况下find()操作和union()成本的增长级数为O(logn)。还有一种理想情况,我们希望每一个节点都直接连接到所在集合的根节点上,但又不想如Quick-Find一样中遍历所有节点。因此,我们可以在加权Quick-Union的find()操作实现路径压缩,即在寻找根节点时将路径上遇到的所有节点都直接链接到根节点上,我们可以得到几乎完全偏平化的树,非常接近理想情况,实现如下:
//返回p的根结点
public int find(intp){
int q = p; //暂存
while(p != id[p])
p = id[p];
//路径压缩:将从q到到根节点p上的所有节点直接连接到根节点上
while(id[q] != p){
int temp = id[q];
id[q] = p;
q = temp;
}
return p;
}