阶段 0 · 递归思维 · 第 2 章普及组 J

递归的分解思维:汉诺塔与斐波那契

同样是「拆成两个小问题」,一个快得理直气壮,一个慢得莫名其妙。差别只有一个字。

需要先学:第 1 章 递归入门:函数怎么调用自己例题:汉诺塔 · 斐波那契建议用时:100 分钟
这一章有两道题,是故意的

前半场:汉诺塔。它教你「分解」—— 把一个看起来毫无头绪的问题, 一刀切成两个和它长得一模一样、只是小一号的问题。

后半场:斐波那契。它用同样的分解方法写出来,却慢到荒唐。 这半场教你的是:分解是要付代价的,而代价的大小取决于一件很具体的事。

第 1 章解决的是「敢不敢信任那个还没写完的函数」。 这一章往前走一步:怎么找到那个该被信任的函数,以及什么时候它会坑你。

前半场 · 汉诺塔

1一句话问题

三根柱子 A、B、C。A 上从下到上套着 n 个盘子,越往上越小。 把它们全部搬到 C 上,规则两条:

  1. 一次只能搬一个盘子(而且只能搬某根柱子最上面的那个)
  2. 任何时候,大盘子都不能压在小盘子上面

输出每一步怎么搬。

输入

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递归写法

hanoi.cpp递归版
改成 4、5 再跑。步数分别是 15、31 —— 每加一个盘子,步数翻倍再加一。
// 汉诺塔 —— 递归版
//
// 输入:一个整数 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

有效代码就那三行。请对着它,把第 3 步那段「① ② ③」再念一遍。

5把分解过程打印出来

trace.cpp过程演示
先跑 n = 3。看输出的形状:每个 hanoi(k) 底下总是「一个 hanoi(k-1) + 一次 ★ + 一个 hanoi(k-1)」。
// 汉诺塔 —— 带打印的递归,用来「看见」它是怎么分解的
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

盯住带 ★ 的那些行 —— 它们才是真正搬了盘子的地方,一共 7 行。 其余全是「拆问题」的过程,一个盘子都没动。

6单步看它怎么拆

汉诺塔:大问题 = 小问题 + 一步真活 + 小问题
共 44 帧
第 1 / 44 步
ABC321
盘子上的数字就是它的编号,越大越宽。大盘永远不会压在小盘上 —— 递归本身就保证了这一点。
已搬动次数
0
递归栈(深度 1)—— 每一层停在哪一步
hanoi(3, A→C) 刚进来
进入 hanoi(3, A→C, 借 B):把 A 最上面的 3 个盘子搬到 C。这一层只做三件事。 已搬 0 / 7 步

播放的时候,主要看右边那根递归栈,不要只顾着看盘子飞来飞去:

  • 栈里每一层后面都标着它停在「① / ② / ③」哪一步 —— 那就是代码里的那三行。
  • 一层「进入」时立刻分成三步,然后第 ① 步又生出新的一层…… 这就是分解。
  • 真正搬盘子的帧(第 ② 步)只有 2ⁿ-1 帧,其余全在拆问题。

把盘子数改成 4、5 各看一遍。你会发现代码一个字都没变, 但拆出来的层数自动变了 —— 这正是第 3 章那句「递归把循环层数交给了运行期」。

7不用递归行不行?

行。下面这份没有任何递归,输出和递归版一模一样:

loop.cpp循环版
★ 这份代码真正想说的

这两条规律都是对的(下一步就用对拍证明给你看)。问题是:你怎么可能想得到?

老实说,没人是先想出这两条规律再写汉诺塔的。 它们是先有了递归解、再从递归解的输出里总结出来的 —— 顺序反不过来。

所以「递归 vs 循环」在这道题上不是风格之争:

递归版 循环版
怎么想出来的 照着「① ② ③」直接翻译 先发现两条不明显的规律
有效代码 3 行 十几行
换个题还能用吗 能,这是通用套路 不能,规律是这道题专属的

递归的价值不是「代码短」,是「思路可复制」。 汉诺塔的三步分解,你下周遇到「地毯填补」「归并排序」时可以原样再用一次; 而那两条位运算规律,出了汉诺塔就再也用不上了。

8★ 对拍验证

★ 正确的用法

把「递归版」那一栏的代码整个删掉,换成你自己默写的,再点开始对拍。

对拍器
生成器造 n ≤ 10 的盘子数。两份程序输出的是完整移动序列,所以这不只是在比对「步数对不对」—— 每一步搬哪个盘、从哪到哪,全都要一模一样。
// 汉诺塔 —— 递归版
//
// 输入:一个整数 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 要跑一百多万步,确实不快。但请分清楚是谁在慢:

steps.cpp
传说里的汉诺塔是 64 层。跑一下看看那个年份 —— 顺便,宇宙年龄大约 138 亿年。
// 汉诺塔要多少步 —— 以及这些步要搬多久
//
// 输入: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 慢的是答案本身,不是算法

⚠ 顺带说一句代码里那个写法: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);      // 递推:拆成两个更小的同类问题
}

职责、边界、递推,三要素齐活,而且它完全正确。点下面的「开始对比」:

同题对比:递归版 vs 循环版
先跑 44。跑完把它改成 46、48 再各跑一次 —— 每加 2 项,递归版的耗时就乘 2.6 倍左右。改到 50 大概率会被 15 秒时限掐断。
递归版
循环版

本机实测:

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) 装了个计数器:

fibTrace.cpp过程演示
先跑 20,看那张表。然后改成 30、40 各跑一次,只看最后三行的数字变化。
// 斐波那契 —— 数一数每个 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑 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% 是纯浪费。

判断的动作很简单:画出递归树,看有没有两个节点在算同一件事。

  • 没有重复 → 这个指数是本质的,认了(或者换个思路重新建模)
  • 有重复 → 这个指数是浪费的,能消掉,代价通常只是一个数组
怎么消掉:第 17 章的剧透(就三行)

既然 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 ≤ 27 的项数(再大递归版自己就跑不完了),并且有 20% 的概率专门造 0、1、2 —— 边界才是出事的地方。
// 斐波那契 —— 递归版(照着定义直接翻译)
//
// 输入: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自测

自测清单0 / 9
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 3 章会把「分解」用到另一个方向:不是把问题切小,而是把所有可能性一个不漏地枚举出来。 到那时你会看到,递归的真身其实是「在一棵决策树上做深度优先遍历」—— 而这一章画的两棵递归树,就是那个说法的第一次预演。