前言
如果能够科学上网,英文水平良好,建议登入cousera进行学习。
平台上有完整的作业提交平台,对提交的作业有详细的性能诊断和反馈;有课程各种资源;有课程讨论。
在课程提问区提问还会收到导师的回答。
链接:
Algorithms, Part I
Algorithms, Part II
《算法》第四版:testbook链接(英文):在此
主要内容
并查集是一种树形的数据结构,通过这种数据结构能够有效处理不相交集合间的合并(union)及查询(find)问题。比如动态连通性问题。
这种数据结构主要涉及两个操作:
- Find:查询元素属于哪一个子集。此操作还可以用来确定两个元素是否属于同一子集。
- Union:将两个子集合并成到一个集合中。
1. 动态连通性问题(dynamic connectivity)
动态连通性的应用很广泛:
比如网络诊断:网络中的两台计算机是否连通,社交网络中的两个人是否存在交集,芯片中的电路元件连通性等等。
场景:对象
数码照片:像素
网络:计算机
社交网络:人
...
在编程中我们会对所有这些不同类型的对象进行简单的编号(0 -- N-1),这样方便利用整数作为数组的索引号,快速地访问每个对象的相关信息,还可以忽略与并查集问题不相关的很多细节。
1.1 建立问题模型
给定一个有N个性质相同的对象的集合
问题大体上所需要的基本操作:
- 连通(Union):两个对象间可以连通
- 连通性的查询(Find/connected):查询两个对象之间是否有连通路径
如下图,通过Union(x,y)命令给若干对象之间建立连通路径;通过connected(x,y)命令查看两个对象是否连通。
我们的任务就是对于给定的对象集合高效地实现这两个命令。因为合并命令和连通命令交叉混合,所以我们还有一个任务是我们的解决方案能支持大量对象和大量混合操作的高效处理。
##1.2 提取连通性质的抽象性
1.2.1 "连接到"的等价关系
假设“连接到(is connected to)”是一个具有下边关系性质的等价关系:那么它会有如下一些性质:
- 反身:每个对象都能连接到自己 (p is connected to p)
- 对称:如果p与q连通,那么q也与p连通
- 传递:如果p与q连通,q与r连通,那么p与r连通
这些性质都是直观自然的。明确了这些等价关系后,下面给出“连通分量”的定义。
1.2.2 连通分量(connected components)
有了等价关系后,一个对象和链接的集合就分裂为子集,这些子集就为连通分量。连通分量是互相连接对象的最大集合,并且连通分量之间并不连通。如下图左边,有3个连通分量。
我们的算法通过维护连通分量来获得效率,并使用连通分量来高效地应答接收的请求。
有了连通分量的概念后,并查集(即动态联通问题)要实现的操作有:
- Find 查找: 查找检查两个对象是否在相同的连通分量中
-
Union 合并命令:将包含两个对象的连通分量合并为一个子集(一个连通分量)。如下图。
1.3 Union-find 数据类型(API)
从之前的讨论我们得到了一个数据类型,在编程的概念中,它是为了解决问题我们想要实现的方法和规范。在Java的模型中,创建一个类来表示这些方法和规范。
其中包含:
- 构造函数:参数是对象的数量,根据对象的数量建立数据结构
- 两个操作:a) 实现合并; b) 连接超找,返回一个布尔值(逻辑变量:真/假)
在实现算法的过程中,应该明确算法在以下的情况下也必须高效:
- 对象和操作的数量都可能是巨大的
- 合并和连接的混合操作也会非常频繁
在处理更深层的问题之前,通过设计一个测试客户端用于检查API,确保任何一种实现都执行了我们希望他执行的操作。
如下图,客户端从标准输入(tinyUF)中读取信息。首先读取“10”这个整数,表示要处理的对象的数量,并创建一个UF对象。然后只要标准输入中还有输入,客户端将从输入读取两个整数,如果他们没有连通,将他们连通并输出。否则忽略这条输入。
2. 实现动态连通性问题的算法
也可以说就是实现并查集的算法。
这些算法的目的并不是给出两个对象的连通路径是什么,而是回答是否存在这么一条路径。虽然给出两个对象相连的路径也回答了是否连通的问题,但是于我们的目的而言不是直接契合,效率也不高。使用并查集数据结构是一个不错的策略。
实现动态连通性问题的算法有:
- quick-find 快速查找 (QF)
- quick-union 快速合并 (QU)
- Weighted quick-union 加权优化 (WQU)
- Quick union with path compression 路径压缩优化(QUPC)
两种优化可以结合一起使用,以下简称"WQUPC",即 weighted QU + path compression
2.1 quick-find
快速查找算法也称为“贪心算法”。
(简单来说贪心算法就是在对问题求解时总是作出当前看来是最好的选择,只得出在某种意义上的局部最优解,不一定能得到整体最优解)
2.1.1 Qinck-find 简介
Qinck-find数据结构:简单的对象索引整数数组 id[](size = n)
(具体来说就是当且仅当两个对象 p 与 q 是在数组中的id一样,那么他们即为连通)
如图:0,5,6在一个连通分量;1,2,7在一个连通分量;3,4,8,9连通
由此:
Find:找到 p 或 q 的 id
Union:合并两个给定对象的的两个连通分量,即需要将与给定对象之一(p)有相同 id 的所有对象的 id 值都变为另一个给定对象(q)的 id 值
如图经过 union(6,1), 通过合并6和1,6所在的连通分量中的所有对象(0,5,6)的id值都变为1的 id 值(1)
这个算法的主要问题是当进行合并操作是,遇往后,需要改变id值的操作就越多。
2.1.2 Java实现:
public class QuickFindUF
{
private int[] id;
//建立一个私有化整数数组,并将对应索引的数组项设为索引值
public QuickFindUF(int N)
{
id = new int[N];
for (int i = 0; i < N; i++)
id[i] = i;
}
//连通判断:两个数组项的 id 值是否相等
public boolean connected(int p, int q)
{ return id[p] == id[q]; }
//合并操作:取出两个参数的 id 值,遍历整个数组,找到同第一个参数id值相同的所以id项,并将他们都赋值成第二个参数的id值。
public void union(int p, int q)
{
int pid = id[p];
int qid = id[q];
for (int i = 0; i < id.length; i++)
if (id[i] == pid) id[i] = qid;
}
}
更具体代码实现: QuickFindUF.java
2.1.3 性能分析
衡量因子:代码需要访问数组的次数增长量级
构造函数初始化和合并操作时都需要遍历整个数组,所以他们必须以常数正比于N次访问数组(增长量级为N)
查找的操作很快,只需要进行常数次比较(增长量级为1)
算法的特点是查找很快,但是合并的代价太大,如果需要在N个对象上进行N次合并操作,访问数组就需要N的平方次增长量级的操作,这是不合理的。平方量级的时间太慢。对于大型问题不能接受平方时间的算法。
随着计算机变得更快,平方时间算法实际会变得更慢,简单来说,假如计算机每秒能进行几十亿次的操作,这些计算机的主内存中有几十亿项,大约一秒钟的时间我们就可以访问主内存所有的项,现在希望算法对几十亿的对象进行几十亿的操作,快速查找算法需要访问数组大约10的18次方次,大概是30多年的计算时间。
2.2 quick-union
快速查找对于处理巨大问题太慢,所以第一个尝试是使用快速合并算法进行替换。
快速合并的算法设计采用了所谓的“懒策略”,即尽量避免计算直到不得不进行计算。
2.2.1 Quick-union简介
Quick-union数据结构:大小为N的整数数组 id[](size = N)
这里的数组看做是一片森林(即一组树的集合),数组中的每一项包含它在树中的父节点。
下图可以看出3的父节点是4, 4的父结点是9,9的父节点是它自身,也就是9是这颗树的父节点。从某个对象出发,一路从父节点向上就能找到根节点。
由此:
Find:找到 p 或 q 的 根节点(root)
Union:合并两个对象的连通分量,即把 p 的根节点的值设为 q 的根节点的值。
如图, union(3,5) 即把3的根节点(9)的id值设为5的根节点(6)的id值。由此,3所在的连通分量与5所在的连通分量进行了合并。
可以看出合并两个连通分量只需要改数组中的一个值,但查找根节点会需要多一些操作。
2.2.2 Java实现:
public class QuickUnionUF
{
private int[] id;
//构造器与Quick-find相同
public QuickUnionUF(int N)
{
id = new int[N];
for (int i = 0; i < N; i++) id[i] = i;
}
//私有方法root()通过回溯实现寻找根节点
private int root(int i)
{
while (i != id[i]) i = id[i];
return i;
}
//通过私有方法实现查找操作(是否连通操作)。通过判断两个对象的根节点是否相同即得出返回
public boolean connected(int p, int q)
{
return root(p) == root(q);
}
//合并操作就是找到两个对象的根节点,并将第一个根节点的id值设为第二个对象根节点的id值
public void union(int p, int q)
{
int i = root(p);
int j = root(q);
id[i] = j;
}
}
详细实现:QuickUnionUF.java
2.2.3 性能分析:
衡量因子:代码需要访问数组的次数增长量级
快速合并的算法依旧很慢。快速查找的缺点在于树可能太高,这样对于查找操作的代价太大,最坏的情况需要N次数组访问才能找到某个对象的根节点。(合并操作的统计也包含了查找根的操作)
3 quick-union算法改进
3.1 加权
QF 和 QU 都不能支持巨大的动态连通性问题,一种有效的改进办法:加权 Weighted quick-union
之前说到了 QU 的缺点就是因为树太高会引起查找操作代价巨大,那么在实现QU算法的时候执行一些操作避免得到很高的树就是改进的一个思路。
QU算法在合并两颗树的时候可能会把大树放在小树的下边,如此树会变高。
为了避免合并后导致更高的树,让小树的根节点指向大树的根节点。由此保证所有的对象不会离根节点太远。可以通过加权的方式可以实现。
WQU:
- 跟踪每棵树中对象的个数
- 将小树(对象所在的连通分量里的元素个数较少一方)的根节点设为大树的根节点的子节点
Java 实现
数据结构
在QU的基础上添加一个额外的数组 size[i] 记录以该对象为根节点的树中的对象个数
Find: 同QU一样,找出节点的根节点
Union:通过 size[i] 先检查两颗树的大小,然后将小树的根节点设为大树的根节点,同时更新合并后连通分量中根节点项的size数组项的值
public void union(int p, int q) {
int i = root(p);
int j = root(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];
}
完整实现:WeightedQuickUnionUF.java
性能分析
-
运行时间
- Find:与结点在树中的运行时间成正比
- Union:给出根节点后花费常量时间
- 命题
树中任意结点的深度上限为lgN(此lg以2为底数) - 证明
理解这个命题的关键在于观察节点(此为X节点)的深度到底是在何时会增加
a) 只有当T2的尺寸>=T1的尺寸的时候,T1和T2才会合并为一个连通分量,此时T1的深度(depth)才会增加
b) 合并后T1的大小至少是原来的2倍,由此粗略估计,x的深度每增加1,则x所在的集合的大小变为原来的2倍
c) 如果整个集合的大小为N,那么x所在的集合大小最大尺寸只能为N,
那么从x=1开始,有1*2^depth=N --> depth = lgN
即树中任意节点的深度最多为lgN
- 三种算法的性能对比
如果N是100万,则lgN=20;如果N是10亿,lgN=30。加权的处理在代码上并不需要多少的修改,可是性能上却取得了很大的提升。
3.2 路径压缩
在经过加权处理后,还可以更进一步优化这个算法。此为添加路径压缩 Quick union with path compression 的操作。
在试图寻找包含给定节点的树的根节点时,我们需要访问从该节点到根节点路径上的每个节点。那么,
于此同时我们可以将访问到的所有节点的根节点顺便移动设置为他所在的树的根节点。
这需要付出常数的额外代价。
如图:当我们找到P的根节点后,把9,6,3的根节点都设为0(1的根节点已经是树的根节点):
这里做的改变是:将路径上的每个节点指向它在路径上的祖父节点,这种实现并没有把树完全展平,但在实际应用中两种方式都差不多一样好。
private int root(int i)
{
while (i != id[i])
{
id[i] = id[id[i]];
i = id[i];
}
return i;
}
将树完全展平:find(int p) 方法回溯一次路径找到根节点,然后再回溯一次将树展平,参考:
QuickUnionPathCompressionUF.java
性能分析
以证明:有N个对象,M个合并与查找操作的任意序列,需要访问数组最多为 c( N + M lg* N )次。
lg*N 是使N变为1所需要的对数的次数,叫迭代对数函数。
在真实世界中可以认为lg*N是一个小于5的数。
这个证明可以不用深究,两种优化的代码实现参考: WeightedQuickUnionPathCompressionUF.java
5 种算法的性能比较
通过WQUPC的优化,之前的例子,假如要对10亿个对象进行10亿个操作,QF需要30年,现在只需要大概6秒。
课后巩固
Programming Assignment: Percolation 编程练习
简介:
通过蒙特卡罗模拟估计逾渗阈值。
Percolation:一种模拟渗滤的抽象过程。
比如给定一个复合系统,它由随机分布的绝缘和金属材料组成:这个系统中金属部分占到百分之几能保证复合系统是电导体。
比如考虑到表面有水的多孔景观(或下面的油),在什么条件下水能够排到底部(或油喷到表面)
科学家已经定义了一种称为渗透的抽象过程,即Percolation来模拟这些情况。
1. Percolation 模型
percolation.java
我们使用n×n站点网格模拟Percolation系统。每个站点都是打开或关闭的。
站点的状态有:
- open:打开状态,白色表示
- blocked: 关闭状态,黑色表示
- full:打开,并且可以通过一系列相邻(左,右,上,下)的开放网格连接到顶行的开放站点。
判断一个系统是否渗滤:底行中有一个状态是full的站点。
换句话说,底行的某个open的站点到顶行的某个open的站点有一条连通路径,则系统渗透。
(对于绝缘/金属材料示例,开放位置对应于金属材料,因此渗透的系统具有从顶部到底部的金属路径,具有完整的位置传导。
对于多孔物质示例,开放位置对应于空的空间,水可以流过,这样一个渗透的系统可以让水充满开放的地方,从上到下流动。)
解决方案很多,可以引入2个虚拟站点分别于顶部和底部的连接
当且仅当虚拟top站点连接到虚拟bottom站点,则系统渗滤。
这个问题比较值得思考的是解决回流问题。
回流(backwash):
如果使用一个并查集(同时使用虚拟的首尾站点)对象来实现,极有可能会出现回流问题,也就是与底行连通的站点实际上并非是full的状态,但是运行系统时被标识为full。这是因为他们与虚拟bottom站点在一个连通分量,而系统渗滤后虚拟bottom站点与top站点在一个连通分量,那么与底行连接的站点也会在同一个连通分量,状态会为full。
可以通过使用两个带虚拟站点的并查集对象避免这个问题,或者一个不带虚拟站点的并查集对象。
性能要求:
构造函数应该花费与n^2成比例的时间;
所有方法都应该花费常量时间(不是平方量级也不是其它量级)
2. 关于阈值
PercolationStats.java
问题:
如果站点被独立设置为以概率p开放(概率1 -p为阻塞状态),那么系统渗透的概率是多少?
当p等于0时,系统不会渗透;当p等于1时,系统渗透。
下图显示了20×20随机网格(左)和100×100随机网格(右)的站点位概率p与渗透概率的关系。
从图中可以看出,当n足够大时,存在阈值p*:
- 当p
- 当p> p*时,随机n-by-n网格几乎总是渗透。
尚未推导出用于确定渗透阈值p*的数学解决方案。
你的任务是编写一个计算机程序来估算p*。
系统渗透时,p* = open状态的站点数/总站点数
API设计及具体要求 看这里
附录
IDE本人使用IntelliJ IDEA, jdk8
需要使用的jar包:algs4.jar
Percolation(100/100) git地址:在此