递归大概是这门课里「第一次见觉得玄,想通之后再也回不去」的思想。它的全部内容一句话:问题的解可以由「更小规模的同构问题」的解构造出来。本章把三样东西讲透:递归在机器上的真实成本(栈帧,计组第 10 章的知识在这里兑现)、递归复杂度的算术(递推式)、以及它最成功的应用形态「分治」。学完这章,前面快排、归并、树遍历里「理所当然」的递归,你会知道它们各自为什么那样拆、合并代价是什么、以及什么情况下必须放弃递归。
每个函数调用,系统栈压一个栈帧(局部变量、返回地址),返回时弹出。递归 = 自己给自己压栈,fact(3) 的执行轨迹:
main → fact(3) → fact(2) → fact(1) 压栈三帧(下到基线)
main ← 6 ← 2*3 ← 1*2 逐层弹回(算上去)
这解释了递归的两个天性:深度受栈空间限制(Linux 默认 8MB,深度百万的递归必爆栈,操作系统课 2 章的练习量过这个数),以及每层调用有真实开销(压栈、跳转、弹栈)。所以「递归 vs 迭代」从来不是美学之争,是成本之争:树的遍历、快排这类「深度 O(log n)」的递归安全且优雅;链表遍历(O(n) 深度)就纯属浪费,一层循环更实在。
递归的运行时间天然写成递推式,直接展开就能解。汉诺塔(移动 n 个盘 = 先移 n-1 个到辅助柱、移最大的、再移 n-1 个回来):
展开:T(n) = 2(2T(n-2)+1)+1 = 4T(n-2)+3 = … = 2ⁿ - 1。64 个盘子要移 2⁶⁴ - 1 ≈ 1.8×10¹⁹ 次,僧侣们移完世界就终结的传说,数学上是认真的(每秒一次也要五千多亿年)。
更常用的是「分成 b 份、每份 a 个、合并 O(nᵈ)」的主定理(第 8 章的算法在这章结算):
| 递推式 | 解 | 对应算法 |
|---|---|---|
| T(n) = T(n/2) + O(1) | O(log n) | 二分查找 |
| T(n) = 2T(n/2) + O(n) | O(n log n) | 归并排序 |
| T(n) = 2T(n/2)(无合并) | O(n) | 树遍历 |
| T(n) = T(n-1) + O(n) | O(n²) | 最坏快排 |
读表读出规律:合并代价决定命运。归并合并 O(n) 是主要工作,n log n;树遍历合并 O(1)(只是访问自己),总代价被 n 个节点封顶 O(n)。以后写递归,先问自己「每次递归后我做了多少额外工作」,复杂度当场现形。
分治把递归模板化成三拍:分解、求解、合并。它的威力在于子问题独立(互不依赖,可以各自为政,甚至并行)。前面章节已经交过两份完美作业,对比一下能看清分治设计的两个流派:
一个「分治思维」的实例,没有代码但值得走一遍:大整数乘法。两个 n 位数相乘,朴素 O(n²);分治拆成各半,朴素做法 4 次子乘法,复杂度没变;Karatsuba 的观察是把 4 次减到 3 次(用一次加法换一次乘法),递推式 T(n) = 3T(n/2) + O(n),解出 O(n^1.585)。这个例子给分治思想的精髓立了碑:分治的收益不在「拆」,在拆完之后能否减少子问题的数量或合并的代价。
三条例外清单,每条都有真实事故背书:
尾递归(递归调用是函数最后一个动作)理论上可以被编译器优化成循环,但 C 标准不保证,只有部分编译器在 -O2 下做。把正确性押在编译器的优化开关上不职业,该迭代就迭代。
fact(n) = n * fact(n-1) 没写 if (n <= 1) 就是无限递归,爆栈;基线判断放在递归调用之前。