1. 先搞清楚第2.2节到底让你学什么
很多人在《算法导论》前两章其实是“带着跑步的心态在读”。第1章讲概念,第2.1节讲插入排序的过程,看完伪代码觉得“哦,这不就是理扑克牌嘛”,然后刷刷刷往下翻,结果到了第2.2节突然开始摆公式、算运行时间,直接被一堆 (c_1、c_2、t_j) 给打懵了。
我在带新人学算法时经常说一句:第2.1节教你“插入排序是什么”,第2.2节才真正教你“分析一个算法到底该怎么分析”。如果你只想写代码,2.1节就够了;但如果你想过面试、想做性能优化、想读后面所有章节,2.2节的这套“成本模型 + 循环不变式 + 习题推导”是绕不开的地基。后面归并排序、堆排序、快速排序的分析,全都是沿用这一节搭起来的框架。
这一节适合谁?两类人特别需要认真读:第一类是刚接触算法分析的同学,需要靠这一节建立“拿数学语言描述程序运行时间”的直觉;第二类是已经工作多年、平时写业务代码为主、想系统补算法底子的开发者,通过这一节把自己从“会用某个排序”提升到“能证明它为什么快、为什么慢”。
1.1 这一节在整本书里的定位
从全书结构看,第2章是“算法分析”的第一课。前面第1章只是告诉你算法是什么、并行性、计算模型这些背景,到了第2章才开始真刀真枪地分析一个具体算法。第2.2节前面第2.1节给出了插入排序的伪代码和基本操作,后面第3章才会正式引入渐近符号(也就是大O、θ这些记号)。所以第2.2节正好卡在“具体算法”和“抽象记号”之间。
这个位置很微妙。你既要把每个语句的执行成本当成具体数值算一遍,又要在最后看出“常数不重要,关键是增长量级”。很多新手容易犯一个方向性错误:要么纠结于每个 (c_i) 到底等于几,要么完全跳过成本计算只记结论。这节的真实意图是让你走完整条路:从具体的行号成本,到局部变量 (t_j),再到整理出 (T(n)),最后丢掉常数看量级。这条路一旦走通了,后面学任何算法都能顺着这个思路来。
1.2 学完这一节后你应该能说出这三件事
我觉得判断自己有没有学会第2.2节,不用看考试分数,就看三件事你能不能独立讲清楚。
第一,你能解释插入排序的运行时间为什么用θ记号描述时是 (\Theta(n^2)),而不是“大概”是 (n^2)。你要知道这个结论是怎么从伪代码的每行成本推出来的,而不是背下来的。
第二,你能理解为什么最好情况是线性时间、最坏情况是二次时间。注意“最好”和“最坏”说的是输入数据的组织方式,不是代码本身的某种运行模式。同一个排序算法,输入已经排好序和输入完全逆序,运行时间天差地别。
第三,你能用“循环不变式”这个东西去证明算法正确。很多人觉得证明很虚,但这一节它会手把手带着你检查初始化、保持、终止三个性质。这其实是把“为什么排序算法是对的”从感性认识变成理性论证。
2. 插入排序的完整过程与成本模型底层逻辑
2.1 从伪代码到CPU:为什么要“一行一行算成本”
我一直觉得第2.2节最容易被低估的部分,是它前面那一大段行号与成本表格。看起来像一个简单的“每行代码计费”,实际上它是在帮你建立算法分析最核心的直觉:运行时间不是靠感觉估的,而是可以逐行累加出来的。
教材把插入排序写成8行伪代码,每一行给一个成本 (c_i),代表这行伪代码在一台抽象机器上执行一次需要的时间。然后定义 (t_j) 为“第 (j) 次外层循环时,内层while循环测试执行的次数”。这里的难点在于 (t_j) 不是固定值,它依赖具体输入数据。
插入排序的伪代码大约是:
for j = 2 to A.length key = A[j] // 将A[j]插入到已排序子数组A[1..j-1] i = j - 1 while i > 0 and A[i] > key A[i+1] = A[i] i = i - 1 A[i+1] = key这个伪代码里有个细节,就是for循环的头部测试执行了 (n) 次,而不是 (n-1) 次。因为当 (j) 从2增加到 (n+1) 时,最后一次测试 (j \le A.length) 不成立,循环结束。这个是计算成本时最容易漏掉的地方。我在讲这节时总提醒学生:你把for循环看成“最后一次额外测试+跳出”,而不是“执行完n-1次循环体就干净结束”。
成本累计出来的表达式长这样:
[ T(n) = c_1 n + c_2(n-1) + c_4(n-1) + c_5\sum_{j=2}^{n} t_j + c_6\sum_{j=2}^{n}(t_j-1) + c_7\sum_{j=2}^{n}(t_j-1) + c_8(n-1) ]
这里省略了第3行注释,因为它不执行额外操作。你要看懂这个式子里的每一项,尤其要理解为什么第6行和第7行的累加项是 (\sum(t_j-1))。因为while循环测试执行了 (t_j) 次,如果测试条件为真,才会执行第6、7行的循环体一次,测试为真时执行循环体,最后一次测试为假不执行循环体,所以循环体执行次数比测试次数少1。
这个公式本身不是让你背的。它的价值在于让你观察到:不同输入会影响 (t_j),而 (t_j) 的变化会直接传导到 (T(n)) 里。没学这一节之前,你可能只会说“数据越乱,排序越慢”。学完之后你能说得更精确:数据乱不乱,本质上改变的是内层while循环到底执行了几次。
2.2 最好情况、最坏情况与平均情况到底怎么推出来的
最好情况很简单:输入已经按非降序排好。这时每一轮的 (key) 都大于等于前面的所有元素,内层while循环第一次判断就发现条件不成立,所以每个 (t_j) 都等于1。代入上面的公式,所有 (\sum(t_j-1)) 都变成0,结果是一个关于 (n) 的线性函数。
[ T_{best}(n) = (c_1 + c_2 + c_4 + c_5 + c_8)n - (c_2 + c_4 + c_5 + c_8) ]
这个结果很有启发:最好情况是线性时间,但你知道现实中不可能所有输入都是排好序的。更常见也更值得关注的是最坏情况。
最坏情况是输入完全逆序。这时每一轮的 (key) 都要一直和前面所有元素比较,直到 (i) 变成0才停下来。第 (j) 轮里,(i) 从 (j-1) 一路减到0,所以 (t_j = j)。于是:
[ \sum_{j=2}^{n} t_j = \sum_{j=2}^{n} j = \frac{n(n+1)}{2} - 1 ]
[ \sum_{j=2}^{n}(t_j - 1) = \sum_{j=2}^{n}(j-1) = \frac{n(n-1)}{2} ]
把这些代进去,最终整理成一个关于 (n) 的二次函数。最高次项系数是 ((c_5 + c_6 + c_7)/2),所以最坏情况运行时间是 (\Theta(n^2))。
平均情况怎么理解?如果输入数据是随机排列的,可以证明平均情况下内层循环大约执行一半的逆序检查,所以平均运行时间也是 (\Theta(n^2)),只是常数比最坏情况小。你不需要死记平均情况的精确公式,但要有这个意识:平均情况是次数的期望值,它针对的是“随机输入”这个概率模型。
实操心得:计算最坏情况时,很多初学者会问“为什么 (t_j) 等于 (j),而不是 (j-1)?”关键在于你把“测试次数”和“循环体执行次数”混在一起了。第 (j) 轮里,(i) 从 (j-1) 开始,每次测试后要么进入循环体让 (i) 减1,要么退出。最坏时一直走到 (i=0),while条件里前半段 (i>0) 为假,这次测试算最后一次。所以测试次数是 (j),循环体执行次数是 (j-1)。想知道自己搞没搞懂,就试着把表格里每个 (t_j) 实际写出来,比盯着公式看十遍管用。
3. 循环不变式与正确性证明:让算法“可靠”的数学根
3.1 插入排序的三条不变式性质怎么检查
很多读者第一次见“循环不变式”这个词会觉得特别抽象,其实就是用数学方法证明“循环每一步都没把结果搞坏”。以插入排序的for循环为例:每次迭代开始时,子数组 (A[1..j-1]) 已经包含了最初 (A[1..j-1]) 里的所有元素,并且已经排好序。这个声明就是循环不变式。
证明要过三关:
第一关叫初始化。第一次迭代前 (j=2),子数组 (A[1..1]) 只有一个元素,单个元素天然是排好序的,所以不变式成立。
第二关叫保持。假设第 (j) 次迭代前不变式成立,也就是 (A[1..j-1]) 已排序。循环体把 (A[j]) 的key从右往左移动,逐个和前面较大的元素交换,直到找到合适位置插入。因为移动的逻辑保证了每移动一次,子数组 (A[1..j]) 仍然只包含原来的那 (j) 个元素且顺序正确,所以迭代结束时 (A[1..j]) 已排序,相当于下一轮迭代开始前 (j) 变成 (j+1),不变式再次成立。
第三关叫终止。当 (j = n+1) 时循环结束。此时不变式说的是 (A[1..n]) 已排序,而 (A[1..n]) 就是整个数组,算法正确性得证。
这个证明结构就是“数学归纳法”的算法版。初始化对应归纳基础,保持对应归纳步骤,终止把循环结束状态和最终目标连接起来。
3.2 证明过程背后的直觉:为什么不是“每一步都正确”
我学“循环不变式”时最大的顿悟是:它不保证算法“每一步都在做对的事情”,它保证的是“每一步结束时,某种性质依然成立”。这个区别特别重要。
插入排序的每一步其实是在做局部调整,比如把某个大元素往后挪一个位置。单看这一步,数组可能是“乱”的,因为原来 (A[j]) 的位置空了,后面留下一个重复值。但你只要坚持做完一整轮插入,整个 (A[1..j]) 就是有序的。循环不变式抓的正是这个“一轮结束后”的全局性质,而不是“每一小步”的局部状态。
所以你可以把循环不变式理解成一个“承诺”:每轮循环结束时,我承诺前 (j) 个元素有序且没有丢失任何元素。只要算法在迭代中不破坏这个承诺,最后承诺扩展到全数组,就证明成功了。
这个思考方式在你以后分析更复杂算法时会反复用到。比如后面讲快速排序的partition过程、堆排序的建堆过程,都要用循环不变式来证明某个中间状态不会出问题。所以这一节不是孤立的内容。
3.3 新手常犯的理解误区
第一个误区:把循环不变式理解成“循环体内每一步都保持不变”。不是的。循环体内可以乱,变量可以变,不变式只要求在“循环条件判断之前”这个检查点上成立。
第二个误区:以为循环不变式能替代代码测试。实际工程里你写一个排序算法,靠测试来验证正确性当然没问题,但测试只能覆盖有限输入。循环不变式提供的是“对所有输入都成立”的逻辑保证。我写代码这么多年,确实不会每次写循环都去画证明,但调试那种“在99%的输入上正确、遇到某种边界就崩”的算法时,回头用不变式排查特别有效。
第三个误区:只证明初始化,不证明终止。循环不变式本身不能保证循环一定会结束。你可能维护了一个全序子数组,但循环永远跑不完,那算法也没意义。所以“终止”这一步必须单独说明:插入排序里 (j) 从2递增到 (n+1),循环必然结束。不要认为这一步是废话,它在大算法里可能变成“证明某种度量在递减”,是很有含量的一步。
实操心得:想练循环不变式,可以选一个你早就会写的简单循环,比如“找数组最大值”,然后自己用初始化、保持、终止三句话写一遍证明。这样练上两三个例子,回头再看插入排序的证明就会觉得顺很多。千万别第一次就从快速排序这种复杂算法开始练,那是自虐。
4. 2.2节课后习题详解:每一步推导都不跳步
很多自学者最缺的不是讲解,而是一份“能看懂每一步”的习题解答。书上的习题都很有意思,但答案不会平白无故给你。我下面把这节最常见的几道题完整捋一遍,包括推导过程和答案里常见的不严谨之处。
4.1 习题2.2-1:用θ记号表示函数量级
题目大意:用 (\Theta) 记号表示函数 (n^3/1000 - 100n^2 - 100n + 3) 的增长量级。
答案:(\Theta(n^3))。
这个题的用意是让你熟悉渐近符号的基本规则:只保留最高次项并丢弃常量系数,就可以确定增长量级。很多人第一次看到这个题会有点犹豫:“前面的系数是1/1000,后面的系数是100,这真的不影响量级吗?”
不影响。因为 (\Theta) 记号关心的是“当 (n) 足够大时,函数被夹在两个常数倍的 (n^3) 之间”。哪怕前面系数再小,当 (n) 超大时,(n^3) 项还是会压过一切 (n^2) 项。你可以把 (\Theta(n^3)) 理解为“增长速度的主干线”,低阶项只是在这个主干线上做小幅度抖动,改变不了走向。
如果想更严格地证明,可以写成这样:对于足够大的 (n),有
[ \frac{n^3}{1000} - 100n^2 - 100n + 3 \le \frac{n^3}{1000} + 100n^3 + 100n^3 = \left(\frac{1}{1000} + 200\right)n^3 ]
同时当 (n) 足够大时,低阶项不会抵消完整个最高项(比如 (n > 200000) 时主项正贡献更大),所以也存在一个正常数下界。这一题不要求你用极限证明,关键是理解“只看最高次项”的直觉。
4.2 习题2.2-2:写选择排序并证明正确性
这个题让你写一个“选择排序”的伪代码,并证明它为什么正确,还要回答为什么只需要运行前 (n-1) 趟,而不是 (n) 趟。
选择排序的思路是:每次从未排序部分选出最小的元素,放到当前未排序区间的开头。伪代码可以写为:
SELECTION-SORT(A) for i = 1 to A.length - 1 min = i for j = i + 1 to A.length if A[j] < A[min] min = j exchange A[i] with A[min]循环不变式可以这样定义:在外层循环每次迭代开始时,子数组 (A[1..i-1]) 包含整个数组中最小的 (i-1) 个元素,并且已经按非降序排列;子数组 (A[i..n]) 包含剩余的元素,顺序未知但元素无丢失。
初始化:(i=1) 时 (A[1..0]) 为空,无元素,不变式平凡成立。
保持:内层循环从 (i+1) 到 (n) 扫描,找到未排序部分最小元素的下标 (min)。交换 (A[i]) 和 (A[min]) 后,新放入 (A[i]) 的元素是整个剩余子数组中的最小元素。因为前面 (A[1..i-1]) 已经是全局最小且有序,(A[i]) 又比剩余所有元素小,所以迭代结束时 (A[1..i]) 仍然包含全局最小的 (i) 个元素且有序。
终止:外层循环结束时 (i = n)(循环条件 (i \le n-1) 最后一次满足后自增到 (n))。此时不变式说 (A[1..n-1]) 包含全局最小的 (n-1) 个元素且已排序。数组中剩下的最后一个元素 (A[n]) 只能是最大的那个,所以整个数组有序。
回答“为什么只运行 (n-1) 趟”:因为前 (n-1) 个位置都放入了正确的“当前剩余最小元素”后,最后剩下的一个元素自动就是全局最大,没有必要再排一遍。这是这类“每轮确定一个最终位置”的排序算法的通用结论。
4.3 习题2.2-3:线性查找的平均检查次数
题目大意:线性查找在一个包含 (n) 个元素的数组中查找值 (v),考虑查找成功且 (v) 等概率出现在任意位置。平均情况下需要检查多少元素?最坏情况呢?
答案:最坏情况检查 (n) 次;平均情况检查 ((n+1)/2) 次。
推平均情况用期望公式。设 (v) 出现在下标 (i) 的概率是 (1/n),那么找到它需要检查 (i) 次。平均检查次数为:
[ \frac{1}{n}\sum_{i=1}^{n} i = \frac{1}{n} \cdot \frac{n(n+1)}{2} = \frac{n+1}{2} ]
仔细想一下这个结果的直觉也很直接:平均来说,随机位置均匀分布在数组里,所以期望位置大致在中点附近,也就是检查一半多一点的数量。如果 (v) 不在数组里,就必须检查全部 (n) 个元素才能返回失败,所以失败情况的最坏和平均都是 (n)。
这个题虽然简单,但换一个问法就会坑到人:比如“查找成功且每个位置概率不同”时,平均次数就要重新用加权公式算。不要只会套 ((n+1)/2),要会从期望定义出发。
4.4 习题2.2-4:我们真的能改善最坏情况吗
这个题问的是:你是否可以修改插入排序,让它最坏情况运行时间改善。教材隐含的答案分成两个层次。
第一个层次,也是最容易想到的:可以加一个预检查,先看看输入是否已经有序,如果有序就直接返回。这样对于“已经有序”这种原本会走完所有插入操作但身上其实没什么可排的输入,运行时间会变得很快。但要注意,这个优化对“完全逆序”这种真正的最坏输入没有帮助,因为预检查后你照样要做完整的排序。
第二个层次:如果你想让所有输入的最坏情况都远小于 (n^2),那就不能靠“修补”插入排序,而要靠完全不同的算法。比如后面章节会讲的归并排序,最坏情况是 (\Theta(n\log n))。这说明优化有时候是换算法,而不是调参数。这道题的真实意图就是让你在想当然之前先意识到:渐近意义下的最坏情况是算法结构决定的,不是调几行代码能改变的。
我在面试算法题时也经常拿这个思路当加分项:候选人优化一个 (O(n^2)) 的暴力算法,先想到“提前退出”等小优化,然后能说出“最坏情况下还是 O(n^2),要真正提升必须换更高效数据结构或算法”,这才是对复杂度的理解到位了。
5. 实战中的学习建议与常见错误
5.1 我建议的“四遍阅读法”
第一次读第2.2节时不要试图搞懂每一个公式。你只需要做一件事:把伪代码复制到一个编辑器里(或者手抄一遍),然后在旁边给每一行标注它执行的次数。这一遍可以慢,但一定要手动走一个长度为4的具体数组,用笔记录每轮 (j)、每次内层比较、每次交换。这张手工表价值极大。
第二遍开始看成本模型。这时你已经知道了 (t_j) 实际长什么样,再回过去看公式 (\sum t_j) 就自然了。你会发现教材不是在变魔术,只是把你第一遍手动记录的“执行次数”符号化了。
第三遍专门研究最好/最坏/平均情况。重点不是背“线性”和“二次”,而是能自己画出三种情况下内层循环的执行次数。我建议你把“最好”和“最坏”两种输入各写一遍,再对比总结。
第四遍做习题。先合上书做,做不出来也不要急着看答案,把卡住的地方标出来。这个“卡住点”非常宝贵,然后带着问题去搜资料或请教别人,比从头再看一遍教材高效得多。
5.2 做题时最容易翻车的几个点
第一个翻车点是搞混最好情况和“所有输入都很快”。有人会说“插入排序是线性时间”,这种话不准确,应该说“插入排序在输入已经有序时是线性时间”。别人问你算法复杂度时,默认问的是最坏情况,除非特别说明。
第二个翻车点是写选择排序时外层循环多跑了一趟。如果 for i = 1 to A.length,那么到最后一次 (i=n) 时,内层循环里就只剩一个元素可比较,等于白白浪费了一轮。正确写法是 A.length - 1,这也是习题2.2-2专门让你回答的问题。
第三个翻车点是不区分“循环体执行次数”和“测试次数”。在分析while循环时,测试次数比循环体执行次数多1,这是最坏情况推导里最容易差一个1导致后面二次时间推不出来的原因。我之前带过的一个朋友,每次写 (t_j) 都写成 (j-1),公式一直推不对,后来发现就是把测试次数漏掉了。遇到这种问题,回到具体数组去数测试次数,别硬想。
第四个翻车点是习题2.2-1里直接把 (\Theta) 写成 (O)。(\Theta) 表示“增长量级既不会更快也不会更慢”,而 (O) 只表示“上界”。对 (n^3/1000 - ...) 这个函数来说说 (O(n^3)) 也对,但不精确。做题时要注意题目要的是精确量级还是上界。
5.3 一个适合自测的小实验
我建议学完这一节后别急着往后翻,先做一个“插桩实验”。随便用熟悉的编程语言实现插入排序,然后加两个计数器:一个统计比较次数,一个统计移动次数。分别输入三种数据:已经升序的数组、随机数组、降序数组,数组长度从1000到10000递增,记录运行时间或操作次数,画成曲线看增长趋势。
你会看到升序数据的计数基本随 (n) 线性增长,而降序数据明显是抛物线形。这个实验花不了太多时间,但能让你把教材上的 (\Theta(n^2)) 从抽象符号变成眼睛能看到的曲线。这也是我认为“做菜以后能尝到味道”的方法。以后学到归并排序时,也可以用同样方式做对照实验,直观感受 (n^2) 和 (n\log n) 的差距到底有多大。
我的个人习惯是每学一个新算法就写一个这样的计时脚本,保留下来。一次偶然发现,这个做法在我后来优化一段实际业务代码里的排序逻辑时派上了用场。回到书本本身,第2.2节最大的价值不是让你记住几个公式,而是让你具备一种“拆解算法、逐行定价、看量级、写证明”的分析习惯。这种习惯会伴随你读完整本书,也会渗透到你日常排查性能问题时。哪怕到后面你不再手写证明,分析问题时脑子里还会有这个框架在。希望这份拆解能帮你把这一节真正吃透。