Algorithm Design · Lecture 3-2

插入排序:递归的第一件作品InsertionSort

上一课我们拿到了递归这件兵器,今天立刻开工:排序 (sorting)——全计算机科学最经典的问题。在大多数人眼里,InsertionSort 是一个双重循环;教授偏要你戴上递归的眼镜看它:「让别人把前 n−1 个排好,我只负责把最后一个插进去。」一句话就是整个算法。顺便,三问仪式全程走一遍:对不对?多快?能更快吗?——第三问的答案,埋在下一课。

✓ 第一周:对不对·怎么数 ✓ 第二周:O·Ω·Θ 全套尺子 ✓ 3-1 递归:黑盒与铁律 ▶ 3-2 第一件作品:InsertionSort ○ 3-3 更快的排序:MergeSort
Ke Chen · May 26, 2026 承接 Lecture 3-1 可交互 中英双语
向下滚动开始 · scroll to begin
01

排序问题 / The sorting problem

老规矩(L1-1):碰到新问题,第一件事不是写代码,而是把问题本身用 Input/Output 定义清楚。排序的定义朴素得几乎无聊——但每个词都有讲究。

Problem · 排序 Sorting(讲义原文)
Input:数组 A[1..n],装着 n 个数 [a₁, a₂, …, an]。
Output:输入的一个重排 (permutation) [a′₁, a′₂, …, a′n],使得 a′₁ ≤ a′₂ ≤ ⋯ ≤ a′ₙ

两个讲究:①「permutation」——输出必须是输入那些数的重新排列,一个不多、一个不少,不许丢、不许改;② 不等号是 ≤ 而不是 <——重复元素完全合法。讲义的例子里就有两个 8:

81
12
93
24
85
46
67
58

↓ 排序(升序 ascending order)之后:

11
22
43
54
65
86
87
98
为什么用它当递归的第一件作品? 教授说得很直白:"normally when we talk about insertion sort, people do not think of it as a recursive procedure. But the idea is: we can."(通常没人把插入排序当递归算法看——但完全可以。)而且它比上一课的 RecursiveLinearSearch "slightly more involved"(略深一层):这次递归调用回来之后,还有真正的拼装工作要做,不再是原样转交答案。3-1 配方的第三问「怎么拼装」,今天第一次动真格。
02

递归的想法:把活儿推给别人 / Delegate the hard part

面对一整个乱序数组,教授的第一反应完全符合他的懒人哲学:"I have a large array to sort. That's a lot of work, and I don't want to do all that work by myself."

那怎么办?"What if I can give this job to someone else... Maybe I ask my TA to solve it. Maybe I'll ask your friends. It's just somehow it's sorted."(要是能把活儿交给别人呢?交给助教、交给你朋友——总之,前 n−1 个元素不知怎么的就排好了。)这正是 3-1 的神谕心态:递归调用只要作用在严格更小的输入上,结果闭眼信。于是全局图景变成:

乱序 A[1..n]一整摊活
→ 推给别人 →
神谕:排好 A[1..n−1]不打开 · 只信结果
有序前缀 + 孤儿 A[n]只剩一件事
把 A[n] 插进去完工
Idea(讲义原文) If A[1..n−1] is already sorted, just need to insert A[n] to the correct position.(只要前 n−1 个已排好,剩下的全部工作就是把 A[n] 插到正确的位置。)这一句话就是 InsertionSort 的全部灵魂——名字里的 insertion,指的就是这一下。
InsertionSort(A[1..n])
if n ≤ 1 then return // base case
InsertionSort(A[1..n−1]) // recursive call
Insert(A[1..n]) // A[1..n−1] in ascending order, insert A[n]
① Base case · n ≤ 1
空数组或单元素数组,天然有序。教授对单元素的评语:"It's both the smallest element and the largest element, but anyway, it's sorted."(它既是最小值也是最大值,但不管怎样,它是有序的。)老叮嘱再敲一遍:"Don't do anything is different from being not important"——它什么都不做,却是整个算法的 stopping point。
② Recursive case · 缩小 1
递归调用作用在 A[1..n−1] 上(讲义特意标红):比原输入恰好小 1。教授:"It doesn't need to be much smaller. It just needs to be smaller."(不需要小很多,只要严格更小。)铁律只问方向,不问步幅——每一步都在向 base case 靠近就行。
教授的自我验收:红牌! 三行写完,教授立刻拿 L1-1 的三支柱验收自己:"so far it's not a valid algorithm. Because in the last line, I say: insert A[n] into the sorted array. But I didn't tell you how. That's not a clear step-by-step instruction."(到目前为止,这还不是一个合法算法——最后一行说「插进去」,却没说怎么插。)unambiguous 支柱当场亮红牌。修法只有一个:把 Insert 本身也写成清清楚楚的伪代码。
03

补全最后一步:Insert / Insert, itself recursive

"As you can imagine, the insert procedure can also be thought of as a recursive procedure."(插入这个动作,本身也能递归地想。)于是一个递归套着另一个递归——双层结构登场。

Subproblem · Insert 的任务书
前提 (precondition):子数组 A[1..n−1] 已按升序排好
任务:把最后一个元素 A[n] 挪到正确位置,使整段 A[1..n] 有序

问 ①:base case 是什么?

n = 1:要把「最后一个元素」插进它前面那个已排序数组——可那个数组是空的。教授:"you have an element trying to insert it into an empty array... well, actually nothing to do, it's already inserted there."(其实什么都不用做,它已经插好了。)一个元素独自呆着,就是有序。

问 ②:怎么严格变小?

做一次比较:拿 A[n] 和它紧前面的那个元素 A[n−1] 比。两种结局:

  • A[n−1] ≤ A[n]:当场收工。A[n−1] 是有序前缀里的最大值,连它都不比你大,说明 A[n] 待的位置已经正确——整段有序,一步都不用再走。注意伪代码:这个分支里 if 后面什么语句都没有,函数直接结束。
  • A[n−1] > A[n]:交换 (Swap) 这两个元素。换完发生一件妙事——新的 A[n](原来的 A[n−1])是整个数组的最大值:它本来就是有序前缀的冠军,还打赢了原 A[n],所以全场最大,位置 n 恰好是它该待的地方。但换过来的新 A[n−1](原来的 A[n])和 A[1..n−2] 的关系还没理清——这恰好又是一个「把最后一个元素插进有序前缀」的问题,只是规模变成了 n−1。递归调用,收工。
Insert(A[1..n])
if n == 1 then return // base case
if A[n−1] > A[n] then
Swap(A[n−1], A[n]) // make the problem smaller
Insert(A[1..n−1]) // recursive call
铁律双检 ✓ InsertionSort 每次递归缩 1,Insert 每次递归也缩 1;两边的 base case 都在(n ≤ 1 与 n == 1)。每条递归路径都严格向 base case 靠近——3-1 的红字铁律,双层同时满足。现在,所有步骤都 unambiguous,这才配叫算法。

忠实记录:教授课上当场修正了讲义的两处笔误——两处递归调用都应作用在 A[1..n−1] 上("Sorry, this is a typo. I will correct it.")。本页伪代码已按修正后的版本排印,与最终 PDF 一致。

InsertionSort(n)外层递归
→ 自我归约 →
InsertionSort(n−1)+ 一次 Insert(n)
→ 而 Insert →
Insert(n)内层递归:比较 + 交换 + Insert(n−1)

双层递归结构:外层每回卷一步,就发动一整场内层递归。这个结构待会儿会精确映进证明(§05)和账单(§06–07)。

04

实验室:全程观察机 / Lab: the actual run

教授明知故犯:"I told you that you should not think of tracing the black box... and here, in order to convince you, I'd like to show you the actual run of an insertion sort."(为了让你服气,我们违一次禁。)讲义用整整一页动画追完 [8,1,9,2,8,4,6,5]——这里把它做成可以逐帧把玩的机器。

互动 · 全程观察机:下潜 → 触底 → 回卷(输入 = 讲义原数组)
就绪
比较次数
0
交换次数
0

图例:虚线 = 被「晾在一边」的元素 · 绿 = 已排好的前缀 · 橙 = 正在插入的旅行元素 · 红圈 = 正在比较的邻居

看清两个阶段了吗?下潜阶段一次比较都没发生——递归只是不停地把活儿往外推:晾下 5、晾下 6、晾下 4……直到单元素触底;所有真正的工作都在回程:每回卷一层,就是「第 k 次迭代」——把第 2 个插进前 1 个,把第 3 个插进前 2 个……教授在转录里就是这么复盘的:"It first tried to insert the second element to the first element, then the third element to the first two elements, and so on."

看完了,教授收玩具(和 3-1 同款动作) "Yes, we spend a lot of time tracing back everything... but this doesn't really tell you more about what we already know about insertion sort. That single sentence describing the recursive algorithm already tells you everything."(追完全程,你只是确认了那句话:让别人排好前 n−1 个,把最后一个插进去——一句话早就道尽一切。)"But that's not telling you anything new... really, don't look inside the black box. Just trust the inductive hypothesis."

彩蛋一:两个 8 全程谁也没跨过谁——比较用严格 >,相等不交换,排序保持相等元素的原始相对次序。这个性质叫 stability(本课未详述,后面的课再见)。彩蛋二:转录里教授顺口说「剥到空数组」,但按伪代码 n ≤ 1,下潜其实在单元素 [8] 就触底了——无伤大雅,两者都是 base case 的地盘。

05

正确性:两层归纳,先内后外 / Correctness, twice

"This may feel tedious and repetitive. Just bear with me——I trust that being exposed to more examples will help you get rid of the fear for induction proofs."(多见几个例子,归纳恐惧症自然痊愈。)新花样只有一个:算法有两层,证明也要两层。

顺序有讲究:先证 Insert,再证 InsertionSort。教授:"because we have two parts of the algorithm, we first prove the correctness of the inner part."为什么?因为 InsertionSort 的归纳步里要引用「Insert 是对的」这个事实——引理必须先立住,才能被引用。依赖链长这样:

引理:Insert 正确自己的归纳证明(硬币①)
→ 被引用 →
InsertionSort 的归纳步IH 排好前缀 + 引理插好尾巴
定理:InsertionSort 正确硬币②铸成

3-1 说递归算法与归纳证明是同一枚硬币的两面 (two sides of the same coin)——今天是两枚硬币:每个递归函数配一份归纳证明,依旧逐行对应:算法的 base case ↔ 证明的 base case,递归调用 ↔ 归纳假设,「变小 + 拼装」那步 ↔ 归纳步里唯一要亲手检查的部分。

证明一(引理):Insert 的正确性(讲义原文 + 注解) · Correctness of Insert
Base case size 1 的数组,Insert 什么都不用做。——为什么这就算对?任务书说「把最后一个元素插进前面的有序段」,而前面是空的:一个元素独自成段,自然有序。✓(不依赖任何假设,独立验证。)
Inductive hypothesis 假设 Insert 对 size n−1 的输入工作正确——即:只要 A[1..n−2] 已排好,调用 Insert(A[1..n−1]) 之后整段 A[1..n−1] 有序。注意 IH 要连前提一起假设:Insert 的正确性从来都是「在前缀已排好的前提下」的正确性。
Inductive step · 情形 1 若 A[n] > A[n−1]:A[1..n−1] 本来有序,而 A[n] 比其中最大的 A[n−1] 还大——整段 A[1..n] 已完全有序,算法恰好什么都不做。✓(讲义写的是严格 >;等号情形 A[n] = A[n−1] 同理有序,伪代码同样不动它——把 ≥ 拆开检查一遍,是把证明写严的好习惯。)
Inductive step · 情形 2 否则交换 A[n] 与 A[n−1]。交换后:位置 n 上是原 A[n−1]——有序前缀的最大值,又大于原 A[n],故为全场最大,呆在末位正确;同时 A[1..n−2] 仍按升序排好,只需把(新的)A[n−1] 插进去——这正是 size n−1 的 Insert 问题,被归纳假设覆盖。∎
品一品 亲手检查的只有「一次比较 + 一次交换没出错」;剩下的照例是那句万能收尾——which is covered by the inductive hypothesis。
证明二(定理):InsertionSort 的正确性(讲义原文 + 注解) · Correctness of InsertionSort
Base case size ≤ 1 的数组 sorted vacuously(空真地有序)——没有任何一对元素可能乱序,无事可做。✓
Inductive hypothesis 假设 InsertionSort 对 size n−1 的数组工作正确。
Inductive step 对 size n 的数组:第二行递归调用后,归纳假设保证 A[1..n−1] 已按升序排好;于是第三行 Insert(A[1..n]) 的前提成立,而 Insert 的正确性我们刚在上一页证过——它把 A[n] 放到正确位置,整段有序。"All the parts of this algorithm are correct, so the whole thing is correct." ∎
品一品 这份证明短得几乎失礼——因为重活全让引理扛了。证明的结构精确复刻算法的结构:算法调用 Insert,证明就引用 Insert 的引理。这就是「同一枚硬币」在双层递归下的样子。
三问仪式 · 第一问答完 "So first, correctness——yes, we do have a correct insertion sorting algorithm now."第二问立刻开火:多快?"How much time is it taking? Is it efficient?"而且教授预告了第三问:"once we figure that out, we can ask: can we do better? Can we have a more efficient sorting algorithm?"——记住这句,它是下一课的引信。
06

账单①:Insert 的 S(n) / Time complexity of Insert

和证明一样,记账也要先内后外:"Just like when we do the proof... we also first need to figure out the time complexity of the insert function."先给它的账单起个名字:S(n)。

教授敲黑板 · 复杂度是函数,不是数 "Remember, time complexity is not a number, it's a function. It's a function parameterized by the input size n."(时间复杂度不是一个数,是一个以输入规模 n 为参数的函数。)正因为 S 是函数,它才能在自己的定义里引用自己——这是待会儿递推式合法的前提。

best case:一步收工

先看最顺的剧本:第二个 if 的条件不成立(A[n−1] ≤ A[n])。教授:"my entire insert will just finish. It's done."——if 后面没有任何语句,函数当场结束,只花 O(1)。但 L1-2 的老规矩:best case 说了不算。"You cannot say for any input I will never execute the body of the if statement——that wouldn't be true... we usually consider the worst case, because that gives us a guarantee: they will never exceed that estimation."(最坏情形给的是「绝不超过」的承诺。)

worst case:逐行记账

最惨的剧本是走进 if 里面。里面两行:Swap 花 O(1)——教授现场拆给你看:"temp = A[n], A[n] = A[n−1], A[n−1] = temp. That's some constant number of operations";递归调用 Insert(A[1..n−1])——输入 size 是 n−1,按 S 的定义,它的账单恰好就是 S(n−1),"because that's how we define S"。加起来:

In the worst case: S(n) = O(1) + S(n−1) ⤳ S(n) = ?

这种自我引用的式子,3-1 已经打过照面:recurrence relation(递推式)。讲义把右边留成红色的问号——但教授在答疑里忍不住现场剥了一次洋葱。跟着剥:

互动 · 剥洋葱:把 S(n) 一层层拆开(复刻课堂答疑)
这一步在干嘛?点「下一步」开始。
课堂问答实录 ① · base case 的账永远是常数吗? 有同学问:递推解到底时,base case 的开销总是 O(1) 吗?教授:"very good question... 剥洋葱必须停在 base case——否则 n−i 变成负数,「负规模的耗时」毫无意义。"本课 S(1) = O(1),微不足道;但 "later we'll also see some examples where S(1) will also do something non-trivial——then you have to take that into consideration, and it will contribute to the final solution."(以后会遇到 base case 干大活的例子,它会写进最终答案。)本课程的简单算法里,"you can mostly safely assume that S or T of a constant would always give you O(1)."
课堂问答实录 ② · best case 不就是 base case 发生吗? 另一位同学的直觉:最好情形,不就是直接撞上 base case 吗?教授:不是。"The base case only happens if the input is of size 1. We're trying to derive a time complexity when the input is of size n."(base case 只在 size 1 触发;而我们讨论的是任意 size n 的输入。)哪怕输入很大,只要第二个 if 的条件不成立,算法照样一步收工——"It's not just the base case. It also includes the possibility that for some input, our algorithm doesn't need to do anything."一句话:base case 看的是输入的「大小」,best case 看的是输入的「长相」——两码事。(§10 坑一伺候。)
07

账单②:套娃递推 T(n) / Time complexity of InsertionSort

轮到外层。给 InsertionSort 的账单起名 T(n),逐行记账——L1-2 的手艺,只是这次账本里会出现两个函数。

InsertionSort(A[1..n])
if n ≤ 1 then return // O(1):一次判断,不足挂齿
InsertionSort(A[1..n−1]) // T(n−1):同款问题,size n−1,按 T 的定义记账
Insert(A[1..n]) // S(n):上一节刚记完的账,整单挂进来
In the worst case: T(n) = T(n−1) + S(n) ⤳ T(n) = ?

教授自己都笑:"now we have a probably even more convoluted recurrence relation."(一条更绕的递推。)绕在哪?T 的肚子里住着 S,而 S 自己还是一条递推——套娃。讲义再次把答案留成红问号:"how do you derive the big O out of this recurrence relation? Again, that has to wait for Thursday."今天不硬解,但我们可以走另一条路偷看答案——下一节。

递归算法记账三步(本课默默立下的配方)

给每个递归函数的账单命名

Insert 记 S(n),InsertionSort 记 T(n)——都是以输入规模为参数的函数。有几个递归函数,就有几本账。

逐行记账

自己干的活按 L1-2 数(判断、交换都是 O(1));递归调用按定义记成「同名函数在更小 size 上的值」:Insert(A[1..n−1]) 记 S(n−1),InsertionSort(A[1..n−1]) 记 T(n−1);调用别的函数就挂它的账单:Insert(A[1..n]) 记 S(n)。

得到递推式——解它是另一门手艺

S(n) = O(1) + S(n−1),T(n) = T(n−1) + S(n)。列账单本课就能做;系统地解账单,周四(4-1 代入法)开讲。剥洋葱只是手感。

08

迭代版与 Gauss 求和 / The iterative version

"For this very simple example, we can actually write a non-recursive version."——不是退回旧世界,而是借道:没有递归调用的代码,账单不含 T 和 S,好算得多。

InsertionSort(A[1..n])
for i = 1 to n do
key = A[i]
j = i − 1
while j > 0 and A[j] > key do
A[j+1] = A[j]
A[j] = key
j = j − 1

逐句对照递归版:while 循环体三行 = 一次 Swap(A[j], A[j+1])——开循环时 key 就住在 A[j+1] 里,所以「A[j+1] = A[j]; A[j] = key」正是把这对邻居对调;while 的两个条件 = Insert 的两道闸门合体——「A[j] > key」对应第二个 if(还要不要继续换),「j > 0」对应 base case(撞到最左端必须停)。外层 for 则复刻了回卷:第 i 轮把 A[i] 插进已排好的 A[1..i−1]。教授把验证留给你:"You can verify this is doing exactly the same as in the previous example."——挑战题 C2 伺候。

通用事实 · 递归 ⇄ 迭代 "In fact, for any recursive algorithm, you can always do this. You can always turn it into an iterative one. Whether that's helpful or whether it's useful, it's up to debate, but you can do that."(任何递归算法都能改写成迭代版——能不能是定理,值不值另说。)这次它值:改写之后,时间复杂度肉眼可算。

粗算:O(n²)

讲义原文:while-loop: O(n) time;for-loop: n rounds, O(n) time each, in total O(n²)。内层 while 每轮最多走 O(n) 步,外层 for 恰好 n 轮,相乘封顶。"It's a quadratic time algorithm... It's not super efficient."

细算:第 i 轮其实只花 O(i)

粗算里「每轮 O(n)」偏宽了:第 i 轮插入 A[i] 时,左边只有 i−1 个元素,while 最多走 i−1 步。把每轮的真实上限加起来——Gauss 求和正式在本课程出场:

A more careful analysis: Σi=1..n O(i) = O(Σi=1..n i) = O(n(n+1)/2) = O(n²)

还是平方档。教授:"even if you do the more careful analysis like this, you still get the quadratic running time."细算把系数从 n² 压到约 n²/2——而 P1(L2-2)早就教过:乘法常数不改档。想真正降档,得换算法,不是换算盘。

回填悬念 · 套娃递推的答案 "So this kind of gives us the answer to the unsolved question we had before... T(n) = T(n−1) + S(n)——hopefully, that will solve to O(n²)."(迭代版给了我们答案的强烈预期;周四的正式工具必须跟这里对上号。)顺带记住教授的另一句:"We'll learn something that runs faster."——第三问「能更快吗」的答案是肯定的,就在下一课。
09

实验室:输入设计台 / Lab: design the input

O(n²) 是 worst case 的判决。可哪种输入把 InsertionSort 折磨得最惨?哪种让它白拿工资?这次你来当出题人——亲手摆一个输入,看比较次数怎么变。

互动 · 输入设计台:比较次数 = 你摆的阵型说了算
倒序(最坏) 已排好(最好) 讲义例子 随机打乱
数组规模 n8

点两个格子交换它们 → 自定义阵型(会自动重新记账)。

每根柱 = 第 k 轮(把 A[k] 插进前 k−1 个)的比较次数。绿 = 该轮 1 次比较、0 次交换(best 分支);红 = 一路走到最左端(full walk);蓝 = 介于其间。

两个极端,一张判决书 已排好的输入:每轮恰好 1 次比较、0 次交换,总账 n−1 = O(n)——最好情况下 InsertionSort 是线性的!倒序输入:第 k 轮必须一路走到最左端,比较数 1, 2, …, n−1 排成阶梯,总面积 n(n−1)/2——这正是上一节 Σ O(i) 的几何形状:一个三角形,面积 ~n²/2。2-3 的老规矩别忘:bound 和 case 是两个正交的旋钮——「worst case Θ(n²)」与「best case Θ(n)」并行不悖,谁也不打谁的脸。挑战题 C4 把这两句话钉死。
10

四个经典陷阱 / Pitfalls

本课的坑,一半来自两场课堂答疑——同学们替你踩过了,对号入座。

坑一 · 把 best case 当成 base case
"最好情形 = 直接撞上 base case" ✗

base case 看输入的大小(size ≤ 1 才触发);best case 看输入的长相(任意大的 size,只要 A[n−1] ≤ A[n],Insert 照样一步收工)。教授答疑原话:"It's not just the base case."

坑二 · 指望「细算」降档
"每轮才 O(i),细算说不定能到 O(n log n)" ✗

细算的结果是 n(n+1)/2——系数减半,档次纹丝不动(P1:常数不改档)。粗算 n·O(n) 和细算 Σ O(i) 给出同一个 O(n²)。降档要换算法,不是换算盘。

坑三 · 伪代码里留一句「插进去就行」
Insert(A[1..n]) // 你懂的 ✗

教授自己的三行版都被自己亮了红牌:"I didn't tell you how."每个未定义的子过程都必须当场写清,否则违反 unambiguous(L1-1),不配叫算法。写作业时这是最容易丢分的一句。

坑四 · 剥洋葱剥穿地心
"S(n−i) 一路代下去就完了" ✗

展开必须停在 base case:n−i 变负,「负规模的耗时」没有意义。本课 S(1) = O(1) 恰好无关痛痒,但教授预警:以后会有 base case 干大活的例子——它的开销会写进最终答案

11

挑战题 / Harder problems

四道,难度递增。C2 是教授课上亲口留的作业;C3 提前试驾周四的活;C4 把第 2-3 课的尺子拿出来做全套鉴定。先动笔再看解。

★★ · 把两个极端算准
C1. 用本课 Insert 的比较约定(相邻比较;遇到 A[j−1] ≤ A[j] 停,或走到 base case 停),证明对 size n 的数组:(a) 已按升序排好的输入,递归版 InsertionSort 总比较次数恰为 n−1、交换 0 次;(b) 严格递减(倒序)的输入,总比较次数与总交换次数都恰为 n(n−1)/2。

提示:逐轮数。第 k 轮(k = 2..n)把 A[k] 插进前 k−1 个,这一轮最少比几次?最多比几次?什么时候取到?

题解 · Solution
(a) 已排好 第 k 轮调用 Insert(A[1..k]):第一次比较就是 A[k−1] 与 A[k],升序输入下 A[k−1] ≤ A[k](允许相等,而交换条件是严格 >),条件不成立 → 当场收工,恰 1 次比较、0 次交换。k 从 2 到 n 共 n−1 轮,总计 n−1 次比较、0 次交换。这就是 best case:整体 O(n)。
(b) 倒序 关键观察:倒序输入下,第 k 轮的旅行元素 A[k] 比前面所有元素都小(它是前 k 个里的最小值)。于是每次比较都触发交换,一路换到位置 1,由 base case(size 1)叫停——恰好 k−1 次比较、k−1 次交换,中途永远不会出现「不用换」。总计 Σk=2..n (k−1) = 1 + 2 + ⋯ + (n−1) = n(n−1)/2,比较与交换同数。
对账 讲义细算写的是 Σi=1..n O(i) = O(n(n+1)/2):那是「每轮至多 O(i)」的上界写法;精确比较数 n(n−1)/2 与 n(n+1)/2 只差一个 n——P1/P3 之下同档,都是 n²/2 级。上界与精确值对得上,账就算平了。
顺手一验 n = 8:best 7 次,worst 28 次;讲义例子实测 20 次——落在两端之间。§09 的设计台随时可复算。
★★★ · 教授留的作业:迭代版 ≡ 递归版
C2. 教授说:"You can verify this is doing exactly the same."请动手验证:证明迭代版 InsertionSort 正确——对 for 循环的轮数 i 做归纳,证明不变式 P(i):「第 i 轮结束后,A[1..i] 恰是原 A[1..i] 那些元素的升序排列,且 A[i+1..n] 未被触碰」。并指出 while 循环体与递归版 Swap 的对应关系。

提示:循环不变式 (loop invariant) 就是「迭代世界的归纳法」——P(i) 扮演归纳假设,一轮 for 扮演归纳步。

题解 · Solution
对应关系先理清 进入 while 时 key 住在 A[j+1] 里,所以循环体「A[j+1] = A[j]; A[j] = key」= 把 A[j] 与 A[j+1] 对调 = 递归版的一次 Swap;while 条件「A[j] > key」对应 Insert 的第二个 if,「j > 0」对应 base case(走到最左端强制停)。逐步对应,连比较次数都一样。
Base i = 1:key = A[1],j = 0,while 条件 j > 0 立即失败,循环体不执行——A[1..1] 单元素自然有序,后段未动。P(1) ✓
IH 假设 P(i−1) 成立:第 i−1 轮后 A[1..i−1] 已是原前缀元素的升序排列。
Step 第 i 轮:key = A[i]。while 每执行一次,一个大于 key 的元素右移一格、key 左移一格——维持「A[1..j] 有序、A[j+2..i] 有序且每个都 > key、key 在 A[j+1]」这个中间态(初始 j = i−1 时由 IH 成立)。while 停止的两种方式:A[j] ≤ key(key 左邻不大于它,三段拼起来整段有序)或 j = 0(key 已是最小,落在 A[1])。两种停法都得到:A[1..i] 为原元素的升序排列;i 之后的格子从未被写。P(i) ✓ ∎
收尾 P(n) 说:第 n 轮后 A[1..n] 是原数组的升序重排——正是排序问题的 Output 规格。终止性也免费拿到:for 恰 n 轮,while 每轮 j 严格递减、非负,必停。递归版的归纳证明、迭代版的循环不变式,是同一枚硬币的第三种花纹。
★★★ · 提前试驾周四:双层剥洋葱
C3. 用 3-1 的「剥洋葱」手法(反复代入,停在 base case)非正式地解出本课两条递推:先解 S(n) = S(n−1) + c(S(1) = c₀),再把结果代进 T(n) = T(n−1) + S(n)(T(1) = c₀)解出 T。答案应与迭代版的 O(n²) 对上。

提示:第二步会剥出一列 c·k 的和——Gauss 刚在 §08 教过。记住这只是手感,不是严格解法(周四见)。

题解 · Solution
第一层:解 S S(n) = c + S(n−1) = 2c + S(n−2) = ⋯ = (n−1)c + S(1) = (n−1)c + c₀。S(n) = O(n)——与教授剧透一致("this solves to linear")。为了下一步方便,取常数 c₁ 使 S(k) ≤ c₁·k 对所有 k ≥ 1 成立(比如 c₁ = c + c₀)。
第二层:解 T T(n) = T(n−1) + S(n) ≤ T(n−1) + c₁·n。剥:T(n) ≤ T(n−2) + c₁(n−1) + c₁·n ≤ ⋯ ≤ T(1) + c₁·(2 + 3 + ⋯ + n) = c₀ + c₁·(n(n+1)/2 − 1)。
定档 n(n+1)/2 = (n² + n)/2:P1 丢掉 1/2,P4 只看最高次项——T(n) = O(n²) ∎ 与迭代版的判决完全一致:"hopefully, that will solve to O(n²)"——验证完毕,hopefully 可以划掉了。
诚实声明 两处都是剥洋葱:凭什么每层式子形状不变?凭什么可以一路剥到底?这些欠着的论证,正是周四(4-1 代入法/substitution)要补的课。今天的答案是「强烈可信」,还不是「证毕」。
★★★★ · 全套鉴定:把 Θ(n²) 钉死,再和 Θ(n) 和解
C4. (a) 用 C1(b) 证明:InsertionSort 的 worst-case 运行时间是 Ω(n²),并结合 O(n²) 得出 worst case 为 Θ(n²)。(b) 证明 best-case 运行时间是 Θ(n)。(c) 判断并解释:「InsertionSort 的时间复杂度是 O(n²)」与「InsertionSort 在某些输入上只花 Θ(n)」这两句话矛盾吗?

提示:2-3 的核心口诀——bound(O/Ω/Θ)和 case(best/worst)是两个正交的旋钮。worst case 的 Ω 只需要展示「一族足够惨的输入」。

题解 · Solution
(a) 下界 对每个 n,取倒序输入 Rn = [n, n−1, …, 1]。由 C1(b),算法在 Rn 上执行恰 n(n−1)/2 次比较,每次比较至少花 1 步,故耗时 ≥ n(n−1)/2 ≥ n²/4(n ≥ 2 时)。worst-case 时间 Tworst(n) 是「所有 size n 输入的最大值」,不小于任何一个具体输入的耗时:Tworst(n) ≥ n²/4。拿出证书 (c = 1/4, n₀ = 2),按 2-3 的 Ω 定义:Tworst(n) = Ω(n²)
(a) 三明治 §08 已给上界 Tworst(n) = O(n²)。上下界同档,2-3 的三明治夹拢:worst case 为 Θ(n²)——「平方」不是悲观估计,是精确刻画:真有输入能逼出这么多步。
(b) best case 上界:已排好输入下,外层 T(n) = T(n−1) + O(1)(每次 Insert 一步收工),剥开得 O(n)。下界:算法至少要让递归下潜 n 层(或迭代版至少跑完 n 轮 for、每轮至少 1 次比较),耗时 ≥ n−1 = Ω(n)。夹拢:best case 为 Θ(n)
(c) 不矛盾 「InsertionSort = O(n²)」的完整读法是:worst-case 运行时间以 n² 为上界——它承诺的是「绝不超过」,并不承诺「总是用满」。某些输入(已排好)只花 Θ(n),恰好说明这个算法是输入敏感的 (adaptive):近乎有序的数据上它飞快。两句话一句说 bound、一句说 case,正交不打架——2-3 的口诀原样适用。这也是实践里「几乎排好的数组用 InsertionSort 收尾」这个工程习惯的理论出处(本课未详述)。
12

随堂小测 / Quick Check

无限次尝试,零压力。七道题,把「委托—插入—两层归纳—两本账单」这条链各敲一遍。

Q1. 递归版 InsertionSort 的一句话思路是?
Q2. 三行版写完,教授为什么说"so far it's not a valid algorithm"?
Q3. Insert 函数的 best case 是什么?
Q4. 对数组 [1, 8, 9, 2] 调用 Insert(A[1..4])(把 2 插进已排好的 [1, 8, 9]),比较和交换各发生几次?
Q5. 递推式 S(n) = O(1) + S(n−1),S(1) = O(1),解出来 S(n) 是哪一档?
Q6. 证明正确性时,为什么要先证 Insert、再证 InsertionSort?
Q7. 迭代版粗算给 O(n²);「更细致的分析」发现第 i 轮只花 O(i)。细算的最终结论是?