上一课 InsertionSort 把数组切成「n−1 + 1」:重的那块丢给递归神谕,轻的那块自己插。教授临走时留了句话:"It's a quadratic algorithm. We'll learn something that runs faster."(二次时间,不够快——我们会学到更快的。)更快的东西今天就来,而且它用的是同一套递归配方,只改一个动作:把刀从尾巴挪到正中间。切成两半,神谕要信两次,最后用一场「双指针缝合」收尾。快多少?答案藏在一个今天还解不开的式子里。
教授开场先把两课缝在一起:"The idea is quite similar to insertion sort."(思路和插入排序非常像。)同一个递归骨架,唯一的分歧是——刀往哪里落。
先复盘 InsertionSort 的刀法:在 A[1..n] 的倒数第一格下刀,切出 A[1..n−1] 和孤零零的 A[n]。教授对这一刀的评语:"the first part is really heavy, it contains almost everything."(第一块特别重,几乎装下了一切。)重的那块整个丢给递归,轻的那块自己动手插进去——插一次最多要比 n−1 回,层层叠起来,worst case 结出 Θ(n²)。
输入是讲义全程使用的例子 [8, 1, 9, 2, 8, 4, 6, 5]——注意里面有两个 8,待会儿它俩会在缝合现场狭路相逢,逼我们表态「平局怎么办」。
"We can translate that idea directly into the pseudocode."(想法可以直译成伪代码。)按 3-1 的配方逐行生长:兜底 → 变小 → 递归 → 拼装。
既然全课都在递归主题里,Merge 当然也递归着写。教授还故意把它写得更慷慨:"k and ℓ can be arbitrary numbers... this merge procedure is useful by itself."(两个输入的长度随意,不必相等——这个过程本身就值得单独拥有。)
MergeSort 里喂给它的两半 size 相等或只差 1,但 Merge 不挑食,长短随意——这份慷慨在挑战题 C4 会派上大用场。伪代码:
输入有两个数组,兜底就要把两边都兜住:X 空了就把 Y 整个交出去,Y 空了就交 X——"you're trying to merge two sorted arrays, if one of them is empty, well, you just return the other one. There's nothing to merge."(一边空了,没什么可缝的,另一边本身就是答案。)注意这个 base case 不是摆设:它一口气交付一整段数组,待会儿在合并机里你会看到它「白送」若干元素。
教授把设计问题问到了骨头上:"What's the minimum amount of work we can do to make the input smaller?"(把输入变小,最少要干多少活?)答案:一次比较。比较两个数组的头一个元素 X[1] 和 Y[1]——赢家(较小者)有资格直接站上输出的第一位,理由是一个干净的三段论:
然后是「变小」:贡献了头元素的那个数组去掉头,X[1..k] 缩成 X[2..k],size 减 1;Y 原封不动。教授连安慰都替你想好了:"Smaller by 1 is smaller, and smaller is good."(小 1 也是小,小就够了。)于是递归合法,神谕接管剩下的一切。X[1] > Y[1] 时对称地走 else 支。
教授明明叮嘱过「别开黑盒」,这次却自己没忍住:"it's really tempting to look inside the black box and see how it's happening. I'm fulfilling that request... let's do this anyways, kind of fun."(忍不住想看?我来满足你,反正挺好玩。)这是全课程第二次官方开盒——盒里是两根红箭头。
图例:▲ = 红箭头(当前被比较的头元素);输出格字色 蓝 = 来自上排 X,紫 = 来自下排 Y;虚线格 = 已出列。
场景 A 里有一场重头戏:两个 8 狭路相逢。教授的现场裁决:"eight versus eight — it's a draw, let's say that the top one wins, breaking the tie arbitrarily."(平局!就让上面的赢吧——怎么破平局无所谓。)伪代码里这份约定写作 X[1] ≤ Y[1] 的那个「≤」:相等时走 if 支,X 先出列。破法随意,不影响正确性——反正两个 8 谁先谁后,数组照样有序。
同一场缝合还有第三种视角——把递归调用链直接写在纸上(每行就是一层递归,和你刚才按的每一步一一对应):
三个场景的比较次数:7、7、3。上限是 n−1(证明在挑战题 C2 等你),下限能低到 min(k, ℓ)——两边谁先耗尽,决定多少元素是 base case 白送的。
三问的第二问,先问 Merge:多快?但这次有个新情况——输入是两个数组,时间函数的自变量 n 该是什么?
教授的处理干脆利落:X 和 Y "are not really used differently... they're both just input arrays containing numbers"(两个数组没有被区别对待,都只是装数字的容器),所以合并记账——令 n = k + ℓ,总 size。Merge 的时间复杂度函数记作 S(n)。然后照 L1-2 的老规矩逐行数:
教授只开了 Merge 一个盒。我们得寸进尺一次:把整趟 MergeSort([8,1,9,2,8,4,6,5]) 全程摊开——先一路切到底,再一路缝回来。(讲义没有这张图,属自费参观;看完记得把盒子还回去。)
似曾相识?3-1 挑战题 C3 的 HalfSum 就是这个形状(对半切、双递归、拼装),当时题解剧透:「这种形状,几周后有个大名鼎鼎的亲戚——MergeSort。」亲戚今天登门了。另外注意:MergeSort 的两个递归调用都会执行(树每层真的裂开),对比 Merge 的 if-else 只执行一个——§08 的式子里会各留一个影子。
讲义 4/4 页在「Correctness?」后面只写了五个词:"Proof by induction. (Exercise)"。教授的原话把台阶都搭好了:"three steps — base case, inductive hypothesis, use that hypothesis to complete your inductive step... I'm sure you can fill in this complete proof."
动笔前清点装备,三件全是旧的:① 归纳三步模板(3-1);② Merge 的正确性定理(§03 已证,归纳步里可以直接引用——教授:"inside your inductive step, obviously you're going to need the correctness of the merge procedure, which we just proved");③ 强归纳的手感(3-1 挑战题 C3:当子问题是对半切出来的,「对 n−1 正确」的弱假设接不住,必须假设「对一切 < n 正确」)。先自己写一遍——这同时就是挑战题 C1——再展开对答案:
第二问轮到 MergeSort 自己:多快?老规矩,设 T(n) 为 MergeSort 处理 size n 输入的时间,照伪代码逐行记账——然后式子停在了一个红色的问号上。
对比一下历代记账,新东西一目了然:
同一份输入,两位选手,只数一件事:元素之间的比较次数。口径:InsertionSort 记 3-2 递归版 insert 的账(逐格比、不合适就换位),MergeSort 记本课 Merge 的账(每输出一个元素至多比一次)。曲线全部是数出来的,不是公式画的。
MergeSort 的坑,一半在 Merge 的细节里,一半在「以为已经学完了」的错觉里。对号入座。
伪代码里写了两处递归,但 if-else 每层只执行一个,总 size 恰好减 1。正确的账:S(n) = S(n−1) + O(1)。数递归调用永远数「执行了几个」。(真正每层执行两个的是 MergeSort 自己——所以 T 的式子里有两个 T。别把两笔账记串。)
Merge 的前置条件是两输入各自有序。喂进乱序数组它照跑不误、绝不报错,然后输出胡话:比较 8 和 4 → 出 4;比较 8 和 2 → 出 2;Y 空,整段白送 [8,1] → 得 [4,2,8,1]。§03 三段论的第 ①② 条全靠前置条件撑着——地基一抽,大楼即塌。
试试 n = 1:m = ⌈1/2⌉ = 1,于是 BL ← MergeSort(A[1..1])——和原问题一样大!违反 3-1 铁律,无限递归。讲义写的是 n ≤ 1:那个「≤」一个字符,接住了对半切永远切不小的最后一格。写分治算法,base case 永远要问一句:最小的递归入口是多大?
本课的合法战利品只有递推式:S(n) = S(n−1) + O(1),T(n) = T(⌈n/2⌉) + T(⌊n/2⌋) + S(n)。递推式 ≠ 闭式解。§09 的曲线是实验证据,不是证明;§06 的「每层 × 层数」是味道,不是菜。答卷上敢写 O(n log n) 的那一天,从周四开始。
四道,难度递增。C1 就是讲义亲手发的那张考卷 (Exercise);C3 会把上一课整个装进口袋;C4 用上教授「k、ℓ 任意」的伏笔。先动笔再看解。
提示:先给两个递归调用的输入 size 做体检(⌈n/2⌉ 和 ⌊n/2⌋ 都严格小于 n 吗?什么时候?),再想拼装那一步凭什么正确。
提示:每做一次比较,输出会发生什么?比较在什么时刻永远停止?§04 的场景 B 和场景 C 就是 (b)(c) 的原型。
提示:BR 永远只有一个元素。用一个单元素数组去 Merge 一个有序数组,像不像某个老朋友的日常工作?
提示:教授把 Merge 写成「k、ℓ 任意」的一般形式,伏笔正是为这种复用打的。(c) 的最后一问,想想本课的工具够不够回答。
无限次尝试,零压力。七道题,把「切—缝—证—记账」这条链各敲一遍。