《计算机程序设计艺术》笔记:Knuth 用半个世纪给算法写的一部《史记》
- 约 24 分钟读完 - 全文 9430 字《计算机程序设计艺术》笔记:Knuth 用半个世纪给算法写的一部《史记》
目标读者:被"要不要读 TAOCP"这个问题折磨过的工程师;也想给正在啃这套书的人一张地图和一份劝退/劝进并存的导读。 参考书籍:Donald E. Knuth《计算机程序设计艺术》(The Art of Computer Programming):卷 1《基本算法》(1968)、卷 2《半数值算法》(1969)、卷 3《排序与查找》(1973)、卷 4A《组合算法(一)》(2011),以及陆续以分册(fascicle)形式发布的 4B、4C。 阅读目标:不假装自己通读了这套书——说实话,没人通读过——而是把它读成一张"算法领域的地形图":每卷解决什么问题、Knuth 的方法论是什么、哪些内容今天仍是工程师的硬通货、正确的打开姿势是什么。
一、定位:这不是教材,是算法学科的《史记》
先破一个流传最广的误读:TAOCP 不是"编程教程",甚至不是"算法教材"。它是 Knuth 从 1962 年开始写的一部算法领域的编年史加分析法典——每一种重要算法都给出精确的描述、完整的正确性论证、严格的运行时间分析,以及详尽到令人发指的文献考证。Bill Gates 那句著名的话是对这套书最准确的注脚:
“如果你觉得自己是个足够优秀的程序员,就去读 Knuth 的《计算机程序设计艺术》……如果你能读完它,请一定把简历发给我。”
这句话的重点不在"读完",在"读完的人值得收简历"——它说的是这套书的密度。Knuth 自己的写作史也配得上这份密度:1962 年动笔时他以为自己写的是一本"编译器构造"的书,越写发现底料越多,最终膨胀成七卷规划;卷 1 到卷 3 在 1968-1973 年间出齐,然后卷 4 一写就是近四十年,2011 年才出了 4A,至今仍在以分册形式连载。顺带一个工程师会心一笑的插曲:1977 年卷 2 出第二版时,出版商改用照相排版,Knuth 对数学公式的排版质量忍无可忍,于是自己花了近十年写了 TeX 排版系统——全世界数学论文的排版标准,是这套书的副产品。
flowchart TD
TAOCP["TAOCP 七卷规划<br/>1962 年动笔"] --> V1["卷 1 基本算法 1968<br/>数学基础 + 数据结构"]
TAOCP --> V2["卷 2 半数值算法 1969<br/>随机数 + 高精度算术"]
TAOCP --> V3["卷 3 排序与查找 1973<br/>内排序 + 外排序 + 查找"]
TAOCP --> V4["卷 4 组合算法<br/>4A 2011 已出<br/>4B/4C 分册连载中"]
TAOCP --> V5["卷 5-7 规划中<br/>句法算法 / 语言理论等"]
V1 -.->|"排版质量事故"| TEX["副产品: TeX<br/>全世界数学排版标准"]
style TEX fill:#fff3cd
为什么用 MIX/MMIX 汇编,而不是高级语言
这是理解全书方法论的一把钥匙。书里所有算法都用一台假想计算机 MIX(后来升级为 64 位 RISC 风格的 MMIX)的机器语言写成。Knuth 给的理由,本质上是三条工程判断:
- 要算性能账,就得有一台确定的机器。算法分析的核心是精确计算执行次数和时间,而"这条语句花多少时间"在高级语言里是说不清的——编译器、运行时、指令集全在中间隔着。MIX 是一台指令集、时序完全定义好的机器,每条指令几个时钟周期白纸黑字,运行时间的分析因此可以精确到指令级。
- 机器语言不赶时髦。1960 年代的高级语言换得飞快(Algol、Fortran、PL/I……),用任何一种写书都会很快过时;而一台假想的、设计得足够经典的机器,五十年不过时。事实证明他赌对了。
- 逼你看清代价。用汇编写,每一次访存、每一次循环、每一条跳转都暴露在纸面上——Knuth 认为程序员必须知道自己写的每一行代码让机器做了什么,这是"程序设计艺术"里"艺术"二字的底线。
代价也是显然的:阅读门槛被这道汇编抬得很高。这也是为什么本文后面会专门谈"怎么读"——这套书的正确打开方式不是从第一页硬啃。
另一个体现 Knuth 写作态度的细节是勘误奖励:他给每个在书中首次发现错误的人签发一张"十六进制一美元"的支票(0x$1.00 = 2.56 美元)。钱少到可笑,但这张支票在技术圈是荣誉勋章——它宣示的是一种工程文化:错误不是羞耻,藏着掖着才是;每一版书都比上一版更接近正确。 半个世纪几十个印次,这套书可能是人类历史上被校对得最狠的技术著作。
还有一个容易忽略的风格特征:几乎每一节结尾都有 “History and bibliography”(历史与文献) 小节,把这个算法从最早的雏形到最新的改进逐年考证,作者、年份、原始论文一个不落。这让 TAOCP 同时是一部算法史的目录——想知道某个思想的最早出处,查这套书通常比查搜索引擎可靠。
二、卷 1《基本算法》:算法的定义与数据结构的奠基
算法是什么:五个特征与欧几里得算法
卷 1 开篇就干了件"法典"该干的事:给"算法"下定义。Knuth 用**算法 E(欧几里得求最大公约数)**做标本,提炼出算法的五个特征:
- 有穷性(Finiteness):算法必须在有限步内终止。(Knuth 特别注明:只满足其余四条而不满足有穷性的,可以叫"计算方法",比如某些永不终止但仍有用处的反应式过程。)
- 确切性(Definiteness):每一步都必须精确定义、无歧义——自然语言描述做不到这一点,这是书里坚持用形式化记号的原因。
- 输入(Input):有零个或多个输入。
- 输出(Output):有一个或多个输出,且与输入有确定关系。
- 能行性(Effectiveness):每一步都必须足够基本,原则上能用纸笔在有限时间内完成。
这个定义的价值在于它立刻产生了区分能力:菜谱通常不满足确切性(“适量盐”),死循环程序不满足有穷性,而"掷骰子决定下一步"不满足确切性——都不是算法。定义不是用来背的,是用来划界的。
数学基础与 O/Ω/Θ 的来历
卷 1 第一章的数学部分(归纳法、数、幂与对数、和与积、排列、斐波那契、生成函数)常被读者跳过,但里面埋着全书的方法论核心:渐近分析(asymptotic analysis)。几个史实值得澄清:
- 大 O 记号不是 Knuth 发明的——它来自数论学家 Bachmann(1894 年),经 Landau 推广;
- Knuth 的贡献是把它引入算法分析,并在 1976 年专门写了一篇短文《Big Omicron and Big Omega and Big Theta》,推广了大 Ω(下界)和大 Θ(紧界)记号,结束了当时文献里"什么都叫 O"的混乱局面。
也就是说,今天我们写在面试白板上的 O(n log n) 这套语言,其语法规范是这套书(及配套论文)定下来的。Knuth 的态度始终如一:“大约 n 的平方量级"不是分析,“3n² + 5n - 7 次 MIX 指令执行"才是分析,O 记号是对前者的严格速写,不是含糊的挡箭牌。
数据结构:栈、队列、链表与树
卷 1 第二章是整部书对后世影响最深的部分之一——今天教科书里的数据结构章节目录,几乎就是这一章的子孙:
- 线性结构:栈、队列、双端队列,以及链表(含循环链表、双向链表)的完整代数化描述——插入、删除、遍历的每一步都用 MIX 实现并计算代价。
- 树与二叉树:形式化定义、三种遍历(先序/中序/后序)、线索二叉树(threaded tree)——利用空指针存放前驱后继信息,让遍历不再需要栈。这是一个漂亮的"空间复用"思想实验。
- 动态存储分配与垃圾回收:内存池的伙伴系统(buddy system)、首次适配/最佳适配策略、以及垃圾回收的标记思想——1968 年就成体系地写在书里,比多数编程语言拥有 GC 早了一代人。
感悟:读这一章最大的震撼是"年代错位感”——我们以为的"现代概念”(垃圾回收、智能指针的引用计数思想、内存分配策略的权衡),全部写在 1968 年的书里,连权衡的框架都没变过。所谓新技术,多数是老思想在新硬件上的重排。
三、卷 2《半数值算法》:随机数与"算得准"的艺术
卷 2 处理一个独特的中间地带:对象是数,但关心的是算法而非数值分析。两大主线是随机数和算术。
先解释一下这个奇怪的书名:“半数值”(seminumerical) 指的是这类算法介于"纯数值计算"(数值分析的地盘,关心连续数学与误差界)和"纯符号处理"(编译、树操作的地盘)之间——输入输出是数,但算法的骨架是离散的组合结构。随机数生成、进制转换、多精度算术都属于这一带。
随机数:不该用随机的方法生成
卷 2 第三章是全书被引用最多的章节之一,开篇那句名言每个工程师都该见过一次:
“Random numbers should not be generated by a method chosen at random."(随机数不该用一个随机选出来的方法生成。)
Knuth 用亲身经历佐证这句话:他年轻时曾设计过一个"超级随机"的生成器——程序里随机地跳转、随机地执行不同操作,自以为复杂到不可能有规律;实际一跑,序列几乎立刻陷入一个短循环或不动点。教训被写成定理级别的警告:复杂性不等于随机性,没有理论分析的"看起来乱"都是自欺欺人。
- 线性同余法(LCG):x 的下一个值 = (a·x + c) mod m。全书用几十页分析三个参数怎么选——模数 m、乘数 a、增量 c 的选择决定了周期长度和统计质量,每一个坏选择的下场都有具体展示。
- 统计检验:卡方检验、Kolmogorov-Smirnov 检验、以及专门测高维均匀性的谱检验(spectral test)——一个 LCG 可以在一维上完美均匀,在二维平面上却排成几条明显的直线,谱检验就是抓这种"隐藏结构"的。
- 这一章的工程翻译到今天依然直接:不要用
rand()做密码学,不要自己发明随机数生成器,用之前先查它的检验记录。
高精度算术与"算得准”
另一条主线是让计算机突破机器字长地算数:
- 多精度算术:加减乘的经典算法之外,重点是算法 D(多精度除法)——除法是四则运算里唯一需要"试商"的,Knuth 给出完整的规范化、试商、校正流程和正确性证明。今天所有大整数库的除法实现,祖师爷都是这一节。
- 进制转换:二进制、十进制之间的转换算法及其代价分析——这个在当年是日常刚需。
- 有理数与浮点运算:浮点数的表示、舍入误差分析——“浮点算术不是实数算术"这个每个工程师都会踩的坑,书里有完整的误差传播分析。
- 多项式算术与幂的快速计算:包括快速幂(平方-乘方法)的前身分析,以及乘法复杂度的下潜——Karatsuba 乘法这类"比课本算法快"的分治思路。更进一步是**加法链(addition chain)**问题:计算 x 的 n 次方最少需要几次乘法?“反复平方"并不总是最优,而求最优加法链本身至今没有完全解决——又一个"看似简单、实则研究级"的 50 分题栖息地。
感悟:卷 2 是四卷里"最数学"的一卷,但它的工程含义一点不虚:凡是钱、坐标、时间戳参与计算的地方,都在和"算得准"打交道。浮点误差、大数溢出、随机数质量——这三类生产事故的完整理论都在这一卷里,早该被列为后端工程师的体检项目。
四、卷 3《排序与查找》:工程师的硬通货
四卷里工程密度最高、最值得当工具书常备的一卷。Knuth 把排序和查找这两个"最古老的生产问题"做到了考古学级别的穷尽。
内排序:一个家族的谱系
flowchart TD
S["内排序算法家族"] --> I["插入类<br/>直接插入 / Shell 排序"]
S --> E["交换类<br/>冒泡 / 快速排序 Hoare 1962"]
S --> SE["选择类<br/>直接选择 / 堆排序 Williams-Floyd"]
S --> M["归并类<br/>二路归并 / 自然归并"]
S --> D["分布类<br/>计数 / 基数排序"]
E -.->|"平均最优<br/>原地性差"| NOTE["各有适用场景<br/>没有全能冠军"]
M -.->|"稳定 + 可外排"| NOTE
style NOTE fill:#e8f5e9
书里对每一类的处理都是"描述 + 实现 + 精确分析"三段式,几个今天仍在生产环境活跃的名字:
- 快速排序(Hoare,1962):Knuth 给出了平均比较次数的完整推导,以及最坏 O(n²) 的触发条件——“已排序输入配最左基准元"这个教科书反面案例就出自这里的分析。
- 堆排序(Williams 提出堆,Floyd 改进):最坏情况也是 O(n log n),堆这个数据结构本身后来长成了优先级队列的标准实现。
- 归并排序:稳定、可预测,并且是通往外排序的桥。
读这一章时建议始终带着 Knuth 的三个评价维度去看每个算法:稳定性(相等元素的相对次序是否保持)、原地性(额外空间是常数还是随 n 增长)、适应性(输入已接近有序时能否变快)。三个维度没有全能冠军——快排快在常数、堆排赢在最坏情况、归并赢在稳定和可外排——选型就是在这张三维表里做取舍,这也是"没有银弹,只有权衡"在算法层的具体形态。
最优排序:n log n 下界的信息论论证
卷 3 最优雅的一段数学是基于比较的排序的下界证明,值得单独记:
flowchart LR
A["n 个元素<br/>共有 n! 种可能排列"] --> B["比较排序 = 决策树<br/>每个内部节点是一次比较<br/>每片叶子对应一种排列"]
B --> C["树至少有 n! 片叶子"]
C --> D["高度至少为 log2(n!)"]
D --> E["Stirling 公式<br/>log2(n!) 约等于<br/>n log2 n - 1.44n"]
E --> F["结论:<br/>任何比较排序的最坏情况<br/>下界为 Ω(n log n)"]
style F fill:#fff3cd
这个论证的力量在于它不针对任何具体算法——它限制的是"比较"这个信息获取方式本身:一次比较最多带来 1 比特信息,而区分 n! 种排列需要 log2(n!) 比特。要快过 n log n,唯一的出路是放弃纯比较——这正是基数排序、计数排序这些"分布类"算法存在的理由。这是我在全书中最喜欢的一段:用信息论给一类算法封顶,一句话堵死一个方向的无效努力。
外排序与查找
- 外排序:当数据放不进内存,瓶颈从比较次数变成读写趟数。多路归并、**置换选择(replacement selection)**生成初始归并段、多相归并——这一整套是磁带时代的遗产,但思想直接活在今天的 LSM-tree(LevelDB/RocksDB 的多路归并 compaction)里。
- 查找:顺序查找与二分查找(Knuth 照例指出:思路人人会,写对的没几个——边界条件的完整分析在书里;他还考据出二分查找的思路 1946 年就见诸文献,而第一个对所有输入规模都正确的完整算法直到 1962 年才发表,一个"显而易见"的算法花了十六年才写对);哈希:拉链法、开放寻址、线性探测的平均探查次数分析——线性探测的完整数学分析正是 Knuth 本人在 1962 年做出的,他自称正是这个问题让他决定写这套书;平衡树:AVL 树的旋转与再平衡,多路查找树与 B 树——数据库索引的物理形态;数字查找树(trie):按位/按字符逐级展开的查找结构。
五、卷 4A《组合算法》:生成一切的艺术
隔了 38 年,2011 年出的卷 4A 换了一个世界:从"算数"和"排序"进入组合对象——布尔函数、组合、排列、划分——的领域。这卷的气质和前三卷一脉相承:先把对象定义到无歧义,再讨论"如何系统性地、不重不漏地枚举出全部实例”。
- 布尔函数与按位运算(7.1 节):真值表、按位技巧的宝库——掩码、并行按位计数(population count 的 sideways addition)、“x 与 x-1 的按位与可以抹掉最低位的 1"这类惯用法——这一节读起来像高级位操作黑魔法集,但每个技巧都有正确性论证。
- 生成所有组合对象(7.2.1 节):全 n 元组与 Gray 码(相邻码字只差一位的编码,枚举时每次只翻转一位)、全排列、全组合、整数划分、集合划分、所有树——每类对象都给出"不重不漏、常数均摊时间"的生成算法。
- 回溯思想:组合生成的算法骨子里都是"受控的回溯”——按字典序推进、到头回退。系统化的回溯框架(Algorithm B)在卷 4B 的 7.2.2 节正式登场;顺带交代一句出版史:4A 出版后 Knuth 继续以分册形式推进,卷 4B 已于 2022 年正式成书,当年那个"卷 4 写了四十年"的段子至此翻篇了一半。
用一张图记住回溯的骨架——所有组合枚举问题都可以投影成同一棵树:
flowchart TD
R["空解<br/>第 0 层"] --> A["第 1 位选 a"]
R --> B["第 1 位选 b"]
A --> A1["第 2 位选 a<br/>...继续深入"]
A --> A2["第 2 位选 b"]
A2 -->|"约束不满足<br/>剪枝回退"| X1["放弃整棵子树"]
A1 --> A1L["到达叶子<br/>输出一个完整解"]
B --> B1["...同样的展开"]
X1 -.->|"回退到最近分叉<br/>试下一个分支"| A
style X1 fill:#ffcccc
style A1L fill:#e8f5e9
核心纪律只有两条:到底就输出,碰壁就回退,回退后试下一个未试过的分支——不重不漏靠的就是这条纪律,而不是事后去重。
感悟:卷 4A 对今天的工程师有一个直接映射:所有"遍历所有可能配置"的需求——测试用例生成、组合优化、穷举校验——都是组合生成问题,而"生成"远比"收集后再去重"高效。不重不漏四个字,说起来像小学要求,做起来是算法设计里最硬的纪律之一。
六、怎么读这套书:习题分级、费马大定理与路线图
习题分级体系:00 到 50 的登山标尺
TAOCP 的习题是半本书——Knuth 给每道题标了难度分,这套编号体系本身就是个值得单独记住的设计:
| 编号 | 含义 | 预期投入 |
|---|---|---|
| 00 | 看一眼就该会 | 几秒钟 |
| 10 | 简单 | 一分钟内 |
| 20 | 中等 | 一刻钟上下 |
| 30 | 较难 | 可能需要数小时 |
| 40 | 学期项目级 | 数周 |
| 50 | 研究级问题 | 出题时可能无人解决 |
编号前可加 M(数学向)或 HM(高等数学向)。最著名的梗是卷 1 里那道 [M50]:题目是"证明费马大定理”——Knuth 的意思是"这道题出题时没人会做”。1995 年 Wiles 真的证明了它之后,后续印次里这道题的处境变成了书迷间的段子。50 分题不是作业,是路标:它告诉你学科边界在哪里。
我的路线图建议
对全职工程师,“通读"是个伪目标,务实的姿势是:
- 卷 3 当工具书常备:排序和查找是唯一一卷会在日常代码评审、性能分析、系统设计面试中直接变现的。通读一遍,之后按需回查。
- 卷 1 读第二章:数据结构部分是"为什么链表这样设计、为什么 GC 这样工作"的第一性原理来源;第一章数学基础按需查,不必顺序啃。
- 卷 2 按需精读:写大数、随机、浮点相关代码之前,把对应章节当 pre-mortem 读一遍。
- 卷 4A 当兴趣读物:遇到组合枚举需求(生成测试组合、配置穷举)时按目录取用。
- 习题只做 20 分以下的:10-20 分题保证理解,30 分以上当文献指南——题目本身常附带一整段文献史,不打算做题也值得读题。
- 别按顺序读。Knuth 自己也没指望人顺序读:每节相对独立,前置依赖写在节首。把这套书当成"算法领域的百科全书"而不是"课程”,是心态上最重要的一步。
七、落到工程实践:这套书改变了什么
- 复杂度分析从"感觉"变成了"账"。 读过 Knuth 的分析后,再说"这个操作是 O(n)“会本能地问:常数是什么?最坏情况和平均情况分别是多少?输入分布假设是什么?这套书最宝贵的遗产不是任何单个算法,而是**“性能是可以、也必须被精确计算的"这个信念**——profile 之前的估算、capacity planning 里的量级推演,全是它的日常形态。
- 对"下界"的敬畏。 n log n 的信息论论证训练了一种思维方式:优化之前先问这个问题的理论下界在哪,离下界还有多远。很多"再优化一下"的努力,看一眼下界就知道是徒劳——知道哪里没有油水,和知道哪里有油水一样值钱。
- 随机与浮点有了红线意识。 “不自己造随机数生成器"“钱不用浮点表示"这类团队规约,以前是被当经验口诀背下来的;读完卷 2 它们变成了有证明的结论,执行的腰杆完全不一样。
- 读旧文献的胆子变大了。 Knuth 的行文示范了一件事:1960 年代的论文不是"过时的古董”,而是很多问题唯一的完整答案所在。排障和选型时敢往回翻原始论文,是这套书给的胆量。
- 对"正确性"的标准被永久抬高了。 书里每个算法都带正确性证明或严格论证,读多了会形成一种肌肉记忆:写完一段非平凡逻辑,先问"循环不变式是什么"“边界情况枚举全了吗”。二分查找十六年才写对的考据,是这条标准最好的反面教材。
本篇小结
- TAOCP 是算法学科的编年史加分析法典:七卷规划,卷 1-3 出版于 1968-1973,卷 4A 迟至 2011,副产品是 TeX;Gates 那句"读完发简历"说的是它的密度。
- MIX/MMIX 假想机不是老派怪癖,是方法论:要精确算性能账,就得有一台指令级确定的机器。
- 卷 1 立规矩:算法五特征(有穷、确切、输入、输出、能行),数据结构大全,O/Ω/Θ 的语法规范(O 来自 Bachmann,Ω/Θ 由 Knuth 推广)。
- 卷 2 管"算得准”:随机数不该用随机方法生成(LCG 参数分析、谱检验),多精度算术(算法 D),浮点误差。
- 卷 3 是工程师硬通货:排序家族全谱、n log n 下界的信息论论证(比较即信息获取)、外排序思想活在今天的 LSM-tree 里、哈希与 B 树。
- 卷 4A 进入组合世界:按位技巧、不重不漏地生成所有组合/排列/划分,回溯的思想雏形。
- 读法:卷 3 常备、卷 1 读数据结构、卷 2/4A 按需精读;习题分级 00-50,[M50] 的费马大定理题是学科边界的路标。
这套书我没读完,大概率也永远不会读完——但这不妨碍它一直在工作:它就立在手边,像一个不说话的考官,每次想偷懒说"差不多 n log n 吧"的时候,抬头就能看见。
参考资料
- Donald E. Knuth, The Art of Computer Programming, Volume 1: Fundamental Algorithms (3rd ed., Addison-Wesley, 1997; 初版 1968).
- Donald E. Knuth, The Art of Computer Programming, Volume 2: Seminumerical Algorithms (3rd ed., 1997; 初版 1969).
- Donald E. Knuth, The Art of Computer Programming, Volume 3: Sorting and Searching (2nd ed., 1998; 初版 1973).
- Donald E. Knuth, The Art of Computer Programming, Volume 4A: Combinatorial Algorithms, Part 1 (Addison-Wesley, 2011).
- Donald E. Knuth, “Big Omicron and Big Omega and Big Theta”, ACM SIGACT News, 1976(O/Ω/Θ 记号规范化的原始短文)。