《编程珠玑》(Programming Pearls)是计算机科学领域的经典著作,由Jon Bentley撰写,最早在1986年出版,后来又出了第二版和续篇,至今仍然是程序员必读的经典书籍之一。

这本书不是一本教你怎么写代码的教程,也不是一本算法教材,而是一本教你怎么思考、怎么解决问题的书。它通过一个个精彩的编程问题,展示了程序员在面对复杂问题时,应该如何分析、如何思考、如何找到优雅高效的解决方案。书中的每个问题都像一颗珍珠,闪耀着智慧的光芒,所以书名叫做"编程珠玑"。

我最近重读了这本书,有很多新的收获和感悟。本文将梳理《编程珠玑》的核心观点,用三分钟的时间带你读懂这本书的精髓。

一、正确理解问题,是解决问题的第一步

《编程珠玑》中反复强调的一个观点是:在动手写代码之前,一定要先正确理解问题。很多时候,我们写不出好的代码,不是因为技术不行,而是因为没有真正理解问题,上来就动手写,结果写出来的代码要么解决不了问题,要么效率低下。

书中有一个经典的例子:给定一个最多包含n个正整数的文件,每个数都小于n,其中n=10^7,文件中大约有10^6个数,而且没有重复的数。要求对这些数进行排序,并且内存限制在1MB左右,运行时间几分钟。

很多人看到这个问题,第一反应是用快速排序、归并排序等常见的排序算法。但是这些算法都需要把所有数据读入内存,10^6个整数,每个整数4字节,就是4MB,再加上排序需要的额外空间,1MB内存根本不够用。

这时候就需要重新理解问题。题目中说,每个数都小于n(n=10^7),而且没有重复的数。这意味着,我们可以用位图(bitmap)来表示这些数:用10^7个二进制位,每个位代表一个数是否存在,如果数存在,对应的位就设为1,否则设为0。10^7个二进制位,大约是1.25MB,刚好在内存限制之内。

用位图的方法,排序就变得非常简单:先把所有位初始化为0,然后读取文件中的每个数,把对应的位设为1,最后从低位到高位遍历位图,把值为1的位对应的数输出,就得到了排序后的结果。这个方法的时间复杂度是O(n),空间复杂度是O(n/8),非常高效。

这个例子告诉我们:正确理解问题,挖掘问题中的隐含条件,往往能找到更优雅、更高效的解决方案。 如果没有注意到"每个数都小于n"和"没有重复"这两个条件,就想不到用位图的方法,只能在常见排序算法里打转,最后可能因为内存限制而无法解决问题。

在实际工作中,我们也经常遇到类似的情况。接到一个需求,不要上来就写代码,先花时间理解需求,搞清楚问题的本质、约束条件、隐含信息,往往能事半功倍。很多看似复杂的问题,只要理解到位了,解决方案其实很简单。

二、算法设计的核心思想:分治、贪心、动态规划

《编程珠玑》中介绍了很多算法设计的思想和技巧,其中最核心的是分治、贪心和动态规划这三种思想。

分治(Divide and Conquer)

分治的核心思想是:把一个大问题分解成若干个小问题,分别解决小问题,然后把小问题的解合并起来,得到大问题的解。

分治思想在算法中应用非常广泛,比如快速排序、归并排序、二分查找、二叉树遍历等,都是分治思想的应用。

书中有一个经典的例子:求一个数组中的最大子数组和(也就是连续子数组的和的最大值)。用分治的方法,可以把数组分成左右两部分,最大子数组和要么在左半部分,要么在右半部分,要么跨越中间点。分别求出这三种情况的最大值,然后取最大的那个,就是整个数组的最大子数组和。

分治思想的关键是:分解问题要合理,子问题要和原问题同构(这样可以递归求解),合并子问题的解要高效。

贪心(Greedy)

贪心的核心思想是:在每一步都做出当前看起来最优的选择,希望通过局部最优来达到全局最优。

贪心算法的优点是简单、高效,但是它并不总是能得到全局最优解,只有在满足一定条件的问题中(比如贪心选择性质和最优子结构),贪心算法才能得到正确的结果。

书中有一个经典的例子: Huffman编码。Huffman编码是一种无损数据压缩算法,它的核心思想就是贪心:每次选择频率最低的两个节点合并,生成一个新节点,新节点的频率是两个子节点频率之和,重复这个过程,直到只剩下一个节点,就得到了Huffman树。根据Huffman树,频率高的字符用短编码,频率低的字符用长编码,从而达到压缩数据的目的。

贪心思想的关键是:要证明贪心选择的正确性,也就是证明每一步的局部最优选择,最终能导致全局最优。如果不能证明,贪心算法可能会得到错误的结果。

动态规划(Dynamic Programming)

动态规划的核心思想是:把一个复杂问题分解成若干个子问题,先求解子问题,然后从子问题的解得到原问题的解。和分治不同的是,动态规划中的子问题往往是重叠的,所以需要把子问题的解保存起来,避免重复计算。

动态规划通常用于求解最优化问题,比如最短路径、最长公共子序列、背包问题等。

书中有一个经典的例子:编辑距离问题。给定两个字符串,求把一个字符串转换成另一个字符串所需的最少操作次数,操作包括插入一个字符、删除一个字符、替换一个字符。用动态规划的方法,可以定义dp[i][j]表示第一个字符串的前i个字符转换成第二个字符串的前j个字符所需的最少操作次数,然后通过状态转移方程递推求解。

动态规划的关键是:定义好状态,找到状态转移方程,确定初始条件和边界条件。状态定义得好,状态转移方程就简单,问题也就容易解决。

这三种算法设计思想,是程序员必须掌握的核心思想。掌握了这些思想,面对新的问题时,就能快速找到解决问题的思路,而不是一筹莫展。

三、数据结构的选择,决定了算法的效率

《编程珠玑》中另一个重要观点是:数据结构的选择,对算法的效率有决定性的影响。好的数据结构,可以让算法变得简单高效;不好的数据结构,可能让算法变得复杂低效。

书中介绍了很多实用的数据结构,比如数组、链表、栈、队列、哈希表、树、堆、并查集、位图等,以及它们的应用场景和使用技巧。

数组:数组是最基础的数据结构,支持随机访问,时间复杂度O(1),但是插入和删除的时间复杂度是O(n)。数组适合用于已知大小、需要随机访问的场景。

链表:链表支持O(1)时间的插入和删除(在已知节点位置的情况下),但是随机访问的时间复杂度是O(n)。链表适合用于大小动态变化、频繁插入删除的场景。

哈希表:哈希表支持平均O(1)时间的插入、删除、查找,是非常实用的数据结构。但是哈希表需要处理哈希冲突,而且不支持有序遍历。

:树是一种层次化的数据结构,二叉搜索树支持O(log n)时间的插入、删除、查找,而且支持有序遍历。平衡二叉树(比如AVL树、红黑树)可以保证在最坏情况下也是O(log n)的时间复杂度。

:堆是一种特殊的完全二叉树,支持O(log n)时间的插入和删除最大(最小)元素,O(1)时间获取最大(最小)元素。堆适合用于实现优先队列、Top K问题等。

位图:位图用二进制位来表示数据,非常节省空间,适合用于处理大量整数的存在性判断、排序等问题。前面提到的排序问题,就是用位图解决的。

在实际工作中,选择合适的数据结构非常重要。比如,要实现一个缓存系统,需要快速查找和删除,就可以用哈希表+链表的组合(LRU缓存);要实现一个任务调度系统,需要按优先级处理任务,就可以用堆;要处理大量数据的去重和排序,就可以用位图或者布隆过滤器。

很多时候,换一个数据结构,问题就迎刃而解了,或者算法的效率能提升好几个数量级。因此,熟练掌握各种数据结构的特点和适用场景,是程序员的基本功。

四、代码优化的技巧:从正确到高效

《编程珠玑》中还介绍了很多代码优化的技巧,教我们如何把正确但低效的代码,优化成高效的代码。

算法层面的优化

最有效的优化,是算法层面的优化。比如,把O(n^2)的算法优化成O(n log n)或者O(n),效果往往比代码层面的微调好得多。

书中有一个经典的例子:求一个数组中的最大子数组和。最朴素的方法是枚举所有的子数组,计算它们的和,取最大值,时间复杂度是O(n^3)(如果用前缀和优化,是O(n^2))。但是用分治的方法,可以优化到O(n log n);用动态规划的方法(Kadane算法),可以优化到O(n)。从O(n^3)到O(n),性能提升了好几个数量级,这就是算法优化的力量。

因此,在优化代码时,首先要考虑算法层面的优化,看看有没有更优的算法,而不是一上来就纠结于代码细节。

代码层面的优化

在算法确定的情况下,也可以通过代码层面的优化来提升性能。书中介绍了一些常用的代码优化技巧:

  1. 循环展开:把循环体内的操作展开,减少循环的次数和循环控制的开销。比如,把一个执行100次的循环,展开成每次执行4次,循环25次,可以减少循环控制的开销。
  1. 减少函数调用:函数调用有一定的开销(参数传递、栈帧创建、返回等),在性能敏感的代码中,可以把小函数内联,减少函数调用的开销。
  1. 减少内存访问:内存访问的速度比CPU运算慢很多,减少不必要的内存访问,可以提升性能。比如,把频繁访问的变量缓存在寄存器中(虽然现代编译器会自动做这个优化,但是写代码时注意一下也有好处),用局部变量代替全局变量,减少缓存失效等。
  1. 预处理和查表:对于一些重复计算的结果,可以预先计算好,存在表中,需要的时候直接查表,避免重复计算。比如,计算正弦函数,可以预先计算好常用角度的正弦值,存在数组中,需要的时候直接查表。
  1. 利用硬件特性:现代CPU有很多特性,比如SIMD(单指令多数据)、缓存行、分支预测等,合理利用这些特性,可以大幅提升性能。比如,处理数组时,按顺序访问比随机访问快很多(因为缓存命中),条件分支尽量可预测(因为分支预测失败会有开销)。

需要注意的是,代码优化要适度,不要为了优化而优化,导致代码变得晦涩难懂、难以维护。Donald Knuth说过:"过早优化是万恶之源。"在优化之前,要先通过性能分析(profiling)找到性能瓶颈,针对性地优化,而不是盲目优化。

五、测试和调试:保证代码正确性的重要手段

《编程珠玑》中也强调了测试和调试的重要性。再聪明的程序员,写出来的代码也可能有bug,通过测试和调试,可以发现和修复bug,保证代码的正确性。

测试

书中介绍了几种常用的测试方法:

  1. 单元测试:对每个函数、每个模块进行独立的测试,验证它们的正确性。单元测试应该覆盖正常情况、边界情况、异常情况。
  1. 断言(assert):在代码中加入断言,验证程序运行过程中的关键假设。比如,函数的输入参数是否合法、数据结构的状态是否正确、计算结果是否在合理范围内等。断言可以在开发和测试阶段帮助发现问题,在生产环境可以关闭。
  1. 压力测试:用大量的数据、极端的情况来测试程序,验证程序在高负载下的性能和稳定性。比如,排序算法要用大数据量测试,验证它的时间复杂度是否符合预期;网络服务要用高并发测试,验证它的吞吐量和响应时间。
  1. 对比测试:对于同一个问题,用两种不同的方法实现,然后对比它们的结果,验证是否一致。如果不一致,说明至少有一个实现有bug。比如,用快速排序和归并排序对同一个数组排序,对比排序后的结果是否一致。

调试

当发现bug时,需要调试来定位和修复问题。书中介绍了一些调试的技巧:

  1. 打印日志:在关键的位置打印日志,输出变量的值、程序的执行路径等,帮助定位问题。打印日志是最简单、最常用的调试方法,在很多场景下都很有效。
  1. 二分法定位:如果不知道bug在哪里,可以用二分法逐步缩小范围。比如,注释掉一半的代码,看看bug是否还存在,如果存在,说明bug在剩下的一半代码中;如果不存在,说明bug在注释掉的一半代码中。重复这个过程,就能快速定位bug。
  1. 最小化复现:尽量用最小的输入、最简单的场景来复现bug,这样更容易定位问题。如果一个bug只有在复杂场景下才能复现,可以逐步简化场景,找到能复现bug的最小场景。
  1. 利用调试工具:现代IDE都有强大的调试工具,支持断点、单步执行、变量查看、调用栈查看等功能。善用这些工具,可以大幅提升调试效率。

测试和调试是程序员的基本功,也是保证代码质量的重要手段。一个好的程序员,不仅要会写代码,还要会测试和调试自己的代码,保证代码的正确性和稳定性。

六、程序员的成长:持续学习,不断思考

除了技术层面的内容,《编程珠玑》中也蕴含了很多关于程序员成长的思考。

保持好奇心,不断学习

计算机科学是一个快速发展的领域,新的技术、新的工具、新的思想层出不穷。作为程序员,要保持好奇心,不断学习,才能跟上技术的发展。

Jon Bentley在书中展示了他对编程的热爱和好奇心,他对每个问题都深入思考,寻找最优解,这种精神值得我们学习。

多思考,不要满足于表面

很多时候,我们解决问题,只是找到一个能work的方案就满足了,不再深入思考有没有更好的方案。但是《编程珠玑》告诉我们,好的解决方案往往来自深入的思考。面对一个问题,不要满足于第一个想到的方案,多想想有没有更优雅、更高效的方案。

比如,排序问题,第一个想到的可能是快速排序,但是深入思考问题的约束条件,就会发现位图排序更合适。这种深入思考的习惯,能让我们不断进步,写出更好的代码。

从实践中学习,从错误中学习

编程是一门实践性很强的学科,光看书是不够的,要多动手实践,在实践中学习和成长。同时,也要从错误中学习,每次遇到bug、每次代码出问题,都要反思原因,总结经验,避免以后再犯同样的错误。

保持代码简洁、优雅

好的代码应该是简洁、优雅、易读、易维护的。不要为了炫技而写复杂晦涩的代码,也不要写冗余的代码。能用简单的方法解决问题,就不要用复杂的方法。代码是写给人看的,顺便给机器执行,所以可读性非常重要。

《编程珠玑》中的代码,虽然都是为了展示算法和技巧,但是都写得非常简洁、优雅,值得我们学习。

七、总结

《编程珠玑》是一本值得反复阅读的经典书籍。它不是一本教你怎么用某个语言、某个框架的书,而是一本教你怎么思考、怎么解决问题的书。书中的每个问题、每个解决方案,都闪耀着智慧的光芒,值得我们细细品味。

本文梳理了《编程珠玑》的核心观点:

  1. 正确理解问题,是解决问题的第一步。
  2. 算法设计的核心思想:分治、贪心、动态规划。
  3. 数据结构的选择,决定了算法的效率。
  4. 代码优化的技巧:从正确到高效。
  5. 测试和调试:保证代码正确性的重要手段。
  6. 程序员的成长:持续学习,不断思考。

当然,本文只是梳理了这本书的核心观点,书中还有很多精彩的例子和细节,值得我们亲自去阅读和体会。如果你还没有读过这本书,强烈推荐你读一读;如果你已经读过了,也建议你重读一遍,每次重读都会有新的收获。

最后,用书中的一句话作为结尾:"优秀的程序员知道怎么写代码,卓越的程序员知道怎么思考问题。"希望我们都能成为卓越的程序员,不仅会写代码,更会思考问题。