问题→
动态连通性:当程序从输入中读取了整数对p q时,如果已知的所有整数对都不能说明p和q是相连的,那么则将这一对整数写入到输出中。如果已知的数据可以说明p和q
是相连的,那么程序应该忽略p q这对整数并继续处理输入中的下一对整数。
该问题的应用→
网络,变量名等价性,数字集合等。
设计API→
UF(int N) | 以整数标识(0到N-1)初始化N个触点 | |
void | union(int p, int q) | 在p和q之间添加一条连接 |
int | find(int p) | p(0到N-1)所在的分量的标识符 |
boolean | connected(int p, int q) | 如果p和q存在于同一个分量中则返回true |
int | count() | 连通分量的数量 |
为解决动态连通性问题设计算法的任务转化为了实现这份API。
quick-find算法→
public class QuickFindUF { private int[] id; // 分量id(以触点作为索引) private int count; // 分量数量 public QuickFindUF(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) { // 将p和q归并到相同的分量中 int pID = find(p); int qID = find(q); // 如果p和q已经在相同的分量之中则不需要采取任何行动 if (pID == qID) return; // 将p的分量重命名为q的名称 for (int i = 0; i < id.length; i++) { if (id[i] == pID) id[i] = qID; } count--; } public static void main(String[] args) { // 解决由StdIn得到的动态连通性问题 int N = StdIn.readInt(); // 读取触点数量 UF uf = new UF(N); // 初始化N个分量 while (!StdIn.isEmpty()) { int p = StdIn.readInt(); int q = StdIn.readInt(); // 读取整数对 if (uf.connected(p, q)) continue; // 如果已经连通则忽略 uf.union(p, q); // 归并分量 StdOut.println(p + " " + q); // 打印链接 } StdOut.println(uf.count() + " components"); } }
分析:
· 每次find()调用只需要访问数组一次,而归并两个分量的union()操作访问数组的次数在(N + 3)到(2N + 1)之间。
· 假设最后只得到了一个连通分量,那么这至少需要调用N - 1次union(),即至少(N + 3)(N - 1) ~ N ^ 2次数组访问。
· quick-find算法是平方级别的。
quick-union算法→
public class QuickUnionUF { private int[] id; // 分量id(以触点作为索引) private int count; // 分量数量 public QuickUnionUF(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]; while (p != id[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; count--; } public static void main(String[] args) { // 解决由StdIn得到的动态连通性问题 int N = StdIn.readInt(); // 读取触点数量 UF uf = new UF(N); // 初始化N个分量 while (!StdIn.isEmpty()) { int p = StdIn.readInt(); int q = StdIn.readInt(); // 读取整数对 if (uf.connected(p, q)) continue; // 如果已经连通则忽略 uf.union(p, q); // 归并分量 StdOut.println(p + " " + q); // 打印链接 } StdOut.println(uf.count() + " components"); } }
分析:
· 最佳情况的输入,用例的运行时间是线性级别的。
· 最坏情况的输入,用例的运行时间是平方级别的。
加权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 (p != id[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) { // 解决由StdIn得到的动态连通性问题 int N = StdIn.readInt(); // 读取触点数量 UF uf = new UF(N); // 初始化N个分量 while (!StdIn.isEmpty()) { int p = StdIn.readInt(); int q = StdIn.readInt(); // 读取整数对 if (uf.connected(p, q)) continue; // 如果已经连通则忽略 uf.union(p, q); // 归并分量 StdOut.println(p + " " + q); // 打印链接 } StdOut.println(uf.count() + " components"); } }
分析:
· 记录每一棵树的大小并总是将较小的树连接到较大的树上。
· 加权quick-union算法是对数级别的。
最优算法→
路径压缩的加权quick-union算法是最优的算法,但并非所有的操作都能在常数时间内完成。
均摊成本的图像→
· 对于quick-find算法:累计平均值一开始较高,后来开始下降,但仍保持了相对较高的水平。
· 对于quick-union算法:累计平均值在初始阶段较低,后期增长很明显。
· 对于加权quick-union算法:没有任何昂贵的操作,均摊成本也很低。
展望→
讨论问题时的基本步骤:
· 完整而详细地定义问题,找出解决问题所必需的基本抽象操作并定义一份API。
· 简洁地实现一种初级算法,给出一个精心组织的开发用例并使用实际数据作为输入。
· 当实现所能解决的问题的最大规模达不到期望时决定改进还是放弃。
· 逐步改进实现,通过经验性分析或(和)数学分析验证改进后的效果。
· 用更高层次的抽象表示数据结构或算法来设计更高级的改进版本。
· 如果可能尽量为最坏情况下的性能提供保证,但在处理普通数据时也要有良好的性能。
· 在适当的时候将更细致的深入研究留给有经验的研究者并继续解决下一个问题。