简介:《算法导论》第四版是MIT Press于2022年推出的计算机科学经典教材,由Cormen、Leiserson、Rivest与Stein四位学者合著,面向计算机专业学生、算法学习者及技术研究人员,用于系统掌握算法的设计、分析与实现方法。资源为单个PDF文件,压缩包约19.64MB,内容完整覆盖原书正文,从算法在计算中的角色、插入排序与运行时间刻画,到分治、动态规划、贪心、回溯、近似算法及NP完全理论等核心章节,并配有大量习题与案例分析。目录结构清晰,便于按主题检索与对照学习。目前已有1241人学习下载,适合希望夯实算法基础、准备课程学习或面试复习的读者,可作为长期查阅的案头参考书。
1. 算法导论第四版:从伪代码到可运行代码的落地路径
很多人把《算法导论》第四版当成一本“读完就忘”的理论书,翻完排序章节就搁在书架上吃灰。但如果你手头正好有这份 PDF,又恰好需要把书里的伪代码变成能跑、能测、能提交的工程代码,那它的价值就完全不一样了。第四版在 2022 年由 MIT Press 出版,作者还是 Cormen、Leiserson、Rivest、Stein 四位,全书 35 章加 4 个附录,931 道练习题、162 道思考题,覆盖从插入排序到 NP 完全性的完整算法谱系。它解决的核心问题不是“教你写代码”,而是“教你判断一个算法在什么规模下会崩、为什么崩、换什么策略能救”。适合谁?正在准备算法面试但只会背模板的人、需要给团队做技术选型但说不清复杂度边界的人、以及想把经典算法真正落到生产代码里的工程师。这份 PDF 的检索关键词集中在“算法导论”和“算法导论 贪心算法”,说明大多数人卡在具体算法章节的落地上,而不是整本书的概览。
2. 第四版新增内容与伪代码转代码的映射策略
2.1 第四版相比第三版的关键变化
第四版不是小修小补。最直观的变化是全书改为彩色印刷,图示的可读性提升明显,尤其是红黑树旋转、B 树分裂、FFT 蝶形图这几类靠图才能讲清的算法。章节结构上,新增了第 25 章“Matchings in Bipartite Graphs”,把二分图匹配从最大流章节里独立出来,补上了稳定婚姻问题和匈牙利算法;第 26 章“Parallel Algorithms”是全新的,讲 fork-join 并行模型、并行矩阵乘法和并行归并排序;第 27 章“Online Algorithms”也是新增,用电梯等待、搜索列表维护、在线缓存三个场景讲在线算法的竞争比分析。第 33 章“Machine-Learning Algorithms”同样是第四版才有的,涵盖聚类、乘法权重算法和梯度下降。这些新增章节意味着如果你手里是第三版的笔记,直接套用到第四版会漏掉一整块内容。
伪代码风格也有调整。第四版更强调“可直接翻译”,数组索引仍然从 1 开始,但作者在前言里明确说了:如果你习惯 0-origin 数组,要么减 1,要么整体多分配一个元素。这个细节在写代码时如果不注意,边界条件会反复翻车。
2.2 伪代码到 Python 的翻译规则
书里的伪代码是类 Pascal 风格,翻译成 Python 需要处理几个固定映射。下面是一套我常用的转换规则,以插入排序为例:
# 算法导论第四版 2.1 节 INSERTION-SORT 的 Python 翻译 # 书里数组索引从 1 开始,这里用 0-origin,所以循环边界整体减 1 def insertion_sort(A): # 书里第 1 行是 for j = 2 to A.length # 翻译成 0-origin 后,j 从 1 到 len(A)-1 for j in range(1, len(A)): key = A[j] # 书里第 3 行:key = A[j] i = j - 1 # 书里第 4 行:i = j - 1 # 书里第 5 行:while i > 0 and A[i] > key # 0-origin 下 i >= 0 对应书里的 i > 0 while i >= 0 and A[i] > key: A[i + 1] = A[i] # 书里第 6 行:A[i+1] = A[i] i = i - 1 # 书里第 7 行:i = i - 1 A[i + 1] = key # 书里第 8 行:A[i+1] = key return A逻辑说明:书里所有for j = 2 to A.length翻译成 Python 时,上界要减 1 变成range(1, len(A)),因为 Python 的 range 右端不包含。所有while i > 0在 0-origin 下变成while i >= 0。参数说明:A是可变序列,函数原地修改并返回,方便链式调用。这个翻译规则适用于书中所有基于数组的算法,包括归并排序、堆排序、快速排序的分区过程。
2.3 数学符号到代码的对应关系
书里大量使用 Θ、O、Ω 记号,这些不是直接写进代码的,而是用来判断“这个实现能不能在给定数据规模下跑完”。比如第 3 章给出的形式化定义,翻译到工程里就是:当你看到 Θ(n log n) 时,意味着 n 翻倍,运行时间大约增加 2 倍多一点;看到 Θ(n²) 时,n 翻倍,运行时间翻 4 倍。这个判断在选排序算法时比背“快排最快”有用得多。常见做法是:n 小于 50 用插入排序,n 在 50 到 10⁶ 之间用快速排序或归并排序,n 超过 10⁶ 且数据有范围限制时考虑计数排序或基数排序。书里第 8 章“Sorting in Linear Time”给了这三者的完整分析。
3. 核心算法章节的代码复现与参数调优
3.1 分治算法:从 Strassen 到实际矩阵乘法
第 4 章“Divide-and-Conquer”里,Strassen 算法是很多人想复现但容易翻车的点。书里 4.2 节给出的递归式是 T(n) = 7T(n/2) + Θ(n²),用主方法解出来是 Θ(n^log₂7) ≈ Θ(n^2.81)。但实际写代码时,Strassen 只在矩阵规模超过某个阈值时才有优势,因为它的常数因子比朴素三重循环大得多。
import numpy as np def strassen(A, B, leaf_size=64): """ Strassen 矩阵乘法,带叶子阈值切换 leaf_size: 当子矩阵小于该尺寸时,切换回朴素乘法 书里 4.2 节假设 n 是 2 的幂,这里用 padding 处理任意尺寸 """ n = A.shape[0] if n <= leaf_size: return A @ B # 朴素乘法,利用 numpy 底层优化 # 书里 4.2 节将矩阵分成四个 n/2 × n/2 的子块 mid = n // 2 A11, A12 = A[:mid, :mid], A[:mid, mid:] A21, A22 = A[mid:, :mid], A[mid:, mid:] B11, B12 = B[:mid, :mid], B[:mid, mid:] B21, B22 = B[mid:, :mid], B[mid:, mid:] # 书里给出的 7 个递归乘法 M1 到 M7 M1 = strassen(A11 + A22, B11 + B22, leaf_size) M2 = strassen(A21 + A22, B11, leaf_size) M3 = strassen(A11, B12 - B22, leaf_size) M4 = strassen(A22, B21 - B11, leaf_size) M5 = strassen(A11 + A12, B22, leaf_size) M6 = strassen(A21 - A11, B11 + B12, leaf_size) M7 = strassen(A12 - A22, B21 + B22, leaf_size) # 组合成结果矩阵的四个子块 C11 = M1 + M4 - M5 + M7 C12 = M3 + M5 C21 = M2 + M4 C22 = M1 - M2 + M3 + M6 # 拼接 top = np.hstack((C11, C12)) bottom = np.hstack((C21, C22)) return np.vstack((top, bottom))逻辑说明:书里假设 n 是 2 的幂,实际工程中矩阵尺寸任意,所以用leaf_size做阈值切换,小于阈值时直接用 numpy 的@运算符,它底层是 BLAS,常数因子远小于纯 Python 递归。参数说明:leaf_size默认 64,这个值需要根据机器缓存大小调,一般取 L1 缓存能放下三个子矩阵的尺寸。如果设得太小,递归开销吃掉收益;设得太大,等于没用 Strassen。我一般会在目标机器上跑一个 512 到 4096 的扫描,找运行时间最低点。
3.2 动态规划:钢条切割与备忘录的工程化
第 14 章“Dynamic Programming”的钢条切割问题是理解 DP 的入口,但书里给的是自底向上版本。实际工程中,自顶向下加备忘录更常见,因为不是所有子问题都需要求解。
from functools import lru_cache def cut_rod(prices, n): """ 钢条切割,书里 14.1 节的自顶向下带备忘录版本 prices: 长度 1 到 n 的价格列表,prices[i-1] 对应长度 i 的价格 n: 钢条总长度 返回最大收益 """ @lru_cache(maxsize=None) def dp(length): if length == 0: return 0 best = float('-inf') # 书里 14.1 节的递归式:r_n = max(p_i + r_{n-i}) for i in range(1, length + 1): best = max(best, prices[i - 1] + dp(length - i)) return best return dp(n)逻辑说明:书里用数组r[0..n]自底向上填表,这里用lru_cache做备忘录,代码更短,且只计算实际用到的子问题。参数说明:prices的长度必须至少为 n,否则索引越界。lru_cache的maxsize=None表示不限制缓存大小,对于 n 达到几千的情况,内存占用可控。如果 n 超过 10⁴,建议改回自底向上的数组版本,避免递归深度和缓存开销。
3.3 贪心算法:Huffman 编码的堆实现
第 15 章“Greedy Algorithms”里,Huffman 编码是唯一一个我每次做数据压缩都会重新翻书确认的算法。书里 15.3 节用最小堆维护候选节点,提取两个最小频率节点合并。
import heapq from collections import Counter def huffman_encode(data): """ Huffman 编码,书里 15.3 节的堆实现 data: 字节串或字符串 返回 (编码表, 编码后的比特串) """ if not data: return {}, "" # 统计频率 freq = Counter(data) # 堆里存 (频率, 唯一序号, 节点),唯一序号避免频率相同时比较节点 heap = [(f, i, {sym: ""}) for i, (sym, f) in enumerate(freq.items())] heapq.heapify(heap) # 书里 15.3 节:循环直到堆中只剩一个节点 while len(heap) > 1: f1, _, left = heapq.heappop(heap) f2, _, right = heapq.heappop(heap) # 合并:左子树编码前加 0,右子树加 1 merged = {} for sym, code in left.items(): merged[sym] = "0" + code for sym, code in right.items(): merged[sym] = "1" + code heapq.heappush(heap, (f1 + f2, len(heap), merged)) code_table = heap[0][2] encoded = "".join(code_table[sym] for sym in data) return code_table, encoded逻辑说明:书里用EXTRACT-MIN两次取出最小频率节点,合并后插回堆。这里用heapq实现,元组第二项是唯一序号,防止频率相同时 Python 尝试比较字典而报错。参数说明:data可以是任意可迭代对象,Counter会自动统计频率。编码表返回的是每个符号对应的 0/1 字符串,编码后的比特串是字符串形式,实际存储时需要按位打包,这一步书里没展开,但工程中必须做,否则压缩率会打折扣。
4. 图算法与 NP 完全性的落地边界
4.1 单源最短路径:Dijkstra 的堆优化与负权处理
第 22 章“Single-Source Shortest Paths”里,Dijkstra 算法是使用频率最高的,但书里 22.3 节给的实现是 O(V²) 的数组版本。实际工程中必须用优先队列优化到 O((V+E) log V)。
import heapq def dijkstra(graph, start): """ Dijkstra 算法,书里 22.3 节的优先队列优化版本 graph: 邻接表,graph[u] = [(v, weight), ...] start: 源点 返回 dist 字典,dist[v] 是 start 到 v 的最短距离 """ dist = {start: 0} pq = [(0, start)] # (距离, 节点) visited = set() while pq: d, u = heapq.heappop(pq) if u in visited: continue visited.add(u) # 书里 22.3 节的 RELAX 操作 for v, w in graph.get(u, []): if v not in visited and d + w < dist.get(v, float('inf')): dist[v] = d + w heapq.heappush(pq, (dist[v], v)) return dist逻辑说明:书里用Q维护未访问节点集合,每次EXTRACT-MIN取最小距离节点。这里用heapq加visited集合实现,visited的作用是跳过已经确定最短路径的节点,避免重复松弛。参数说明:graph是邻接表,权重必须非负,否则 Dijkstra 不保证正确。如果图中有负权边,必须换用第 22.1 节的 Bellman-Ford 算法,它的复杂度是 O(VE),但能检测负权环。
4.2 NP 完全性:什么时候该放弃精确解
第 34 章“NP-Completeness”是全书最容易被跳过的一章,但它决定了你在工程中什么时候该停止寻找多项式算法。书里 34.5 节列出了几十个 NP 完全问题,包括旅行商问题、集合覆盖、子集和。实际工程中的判断规则很简单:如果问题规模超过 20 到 30,且没有特殊结构,精确解基本不可行,应该直接转向第 35 章的近似算法。
| 问题 | 精确算法复杂度 | 近似算法 | 近似比 |
|---|---|---|---|
| 顶点覆盖 | O(2^n) | 贪心取边两端点 | 2 |
| 旅行商(满足三角不等式) | O(n²2^n) | Christofides | 1.5 |
| 集合覆盖 | O(2^n) | 贪心选最大覆盖 | ln n + 1 |
| 子集和 | O(2^n) | 舍入后 DP | 1 + ε |
这张表里的近似比是书里第 35 章给出的理论保证。实际使用时,近似算法的运行时间通常是多项式级别,比如集合覆盖的贪心是 O(nm),n 是元素数,m 是集合数。参数说明:ε 是精度参数,越小结果越接近最优,但运行时间按 1/ε 增长。
5. 避坑与排查:伪代码翻译中的五个血泪教训
5.1 数组索引从 1 开始导致的越界
现象:把书里的for i = 1 to n直接翻译成for i in range(1, n),结果少循环一次,排序结果末尾元素错位。原因:书里数组索引从 1 开始,A.length是 n,循环包含 n;Python 的range(1, n)不包含 n。解决:统一改成range(1, n+1)或者整体减 1 用 0-origin,但减 1 后所有边界条件都要重新检查,包括while i > 0变成while i >= 0。
5.2 哨兵值在 Python 中的替代
现象:归并排序的MERGE过程里,书里用 ∞ 做哨兵,Python 里写float('inf')后比较没问题,但输出结果里混入了inf。原因:哨兵值被写入了结果数组。解决:不要用哨兵,改用索引边界判断,或者用float('inf')但在合并后截断到实际长度。我一般直接写while i < len(L) and j < len(R)这种显式边界,比哨兵更可控。
5.3 递归深度超过默认限制
现象:快速排序在近乎有序的数据上递归深度达到 n,Python 默认递归限制 1000,直接RecursionError。原因:书里 7.1 节的QUICKSORT在最坏情况下递归深度是 Θ(n)。解决:要么在QUICKSORT里先对较小分区递归、较大分区用循环处理(书里 7.4 节的问题 7-4 提到了这个优化),要么用sys.setrecursionlimit调高限制,但后者治标不治本。常见做法是混合使用:递归深度超过 2 log n 时切换到堆排序,这就是 introsort 的思路。
5.4 浮点数比较导致的最短路径错误
现象:Dijkstra 在浮点权重图上偶尔给出错误的最短路径。原因:书里假设权重是整数或精确可比较的实数,浮点加法有舍入误差,d + w < dist[v]可能因为误差判断错误。解决:如果权重是浮点数,改用d + w < dist[v] - 1e-9这样的容差比较,或者把所有权重乘以一个因子转成整数。我一般会在输入阶段就把浮点权重转成整数,避免在算法内部处理精度问题。
5.5 主方法不适用的递归式
现象:用主方法解 T(n) = 2T(n/2) + n log n,发现三种情况都不匹配。原因:书里 4.5 节的主方法要求递归式是 T(n) = aT(n/b) + f(n) 的形式,且 f(n) 必须是多项式级别的。n log n虽然比 n 大,但不是多项式级别大于 n,所以主方法不适用。解决:改用第 4.4 节的递归树方法,或者第 4.7 节的 Akra-Bazzi 方法。这个坑在分析自定义分治算法时特别常见,很多人硬套主方法得出错误复杂度。
6. 用练习题反推算法边界:一个具体的验证习惯
书里 931 道练习题和 162 道思考题不是摆设。我的习惯是:每复现一个算法,至少做三道练习题——一道基础题验证正确性,一道边界题验证鲁棒性,一道思考题验证复杂度分析。以第 2 章插入排序为例,练习题 2.1-2 要求改写插入排序为非升序,2.1-3 要求写线性搜索并证明循环不变式,2.1-4 要求把两个 n 位二进制整数相加。这三道题分别对应:改比较方向、验证正确性证明、扩展到新场景。
具体操作上,我会建一个exercises/目录,每个算法一个子目录,里面放solution.py和test.py。test.py用pytest写,至少包含随机数据、有序数据、逆序数据、重复数据四种情况。下面是一个测试模板:
import pytest import random from insertion_sort import insertion_sort def test_random(): A = [random.randint(-1000, 1000) for _ in range(100)] assert insertion_sort(A[:]) == sorted(A) def test_sorted(): A = list(range(100)) assert insertion_sort(A[:]) == A def test_reversed(): A = list(range(100, 0, -1)) assert insertion_sort(A[:]) == sorted(A) def test_duplicates(): A = [5] * 50 + [3] * 50 assert insertion_sort(A[:]) == sorted(A) def test_empty_and_single(): assert insertion_sort([]) == [] assert insertion_sort([1]) == [1]这个模板覆盖了书里练习题最常考的边界。参数说明:random.randint(-1000, 1000)的范围要足够大,避免随机数据恰好有序;A[:]是为了不修改原列表,因为插入排序是原地排序。跑通这个模板后,再去看书里对应章节的复杂度分析,会发现 Θ(n²) 的最坏情况和 Θ(n) 的最好情况在测试里都能观察到——逆序数据跑得最慢,有序数据跑得最快。
从那以后我每次翻《算法导论》第四版,都强制走一遍“伪代码翻译 → 练习题验证 → 边界测试 → 复杂度实测”这个流程,不再只是眼睛过一遍。希望帮到你。
本文还有配套的精品资源,点击获取