**前半场:汉诺塔。**它教你「分解」—— 把一个看起来毫无头绪的问题, 一刀切成两个和它长得一模一样、只是小一号的问题。
**后半场:斐波那契。**它用同样的分解方法写出来,却慢到荒唐。 这半场教你的是:分解是要付代价的,而代价的大小取决于一件很具体的事。
第 1 章解决的是「敢不敢信任那个还没写完的函数」。 这一章往前走一步:怎么找到那个该被信任的函数,以及什么时候它会坑你。
前半场 · 汉诺塔
1 一句话问题
三根柱子 A、B、C。A 上从下到上套着 n 个盘子,越往上越小。
把它们全部搬到 C 上,规则两条:
- 一次只能搬一个盘子(而且只能搬某根柱子最上面的那个)
- 任何时候,大盘子都不能压在小盘子上面
输出每一步怎么搬。
输入 3
输出 盘 1: A -> C
盘 2: A -> B
盘 1: C -> B
盘 3: A -> C
盘 1: B -> A
盘 2: B -> C
盘 1: A -> C
共 7 步
2 先用纸笔手算一遍
真的拿三本书摞起来试一次,比看十遍讲解都管用。
n = 1 直接把它从 A 搬到 C。1 步。
n = 2 小盘 A→B,大盘 A→C,小盘 B→C。3 步。
注意中间那一步:为了搬大盘,必须先把小盘挪到「不碍事的地方」。
n = 3 ? 这里绝大多数人开始卡壳 —— 因为想在脑子里同时管住三个盘子。
卡住的时候,换一个问法。不要问「第一步搬哪个」,要问「最大的那个盘子什么时候动」。
它只可能动一次(动多了纯属浪费),而它要从 A 搬到 C,那一刻的棋盘必须长这样:
A: [3] 只剩最大的那个
B: [2][1] 上面两个全在这儿呆着
C: (空) 腾干净了,等着接
这张图一画出来,整件事就没有悬念了:
① 先把上面 2 个盘子从 A 搬到 B ← 这是「把 2 个盘子从一根柱子搬到另一根」
② 把盘 3 从 A 搬到 C ← 一步真活
③ 再把那 2 个盘子从 B 搬到 C ← 又是「把 2 个盘子从一根柱子搬到另一根」
① 和 ③ 是什么?是同一道题,只是盘子少了一个。
3 ★ 关键的一步
大问题 = 小问题 + 一步真活 + 小问题。
把上面那三行写成函数,就是这一章的全部:
// 职责:把 from 柱最上面的 k 个盘子搬到 to 柱,中途可以借用 via 柱
void hanoi(int k, char from, char to, char via) {
if (k == 0) return; // 边界:没有盘子,什么都不用做
hanoi(k - 1, from, via, to); // ① 上面 k-1 个:挪去 via
cout << "盘 " << k << ": " << from << " -> " << to << "\n"; // ② 真正搬一次
hanoi(k - 1, via, to, from); // ③ 那 k-1 个:从 via 搬到 to
}请注意 ① 和 ③ 里参数的位置换了:
① 的目的地是 via,③ 的出发地是 via。
「借谁」这件事每层都不一样,而这正是三个参数存在的理由。
再强调一次第 1 章那句话:写 hanoi(k-1, ...) 的时候,
不要去想它内部怎么把那 k-1 个盘子搬过去的。
你只需要确认一件事:它的职责说的是「把 k-1 个盘子从某根柱搬到某根柱」,
而我现在要的正好就是这个。够了,收工。
很多人写到这里会心虚:规则里那条「大不能压小」,代码里怎么一个字都没提?
因为它是自动成立的。hanoi(k-1, ...) 搬的那 k-1 个盘子,全都比盘 k 小;
而它们要么在 from 上,要么在 via 上 —— 反正不在我们要放盘 k 的地方。
递归的职责划分把这条规则消化掉了,不需要额外的判断。 这种「说清楚职责,麻烦自己消失」的体验,后面还会遇到很多次。
4 递归写法
点「运行 ▶」看结果
有效代码就那三行。请对着它,把第 3 步那段「① ② ③」再念一遍。
5 把分解过程打印出来
点「运行 ▶」看结果
盯住带 ★ 的那些行 —— 它们才是真正搬了盘子的地方,一共 7 行。 其余全是「拆问题」的过程,一个盘子都没动。
6 单步看它怎么拆
播放的时候,主要看右边那根递归栈,不要只顾着看盘子飞来飞去:
- 栈里每一层后面都标着它停在「① / ② / ③」哪一步 —— 那就是代码里的那三行。
- 一层「进入」时立刻分成三步,然后第 ① 步又生出新的一层…… 这就是分解。
- 真正搬盘子的帧(第 ② 步)只有 2ⁿ-1 帧,其余全在拆问题。
把盘子数改成 4、5 各看一遍。你会发现代码一个字都没变, 但拆出来的层数自动变了 —— 这正是第 3 章那句「递归把循环层数交给了运行期」。
7 不用递归行不行?
行。下面这份没有任何递归,输出和递归版一模一样:
这两条规律都是对的(下一步就用对拍证明给你看)。问题是:你怎么可能想得到?
老实说,没人是先想出这两条规律再写汉诺塔的。 它们是先有了递归解、再从递归解的输出里总结出来的 —— 顺序反不过来。
所以「递归 vs 循环」在这道题上不是风格之争:
| 递归版 | 循环版 | |
|---|---|---|
| 怎么想出来的 | 照着「① ② ③」直接翻译 | 先发现两条不明显的规律 |
| 有效代码 | 3 行 | 十几行 |
| 换个题还能用吗 | 能,这是通用套路 | 不能,规律是这道题专属的 |
递归的价值不是「代码短」,是「思路可复制」。 汉诺塔的三步分解,你下周遇到「地毯填补」「归并排序」时可以原样再用一次; 而那两条位运算规律,出了汉诺塔就再也用不上了。
8 ★ 对拍验证
把「递归版」那一栏的代码整个删掉,换成你自己默写的,再点开始对拍。
值得故意写错的地方,每一个都是真实高频错误:
hanoi(k-1, from, via, to)里的三个柱子写错顺序(比如写成from, to, via) → 步数居然还是对的,但搬法完全不合法。这就是为什么要比对整个序列,而不是只比步数。- 两次递归调用之间的
cout挪到最前面(先搬大的再挪小的)→ 顺序全错 - 边界写成
if (k == 1) return;→ 最小的那个盘子永远搬不动,少一堆步骤
9 这 2ⁿ 步是省不掉的
递归版慢吗?n = 20 要跑一百多万步,确实不快。但请分清楚是谁在慢:
点「运行 ▶」看结果
2ⁿ - 1 步是这道题的下界,任何算法都逃不掉,理由一句话就能说清:
要把最大的盘子从 A 搬到 C,那一刻上面 n-1 个盘子必须全部离开 A 且不在 C, 也就是说,在那之前你至少已经完成了一次「搬 n-1 个盘子」; 搬完最大的之后,还得再完成一次「搬 n-1 个盘子」。 所以 f(n) ≥ 2·f(n-1) + 1。
答案本身就有 2ⁿ 步,程序就必须输出 2ⁿ 行。这不叫慢,这叫诚实。
请记住这个判断动作 —— 拿到一道题先问「答案规模有多大」。 后半场的斐波那契同样是 2ⁿ 级别,但性质完全相反:那个 2ⁿ 是纯浪费。 两种 2ⁿ 长得一模一样,处理方式截然不同,分不清就会白白优化半天。
后半场 · 斐波那契
10 同样的分解,写出来却慢得莫名其妙
斐波那契数列:f(0) = 0,f(1) = 1,之后每一项都是前两项之和。
分解思维用在这儿再自然不过 —— 题目本身就是按「大问题 = 两个小问题」定义的:
long long fib(int n) {
if (n < 2) return n; // 边界
return fib(n - 1) + fib(n - 2); // 递推:拆成两个更小的同类问题
}
职责、边界、递推,三要素齐活,而且它完全正确。点下面的「开始对比」:
本机实测:
| n | 递归版 | 循环版 |
|---|---|---|
| 40 | 0.13 秒 | 0.003 秒 |
| 44 | 0.74 秒 | 0.003 秒 |
| 46 | 1.96 秒 | 0.003 秒 |
| 48 | 5.17 秒 | 0.003 秒 |
| 50 | 13.6 秒 | 0.003 秒 |
答案只是一个数字,f(50) = 12586269025。循环版三毫秒就给出来了。
汉诺塔慢得有道理 —— 它要输出一百万行。可斐波那契输出只有一个数字,凭什么要跑 13 秒?
这里的 2ⁿ 和汉诺塔的 2ⁿ,性质完全不同。
11 慢在哪:把重复次数数出来
不要猜,数出来。下面这份给每个 fib(k) 装了个计数器:
点「运行 ▶」看结果
跑 n = 20 的结果(截取):
k 被调用次数
----- ----------
20 1
19 1
18 2
17 3
16 5
15 8
14 13
...
1 6765
0 4181
调用次数本身又是一串斐波那契数 —— 很漂亮,也很吓人。全表汇总:
| n | 需要算的不同值 | 实际调用次数 | 白算的比例 |
|---|---|---|---|
| 10 | 11 | 177 | 93.8% |
| 20 | 21 | 21 891 | 99.90% |
| 30 | 31 | 2 692 537 | 99.9988% |
| 40 | 41 | 331 160 281 | 99.99999% |
n = 40 时,一共只有 41 个不同的值要算,程序却调用了 3.3 亿次。
其中每一次算出来的结果都和之前某一次一模一样。
12 ★ 关键的一步:子问题重不重叠
把两道题的分解并排画出来,差别一眼就看见了:
汉诺塔 hanoi(3, A→C) 斐波那契 fib(5)
├── hanoi(2, A→B) ├── fib(4)
│ ├── hanoi(1, A→C) │ ├── fib(3)
│ └── hanoi(1, C→B) │ │ ├── fib(2) ← 和右边那个
└── hanoi(2, B→C) │ │ └── fib(1)
├── hanoi(1, B→A) │ └── fib(2) ← 一模一样,重算了
└── hanoi(1, A→C) └── fib(3) ← 整棵子树又重算了一遍
左右两棵子树在搬「不同的盘子、不同的柱子」 左右两棵子树大面积重叠
每个任务都只出现一次 同一个 fib(k) 出现无数次
两道题都是「拆成两个小问题」,但:
- 汉诺塔的两个子问题不重叠。左边搬的和右边搬的是两拨不同的活, 谁也替不了谁。所以那 2ⁿ 步是必须干的活。
- 斐波那契的两个子问题大面积重叠。
fib(n-1)内部会算fib(n-2), 而fib(n-2)外面又被独立算了一遍 —— 同一件事干了两遍,而且层层放大。 所以那 2ⁿ 次调用里,99.99% 是纯浪费。
判断的动作很简单:画出递归树,看有没有两个节点在算同一件事。
- 没有重复 → 这个指数是本质的,认了(或者换个思路重新建模)
- 有重复 → 这个指数是浪费的,能消掉,代价通常只是一个数组
既然 fib(7) 永远等于 13,那第一次算完就把它记在数组里,下次直接取:
long long f[100]; bool vis[100];
long long fib(int n) {
if (n < 2) return n;
if (vis[n]) return f[n]; // ← 查表:算过就直接还回去
vis[n] = true; // ← 存表
return f[n] = fib(n-1) + fib(n-2);
}3.3 亿次调用变成 41 次,O(2ⁿ) 变成 O(n),而算法思路一个字都没改。
这就是记忆化搜索,第 17 章的主角,也是整个动态规划的入口。 现在不用深究 —— 这一章你只要能认出「子问题重叠」这个病灶就够了。 认出病灶比会开药重要得多,因为药只有一味,而病灶藏在各种题里。
13 循环版 + ★ 对拍验证
回过头看斐波那契的循环版,和汉诺塔的循环版是完全不同的境遇:
long long a = 0, b = 1;
for (int i = 2; i <= n; i++) { long long c = a + b; a = b; b = c; }
这个谁都想得到 —— 从小往大一项项推过去就完了,天然不会重复。 汉诺塔的循环版要靠魔法规律,斐波那契的循环版是常识。同样是「改成循环」,难度天差地别。
把「递归版」那一栏换成你自己默写的,再点开始。
必踩的坑,试一个:
- 边界写成
if (n <= 1) return 1;→f(0)返回 1,一对拍就抓住。 这是最常见的错误,因为「斐波那契从 1 1 2 3 开始」这个印象太深了。 - 写成
fib(n-1) + fib(n-1)→ 得到 2ⁿ,还挺快,就是全错 - 循环版写成
a = b; b = a + b;(顺序错了,a已经被改掉)→ 也是错的
14 自测
- 洛谷 P1228 地毯填补问题 —— 和汉诺塔同一个套路:切成四块,其中三块想办法变成同一个小问题。想通了代码很短
- 洛谷 P1255 数楼梯 —— 斐波那契本尊。递归会 TLE —— 先用递推过掉,高精度部分慢慢写
- 洛谷 P1464 Function —— 照着题意直接写递归会跑不完,正好体会「子问题重叠」。加个数组就过了
- 洛谷 P1096 Hanoi 双塔问题 —— NOIP1998。汉诺塔的变形,先推出公式,再写高精度。想不出来可以先跳过
第 3 章会把「分解」用到另一个方向:不是把问题切小,而是把所有可能性一个不漏地枚举出来。 到那时你会看到,递归的真身其实是「在一棵决策树上做深度优先遍历」—— 而这一章画的两棵递归树,就是那个说法的第一次预演。