设计和分析算法,主要强调以下几点:

  • 优秀的算法因为能够解决实际问题而变得更为重要;
  • 高效算法的代码也可以很简单;
  • 理解某个实现的性能特点是一项有趣而令人满足的挑战;
  • 在解决同一个问题在多种算法之间进行选择时,科学方法是一种重要的工具;
  • 迭代式改进能够让算法的而执行那个效率越来越高.

我们会不断巩固这些思想,本节的例子只一个原型.它将为我们用相同的方法解决许多其他问题打下基础.

动态连通性

什么是动态连通性问题

首先我们详细说明一下问题:问题的输入是一列整数对,其中每个整数都表示一种类型的对象,一对整数 p q可以理解为”p和q是相连的.”我们假设相连是一种等价关系,这也就意味着它具有:

  • 自反性:p和q是相连的
  • 对称性: 如果p和q相连,那么q和p也是相连的.
  • 传递性:如果 p和q相连,q和r相连,那么p和r也是相连的.

等价关系能够将对象分为多个等价类.在这里当且仅当两个对象相连时他们才属于同一个等价类.

我们的目标时编写一个程序来过滤掉序列中所有无意义的整数对(两个整数均来自一个等价类中).换句话说,当程序从输入中读取了整数对 p q 后,如果一直所有整数对都不能说明p和q是相连的,那么则将这一对整数写入到输出中.如果已知的数据可以说明p和q是相连的,那么程序忽略p和q这对整数对,继续处理下一个整数对.

为了达到所期望的效果,我们需要设计一个数据结构来保存程序已知的所有整数对的信息,并用他们判断一对新对象是否是相连的.我们讲这个问题通俗的叫做 动态连通性 问题.

网络

连通性问题可以有一下应用.输入中的整数对表示的可能是一个大型计算机网络中的计算机,而整数对则表示网络中的连接,这个程序可以判断我们是否需要在p和q之间架设一条连接才可以通信,或是我们可以通过已有的连接在两者之间建立通信线路;或者这些整数可能表示电子电路中的触点,而整数对表示连接触点之间的电路;或者这些整数可能表示社交网络中的人,而整数对表示的是朋友关系.在此类应用中,我们可能需要数百万的对象和数十亿的连接.

  • 为了进一步限定话题,我们会在本节以下内容中使用网络相关的术语.将对象成为触点,将整数对成为连接,将等价类成为连通分量或者简称为分量.简单起见,假设我们有用到 0到N-1 的整数所表示的N个触点.这样做并不会降低算法的通用性,因为我们在后面的文章中将会学习一组高效的算法,将整数标识符和任意名称关联起来.

连通性问题只要求我们的程序能够判别给定的整数对 p q是否相连,但并没有要求给出两者之间通路上的所有连接.这样的要求会使问题更加复杂,并得到另一组不同的算法.我们会在后面关于 图的讲解中使用到它.

动态连通性之union-find算法步步优化_动态连通

union-find算法

union-find的代码实现

package 动态连通性;

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;
}
}
/**
* 连通分量的数量
* @return
public int count(){
return count;
}
/**
* 如果p q之间存在一条分量,则返回true
* @param p
* @param q
* @return
public boolean connected(int p,int q){
return find(p) == find(q);

}
/**
* p所在分量的标识符
* @param p
* @return
public int find(int p){return 0;}//暂未实现
/**
* 在p和q之间添加一条连接
* @param p
* @param
public void union(int p,int q){}//暂未实现

我们可以看到,为解决动态连通性问题设计算法的任务转化为了实现这份API.

动态连通性之union-find算法步步优化_算法第四版_02

众所周知,数据结构的性质将直接影响算法的效率,因此数据结构和算法的设计是密切相关的.API已经说明触点和分量都会用int表示,所以我们可以用一个 以触点为索引的数组id[] 作为基本数据结构来表示所有的分量. 我们将使用分量中某个触点的名称作为分量的标识符,因此你可以认为每个分量都是它的触点之一所表示的. 一开始我们有N个分量,每个分量都构成了一个只含有他自己的分量,因此我们就那个id[i]的值初始化为i.对于每个触点i,我们使用find(int p) 方法判断它所在分量的信息并保存在id[i]之中. connectend() 方法的实现只有一条语句 find(p) == find(q) ,他返回的是一个boolean 值.

  • quick-find完整代码实现
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;
}
}

/**
* 连通分量的数量
*
* @return
public int count() {
return count;
}

/**
* 如果p q之间存在一条分量,则返回true
*
* @param p
* @param q
* @return
public boolean connected(int p, int q) {
return find(p) == find(q);

}

/**
* p所在分量的标识符
*
* @param p
* @return
public int find(int p) {

return id[p];

}

/**
* 在p和q之间添加一条连接
*
* @param p
* @param
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
  • quick-find算法分析在本算法中,每次find()方法只需要访问数组一次,而归并两个分量 的union() 操作访问数组的次数在(N+3)到(2N+1) 之间,假设我们使用 quick-find 算法来解决动态连通性问题并且最后只得到了一个连通分量,那么这需要(N-1)此unoin(),则至少需要 (N+3)*(N-1)–N²次数组访问.因此我们马上就可以知道动态连通性的 该算法是平方级别的.我们需要寻找更好的算法

quick-union 算法

我们讨论的下一个算法的重点是要提高 union() 方法的速度.

确切的说,每个触点所对应的id[] 元素都是同一个连通分量中的另一个触点的名称(也可能是它自己)—-我们将这种联系成为 链接.

在实现find() 算法时,我们从给定出点开始,由它的链接得到另一个触点,再由这个触点链接到第三个触点,如此继续跟随者链接直到达一个根触点,及链接指向自己的触点,这样一个触点必然存在.

当且仅当分别由这连个触点开始的这个过程之中到达了同一个跟触点时,他们才在同一个分量之中.

下面是改进部分的代码

/**
* p所在分量的标识符
*
* @param p
* @return
public int find(int p) {

//找出分量的名称,跟随链接找到根节点
while(p!=id[p]) p = id[p];
return p;

}

/**
* 在p和q之间添加一条连接
*
* @param p
* @param
public void union(int p, int q) {

int pRoot = find(p);
int qRoot = find(q);

//将p和q的根节点统一
if(pRoot == qRoot)return;
id[pRoot] = qRoot;
count--;
}

find() 方法用来找链接的根节点的名称.

union() 方法实现很简单:我们由 p和 q 的链接通过find() 方法分别找到它们的根触点,然后只需将一个根触点连接到另一个根触点即可实现将一个分量重命名另一个分量. 因此这个算法叫做 quick-union.

动态连通性之union-find算法步步优化_union-find_03

  • quick-union 算法分析

quick-union 算法看起来比 quck-find 算法更快,因为它不需要为每对输入遍历整个数组.分析可知:在最佳情况下是线性级别的,在最坏情况下是平方级别的.

目前我们可以将quick-union 算法看作是对 quick-find算法的改良,因为它解决的quick-find 算法中最主要的问题(unio操作总是线性级别的).对于一般的输入数据,这个算法显然是一次进步,但quick-union算法仍然存在问题,我们不能保证在所有情况下它都比quick-find 快的多.

我们需要对quick-union 算法继续改进.

加权 quick-union 算法

幸好,我们只需要简单的修改quick-union算法就能保证像这样糟糕的情况不会再出现.与其在union() 方法中随意的将一棵树连接到另一棵树.我们现在会记录每一棵输的大小并将小叔连接到大树上面去.这项改动需要添加一个数组和一些代码来记录树中的节点数.它能够大大改进算法的效率,我们称它为 加权 quick-union 算法.

package 动态连通性;

public class UF2

private int[] id;// 分量id(以触点为索引)
private int count;// 分量数量
private int[] sz; // 各根节点所对应的分量的大小

public UF2(int N) {
// 初始化分量ID数组
count = N;
id = new int[N];
sz = new int[N];
for (int i = 0; i < N; i++) {
id[i] = i;
sz[i] = 1;
}

}

/**
* 连通分量的数量
*
* @return
public int count() {
return count;
}

/**
* 如果p q之间存在一条分量,则返回true
*
* @param p
* @param q
* @return
public boolean connected(int p, int q) {
return find(p) == find(q);

}

/**
* p所在分量的标识符
*
* @param p
* @return
public int find(int p) {

// 找出分量的名称,跟随链接找到根节点
while (p != id[p])
p = id[p];
return p;

}

/**
* 在p和q之间添加一条连接
*
* @param p
* @paramUF2.java q
*/
public void union(int p, int q) {

int pRoot = find(p);// 找到其根节点
int qRoot = find(q);

// 将p和q的根节点统一
if (pRoot == qRoot)
return;

// 将小树的根节点链接到大树的根节点
if (sz[pRoot] < sz[qRoot]) {
id[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];// 两树的节点数相加
} else
  • 加权quick-union 算法的分析对于加权 quick-union算法和N个触点,最坏情况下,find90,connected()和union()算法的成本的增长数量级为logN .

有了加权 quick-union算法我们就能够保证在河里的范围内解决十几种大规模动态连通性问题.只需要多写几行代码,我们所得到的程序在实际应用中的大型动态连通性问题时就会比简单的算法快数百倍.

展望

直观感觉上,我们学习的每一种UF的实现都改进了上一个版本的实现,但这个过程并不突兀,因为我们可以总结为学者们对这些算法对年的研究.我们明确的说明了问题,解决方法的实现也很简单.