继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

投稿007期|The Most Beautiful Code I Never Wrote(文末有福利哦)

小强聊架构
关注TA
已关注
手记 40
粉丝 9454
获赞 241

题记:
本文是作者的读书笔记+原创翻译,首发于慕课网。
这本书的名字是:
Beautiful Code
by Greg Wilson; Andy Oram
Published by O'Reilly Media, Inc., 2007

什么是最漂亮的代码呢? 好多人都在问,恰好,有一本书,它的名字就叫做"Beautiful Code"。书中作者谈到漂亮代码的几个特质。
漂亮的代码有一种美感,那就是简洁美,优雅的美。

  1. 努力通过删除代码来改进功能。
  2. 代码就像艺术品,多一点多余,少一点欠缺,好了,这就完美了。
  3. 计算机系统中,最便宜、最快、最可靠的组件是不存在的。
  4. 努力做到事半功倍。
  5. 简单并不是在复杂之前,而是复杂之后。
  6. 少即是多。
  7. 尽量使每件事情都尽可能简单,直到不能再简单为止。
  8. 在简单中寻找美。

来,一起欣赏书中的第三章的一些片段。

——————————————————————————

我曾经听到一个高级程序员称赞道:“他通过删除代码来增加功能。”
法国作家兼飞行员安东尼·德·圣埃克苏佩里(Antoine de Saint-Exupery)更广泛地表达了这种观点,他说:“ 设计师们明白,只有在没有什么东西可以添加,而且也没有什么东西可以删除的时候,他们才认为这项工作已经完美”
在软件中,最漂亮的代码、最漂亮的函数和最漂亮的程序有时根本不存在。

当Greg Wilson第一次描述这本书的想法时,我问自己我写过的最漂亮的代码是什么。这个有趣的问题在我脑子里转了一天,我意识到答案很简单:快速排序。当然,确切的来说,按照不同的表达,这个问题有三个不同的答案。

我写了一篇关于分治算法的论文,发现C.A.R. Hoare的快速排序无疑是所有算法的鼻祖。对于一个可以用优雅的代码实现的基本问题,它是一个漂亮的算法。我喜欢这个算法,但我总是弄不明白它最内层的循环。我曾经花了两天时间调试一个基于这个循环的复杂程序,多年来,每当我需要执行类似的任务时,我都会仔细地复制那段代码。它解决了我的问题,但我并没有真正理解它。

我最终从Nico Lomuto那里学到了一个优雅的分块方案,并最终能够编写一个我能理解甚至证明是正确的快速排序。William Strunk Jr.认为“良好的写作风格是简洁的”,这句话不仅适用于英语,也适用于代码,所以我遵循了这个原则。最后,我将大约40行代码减少到12行。所以,如果问题是,“你写过的最漂亮的一小段代码是什么?” 这个用C语言实现的快速排序函数就是,如下所示。

voidquicksort(int l, int u)
{   int i, m;
    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);
    quicksort(l, m-1);
    quicksort(m+1, u);
}

当调用quicksort(0,n-1)的时候,这段代码对大小为n的数组进行排序, l为下标,u为上标,调用swap(i,j) 去交换第i和第j个元素的值。第一次swap调用,将会按照均匀分布的方式在l和u之间随机选择一个元素。

《Programming Pearls》一书包含了快速排序函数的详细推导和正确性证明。
如果你把问题改为,“你写的最漂亮的、被广泛使用的代码是什么?”
我与m.d. McIlroy一起写的一篇文章描述了一个严重的Unix qsort函数的性能缺陷。我们着手构建一个新的C库排序函数,并考虑了许多不同的算法,包括合并排序和堆排序。在比较了几种可能的实现之后,我们确定了快速排序算法的一个版本。那篇论文描述了我们如何设计出一个比竞争对手更清晰、更快、更强大的新功能——少部分原因是它更小。Gordon Bell明智的建议是正确的: “计算机系统中最便宜、最快、最可靠的组件是不存在的。” 这个功能已经被广泛使用了十多年,没有任何失败的案例。

考虑到通过减少代码大小可以获得的收益,我最后问了自己这个问题的第三种方式,“你从没写过的最漂亮的代码是什么?” 我怎么能事半功倍呢? 答案又一次与快速排序有关,具体来说就是对其性能的分析。

快速排序是一种优雅的算法,可以进行细致的分析。1980年左右,我和Tony Hoare就他的算法的历史进行了一次精彩的讨论。他告诉我,当他第一次开发 Quicksort时,他认为它太简单了,无法发布,只有在分析了预期的运行时之后,他才写出了他的经典的“Quicksort”论文。

很容易看到,在最坏的情况下,快速排序可能需要大约N*N时间来排序一个n个元素的数组。在最好的情况下,它选择中值作为分区元素,因此对数组进行大约nlogn的比较。那么,对于一个n个不同值的随机数组它平均使用多少个比较?

Hoare对这个问题的分析很漂亮,但不幸的是,这超出了许多程序员的数学头脑。当我教本科生快速排序的时候,我很沮丧,因为很多人就是没有“得到”证明,即使经过了真诚的努力。我们现在用实验的方法来解决这个问题。我们将从Hoare的程序开始,最终以他的分析结束。

我们的任务是修改随机快速排序代码的示例3-1,分析用于对不同输入数组排序的平均比较次数。我们还将尝试用最少的代码、运行时和空间获得最大的效果。

为了确定平均比较次数,我们首先增加程序来计算它们。为此,我们在内部循环的比较之前增加变量comps(例3-2)。

例3-2:

for (i = l+1; i <= u; i++) {
    comps++;
    if (x[i] < x[l])
        swap(++m, i);
}

如果我们对给定值n来运行这个程序,我们会看到这个运行需要多少次的比较。如果我们对n的许多值进行多次重复,并对结果进行统计分析,我们将观察到,平均而言,快速排序需要对n个元素进行1.4 n log n 的比较。

这对于深入了解程序的行为并不是一个坏方法。13行代码和一些实验可以揭示很多东西。Blaise Pascal 和 T. S. Eliot 等作家的名言说过,“如果我有更多的时间,我会给你写一封更短的信。” 我们有时间,所以让我们试验一下代码,尝试创建一个更短(更好)的程序。

我们将加快实验的速度,努力提高统计准确性和编程水平。因为内部循环总是进行精确的(u-l) 比较,我们可以通过在循环外部的单个操作中计算这些比较来让程序稍微快一点。这个更改产生了示例3-3所示的快速排序。

示例3-3

comps += u-l;
for (i = l+1; i <= u; i++)
    if (x[i] < x[l])
        swap(++m, i);

该程序对数组进行排序,并计算在此过程中使用的比较数。但是,如果我们的目标只是统计比较,那么我们实际上不需要对数组进行排序。示例3-4删除了对元素排序的“真正工作”,只保留了程序发出的各种调用的“骨架”。

示例3-4

void quickcount(int l, int u)
{   int m;
    if (l >= u) return;
    m = randint(l, u);
    comps += u-l;
    quickcount(l, m-1);
    quickcount(m+1, u);
}

这个程序能够工作是因为Quicksort以“随机”的方式选择分区元素,并且假定所有元素都是不同的。这个新程序现在与n成比例运行,而例3-3需要与n成比例的空间,这个空间现在被缩减为递归堆栈,平均与lgn成比例。
虽然数组的索引(l和u)在实际程序中是关键的,但在这个框架版本中它们并不重要。我们可以用一个指定要排序的子数组大小的整数(n)替换这两个索引(参见示例3-5)。

示例3-5

void qc(int n)
{   int m;
    if (n <= 1) return;
    m = randint(1, n);
    comps += n-1;
    qc(m-1);
    qc(n-m);
}

现在更自然的做法是将这个过程重新定义为一个比较计数函数,该函数返回一个随机快速排序运行所使用的比较数。该函数如例3-6所示。

例3-6

int cc(int n)
{   int m;
    if (n <= 1) return 0;
    m = randint(1, n);
   return n-1 + cc(m-1) + cc(n-m);
}

例3-4、例3-5和例3-6都解决了相同的基本问题,并且使用了相同的运行时和内存。每个后继函数都改进了函数的形式,因此比前一个函数更清晰、更简洁。
在 "发明家的悖论"(如何解决它,普林斯顿大学出版社) 这本书中, George Pólya说:“更雄心勃勃的计划有更多的成功机会。” 我们现在将在快速排序的分析中尝试利用这个悖论。到目前为止,我们已经问过了,“快速排序在一次大小为n的运行中做了多少比较?”我们现在要问一个更有野心的问题,“对于大小为n的随机数组,Quicksort平均做了多少比较?” 我们可以扩展示例3-6来生成示例3-7中的伪代码。

例3-7

float c(int n)
    if (n <= 1) return 0
    sum = 0
    for (m = 1; m <= n; m++)
        sum += n-1 + c(m-1) + c(n-m)
    return sum/n

如果输入最多只有一个元素,Quicksort就不使用比较,就像示例3-6中那样。对于较大的n,该代码考虑每个分区值m(从第一个元素到最后一个元素,每个元素的可能性都是相等的),并确定分区的成本。然后,代码计算这些值的和(从而递归地解决一个大小为m-1的问题和一个大小为n-m的问题),然后将这个和除以n以返回平均值。
如果我们能计算出这个数字,我们的实验就会更强大。而不是在单值n的情况下进行多次试验来估计均值,一次试验就能得到真正的均值。不幸的是,这种能力是有代价的:程序运行的时间与 3<sup>n</sup> 成正比。
示例3-7花费了它所花费的时间,因为它一次又一次地计算子问题。当程序这样做时,我们通常可以使用动态编程来存储子程序,以避免重新计算它们。在本例中,我们将引入表t[N+1],其中t[N]存储c(N),并按递增顺序计算其值。我们让N表示N的最大大小,这是要排序的数组的大小。结果如例3-8所示。

例3-8

t[0] = 0
for (n = 1; n <= N; n++)
    sum = 0
    for (i = 1; i <= n; i++)
        sum += n-1 + t[i-1] + t[n-i]
    t[n] = sum/n

本程序是例3-7的粗略重写,用t[n]代替c(n)。其运行时正比于N*N 和空间n成正比的其中一个好处是,在执行结束时,数组t包含真正的平均值(不仅仅是样本均值的估计)数组元素0到n可以分析这些值产生洞察力的预期数量的函数形式比较使用快速排序。
我们现在将进一步简化这个程序。第一步是将术语n-1移出循环,如示例3-9所示。

例3-9

t[0] = 0
for (n = 1; n <= N; n++)
    sum = 0
    for (i = 1; i <= n; i++)
        sum += t[i-1] + t[n-i]
    t[n] = n-1 + sum/n

现在,我们将通过使用对称进一步调整循环。例如,当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中所示的快速排序。

例3-10

t[0] = 0
for (n = 1; n <= N; n++)
    sum = 0
    for (i = 0; i < n; i++)
        sum += 2 * t[i]
    t[n] = n-1 + sum/n

然而,这段代码再次浪费,因为它一次又一次地重新计算相同的总和。我们可以在循环外部初始化sum并添加下一项,而不是添加所有前面的项,从而生成示例3-11。

例3-11

sum = 0; t[0] = 0
for (n = 1; n <= N; n++)
    sum += 2*t[n-1]
    t[n] = n-1 + sum/n

这个小程序确实很有用。在与N成比例的时间内,它为从1到N的每一个整数生成一个表,表中显示了快速排序的真实预期运行时。
示例3-11可以直接在电子表格中实现,其中的值可以立即用于进一步分析。表3-1显示了第一行。

N Sum t[n]
0 0 0
1 0 0
2 0 1
3 2 2.667
4 7.333 4.833
5 17 7.4
6 31.8 10.3
7 52.4 13.486
8 79.371 16.921

该表中的第一行数字是用代码中的三个常量初始化的。在电子表格表示法中,下一行数字(电子表格的第三行)使用以下关系计算:

A3 = A2+1 B3 = B2 + 2*C2 C3 = A3-1 + B3/A3

向下拖动这些(相对)关系完成电子表格。这个电子表格是“我写过的最漂亮的代码”的有力竞争者,它使用的标准是只用几行代码就能完成大量工作。
但如果我们不需要所有的值呢?如果我们更愿意分析其中的一些值(例如,2的所有幂从20到232)呢?尽管示例3-11构建了完整的表t,但它只使用了该表的最新值。
因此,我们可以将表t[]的线性空间替换为变量t的常量空间,如例3-12所示。
例3-12

sum = 0; t = 0
for (n = 1; n <= N; n++)
    sum += 2*t
    t = n-1 + sum/n

然后,我们可以插入额外的一行代码来测试n是否合适,并根据需要打印这些结果。
这个小程序是我们漫长道路上的最后一步。Alan Perlis的观察很恰当地考虑了这一章所走的道路:“简单并不先于复杂,而是在它之后”(“关于编程的警句”,Sigplan notice,第17卷,第9期)。

————————————————————————
历时1周,终于译完啦,不错吧,一个快速排序可以写这么久,而且从复杂到简单,从算法到性能分析,受益匪浅!

说好的文末福利来啦!!!!!!

运行下下面代码,有惊喜哦!!

#include<stdio.h>
int main()
{
    int i=3;
    int arr[]={85,3,73};
    while(i--)
        printf("%c ",arr[i]);
    return 0;
}

欢迎评论回复大家的执行结果。

打开App,阅读手记
6人推荐
发表评论
随时随地看视频慕课网APP

热门评论

学习了,感触很深,只有精益求精才能写出漂亮的代码。喜欢那句话,简单就是美,代码要优雅,要简洁,事半功倍!

答案揭晓,会打出“I♡U”,码农撩妹专用哈?

没人执行我最后的代码啊,惭愧,好奇心哪儿去啦啊?。。

查看全部评论