基本概念和API
下图是并查集基本的API。一个并查集可以将两个触点合并,可以查询某个触点的标识符,可以判断两个触点之间是否连接,可以返回并查集中的连通分量。
我们用一个以触点为索引的数组id[]作为基本数据结构来表示所有分量。我们将使用分量中的某个触点的名称作为分量的标识符,因此你可以认为每个分量都是由它的触点之一所表示的。它的大体框架如下:
public class UnionFind {
private int[] id;
private int count;
UnionFind(int N){
id = new int[N];
for (int i = 0; i < N; i++){
id[i] = i;
}
count = N;
}
private int find(int p)
public void union(int p, int q){
//find()和union...有多种实现,具体见下文
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public int count(){
return count;
}
}
这份代码是我们对UF的实现。它维护了一个整型数组id[],使得find()对于处在同一个连通分量中的触点均返回相同的整数值。union()方法必须保证这一点。
在研究实现union-find的API的各种算法时,我们统计的是数组的访问次数(访问任意数组元素的次数,无论读写)。
实现
quick-find算法
一种方法是保证当且仅当id[p]等于id[q]时p和q是连通的。这意味着connected(p, q)只需要判断id[p] == id[q]。算法流程如下:
首先检查p和q是否在一个连通分量中,如果是,算法结束。否则,p所在的连通分量中的触点为一个值,q所在的连通分量中的触点为另一个值。为了将两个连通分量合并,我们必须将两个集合中所有触点对应的id[]改为同一个值。实现代码如下:
private int find(int p){
return id[p];
}
public void union(int p, int q){
int pId = find(p);
int qId = find(q);
if(qId == pId) return;
for(int i = 0; i < id.length; i++){
if(id[i] == qId) id[i] = pId;
}
count--;
}
算法分析:在quick-find算法中,union操作访问数组的次数在(N+3)到(2N+1)之间。所以,假设我们使用quick-find算法来解决动态连通性问题并且最后只得到了一个连通分量,那么这至少需要调用N-1次union(),即至少次数组访问。我们马上可以猜想动态连通性的quick-find算法是平方级别的。将这种分析推广我们可以得到,quick-find算法的运行时间对于最终只能得到少数连通量的一般应用是平方级别的。
quick-union算法
我们定义每个触点所对应的id[]元素都是同一个分量中的另一个触点的名称(也可能是它们自己)——我们将这种联系称为链接。在实现find()方法时,我们从给定的触点开始,由它的链接得到另一个触点,再由这个触点的链接到达第三个触点,如此继续跟随着链接直到到达一个根触点,即链接指向自己的触点,这样一个触点必然存在。当且仅当分别由两个触点开始的这个过程到达了同一个根触点时它们存在于同一个连通分量中。为了保证这个过程的有效性,我们需要union(p, q)来保证这一点。它的实现很简单:我们由p和q的链接分别找到它们的根触点,然后只需将一个根触点链接到另一个即可将一个分量重命名为另一个分量。
用节点表示触点,用从一个节点到另一个节点的箭头表示链接,由此得到数据结构的图像表示使我们理解算法的操作变得相对容易,我们得到的结构是树——从技术上来说,id[]数组用父链接的形式表示了一片森林。
代码如下:
private int find(int p){
while (p != id[p]) p = id[p];
return p;
}
public void union(int p, int q){
int pId = find(p);
int qId = find(q);
if(qId == pId) return;
id[pId] = qId;
count--;
}
算法分析:quick-union算法看起来比quick-find算法更快,因为它不需要为每对输入遍历整个数组。但是分析它非常困难,因为这依赖于输入的特点。在最好的情况下,find()只需要访问一数组一次就能够得到一个触点所在的分量的标识符;而在最坏的情况下,这需要2N+1次数组访问。
加权quick-union算法
public class WeightedQuickUnionUF {
private int[] id;
private int[] size;
private int count;
WeightedQuickUnionUF(int N){
id = new int[N];
size = new int[N];
for (int i = 0; i < N; i++){
id[i] = i;
size[i] = 1;
}
count = N;
}
private int find(int p){
while (p != id[p]) p = id[p];
return p;
}
public void union(int p, int q){
int pId = find(p);
int qId = find(q);
if(qId == pId) return;
if(size[pId] < size[qId]){
id[pId] = qId;
size[qId] += size[pId];
}else{
id[qId] = pId;
size[pId] += size[qId];
}
count--;
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public int count(){
return count;
}
}
算法复杂度:
- 对于N个触点,加权quick-union算法构造的森林中的任意节点的深度最多为。
- 对于加权quick-union算法和N个触点,在最坏情况下find()、connect()和union()的成本的增长数量级为lgN。
路径压缩的加权quick-union算法
该算法是最优的算法,但并非所有操作都能在常数时间内完成。要实现路径压缩,只需要为find()添加一个循环,将在路径上遇到的所有节点都直接链接到根节点。我们所得到的结果几乎是几乎完全扁平化的树,它和quick-find算法理想情况下所得到的树非常接近。
private static class UF{
private int[] parent;
private byte[] rank;
UF(int N){
parent = new int[N + 1];
rank = new byte[N + 1];
for(int i = 0; i <= N; i++){
parent[i] = i;
}
}
public int find(int p){
while (p != parent[p]){
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
public void union(int p, int q){
int rootP = find(p), rootQ = find(q);
if(rank[rootP] < rank[rootQ]){
parent[rootP] = rootQ;
}else if(rank[rootP] > rank[rootQ]){
parent[rootQ] = rootP;
}else {
parent[rootQ] = rootP;
rank[rootP]++;
}
}
}
总结
详细代码:https://github.com/617976080/algorithms-4th-code
参考书籍:《算法(第4版)》Robert Sedgewick / 美Kevin Wayne编写,人民邮电出版社在2012年出版。