我曾经听一位大师级的程序员这样称赞到,“我经过删除代码来实现功能的提高。”而法国著名做家兼飞行家Antoine de Saint-Exupéry的说法则更具表明性,“只有在不只没有任何功能能够添加,并且也没有任何功能能够删除的状况下,设计师才可以认为本身的工做已臻完美。” 某些时候,在软件中根本就不存在最漂亮的代码,最漂亮的函数,或者最漂亮的程序。
固然,咱们很难对不存在的事物进行讨论。本文将对经典Quicksort算法的运行时间进行全面的分析,并试图经过这个分析来讲明上述观点。
3.1 我编写过的最漂亮代码
当Greg Wilson最初告诉我本书的编写计划时,我曾自问编写过的最漂亮的代码是什么。这个有趣的问题在我脑海里盘旋了大半天,而后我发现答案其实很简单:Quicksort算法。但遗憾的是,根据不一样的表达方式,这个问题有着三种不一样的答案。
当我撰写关于分治算法的论文时,我发现C.A.R. Hoare的Quicksort算法无疑是各类Quicksort算法的鼻祖。这是一种解决基本问题的漂亮算法,能够用优雅的代码实现。
我很喜欢这个算法,但我老是没法弄明白算法中最内层的循环。我曾经花两天的时间来调试一个使用了这个循环的复杂程序,而且几年以来,当我须要完成相似的任务时,我会很当心地复制这段代码。虽然这段代码可以解决我所遇到的问题,但我却并无真正地理解它。
我后来从Nico Lomuto那里学到了一种优雅的划分(partitioning)模式,而且最终编写出了我可以理解,甚至可以证实的Quicksort算法。
William Strunk Jr.针对英语所提出的“良好的写做风格即为简练”这条经验一样适用于代码的编写,所以我遵循了他的建议,“省略没必要要的字词”。我最终将大约40行左右的代码缩减为十几行的代码。
所以,若是要回答“你曾编写过的最漂亮代码是什么?”这个问题,那么个人答案就是:在我编写的《Programming Pearls, Second Edition》(Addison-Wesley)一书中给出的Quichsort算法。
在示例3-1中给出了用C语言编写的Quicksort函数。咱们在接下来的章节中将进一步地研究和改善这个函数。
void quicksort(int l, int u)程序员
{ int i, m;web
if (l >= u) return;算法
swap(l, randint(l, u));编程
m = l;小程序
for (i = l+1; i <= u; i++)数组
if (x[i] < x[l])微信
swap(++m, i);数据结构
swap(l, m);app
quicksort(l, m-1);框架
quicksort(m+1, u);
}
若是函数的调用形式是quicksort(0, n-1),那么这段代码将对一个全局数组x[n]进行排序。
函数的两个参数分别是将要进行排序的子数组的下标:l是较低的下标,而u是较高的下标。函数调用swap(i,j)将会交换x[i]与x[j]这两个元素。第一次交换操做将会按照均匀分布的方式在l和u之间随机地选择一个划分元素。
在《Programming Pearls》一书中包含了对Quicksort算法的详细推导以及正确性证实。在本章的剩余内容中,我将假设读者熟悉在《Programming Pearls》中所给出的Quicksort算法以及在大多数初级算法教科书中所给出的Quicksort算法。
若是你把问题改成“在你编写那些广为应用的代码中,哪一段代码是最漂亮的?”个人答案仍是Quicksort算法。在我和M. D. McIlroy一块儿编写的一篇文章中指出了在原来Unix qsort函数中的一个严重的性能问题。
随后,咱们开始用C语言编写一个新排序函数库,而且考虑了许多不一样的算法,包括合并排序(Merge Sort)和堆排序(Heap Sort)等算法。在比较了Quicksort的几种实现方案后,咱们着手建立本身的Quicksort算法。
在这篇文章中描述了咱们如何设计出一个比这个算法的其余实现要更为清晰,速度更快以及更为健壮的新函数——部分缘由是因为这个函数的代码更为短小。
Gordon Bell的名言被证实是正确的:“在计算机系统中,那些最廉价,速度最快以及最为可靠的组件是不存在的。”如今,这个函数已经被使用了10多年的时间,而且没有出现任何故障。
考虑到经过缩减代码量所获得的好处,我最后以第三种方式来问本身在本章之初提出的问题。“你没有编写过的最漂亮代码是什么?”。我如何使用很是少的代码来实现大量的功能?答案仍是和Quicksort有关,特别是对这个算法的性能分析。我将在下一节给出详细介绍。
3.2 事倍功半
Quicksort是一种优雅的算法,这一点有助于对这个算法进行细致的分析。大约在1980年左右,我与Tony Hoare曾经讨论过Quicksort算法的历史。他告诉我,当他最初开发出Quicksort时,他认为这种算法太简单了,不值得发表,并且直到可以分析出这种算法的预期运行时间以后,他才写出了经典的“Quicksoft”论文。
咱们很容易看出,在最坏的状况下,Quicksort可能须要n2的时间来对数组元素进行排序。而在最优的状况下,它将选择中值做为划分元素,所以只需nlgn次的比较就能够完成对数组的排序。那么,对于n个不一样值的随机数组来讲,这个算法平均将进行多少次比较?
Hoare对于这个问题的分析很是漂亮,但不幸的是,其中所使用的数学知识超出了大多数程序员的理解范围。当我为本科生讲授Quicksort算法时,许多学生即便在费了很大的努力以后,仍是没法理解其中的证实过程,这令我很是沮丧。下面,咱们将从Hoare的程序开始讨论,而且最后将给出一个与他的证实很接近的分析。
咱们的任务是对示例3-1中的Quicksort代码进行修改,以分析在对元素值均不相同的数组进行排序时平均须要进行多少次比较。咱们还将努力经过最短的代码、最短运行时间以及最小存储空间来获得最深的理解。
为了肯定平均比较的次数,咱们首先对程序进行修改以统计次数。所以,在内部循环进行比较以前,咱们将增长变量comps的值(参见示例3-2)。
【示例3-2】 修改Quicksort的内部循环以统计比较次数。
for (i = l+1; i <= u; i++) {
若是用一个值n来运行程序,咱们将会看到在程序的运行过程当中总共进行了多少次比较。若是重复用n来运行程序,而且用统计的方法来分析结果,咱们将获得Quicksort在对n个元素进行排序时平均使用了1.4 nlgn次的比较。
在理解程序的行为上,这是一种不错的方法。经过十三行的代码和一些实验能够反应出许多问题。这里,咱们引用做家Blaise Pascal和T. S. Eliot的话,“若是我有更多的时间,那么我给你写的信就会更短。”如今,咱们有充足的时间,所以就让咱们来对代码进行修改,而且努力编写出更短(同时更好)的程序。
咱们要作的事情就是提升这个算法的速度,而且尽可能增长统计的精确度以及对程序的理解。因为内部循环老是会执行u-l次比较,所以咱们能够经过在循环外部增长一个简单的操做来统计比较次数,这就可使程序运行得更快一些。在示例3-3的Quicksort算法中给出了这个修改。
【示例3-3】 Quicksort的内部循环,将递增操做移到循环的外部
for (i = l+1; i <= u; i++)
这个程序会对一个数组进行排序,同时统计比较的次数。不过,若是咱们的目标只是统计比较的次数,那么就不须要对数组进行实际地排序。在示例3-4中去掉了对元素进行排序的“实际操做”,而只是保留了程序中各类函数调用的“框架”。
【示例3-4】将Quicksort算法的框架缩减为只进行统计
void quickcount(int l, int u)
这个程序可以实现咱们的需求,由于Quichsort在选择划分元素时采用的是“随机”方式,而且咱们假设全部的元素都是不相等的。如今,这个新程序的运行时间与n成正比,而且相对于示例3-3须要的存储空间与n成正比来讲,如今所需的存储空间缩减为递归堆栈的大小,即存储空间的平均大小与lgn成正比。
虽然在实际的程序中,数组的下标(l和u)是很是重要的,但在这个框架版本中并不重要。所以,咱们能够用一个表示子数组大小的整数(n)来替代这两个下标(参见示例3-5)
【示例3-5】 在Quicksort代码框架中使用一个表示子数组大小的参数
如今,咱们能够很天然地把这个过程整理为一个统计比较次数的函数,这个函数将返回在随机Quicksort算法中的比较次数。在示例3-6中给出了这个函数。
【示例3-6】 将Quicksort框架实现为一个函数
return n-1 + cc(m-1) + cc(n-m);
在示例3-四、示例3-5和示例3-6中解决的都是相同的基本问题,而且所需的都是相同的运行时间和存储空间。在后面的每一个示例都对这些函数的形式进行了改进,从而比这些函数更为清晰和简洁。
在定义发明家的矛盾时,George Póllya指出“计划越宏大,成功的可能性就越大。”如今,咱们就来研究在分析Quicksort时的矛盾。
到目前为止,咱们遇到的问题是,“当Quicksort对大小为n的数组进行一次排序时,须要进行多少次比较?”咱们如今将对这个问题进行扩展,“对于大小为n的随机数组来讲,Quichsort算法平均须要进行多少次的比较?”咱们经过对示例3-6进行扩展以引出示例3-7。
【示例3-7】 伪码:Quicksort的平均比较次数
sum += n-1 + c(m-1) + c(n-m)
若是在输入的数组中最多只有一个元素,那么Quichsort将不会进行比较,如示例3-6中所示。对于更大的n,这段代码将考虑每一个划分值m(从第一个元素到最后一个,每一个都是等可能的)而且肯定在这个元素的位置上进行划分的运行开销。
而后,这段代码将统计这些开销的总和(这样就递归地解决了一个大小为m-1的问题和一个大小为n-m的问题),而后将总和除以n获得平均值并返回这个结果。
若是咱们可以计算这个数值,那么将使咱们实验的功能更增强大。咱们如今无需对一个n值运行屡次来估计平均值,而只需一个简单的实验即可以获得真实的平均值。不幸的是,实现这个功能是要付出代价的:这个程序的运行时间正比于3n(若是是自行参考(self-referential)的,那么用本章中给出的技术来分析运行时间将是一个颇有趣的练习)。
示例3-7中的代码须要必定的时间开销,由于它重复计算了中间结果。当在程序中出现这种状况时,咱们一般会使用动态编程来存储中间结果,从而避免重复计算。所以,咱们将定义一个表t[N+1],其中在t[n]中存储c[n],而且按照升序来计算它的值。咱们将用N来表示n的最大值,也就是进行排序的数组的大小。在示例3-8中给出了修改后的代码。
【示例3-8】 在Quicksort中使用动态编程来计算
sum += n-1 + t[i-1] + t[n-i]
这个程序只对示例3-7进行了细微的修改,即用t[n]来替换c(n)。它的运行时间将正比于N2,而且所需的存储空间正比于N。这个程序的优势之一就是:在程序执行结束时,数组t中将包含数组中从元素0到元素N的真实平均值(而不是样本均值的估计)。咱们能够对这些值进行分析,从而生成在Quichsort算法中统计比较次数的计算公式。
咱们如今来对程序作进一步的简化。第一步就是把n-1移到循环的外面,如示例3-9所示。
【示例3-9】 在Quicksort中把代码移到循环外面来计算
如今将利用对称性来对循环作进一步的调整。例如,当n为4时,内部循环计算总和为:
t[0]+t[3] + t[1]+t[2] + t[2]+t[1] + t[3]+t[0]
在上面这些组对中,第一个元素增长而第二个元素减小。所以,咱们能够把总和改写为:
2 * (t[0] + t[1] + t[2] + t[3])
咱们能够利用这种对称性来获得示例3-10中的Quicksort。
【示例3-10】 在Quichsort中利用了对称性来计算
然而,在这段代码的运行时间中一样存在着浪费,由于它重复地计算了相同的总和。此时,咱们不是把前面全部的元素加在一块儿,而是在循环外部初始化总和而且加上下一个元素,如示例3-11所示。
【示例3-11】 在Quicksort中删除了内部循环来计算
这个小程序确实颇有用。程序的运行时间与N成正比,对于每一个从1到N的整数,程序将生成一张Quicksort的估计运行时间表。
咱们能够很容易地把示例3-11用表格来实现,其中的值能够当即用于进一步的分析。在3-1给出了最初的结果行。
这张表中的第一行数字是用代码中的三个常量来进行初始化的。下一行(输出的第三行)的数值是经过如下公式来计算的:
A3 = A2+1 B3 = B2 + 2*C
2 C
3 = A3-1 + B3/A3
把这些(相应的)公式记录下来就使得这张表格变得完整了。这张表格是“我曾经编写的最漂亮代码”的很好的证据,即便用少许的代码完成大量的工做。
可是,若是咱们不须要全部的值,那么状况将会是什么样?若是咱们更但愿经过这种来方式分析一部分数值(例如,在20到232之间全部2的指数值)呢?虽然在示例3-11中构建了完整的表格t,但它只须要使用表格中的最新值。所以,咱们能够用变量t的定长空间来替代table t[]的线性空间,如示例3-12所示。
【示例3-12】 Quicksoft 计算——最终版本
而后,咱们能够插入一行代码来测试n的适应性,而且在必要时输出这些结果。
这个程序是咱们漫长学习旅途的终点。经过本章所采用的方式,咱们能够证实Alan Perlis的经验是正确的:“简单性并非在复杂性以前,而是在复杂性以后” ("Epigrams on Programming," Sigplan Notices, Vol. 17, Issue 9)。
3.3 观点
在表3-2中总结了本章中对Quicksort进行分析的程序。
表 3-2 对Quicksort比较次数的统计算法的评价
在咱们对代码的每次修改中,每一个步骤都是很直接的;不过,从示例3-6中样本值到示例3-7中准确值的过渡过程多是最微妙的。随着这种方式进行下去,代码变得更快和更有用,而代码量一样获得了缩减。
在19世纪中期,Robert Browning指出“少便是多(less is more)”,而这张表格正是一个证实这种极少主义哲学(minimalist philosophy)的实例。
咱们已经看到了三种大相径庭的类型的程序。示例3-2和示例3-3是可以实际使用的Quicksort,能够用来在对真实数组进行排序时统计比较次数。
示例3-4到示例3-6都实现了Quicksort的一种简单模型:它们模拟算法的运行,而实际上却没有作任何排序工做。从示例3-7到示例3-12则实现了一种更为复杂的模型:它们计算了比较次数的真实平均值而没有跟踪任何单次的运行。
* 示例3-2,示例3-4,3-7:对问题的定义进行根本的修改。
* 示例3-5,示例3-6,3-12:对函数的定义进行轻微的修改
这些技术都是很是典型的。咱们在简化程序时常常要发出这样的疑问,“咱们真正要解决的问题是什么?”或者是,“有没有更好的函数来解决这个问题?”
当我把这个分析过程讲授给本科生时,这个程序最终被缩减成零行代码,化为一阵数学的轻烟消失了。咱们能够把示例3-7从新解释为如下的循环关系:
这正是Hoare所采用的方法,而且后来由D.E.Knuth在他经典的《The Art of Computer Programming》(Addison-Wesley)一书的第三卷:排序与查找中给出的方法中给出了描述。经过从新表达编程思想的技巧和在示例3-10中使用的对称性,使咱们能够把递归部分简化为:
Knuth删除了求和符号,从而引出了示例3-11,这能够被从新表达为一个在两个未知量之间有着两种循环关系的系统:
Knuth使用了“求和因子”的数学方法来实现这种解决方案:
其中 表示第n个调和数(harmonic number),即1 + 1/2 + 1/3 + … 1/n。这样,咱们就从对程序不断进行修改以获得实验数据顺利地过渡到了对程序行为进行彻底的数学分析。
在获得这个公式以后,咱们就能够结束咱们的讨论。咱们已经遵循了Einstein的著名建议:“尽可能使每件事情变得简单,而且直到不可能再简单为止。”
附加分析
Goethe的著名格言是:“建筑是静止的音乐”。按照这种说法,我能够说“数据结构是静止的算法。”若是咱们固定了Quichsort算法,那么就将获得了一个二分搜索树的数据结构。在Knuth发表的文章中给出了这个结构而且采用相似于在Quichsort中的循环关系来分析它的运行时间。
若是要分析把一个元素插入到二分搜索树中的平均开销,那么咱们能够以这段代码做为起点,而且对这段代码进行扩展来统计比较次数,而后在咱们收集的数据上进行实验。
接下来,咱们能够仿照前面章节中的方式来简化代码。一个更为简单的解决方案就是定义一个新的Quichsort,在这个算法中使用理想的划分算法把有着相同关联顺序的元素划分到两边。Quichsort和二分搜索树是同构的,如图3-1所示。
图3-1 实现理想划分的Quicksort以及相应的二分搜索树
左边的方框给出了正在进行中的理想划分的Quicksort,右边的图则给出了相应的从相同输入中构建起来的二分搜索树。这两个过程不只须要进行相同次数的比较,并且还将生成相同的比较集合。
经过在前面对于在一组不一样元素上进行Quicksort实验的平均性能分析,咱们就能够获得将不一样的元素随机插入到二分搜索树中的平均比较次数。
3.4 本章的中心思想是什么?
表面上看来,我“所写的”内容就是从示例3-2到示例3-12的程序。我最初是漫不经心地编写这些程序,而后将这些程序写在给本科生讲课的黑板上,而且最终写到本章中。
我有条不紊地进行着这些程序的修改,而且花了大量的时间来分析这些程序,从而确信它们都是正确的。然而,除了在示例3-11中实现的表格外,我历来没有把任何一个示例做为计算机程序运行过。
我在贝尔实验室呆了将近二十年,我从许多教师那里学到了:要“编写”一个在大众面前展现的程序,所涉及到的东西比键入这个程序要多得多。有人用代码实现了这个程序,最初运行在一些测试示例中,而后构建了完整的系统框架、驱动程序以及一个案例库来支撑这段代码。
理想的状况是,人们能够手动地把编译后的代码包含到文本中,不加入任何的人为干涉。基于这种想法,我编写了示例3-1(以及在《Programming Pearls》中的全部代码)。
为了维护面子,我但愿永远都不要实现从示例3-2到示例3-12的代码,从而使我保持诚实的名声。然而,在计算机编程中的近四十年的实践使我对这个任务的困难性有着深深的敬畏。我妥协了,把示例3-11用表格方式实现出来,而且无心中获得了一个完备的解答。
当这两个东西完美地匹配在一块儿时,你能够想象一下我当时的喜悦吧!所以,我向世界提供了这些漂亮的而且不曾实现的程序,虽然在这些程序中可能会有一些还未发现的错误,但我对这些程序的正确性仍是有必定信心的。我但愿一些细微的错误不会掩盖我在这些程序中所展现的那些漂亮思想。
当我为给出这些没有被实现过的程序感到不安时,Alan Perlis的话安慰了我,他说“软件是否是不像任何一个事物,它就是意味着被抛弃:软件的全部意义就是把它看做为一个肥皂泡?”
3.5 结论
漂亮的含义有着许多来源。本章经过简化、优雅以及精简来刻画了漂亮的含义。下面这些名言表达的是一样的意思:
* 只有在不只没有任何功能能够添加,并且也没有任何功能能够删除的状况下,设计师才可以认为本身的工做已臻完美。
* 有时候,在软件中根本就不存在最漂亮的代码,最漂亮的函数,或者最漂亮的程序。
* 良好的写做风格即为简练。省略没必要要的字词。(Strunk and White)
* 在计算机系统中,那些最廉价、速度最快以及最为可靠的组件是不存在的(Bell)
* 若是我有更多的时间,那么我给你写的信就会越短(Pascal)
* 发明家的矛盾:计划越宏大,成功的可能性就越大。(Pólya)
* 简单性并非在复杂性以前,而是在复杂性以后(Perlis)
* 尽可能使每件事情变得简单,而且直到不可能再简单为止(Einstein)
* 软件有时候应该被视做为一个肥皂泡(Perlis)
本章的内容到此结束。读者能够复习所学到的内容并进行模拟实验。
对于那些想要获得更具体信息的人们,我在下面给出了一些观点,这些观点分为三类
程序分析
深刻理解程序行为的方式之一就是修改这个程序,而后在具备表明性的数据上运行这个程序,就像示例3-2那样。不过,咱们一般会更关心程序的某个方面而不是程序的总体。例如,咱们只是考虑Quichsort所使用的平均比较次数,而忽略了其余的方面。Sedgewick 研究了Quichsort的其余特性,例如算法所需的存储空间以及各类Quicksort运行时间的其余方面。咱们能够关注这些关键问题,而暂时忽略了程序其余不过重要的方面。
在个人一篇文章"A Case Study in Applied Algorithm Design"中指出了我曾经遇到过的一个问题:对在单元空间中找出货郎行走路线的strip启发式算法的性能进行评价。我估计完成这个任务所要的程序大概在100行代码左右。
在经历了一系列相似于本章前面看到的分析步骤以后,我只使用了十几行代码的模拟算法就实现了更为精确的效果(在我写完了这个模拟算法后,我发现Beardwood 等人["The Shortest Path Through Many Points," Proc. Cambridge Philosophical Soc., Vol. 55]已经更完整地表述了个人模拟算法,所以已经在二十几年前就从数学上解决了这个问题)。
小段代码
我相信计算机编程是一项实践性的技术,而且我也赞成这个观点:“任何技术都必须经过模仿和实践来掌握。” 所以,想要编写漂亮代码的程序员应该阅读一些漂亮的程序以及在编写程序时模仿所学到的技术。
我发如今实践时有个很是有用的东西就是小段代码,也就是一二十行的代码。编写《Programming Pearls》这本书是一件艰苦的工做,但同时也有着极大的乐趣。我实现了每一小段代码,而且亲自把每段代码都分解为基本的知识。我但愿其余人在阅读这些代码时与我在编写这些代码时有着一样的享受过程。
软件系统
为了有针对性,我极其详尽地描述了一个小型任务。我相信其中的这些准则不只存在于小型程序中,它们一样也适用于大型的程序以及计算机系统。Parnas给出了把一个系统拆分为基本构件的技术。为了得用快速的应用性,不要忘了Tom Duff的名言: