前半场:汉诺塔。它教你「分解」—— 把一个看起来毫无头绪的问题, 一刀切成两个和它长得一模一样、只是小一号的问题。
后半场:斐波那契。它用同样的分解方法写出来,却慢到荒唐。 这半场教你的是:分解是要付代价的,而代价的大小取决于一件很具体的事。
第 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递归写法
// 汉诺塔 —— 递归版//// 输入:一个整数 n(盘子数)// 输出:把 n 个盘子从 A 柱借助 B 柱全部搬到 C 柱的每一步,最后一行是总步数//// 规则:一次只能搬一个盘子,任何时候大盘子都不能压在小盘子上面。//// 递归三要素(第 1 章那三条,一条都没变):// 1. 职责 —— hanoi(k, from, to, via) 负责「把 from 柱最上面的 k 个盘子搬到 to 柱,// 中途可以借用 via 柱」。注意它不关心底下还压着什么,也不该关心。// 2. 边界 —— k == 0:一个盘子都没有,什么都不用做。// 3. 递推 —— 想把 k 个搬过去,只有一条路:// 先把上面 k-1 个挪开(挪到 via),// 再把露出来的第 k 个(最大的那个)搬到 to,// 最后把那 k-1 个从 via 搬到 to。//// 中间那一步是「真正搬了一个盘子」,前后两步都是「同一个问题,只是小了一号」。// 这就是分解:大问题 = 小问题 + 一步真活 + 小问题。//// 请不要试图在脑子里模拟它怎么跑 —— n = 5 就有 31 步,谁也模拟不动。// 你要做的是检查上面那三行的「说法」对不对;说法对,代码就对。
#include <bits/stdc++.h>using namespace std;
long long steps = 0;
void hanoi(int k, char from, char to, char via) { if (k == 0) return; // 出口:没有盘子要搬
hanoi(k - 1, from, via, to); // 1. 上面 k-1 个:from -> via(借 to) steps++; cout << "盘 " << k << ": " << from << " -> " << to << "\n"; // 2. 第 k 个:真正搬一次 hanoi(k - 1, via, to, from); // 3. 那 k-1 个:via -> to(借 from)}
int main() { int n; if (!(cin >> n)) return 0;
hanoi(n, 'A', 'C', 'B'); cout << "共 " << steps << " 步\n"; return 0;}点「运行 ▶」看结果
有效代码就那三行。请对着它,把第 3 步那段「① ② ③」再念一遍。
5把分解过程打印出来
// 汉诺塔 —— 带打印的递归,用来「看见」它是怎么分解的//// 和 hanoi.cpp 算的是同一件事,只是在每次进入 / 返回时打印一行,缩进表示层数。//// 跑一遍 n = 3,然后盯住输出里的这个形状:// 每个 hanoi(k, ...) 都会立刻变成三行:// 一个 hanoi(k-1, ...) ← 同样形状的小问题// 一次真正的搬盘子// 又一个 hanoi(k-1, ...) ← 同样形状的小问题//// 「大问题 = 小问题 + 一步真活 + 小问题」这句话,在输出里是看得见的。
#include <bits/stdc++.h>using namespace std;
int depth = 0;long long steps = 0;
void indent() { for (int i = 0; i < depth; i++) cout << "| ";}
void hanoi(int k, char from, char to, char via) { indent(); cout << "-> hanoi(" << k << ", " << from << " -> " << to << ", 借 " << via << ")\n"; depth++;
if (k == 0) { indent(); cout << " 没有盘子,直接回去\n"; } else { hanoi(k - 1, from, via, to);
steps++; indent(); cout << "★ 第 " << steps << " 步:把盘 " << k << " 从 " << from << " 搬到 " << to << "\n";
hanoi(k - 1, via, to, from); }
depth--; indent(); cout << "<- hanoi(" << k << ", " << from << " -> " << to << ") 完成\n";}
int main() { int n; if (!(cin >> n)) return 0;
cout << "把 " << n << " 个盘子从 A 搬到 C\n\n"; hanoi(n, 'A', 'C', 'B'); cout << "\n一共 " << steps << " 步\n"; return 0;}点「运行 ▶」看结果
盯住带 ★ 的那些行 —— 它们才是真正搬了盘子的地方,一共 7 行。 其余全是「拆问题」的过程,一个盘子都没动。
6单步看它怎么拆
播放的时候,主要看右边那根递归栈,不要只顾着看盘子飞来飞去:
- 栈里每一层后面都标着它停在「① / ② / ③」哪一步 —— 那就是代码里的那三行。
- 一层「进入」时立刻分成三步,然后第 ① 步又生出新的一层…… 这就是分解。
- 真正搬盘子的帧(第 ② 步)只有 2ⁿ-1 帧,其余全在拆问题。
把盘子数改成 4、5 各看一遍。你会发现代码一个字都没变, 但拆出来的层数自动变了 —— 这正是第 3 章那句「递归把循环层数交给了运行期」。
7不用递归行不行?
行。下面这份没有任何递归,输出和递归版一模一样:
这两条规律都是对的(下一步就用对拍证明给你看)。问题是:你怎么可能想得到?
老实说,没人是先想出这两条规律再写汉诺塔的。 它们是先有了递归解、再从递归解的输出里总结出来的 —— 顺序反不过来。
所以「递归 vs 循环」在这道题上不是风格之争:
| 递归版 | 循环版 | |
|---|---|---|
| 怎么想出来的 | 照着「① ② ③」直接翻译 | 先发现两条不明显的规律 |
| 有效代码 | 3 行 | 十几行 |
| 换个题还能用吗 | 能,这是通用套路 | 不能,规律是这道题专属的 |
递归的价值不是「代码短」,是「思路可复制」。 汉诺塔的三步分解,你下周遇到「地毯填补」「归并排序」时可以原样再用一次; 而那两条位运算规律,出了汉诺塔就再也用不上了。
8★ 对拍验证
把「递归版」那一栏的代码整个删掉,换成你自己默写的,再点开始对拍。
// 汉诺塔 —— 递归版//// 输入:一个整数 n(盘子数)// 输出:把 n 个盘子从 A 柱借助 B 柱全部搬到 C 柱的每一步,最后一行是总步数//// 规则:一次只能搬一个盘子,任何时候大盘子都不能压在小盘子上面。//// 递归三要素(第 1 章那三条,一条都没变):// 1. 职责 —— hanoi(k, from, to, via) 负责「把 from 柱最上面的 k 个盘子搬到 to 柱,// 中途可以借用 via 柱」。注意它不关心底下还压着什么,也不该关心。// 2. 边界 —— k == 0:一个盘子都没有,什么都不用做。// 3. 递推 —— 想把 k 个搬过去,只有一条路:// 先把上面 k-1 个挪开(挪到 via),// 再把露出来的第 k 个(最大的那个)搬到 to,// 最后把那 k-1 个从 via 搬到 to。//// 中间那一步是「真正搬了一个盘子」,前后两步都是「同一个问题,只是小了一号」。// 这就是分解:大问题 = 小问题 + 一步真活 + 小问题。//// 请不要试图在脑子里模拟它怎么跑 —— n = 5 就有 31 步,谁也模拟不动。// 你要做的是检查上面那三行的「说法」对不对;说法对,代码就对。
#include <bits/stdc++.h>using namespace std;
long long steps = 0;
void hanoi(int k, char from, char to, char via) { if (k == 0) return; // 出口:没有盘子要搬
hanoi(k - 1, from, via, to); // 1. 上面 k-1 个:from -> via(借 to) steps++; cout << "盘 " << k << ": " << from << " -> " << to << "\n"; // 2. 第 k 个:真正搬一次 hanoi(k - 1, via, to, from); // 3. 那 k-1 个:via -> to(借 from)}
int main() { int n; if (!(cin >> n)) return 0;
hanoi(n, 'A', 'C', 'B'); cout << "共 " << steps << " 步\n"; return 0;}值得故意写错的地方,每一个都是真实高频错误:
hanoi(k-1, from, via, to)里的三个柱子写错顺序(比如写成from, to, via) → 步数居然还是对的,但搬法完全不合法。这就是为什么要比对整个序列,而不是只比步数。- 两次递归调用之间的
cout挪到最前面(先搬大的再挪小的)→ 顺序全错 - 边界写成
if (k == 1) return;→ 最小的那个盘子永远搬不动,少一堆步骤
9这 2ⁿ 步是省不掉的
递归版慢吗?n = 20 要跑一百多万步,确实不快。但请分清楚是谁在慢:
// 汉诺塔要多少步 —— 以及这些步要搬多久//// 输入:n// 输出:2^n - 1 步,以及按「一秒搬一个盘子」算需要多长时间//// 这份代码没有任何算法,它只是把一个数字算出来给你看。// 但请一定跑一次 n = 64(传说里那个版本),看看那个时间。//// 它要说明的事情是:汉诺塔慢,不是因为我们的程序写得笨,// 而是因为**答案本身就有那么多步**。2^n - 1 步是这道题的下界,一步都省不掉:// 要把最大的盘子搬走,必须先把上面 n-1 个全挪开(至少 f(n-1) 步),// 搬完之后还得把它们全搬回来(又至少 f(n-1) 步),// 所以 f(n) >= 2 * f(n-1) + 1。//// 这一点和下面斐波那契那半章形成鲜明对比:// 斐波那契的递归也是 2^n 级别,但那个 2^n 是**浪费**,可以被消掉(第 17 章)。
#include <bits/stdc++.h>using namespace std;
int main() { int n; if (!(cin >> n)) return 0; if (n < 1 || n > 64) { cout << "请输入 1 到 64 之间的 n\n"; return 0; }
// n = 64 时 2^64 - 1 会溢出 long long,用无符号 unsigned long long steps = (n == 64) ? ~0ULL : ((1ULL << n) - 1);
cout << n << " 个盘子需要 " << steps << " 步\n";
long double sec = (long double)steps; cout << fixed << setprecision(1); if (sec < 60) cout << "按一秒一步算:" << (double)sec << " 秒\n"; else if (sec < 3600) cout << "按一秒一步算:" << (double)(sec / 60) << " 分钟\n"; else if (sec < 86400) cout << "按一秒一步算:" << (double)(sec / 3600) << " 小时\n"; else if (sec < 86400.0L * 365)cout << "按一秒一步算:" << (double)(sec / 86400) << " 天\n"; else cout << "按一秒一步算:" << (double)(sec / (86400.0L * 365)) << " 年\n";
return 0;}点「运行 ▶」看结果
⚠ 顺带说一句代码里那个写法:loop.cpp 里的 1LL << n 就是 2ⁿ
(<< 叫左移,左移一位就是乘 2;带上 LL 是为了让它按 long long 算,不然 n 一大就溢出)。
位运算本身第 46 章才讲,现在记住这一句就够 ——
要看它跑起来什么样,C++ 速查的第一组有可运行的例子。
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) 装了个计数器:
// 斐波那契 —— 数一数每个 fib(k) 到底被算了几遍//// 输入:n(建议 10 ~ 25,别太大)// 输出:每个 k 对应的调用次数,以及总调用次数、白算的比例//// 不要猜「大概重复了一些」,直接数出来。数完你会看到两件事:// 1. fib(k) 被调用的次数本身也是一串斐波那契数(很漂亮,也很吓人)// 2. 明明只有 n+1 个不同的值要算,实际却算了几十万、几百万次//// 这就是第 17 章记忆化搜索要解决的问题:把「算过的记下来」,// 于是 O(2^n) 一步变成 O(n)。这一章先把病灶看清楚,不急着治。
#include <bits/stdc++.h>using namespace std;
vector<long long> calls; // calls[k] = fib(k) 被调用了多少次long long total = 0;
long long fib(int n) { calls[n]++; total++; if (n < 2) return n; return fib(n - 1) + fib(n - 2);}
int main() { int n; if (!(cin >> n)) return 0; if (n < 0 || n > 40) { cout << "请输入 0 到 40 之间的 n\n"; return 0; }
calls.assign(n + 1, 0); long long ans = fib(n);
cout << "fib(" << n << ") = " << ans << "\n\n"; cout << " k 被调用次数\n"; cout << "----- ----------\n"; for (int k = n; k >= 0; k--) { cout << setw(3) << k << " " << setw(10) << calls[k] << "\n"; }
cout << "\n不同的 k 一共 " << (n + 1) << " 个 —— 也就是说只有 " << (n + 1) << " 个值需要算。\n"; cout << "实际调用了 " << total << " 次。\n"; if (total > 0) { double waste = 100.0 * (total - (n + 1)) / (double)total; cout << fixed << setprecision(4); cout << "其中 " << waste << "% 是在重复计算已经算过的东西。\n"; } return 0;}点「运行 ▶」看结果
跑 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; }
这个谁都想得到 —— 从小往大一项项推过去就完了,天然不会重复。 汉诺塔的循环版要靠魔法规律,斐波那契的循环版是常识。同样是「改成循环」,难度天差地别。
把「递归版」那一栏换成你自己默写的,再点开始。
// 斐波那契 —— 递归版(照着定义直接翻译)//// 输入:n(0 <= n <= 50)// 输出:第 n 项。约定 f(0) = 0, f(1) = 1, f(n) = f(n-1) + f(n-2)//// 这份代码和汉诺塔是同一个套路:大问题拆成同形状的小问题。// 三要素也齐了:职责(返回第 n 项)、边界(n < 2 时就是 n)、递推(前两项之和)。//// 它是**完全正确**的,n 小的时候还快得看不出来。// 但把 n 调到 44 以上再跑一次 —— 那个卡顿就是这半章要讲的东西。
#include <bits/stdc++.h>using namespace std;
long long fib(int n) { if (n < 2) return n; // 出口:f(0)=0, f(1)=1 return fib(n - 1) + fib(n - 2); // 递推:拆成两个更小的同类问题}
int main() { int n; if (!(cin >> n)) return 0; cout << fib(n) << "\n"; return 0;}必踩的坑,试一个:
- 边界写成
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 章会把「分解」用到另一个方向:不是把问题切小,而是把所有可能性一个不漏地枚举出来。 到那时你会看到,递归的真身其实是「在一棵决策树上做深度优先遍历」—— 而这一章画的两棵递归树,就是那个说法的第一次预演。