为了说明我们设计和分析算法的基本方法,我们现在来学习一个具体的例子。我们的目的是强调以下几点:
下面我们要研究的就是关于动态连通性的计算性问题,首先我们会给出一个简单的方案,然后对它的性能进行研究并由此得出应用如果继续改进我们的算法。
有以下问题:问题的输入是一列整数对,其中某个整数表示某个类型的对象,一对整数p,q表示“p和q是相连的”。相连时一种等价关系,具有以下性质:
依据等价关系分类,可以把对象分为若干个等价类。我们的目标是编写程序来过滤掉掉没有意义的整数对(两个整数在同一个等价类中)。
问题处理描述:
为了达到上述效果,我们需要一个数据结构存储已知的整数足够多的信息,并用它们判断一对新对象是否相连。 这种问题我们称之为动态连通性问题。动态连通性应用,如下表所示:
应用场景 | 对象 | 对象关系(整数对) | 问题描述 |
---|---|---|---|
大型计算机网络 | 计算机 | 网络中连接 | 计算机与计算机之间是否需要假设一条新的连接才能通信 |
电路板 | 触点 | 连接触点之间的电路 | 触点之间是否电路已经连通 |
变量名等价性 | 变量名 | 同一个对象的多个引用 | 判断两个变量名是否等价(指向同一个对象的引用) |
在以后内容中我们使用网络中的术语,称对象为触点,将整数对称为连接,将等价类称为连通分量或者分量。简单起见,用0到N-1之间到整数表示N个触点。
如下图2-1所示,有几个连通分量?怎么判断两个触点是否在同一个分量中呢?
**我们设计算法的第一个任务就是要精确的定义问题。**一般情况下,算法能解决的问题越大,它完成任务所需的时间和空间就越多。但是它们之间的量化关系很难预先知道。通常我们只会在发现解决问题很困难,或者代价太大,或者幸运地发现算法所提供的信息比原问题所需的更加有用是修改问题。
以连通性问题为例,问题只要求我们能够判断给定的整数对p和q是否相连,但并没有要求给出两者之间的通路上的所有连接。
为了说明这个问题,我们设计类一份API封装所需的基本操作:初始化、连接两个触点、判断包含某个触点的分量、判断两个触点是否在同一个分量中以及返回分量的数量。
详细的API如下表3.1-1所示:
public class | UF | |
---|---|---|
public | UF(int N) | 初始化N个触点 |
public void | union(int p, int q) | 连接触点p和q |
public int | find(int p) | p所在分量的标志符 |
public boolean | connected(int p, int q) | 触点p和q是否相连 |
public int | count() | 连通分量的数量 |
为解决动态连通性问题设计算法的任务转化为实现这份API,所有的实现都应该:
**数据结构的性质直接影响算法的效率。**这里我们触点和分量用int值表示,我们用一个以触点为索引的数组id[]作为数据结构表示所有的分量。
union-find的成本模型:在研究实现union-find的APi的各种算法时,我们统计的是数组的访问次数(访问任意数组元素的次数,无论读写)。
一种思想是保证当前仅当ip[p]等于id[q]时p和q时连通的。即同一连通分量中所有的id[]的值都是相等的。
我们称这种实现方式为quic-find算法,实现代码3.3-1如下所示:
package edu.princeton.cs.algs4;
/**
* 动态连通性quick-find算法
*/
public class QuickFindUF {
/**
* 触点所在分量标志
*/
private int[] id;
/**
* 连通分量数量
*/
private int count;
/**
* 初始化触点数量
* {@code n} elements {@code 0} through {@code n-1}.
*
* @param 初始化触点数量{@code n}
*/
public QuickFindUF(int n) {
count = n;
id = new int[n];
for (int i = 0; i < n; i++)
id[i] = i;
}
/**
* 连通分量的数量
*
* @return 数量 (between {@code 1} and {@code n})
*/
public int count() {
return count;
}
/**
* 返回触点p所在的分量标志
*
* @param 触点p
* @return {@code p}所在分量的标志
*/
public int find(int p) {
validate(p);
return id[p];
}
/**
* 校验触点p是否合法
* @param 触点p
*/
private void validate(int p) {
int n = id.length;
if (p < 0 || p >= n) {
throw new IllegalArgumentException("index " + p + " is not between 0 and " + (n-1));
}
}
/**
* 判断触点p和触点q是否相连
*
* @param 触点p
* @param 触点q
* @return {@code true} 如果 {@code p} 和 {@code q} 相连;
* {@code false} 否则
*/
@Deprecated
/**
* 连接触点p所在的分量和触点q所在的分量
*/
public void union(int p, int q) {
validate(p);
validate(q);
int pID = id[p]; // needed for correctness
int qID = id[q]; // to reduce the number of array accesses
// p and q are already in the same component
if (pID == qID) return;
for (int i = 0; i < id.length; i++)
if (id[i] == pID) id[i] = qID;
count--;
}
}
测试数据如下所示:
10
4 3
3 8
6 5
9 4
2 1
8 9
5 0
7 2
6 1
1 0
6 7
测试程序3.3-2如下所示:
public static void testQF() {
String path = System.getProperty("user.dir") + File.separator + "asserts/tinyUF.txt";
In in = new In(path);
int n = in.readInt();
QuickFindUF uf = new QuickFindUF(n);
while (in.hasNextLine()) {
int p = in.readInt();
int q = in.readInt();
if (uf.find(p) == uf.find(q)) continue;
uf.union(p, q);
StdOut.println(p + " " + q);
}
StdOut.println(uf.count() + " 连通分量");
}
测试结果如下所示:
4 3
3 8
6 5
9 4
2 1
5 0
7 2
6 1
2 连通分量
轨迹示意图3.3-1如下所示:
find()操作的速度很快,因为它只需要访问id[]数组一次。但quick-find算法一般无法处理大型问题,因为对于每一对输入uinon()都需要扫描整个id[]数组。
命题F。在quick-find算法中,每次find()调用只需要访问数组一次,而归并两个分量的union()操作访问数组的次数在N到2N+2次之间;
证明:find()访问数组一次很明显。union()获取pid,qid访问2次id[]。如果全部pid等于qid那么最少需要遍历整个数组即N次;如果pid和qid都不想等,那么会检查id[]数组中的全部N个元素并改变它们中的1-N个元素的值,即最多2N+2次。
在最坏情况下,我们使用quick-find算法来解决动态连通性问题并且最坏只得到了一个连通分量,那么至少需要调用N-1次union(),即至少N(N-1)~(2N+2)(N-1)次数组访问-qucik-find解决动态连通性问题算法是平方级别的。
即然qucik-find算法的瓶颈在于union连接,那么我们想办法提高union方法的速度。
quick-uinon算法使用相同的数据结构:
find()方法实现:
connected()方法实现:
union(p,q)方法实现:
实现代码3.4.2-1如下所示:
package edu.princeton.cs.algs4;
/**
* quick-union算法
*/
public class QuickUnionUF {
/**
* 父链接(触点)数组
*/
private int[] parent;
/**
* 分量数量
*/
private int count;
/**
* 初始化有n个触点的parent[]
*
* @param n the number of element
*/
public QuickUnionUF(int n) {
parent = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
/**
* 分量的数量
*
* @return 分量的数量
*/
public int count() {
return count;
}
/**
* 返回触点p所在的分量标志
*
* @param p 触点p
*/
public int find(int p) {
validate(p);
while (p != parent[p])
p = parent[p];
return p;
}
/**
* 校验触点p
*/
private void validate(int p) {
int n = parent.length;
if (p < 0 || p >= n) {
throw new IllegalArgumentException("index " + p + " is not between 0 and " + (n-1));
}
}
/**
* 判断触点p和触点q是否在同一分量中
*
* @param p 触点p
* @param q 触点q
* @return {@code true} 如果触点 {@code p} and {@code q} 在同一分量中;
* {@code false} 否则
*/
@Deprecated
public boolean connected(int p, int q) {
return find(p) == find(q);
}
/**
* 合并触点p所在分量和触点q所在分量
*
* @param p 触点或者所在分量
* @param q 触点q所在分量
*/
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) return;
parent[rootP] = rootQ;
count--;
}
}
测试用例同quick-find算法,测试代码3.4.2-2如下所示:
public static void testQU() {
String path = System.getProperty("user.dir") + File.separator + "asserts/tinyUF.txt";
In in = new In(path);
int n = in.readInt();
QuickUnionUF uf = new QuickUnionUF(n);
while (!in.isEmpty()) {
int p = in.readInt();
int q = in.readInt();
if (uf.find(p) == uf.find(q)) continue;
uf.union(p, q);
StdOut.println(p + " " + q);
}
StdOut.println(uf.count() + " components");
}
测试结果如下所示:
4 3
3 8
6 5
9 4
2 1
5 0
7 2
6 1
2 components
id[]用父链接的形式表示了一片森林,union实现了将一个根结点变为另外一个根结点的父结点,从而归并了两棵树。如下图3.4.3-1所示:
定义:一棵树的大小树它的节点数量。树中的一个节点的深度树它到根结点路径上的链接数。树的高度树它的所有节点中的最大深度值。
命题G。quick-union算法中的find()方法访问数组的次数为1加上给定触点对应的节点的深度2倍。union()和connected()访问数组的次数为两次find()操作给定两个触点的分别存在不同树中则还需要加1。
假设我们使用quick-union算法最终解决了动态连通性问题并最终只得到一个分量,由命题G只算法在最坏情况下上平方级别的。
最坏情况即我们的整数对为有序的0-1、0-2、0-3等,最后我们所有的触点全都在一个分量中,id[]形成一条链表。整数对0-i,union()操作访问数组次数2i+1(触点0的深度i-1,触点i的深度0),处理N对整数所需所有find()操作数组的总次数 3 + 5 + 7 + ⋯ + ( 2 N − 1 ) ∽ N 2 3+5+7+\cdots+(2N-1)\backsim N^2 3+5+7+⋯+(2N−1)∽N2。
如果小伙伴什么问题或者指教,欢迎交流。
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/algorithm
参考链接:
[1][美]Robert Sedgewich,[美]Kevin Wayne著;谢路云译.算法:第4版[M].北京:人民邮电出版社,2012.10.p136-149.