Algorithm Design · Lecture 3-1

递归:向自己借一个黑盒Recursion

前两周我们打造了一整套「度量」的尺子:数步数、看尾巴、O/Ω/Θ 定档。从这一课起,课程调转方向——开始造算法。第一件兵器叫递归 (recursion):把问题交给一个「会解同款问题」的黑盒,而那个黑盒就是你自己。听起来像作弊?只要每一步严格变小、有一个兜底的 base case,这不但合法,还自动附赠一份归纳法正确性证明。

✓ 第一周:什么是算法·怎么数 ✓ 第二周:O·Ω·Θ 全套尺子 ▶ 第三周:开始设计——递归 ○ 接下来:排序 · 分治
Ke Chen · May 26, 2026 承接 Lecture 2-4 可交互 中英双语
向下滚动开始 · scroll to begin
01

最重要的设计原则:归约 / Reduction

教授开场就把底牌亮了:"arguably, this is the most important principle in algorithm design."(可以说是算法设计中最重要的原则。)它不是什么高深技巧,而是一种懒人哲学——归约。

先回望一下坐标。第 1–2 周,我们回答的都是「拿到一个算法,怎么评价它」:对不对(L1-1)→ 数多快(L1-2)→ O/Ω/Θ 定档(L1-3 至 2-4)。尺子已配齐,现在换问题:算法从哪来?本周开始回答「怎么设计」。

Definition · 归约 Reduction(讲义原文)
解决问题 A 的方式:把它转换 (transform) 成一个或多个 问题 B 的实例——而 B 是我们已经会解的问题。

为什么说是懒人哲学?教授的自嘲:"We're computer scientists. We're lazy people."(我们是计算机科学家,我们是懒人。)现成能用的东西绝不重造——don't reinvent the wheels。面对新问题 A,第一反应永远是:手边有没有现成的零件能搭?

新问题 A输入进来
→ 转换 →
黑盒:解 B 的算法别打开 · 只认输入/输出
B 的答案拼装一下
A 的答案完工
用黑盒的三个「只需要」 只需要知道输入是什么、输出是什么、并相信它是对的。教授原话:"I don't even need to know how that algorithm works."(我甚至不需要知道那个算法怎么工作。)当然,黑盒往往不能直接解掉 A——但至少能当零件,让整件事变容易。这个「不打开黑盒」的纪律,待会儿在递归里会升级成救命的心法(§07)。
整个学期的主旋律 教授预告:"this is a recurring theme for this whole semester."(这是贯穿整学期的主题,会连续应用好几周。)本周它化身递归;之后的排序、分治,直到学期末的 NP-completeness,全是归约的变体。今天这一课,是整个下半场的地基。
02

递归 = 自我归约 / Recursion is self-reduction

归约要求「B 是已经会解的问题」。递归的狂想是:让 B 就是 A 自己——拿一个还没写完的算法当黑盒,凭什么合法?

Definition · 递归 = 自我归约 Self-reduction(讲义原文)
递归就是自我归约:BA同一个问题,只是输入更小 (smaller input)

合法性的全部支点就是「更小」两个字。假设你已经能解 size 更小的同款问题,把它当黑盒,大问题就能搭在它上面。听起来像空中楼阁——但只要有一块真正落地的地基,楼就是稳的。这块地基,叫 base case。讲义:每个递归解法都由两部分组成:

① Base case · 基线情形
最小的、能直接给出答案的实例:也许答案显然,也许用别的算法算——总之不再递归。教授强调它的身份:整个递归的 stop point / safeguard(停止点、安全网)。
② Recursive case · 递归情形
把问题归约成一个或多个严格更小 (strictly smaller) 的同款实例,拿到它们的答案后拼装结果 (combine results)。所有真正的活都在这里干。
铁律 · Key requirement(讲义红字) 每一步归约都必须向 base case 靠近,否则就是无限循环 (infinite loop)。教授举的反面例子:给你 size n 的数组,你原封不动对同样的输入再调用一次自己——"we're not making any progress"(没有任何进展)。§05 的实验会让你亲眼看着它坠机。
教授的洋葱比喻 "By the magic of recursion, each step is kind of like an onion."(递归的魔法就像剥洋葱。)每递归一步,洋葱剥掉一层,问题小一圈:smaller and smaller and smaller——最后露出芯,也就是 base case,整个问题瞬间解完。方向感是灵魂:每一刀都必须朝着芯去
03

三行写出递归版 LinearSearch / Recursive LinearSearch

第一个作品不整新活,请出全课程的老朋友——LinearSearch(L1-1 登场,L1-2 给它数出 T(n) = 10n + 7)。任务不变:找 k 在 A 中「最后一次出现」的位置。迭代版是从尾往头的一个循环;现在,换递归的眼光重写它。

Problem · 递归版线性查找(讲义原文)
Input:数组 A[1..n],目标 k
Output:k 在 A 中最后一次出现的下标;不存在则返回 −1

问 ①:什么时候答案是白送的?(base case)

教授课上现场发问:"In what case would the solution be trivial?"(什么情况下答案是显然的?)答:空数组。n = 0 时不用做任何检查——空数组里目标不可能存在,直接 return −1。教授自己都承认它 "felt trivial and felt useless"(显得又平凡又没用)——但请记住:base case 的职责本来就不是干活,是兜底。活全是递归干的,它只负责让一切能停下来。

问 ②:怎么把问题变小?(recursive case)

变小不是白来的——教授:"you have to do some work to make it smaller."(得干点活才能换来变小。)干的活是:检查最后一个元素 A[n]。两种结局:命中 → 整个算法当场完工,return n——而且它必然是「最后一次出现」,因为它就是最后一个元素;未命中 → 你赚到了一条确定的信息:A[n] 不可能是答案。于是问题从 A[1..n] 缩成 A[1..n−1]——严格变小,可以安全递归了

RecursiveLinearSearch(A[1..n], k)
if n == 0 then return −1 // base case
if A[n] == k then return n // make the problem smaller
return RecursiveLinearSearch(A[1..n−1], k) // recursive case
教授的得意一句 "That's the entire algorithm, just three lines, exactly following our design principle."(整个算法就三行,严格按设计原则来。)兜底 → 变小 → 递归,逐行对应。迭代版的 while 循环、计数器、越界条件……全都消失了,剩下的只有结构本身
老规矩 · 三问仪式(L1-1) 见到新算法,永远先问:① 对不对?② 多快?③ 能更快吗?本课把第 ① 问答透(§06);第 ② 问会捅出一个全新的麻烦(§08 埋雷);第 ③ 问对 LinearSearch 来说要等几周后——先把数组排好序,才谈得上更快。
04

实验室:调用栈观察机 / Lab: watch the call stack

教授马上要没收这个玩具(他叮嘱:别追调用栈)。但没亲眼见过机器怎么转,「选择不看」就无从谈起。先痛痛快快违一次禁——逐帧看三种命运。

互动 · 调用栈观察机:先看一次,然后学会不看
场景 A:[4,7,2,7,5] 找 7 场景 B:[4,2,5] 找 7(不存在) 场景 C:[4,2,7] 找 7(一步命中)
调用栈 · 新调用压在上面
点「下一步」或「自动播放」逐帧观察。灰色虚线格 = 已被排除出当前问题窗口。
看完了?现在再读这句话,应该有感觉了 教授:"Yes, we can trace the entire algorithm, but it doesn't really tell us much more than what we already know from the recursive procedure."(能追,但追完并没有多学到什么。)场景 B 里十二步的起落,信息量其实等于三行伪代码 + 一句「归纳法保证」。§07 正式收玩具。

场景 A 的彩蛋:A[2] 也是 7,却从头到尾没被看过一眼——从尾部剥洋葱,天然保证「最后一次出现」。场景 C 则是 L1-2 说的 best case:目标恰好在扫描起点,递归深度 1。

05

实验室:拆掉安全网 / Lab: sabotage the recursion

讲义那行红字铁律,值得一个专门的破坏性实验:把三行代码里的安全装置逐个拆掉,看递归怎么死。

互动 · 拆掉安全网:三种命运
① 完好版 ② 拆掉 base case ③ 拆掉「变小」
递归深度
0
当前 n
栈(对数刻度)
顶 = 物理极限
输入固定为 A = [4, 2, 5],k = 7(目标不存在,必须一路递归到底)。选一个版本,点「运行」。
两种死法,一个死因 拆掉 base case = 没有 stop point;拆掉「变小」= 永远够不到 stop point。殊途同归:递归不再终止——而 L1-1 的定义说过,不终止的东西根本不配叫算法(finiteness 三支柱之一)。写递归先自检两条:① 有 base case 吗?② 每条递归路径都严格变小吗?
06

正确性 = 数学归纳法 / Correctness via induction

"I know mathematical induction sounds scary for some reason for many of you."(我知道归纳法不知为何让很多人发怵。)教授的脱敏疗法:让你看清——递归算法和归纳证明是同一枚硬币的两面 (two sides of the same coin),几乎逐行对应。

互动 · 同一枚硬币的两面:点任意一行,亮出它的搭档
递归算法(三行)
if n == 0 then return −1
if A[n] == k then return n
return RLS(A[1..n−1], k)
归纳证明(三步)
Base case:size 0 时算法正确
Inductive hypothesis:假设对一切 size < n 正确
Inductive step:检查「变小的那一步」没出错
对应关系点任意一行试试——注意右列的顺序是打乱的,配对要靠理解。

归纳法凭什么能证「任意大小都对」?两个零件的合力:base case 说「size 0 对」;归纳步说「比 n 小的全对 ⇒ n 也对」。组合起来:0 对 → 1 对 → 2 对 → 3 对 → ……多米诺一路推到任意大的输入。教授反复叮嘱对归纳假设的正确心态:"It's called a hypothesis... but I want you to think of it as a fact you know."(名字叫假设,但请把它当成已知事实来用。)

完整证明:RecursiveLinearSearch 的正确性(讲义全文 + 注解) · Write-up
Base case RecursiveLinearSearch 对 size 0 的输入数组正确。——空数组里目标不可能存在,算法第一行返回 −1,恰好符合输出要求。这一步不依赖任何假设,必须独立验证。
Inductive hypothesis 假设 RecursiveLinearSearch 对一切 size < n 的输入数组正确。(transcript 补充:本算法每次只缩一格,所以说「对 size n−1 正确」也够用;写成 < n 的形式更强、更通用——为什么需要强形式,见挑战题 C3。)
Inductive step 对 size n 的输入:若最后一个元素是目标,算法第二行返回 n,正确;否则,算法在 A[1..n−1] 上递归调用自己——其 size 为 n−1 < n,由归纳假设,该调用返回正确答案,而算法第三行将其原样上交。两种情形都正确,故算法对 size n 正确。∎
品一品 归纳步里「亲手检查」的只有一件事:把大问题变小的那一步没出错。剩下的永远是同一句万能收尾——"the correctness follows from the inductive hypothesis."(其余情形的正确性由归纳假设直接给出。)递归证明的最后一行,几乎总长这样。
等等,这不是循环论证吗? 用「算法对 < n 正确」去证「算法对 n 正确」,看着像自己证自己。不是:① 假设只作用在严格更小的输入上,从不包含当前的 n;② 整条推理链的最底端钉在 base case 上——那是一步不依赖任何假设、亲手验证过的事实。没有 base case 的「归纳」才是空中楼阁(→ §09 坑三)。
07

心法与配方 / The mental model & recipes

玩具正式没收。从今天起,面对递归算法的标准姿势只有两步——这是本课最重要的一页幻灯片。

心法(讲义原文)· Don't trace the full call stack 别追完整的调用栈。改用两步推理:① 独立验证 base case——最简单的输入,不经任何递归调用,答案对不对?② 信任归纳假设——默认递归调用对更小的输入全都正确;你唯一的工作,是用它们的结果拼出当前实例的答案。追栈不仅更累("that always is more complicated"),还几乎不提供额外洞见。
神谕心态 · The oracle 教授的私房技巧:"I often find it's helpful to think that the recursive calls just give you the answer magically. It's a black box. It's an oracle."(把递归调用想成魔法般直接递给你答案的神谕。)敢这么想不是心大——§06 的归纳法已经给这份信任背了书,它是被证明兑付过的支票。

设计配方:写递归,只答三问

Base case 是什么?

找到「最小合法输入」,直接给出答案。它是 stop point——宁可显得没用,不可缺席。

怎么严格变小?

做一点工作(检查一个元素、砍掉一半……),换来一个或多个更小的同款问题。逐条检查:每条递归路径都在向 base case 靠近吗?

怎么拼装 (combine results)?

把子问题的答案(神谕说了算)组合成当前答案。LinearSearch 的拼装最朴素:原样转交;后面几课的拼装会越来越有戏。

证明配方:归纳三步(讲义 4/4 的模板)

Base case

证明算法对最小输入正确。

Inductive hypothesis

假设算法对一切 size < n 的输入都正确。

Inductive step

证明 size n 时正确:算法归约出的子问题 size 全都 < n(被假设覆盖),且拼装步骤无误。收尾永远是那句:correctness follows from the inductive hypothesis。

课堂问答实录 · base case 一定从 n = 0 起吗? 有同学举手提了这个问题。教授:不一定,看算法。数组问题里 size 0 很好用,但有的例子会用 size 1 的单元素数组;学期后面讲图算法时,base case 可能是空图,更多时候是「已经含有某种简单结构」的图。一句话:base case = 你的算法需要处理的最小合法输入。挑战题 C1 会让你立刻用上这一条。
08

悬念:递归算法的 T(n) 怎么算? / The cliffhanger

三问的第二问该登场了:RecursiveLinearSearch 有多快?一动笔就发现不对劲——T(n) 的式子里,居然有 T 自己

按 L1-2 的老规矩数 worst case(目标不存在):每一层调用「自己干的活」只有常数几步——判断 n == 0、比较 A[n] 与 k——记作 c;剩下的账,全记在「size n−1 的同款问题」头上。于是:

T(n) = T(n−1) + c, T(0) = c₀

这种自我引用的式子叫递推式 (recurrence)——递归算法的标配。正式解法是本周四的主题;今天先用「剥洋葱」找找手感:

互动 · 剥洋葱:把 T(n) 一层层拆开
这一步在干嘛?点「下一步」开始。
诚实声明 · 本课未详述 上面的「剥洋葱」(反复代入)只是直觉演算,不是严格解法——凭什么能一路剥到底?中途式子形状不变吗?这些都需要论证。教授开场就预告了归宿:"that leads us to something we will talk about on Thursday, about how to solve the time complexity of recursive algorithms."(这正是周四的主题:如何求解递归算法的时间复杂度。)今天先记结论:递归版 LinearSearch 也是 O(n),和迭代版 10n + 7 同档——换写法,不换档
09

四个经典陷阱 / Pitfalls

递归的坑,条条都是「终止」和「信任」出的事故。对号入座。

坑一 · 省掉 base case
"活都是递归干的,兜底可以省" ✗

省掉的不是一行代码,是 stop point。§05 亲眼看过:一路冲穿调用栈。base case 宁可「看似无用」,不可缺席——它是安全网,不是装饰。

坑二 · 递归没有严格变小
RLS(A[1..n], k) 里调用 RLS(A[1..n], k) ✗

讲义红字铁律:每一步必须向 base case 靠近。原样调用自己 = 没有任何进展 = 无限循环。变小的方式可以是 n−1、n/2、剥掉一个结构……但必须严格

坑三 · 排好多米诺,忘推第一块
"假设对 < n 成立,则对 n 成立——证毕" ✗

归纳步写得再漂亮,不验证 base case 就一文不值:整条推理链悬在空中。反过来,只有 base case 没有归纳步也推不远。两条腿缺一不可。

坑四 · 在脑内展开全部调用栈
"让我想想递归到第 7 层时变量是多少……" ✗

教授:更复杂,而且 "doesn't give you much insight"。标准姿势永远两步:验 base case,信归纳假设——只亲手检查「变小 + 拼装」那一步。

10

挑战题 / Harder problems

四道,难度递增。C1 直接用上教授答疑里的观点;C4 把第一周的老朋友请回来对质。先动笔再看解。

★★ · 配方首秀
C1. 用「三问配方」写出递归版 FindMax(A[1..n]):返回数组的最大值。特别注意:base case 应该选 n = 0 还是 n = 1?为什么?

提示:回想教授答疑——base case 是「算法需要处理的最小合法输入」。空数组有最大值吗?

题解 · Solution
问① base casen = 1:单元素数组的最大值就是 A[1],直接返回。不能选 n = 0——空数组根本没有最大值,「返回什么」都是错的。这正是教授说的:base case 不总是 0,而是最小的合法输入。(对比 LinearSearch:空数组「查无此人」是完全合法的答案,所以它可以用 n = 0。)
问② 变小 砍掉最后一个元素:子问题 FindMax(A[1..n−1]),size n−1 < n。✓ 严格变小。
问③ 拼装 m ← FindMax(A[1..n−1]);return max(m, A[n])。整个数组的最大值只可能是「前 n−1 个的最大值」或「最后一个」,二选一取大。
正确性(归纳) Base:n = 1 直接正确。IH:对一切 1 ≤ size < n 正确。Step:n ≥ 2 时,由 IH,m 是 A[1..n−1] 的最大值;max(m, A[n]) 恰是 A[1..n] 的最大值。∎ 注意递归只会下探到 size 1(n = 2 时调用 size 1),永远碰不到空数组——base case 够得到,且够用。
★★★ · 把「最后一次」证严
C2. 教授的证明说算法「correctly reporting」。请把命题陈述到位并证明:RecursiveLinearSearch 返回的是「最后一次出现的下标」(若存在),而不仅仅是「某一次出现」。

提示:归纳的命题 P(n) 本身要带上「最大下标」三个字。命题太弱,归纳步会接不上。

题解 · Solution
命题 P(n):对任意 size n 的数组 A 与任意 k,RLS(A[1..n], k) 返回「使 A[i] = k 的最大下标 i」;若 k 不出现,返回 −1。
Base n = 0:k 不出现,返回 −1。✓
IH P(m) 对一切 m < n 成立。
Step · 情形 1 A[n] = k:下标最大也只能到 n,而 A[n] 本身就等于 k——所以 n 就是最大命中下标。算法返回 n。✓
Step · 情形 2 A[n] ≠ k:k 在 A[1..n] 中的所有出现(如果有)全部落在 1..n−1 内,于是「A[1..n] 中最后一次出现」=「A[1..n−1] 中最后一次出现」;不出现的情形也一致。由 IH,递归调用恰好返回这个值,算法原样上交。∎
教训 归纳证明的命题必须陈述得足够精确:如果 P(n) 只说「返回某个出现位置」,情形 2 就推不出「最后一次」——弱命题连自己都归纳不动。规格 (specification) 写严,证明才有得写。
★★★ · 为什么讲义写 "< n" 而不是 "n−1"
C3. 递归求和的另一种切法——对半分:
HalfSum(A[1..n])
if n == 0 then return 0
if n == 1 then return A[1]
m ← ⌊n/2⌋
return HalfSum(A[1..m]) + HalfSum(A[m+1..n])
证明它正确,并指出:为什么这里归纳假设「对 size n−1 正确」不够用,必须用讲义的强形式「对一切 size < n 正确」?

提示:n = 8 时两个子问题的 size 各是多少?它们是 n−1 吗?

题解 · Solution
子问题尺寸 左半 size m = ⌊n/2⌋,右半 size n − m = ⌈n/2⌉。n ≥ 2 时:m ≥ 1 且 n − m ≤ n − 1 < n,两个子问题都严格更小,且都 ≥ 1(碰不到非法输入)。✓ 铁律满足。
Base n = 0:空数组之和为 0 ✓;n = 1:和就是 A[1] ✓。
IH(强形式) 假设 HalfSum 对一切 size < n 的输入正确。
Step n ≥ 2 时,两个递归调用的 size 分别是 ⌊n/2⌋ 和 ⌈n/2⌉,都 < n → 都被 IH 覆盖 → 各自返回正确的子段和;两段拼起来恰好是全段(m 处一刀,不重不漏),相加即总和。∎
为什么必须 "< n" n = 8 时子问题 size 是 4 和 4——不是 7。如果 IH 只覆盖 n−1,这两个调用就没有任何保证,归纳步直接断链。递归每次剥几层是算法说了算,所以讲义的模板一律写「for all inputs of size < n」(数学上这叫强归纳 / strong induction)。顺带一提:这种「对半切、再拼装」的形状,几周后有个大名鼎鼎的亲戚——MergeSort。
★★★★ · 老朋友对质:铁律与 Collatz
C4. 看这个递归(n 为正整数):
Mystery(n)
if n == 1 then return 1
if n 为偶数 then return Mystery(n/2)
return Mystery(3n+1)
(a) 用本课的「铁律」解释:为什么至今没人能证明它对所有 n 终止?(b) 证明:若 n 是 2 的幂,它一定终止并返回 1。

提示:这是谁?L1-1 的开场嘉宾。教授小学时想用一个下午解决它,没成。

题解 · Solution
(a) 认人 这就是 Collatz(L1-1):实验验证到天文数字都会停,但无人能证明。用本课语言看病根:铁律要求每次递归调用「更靠近 base case」。n/2 确实变小,但 3n+1 变大——找不到一个每步严格递减的度量,「剥洋葱」的保证就建立不起来。
(a) 一个细节 铁律是终止的充分条件,不是必要条件:违反它不等于必死,只是失去保证——Mystery 可能对所有 n 都停(大多数人相信如此),但没人能证。这也是 L1-1 说 Collatz 过程「配不上算法称号」的深层原因:finiteness 无法验证。
(b) 换度量归纳 设 n = 2t,对指数 t 归纳。Base:t = 0,n = 1,第一行直接返回 1 ✓。IH:对一切指数 < t 的 2 的幂,Mystery 终止且返回 1。Step:t ≥ 1 时 n = 2t 是偶数 → 调用 Mystery(2t−1),指数 t−1 < t,由 IH 终止且返回 1,本层原样上交。∎
品一品 「变小」的度量可以自己挑——这里不是 n 本身,而是指数 t:每步严格减 1、非负整数、不能无限下降。只要找得到这样一个度量,铁律就满足,终止就有保证;Collatz 难,难就难在这样的度量至今没人找到。
11

随堂小测 / Quick Check

无限次尝试,零压力。七道题,把「归约—递归—归纳」这条链各敲一遍。

Q1. 按讲义的定义,递归是什么?
Q2. base case 在递归里「看起来什么活都没干」。正确的理解是?
Q3. 某递归算法的递归调用作用在「和原输入一样大」的输入上,会发生什么?
Q4. RecursiveLinearSearch([4, 7, 2, 7, 5], 7) 返回什么?(下标从 1 起)
Q5. 归纳证明里「假设算法对 size < n 正确」——这是循环论证吗?
Q6. 「同一枚硬币的两面」——下面哪组对应关系是对的?
Q7. 检查一个递归算法是否正确,教授给的标准姿势是?