Princeton 算法课笔记(一)

2,413 阅读9分钟

最近打算重新过一遍算法,就把以前学习该课程时所记的顺便整理了出来。这门课真的很棒,说实话我个人甚至觉得比当初看 MIT 的算法导论体验更好,比较偏向于实用而不是理论(也可能是 MIT 的太难了)。


第一部分里涉及到的经典问题是动态连通性问题。这个安排我觉得还挺新奇的,因为以前不论是算法导论,还是我们学校自己的教科书,这都是放在很后面的部分。动态连通性问题[1]实质上有3种:

  1. Edges are only added to the graph (this can be called incremental connectivity);
  2. Edges are only deleted from the graph (this can be called decremental connectivity);
  3. Edges can be either added or deleted (this can be called fully dynamic connectivity).

我们常用的并查集[2]针对的是第一种情况。

Disjoint-set data structure

并查集(Disjoint-set data structure)所解决的问题可以被通俗的叫做 Union-Find 问题,即只有合并和查询这两种操作的场景。

并查集的典型应用是解决网络中的点(比如计算机)之间的连通:

注意这和使用 DFS 搜索迷宫的区别。

我们如何设计这个数据结构呢?首先,彼此连通的点应该被划为一个单元。我们可以设计一个数组,给所有点都分配一个单元的序号——不妨将这个序号直接设为单元里某个点的编号。因为不会进行删除,所以取哪个点无关紧要。

这样,Find 方法只需要判断两个点对应的单元号是否一致即可。这个算法被叫做 Quick Find。听起非常简单,但是在 Union 操作时,如果合并的不是一个孤立的点,而是属于另一个单元的点,该如何处理?答案是需要遍历找到所有属于这个单元的点,修改它们的单元号。

不需要写出代码,我们就可以算出复杂度:

我们可以把 Quick Find 看作是两棵非常扁平(只有一层)的树的合并。合并的方式是将一棵树的根移除,嫁接到另一棵树上。这样,很自然我们能想到一种惰性的方法:不是将所有叶子嫁接到根上,而是只嫁接 UNION 操作指定的节点。

这种算法被叫做 Quick Union。它通过牺牲查询来均摊合并时间。注意到合并并不是O(1)的,因为附带了查找两个节点的根的操作。

通过并查集我们可以更好的理解树这一基础数据结构。树总是和均摊联系在一起。

Balance

可以发现,并查集的时间开销取决于树的深度。因此,它就涉及到一个树的关键问题:平衡。一般在二叉搜索树里,我们都是通过树的旋转实现自平衡的,但这个操作不适用于多叉树。不过,我们可以采用一种最简单的策略:将小树总是合并到大树中。为此,需要记录树的深度作为权重。这种策略被称为加权的 Quick Union。

不过,书中首先提到的一种权重,是树中的节点数量。为什么可以用数量代替深度呢?这就涉及到理解 Quick Union 算法的本质:它总是将树的合并委托到根来执行。这样,若树|T_1|\ge|T_2|(这里绝对值符号代表求树的大小,即节点数),则合并后深度只能为Depth (T_1)或者Depth(T_2)+1。这意味着,树的深度每次合并最多增加1,且此时节点数量至少翻倍。而节点数量翻倍的次数最多是\log(N) 次(N 是操作总次数)。综合这两点,可以证明树的深度不会超过\log(N)

另外,以个数作为权重还可以有效处理两棵树深度相同的情况。

可以看到,平衡的优化将彻底改变并查集的性能:

这就是算法的奇妙之处啊。

最后,由于路径压缩会改变深度,因此我们常用术语秩来代替深度。采用秩还是大小作为权重其实差别不大,除了一种极端情况,一棵树比另一棵的深度更大而个数更小,即一棵树纤长,而另一棵树扁平但更密集。此时不同的策略将会决定合并的方向,但这不影响算法的理论复杂度。

Path Compression

路径压缩是一个非常朴素的工程思路:既然需要在 FIND 时每次搜索根,不如就地修改对应的节点,直接连接到根上。代码类似于:

private int root(int i)
{
 while (i != id[i])
 {
 id[i] = id[id[i]];
 i = id[i];
 }
 return i;
}

这种手法在树中很常见,例如线段树的标记。

Running Time

按照维基百科的说法,单独使用路径压缩的最坏时间开销是\Theta (n +f\cdot (1+\log _{2+f/n}n)),其中f是查找的次数,n是合并的次数。单独使用按秩合并的时间开销是O(m\log_{2}n),其中m是操作的次数。

同时使用路径压缩和按秩合并,对于单个操作的时间复杂度是O(\alpha(n))。这里的\alpha是反阿克曼函数[3]。可以认为,反阿克曼函数在现实中是一个小于5的常数。阿克曼函数是一个非原始递归函数[4]。它的特点是,比所有的原始递归函数(构成的集合是PR)增长都要快,因此常常被用来作为算法分析的上限。可以证明任何一个原始递归函数 f(\vec{x}) 都存在一个 r \in N使得对所有的\vec{x}f(\vec{x})  <
A(r, max\{\vec{x}\})。注:算法导论使用了势方法证明这一点。

另外,并查集的最坏时间复杂度和Davenport–Schinzel Sequence相关(才疏学浅,告辞)。

书中给出的是一个比较简单的上界:

这里用到的是类似于反阿克曼函数的迭代对数\log^* n

Application

Percolation

这是一个很有趣的例子:渗析模型(Percolation)。图中的每个点都有概率 1-p 被阻塞(blocked),如果最后存在一条自顶向下的连通路径,我们就认为这是可以渗透的(Percolated)。 你可以把它想象成电流,水流,或者是社交网络。

每当这时候我总会觉得,啊,果然还是学xx更有意思——大概是计算机严格意义上只是一门辅助学科,一个纯粹虚构的世界。它的本质在于优化生产而不是研究现实。但悲剧的是,如今传统学科能研究的都被研究透了……

我们希望得到的是这个模型和p之间的关系。实际上,最后会发现渗析模型是相变(phase transition)的:它在某个阈值p^\star前几乎不可渗透,而之后几乎总是可渗透。

渗析模型和p的关系是如何得到的呢?实际上目前还没有真正意义上有效的解法,但是可以根据大量的随机测试来逼近这个解,即蒙特卡洛方法。

严格意义上,蒙特卡洛法不是一种算法,它是一种通过统计解决问题的思路。它通过随机和统计来计算无法用数学定理解决的问题——与之对应的是确定性算法。注意,确定性算法和随机化算法的差别不在于是否给出正确的结果。给出正确结果的随机化算法叫做拉斯维加斯算法(Las Vegas algorithm[5])。典型的拉斯维加斯算法就是快速排序。

在得到一个随机的分布后,我们将图中上下方所有点分别通过一个虚构的点(类似于链表中的虚构头)连接在一起。这样问题就转化为了两个点的连通性测试,非常巧妙。最后我们可以得到这个p^{\star}接近0.593。

其他应用(从讲义照搬的):

  • Games (Go, Hex).
  • Least common ancestor.
  • Equivalence of finite state automata.
  • Hoshen-Kopelman algorithm in physics.
  • Hinley-Milner polymorphic type inference.
  • Kruskal's minimum spanning tree algorithm.
  • Compiling equivalence statements in Fortran.
  • Morphological attribute openings and closings.
  • Matlab's bwlabel() function in image processing.

Analysis of Algorithms

这门课的目的在于让程序员更好的理解算法运作的机制,从而避免写出低效的代码。所以,它是实用主义的。一些非常成功的算法比如——

快速傅立叶变换(FFT):这个多重要就不多说了吧。

N体模拟(N - body simulation):它的复杂度恰好和FFT一样。

高德纳(Donald Knuth)首次提出用科学的方法分析算法性能:

  • Observe some feature of the natural world.
  • Hypothesize a model that is consistent with the observations.
  • Predict events using the hypothesis.
  • Verify the predictions by making further observations.
  • Validate by repeating until the hypothesis and observations agree.

这部分普林斯顿的内容比较简单,如果想要更深入的分析算法性能,比如递归之类的,可以看看 MIT 的算法导论。

总而言之,就是证明通过抽象的数学模型而不是精确的测量每条指令的运行时间,是可行的。也就是图灵提出的,实际上可以用开销最大的操作来代表整个算法的开销,即近似模型。这里书中提到,实际上许多人误用了O来表示算法的近似模型(当然也包括我),以后应该用波浪号\sim代替。

(初级 Java 程序员的一个经典问题是认为字符串连接是常数级别的——当然了解其不可变性后其实很简单)

在过去,老师提到,研究中喜欢关注问题的最坏情况,但是这可能在实际中意义很小。老师举的例子有点和阿里安全大神吴翰清如出一辙:你不能总是用最糟糕的状况来衡量 解决的方案,比如被一道闪电劈中的概率。就像安全中,必须要有可以信任的边界,否则就没有安全可言。

Reference


  1. en.wikipedia.org/wiki/Dynami… ↩︎

  2. en.wikipedia.org/wiki/Disjoi… ↩︎

  3. en.wikipedia.org/wiki/Ackerm… ↩︎

  4. blog.sciencenet.cn/blog-320682… ↩︎

  5. en.wikipedia.org/wiki/Las_Ve… ↩︎