本文是《算法》书1.5节 动态连通性问题 的读书笔记
问题描述
问题的输入是一列整数对,其中每个整数都表示一个某种类型的对象,一对整数p q可以被理解为“p和q是相连的”。我们假设“相连”是 一种对等的关系,这也就意味着它具有:
□自反性:p和p是相连的;
□对称性:如果p和q是相连的,那么q和p也是相连的;
□传递性:如果p和q是相连的且q和r是相连的, 那么p和r也是相连的。
对等关系能够将对象分为多个等价类。在这里,当且仅当两个对象相连时它们才属于同一个等价类。我们的目标是 编写一个程序来过滤掉序列中所有无意义的整数对(两个整数均来自于同一个等价类中)。
问题分析
换句话说,当程序从输入中读取了整数对p q时,如果已知的所有整数对都不能说明p和q是相连的,那么则将这一对整数写入到输出中。如果已知的数据可以说明p和q是相连的,那么程序应该忽略p q这对整数并继续处理输入中的下一对整数。
在更高的抽象层次上,可以将输入的所有整数看做属于不同的数学集合。在处理一个整数对p q时,我们是在判断它们是否属于相同的集合。如果不是,我们会将p所属的集合和q所属的集合归并,最终所有的整数属于同一个集合。
API
数据结构
我们可以用一个以触点为索引的数组id[ ]作为基本数据结构来表示所有分量。我们将使用分量中的某个触点的名称作为分量的标识符,因此每个分量都是由它的触点之一所表示的。一开始,我们有N个分量,每个触点都构成了一个只含有它自己的分量,因此我们将id[i]的值初始化为1,其中i在0到N-1之间。
初级实现 quick-find
这种实现是保证当且仅当id[p]等于id[q]时p和q是连通的。换句话说,在同一个连通分量中的所有触点在id[ ]中的值必须全部相同。要将两个分量合二为一,我们必须将两个集合中所有触点所对应的id[ ]元素变为同一个值。
代码实现
import java.util.*;
public class UF
{
private int[] id; //分量id
private int count; //分量数量
public UF(int N) //初始化id数组
{
count = N;
id = new int[N];
for (int i=0; i<N; i++)
id[i] = i;
}
public int count()
{ return count; }
public boolean connected(int p, int q)
{ return find(p) == find(q); }
public int find(int p)
{ return id[p]; }
public void union(int p, int q)
{
int pID = find(p);
int qID = find(q);
if(pID == qID) return;
for(int i=0; i < id.length; i++)
if(id[i] == pID) id[i] = qID;
count--;
}
public static void main(String[] args)
{
Scanner in = new Scanner(System.in);
int N = in.nextInt();
UF uf = new UF(N);
while(in.hasNextInt())
{
int p = in.nextInt();
int q = in.nextInt();
if(uf.connected(p, q)) continue; //如果已连通则忽略
uf.union(p, q);
System.out.println(p + " " + q);
}
System.out.println(uf.count() + "components");
}
}
成本模型
在研究实现union-find的 API 的各种算法时,我们统计的是数组的访问次数(访问任意数组元素的次数,无论读写)。
quick-find的轨迹
quick-find算法分析
find()操作的速度显然是很快的,因为它只需要访问id[ ]数组一次。但quick-find算法一般无法处理大型问题,因为对于每一对输入union()都需要扫描整个id[]数组。
在quick-find算法中,每次find()调用只需要访问数组一次,而归并两个分量的 union()操作访问数组的次数(N+3)到(2N+1)之间。
假设我们使用quick-find算法来解决动态连通性问题并且最后只得到了一个连通分量,那么这至少需要调用N-1次union(),即至少(N+3)(N-1) ~ N^2次数组访问— 我们马上可以猜想动态连通性的quick-find算法是平方级别的。
改进实现 quick-union
算法的重点是提高union()方法的速度,它和quick-find算法是互补的。
每个触点所对应的id[ ]元素都是同一个分量中的另一个触点的名称(也可能是它自己)— 我们将这种联系称为链接。在实现find()方法时,我们从给定的触点开始,由它的链接得到另一个触点,再由这个触点的链接到达第三个触点,如此继 续跟随着链接直到到达一个根触点,即链接指向自己的触点(这样一个触点必然存在)。
当且仅当分别由两个触点开始的这个过程到达了同一个根触点时它们存在于同一个连通分量之中。为了保证这个过程的有效性,我们需要 union(p, q)来保证这一点。它的实现很简单: 我们由p和q的链接分别找到它们的根触点, 然后只需将一个根触点链接到另一个即可将一 个分量重命名为另一个分量,因此这个算法叫做quick-union。
代码实现
以下代码仅展示find()和union()方法的实现,其他部分代码相同。
private int find(int p)
{
while(id[p] != p) p = id[p]; //找出分量的根
return p;
}
public void union(int p, int q)
{
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) return;
id[pRoot] = qRoot; //将p的根触点连接到q的根触点上
count--;
}
quick-union的轨迹
quick-union算法分析
quick-union算法中的find()方法访问数组的次数为1加上给定触点所对应的节点的深度的两倍。union()和connected()访问数组的次数为两次find()操作(如果union()中 给定的两个触点分别存在于不同的树中则还需要加1)。
在最好的情况下,find()只需要访问数组一次就能够得到一个触点所在的分量的标识符;而在最坏情况下,这需要2N- 1次数组访问,如图1.5.6中的0触点(这个估计是较为保守的, 因为while循环中经过编译的代码对id[p]的第二次引用一般都不会访问数组)。由此我们不难构造一个最佳情况的输入使得解决动态连通性问题的用例的运行时间是线性级别的;另一方面,我 们也可以构造一个最坏情况的输入,此时它的运行时间是平方级别的。
quick-union算法仍然存在问题,我们不能保证在所有情况下它都能比quick-find算法快得多。
专业术语
(1)一棵树的大小是它的节点的数量。
(2)树中的一个节点的深度是它到根节点的路径上的链接数。
(3)树的高度是它的所有节点中的最大深度。
加权quick-union算法
我们现在会记录每一棵树的大小并总是将较小的树连接到较大的树上。这项改动需要添加一个数组和一些代码来记录树中的节点数。该算法构造的树的高度也远远小于未加权的版本所构造的树的高度。
代码实现
public class WeightedQuickUnionUF
{
private int[] id; //父链接数组(由触点索引)
private int[] sz; //各个根节点所对应的分量的大小
private int count; //分量数量
public WeightedQuickUnionUF(int N)
{
count = N;
id = new int[N];
for (int i=0; i<N; i++)
id[i] = i;
sz = new int[N];
for (int i=0; i<N; i++)
sz[i] = 1;
}
public int count()
{ return count; }
public boolean connected(int p, int q)
{ return find(p) == find(q); }
public int find(int p)
{
while(id[p] != p) p = id[p]; //找出分量的根
return p;
}
public void union(int p, int q)
{
int i = find(p);
int j = find(q);
if(i == j) return;
//将小树的根节点连接到大树的根节点
if(sz[i] < sz[j]) { id[i] = j; sz[j]+ = sz[i];}
else { id[j] = i; sz[i]+ = sz[j];}
count--;
}
public static void main(String[] args)
{
Scanner in = new Scanner(System.in);
int N = in.nextInt();
UF uf = new UF(N);
while(in.hasNextInt())
{
int p = in.nextInt();
int q = in.nextInt();
if(uf.connected(p, q)) continue; //如果已连通则忽略
uf.union(p, q);
System.out.println(p + " " + q);
}
System.out.println(uf.count() + "components");
}
}
加权quick-union的轨迹
加权quick-union算法分析
加权quick-union算法的最坏情况,其中将要被归并的树的大小总是 相等的(且总是2的幂)。这些树的结构看起来很复杂,但它们均含有2^n个节点,因此 高度都正好是n。另外,当我们归并两个含 有2n个节点的树时,我们得到的树含有2n+1个节点,由此将树的高度增加到了 n+1。由 此推广我们可以证明加权quick-union算法能够保证对数级别的性能。
对于N个触点,加权quick-union算法构造的森林中的任意节点的深度最多为 lgN。
对于加权quick-union算法和N个触点,在最坏情况下find()、connected ()和 union()的成本的增长数量级为logN。
加权quick-union算法是三种算法中唯一可以用于解决大型实际问题的算法。加权quick-union算法处理N个触点和M条连接时最多访问 数组cMlgN次,其中c为常数。这个结果和quick-find算法(以及某些情况下的quick-union算法) 需要访问数组至少MN次形成了鲜明的对比。因此,有了加权quick-union算法我们就能保证能够在合理的时间范围内解决实际中的大规模动态连通性问题。