阶段 0 · 递归思维 · 第 2 章

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

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

例题:汉诺塔 · 斐波那契 建议用时: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 —— 每加一个盘子,步数翻倍再加一。
输入(stdin)
输出
点「运行 ▶」看结果

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

5 把分解过程打印出来

trace.cpp过程演示
先跑 n = 3。看输出的形状:每个 hanoi(k) 底下总是「一个 hanoi(k-1) + 一次 ★ + 一个 hanoi(k-1)」。
输入(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 的盘子数。两份程序输出的是完整移动序列,所以这不只是在比对「步数对不对」—— 每一步搬哪个盘、从哪到哪,全都要一模一样。

值得故意写错的地方,每一个都是真实高频错误:

  • hanoi(k-1, from, via, to) 里的三个柱子写错顺序(比如写成 from, to, via) → 步数居然还是对的,但搬法完全不合法。这就是为什么要比对整个序列,而不是只比步数。
  • 两次递归调用之间的 cout 挪到最前面(先搬大的再挪小的)→ 顺序全错
  • 边界写成 if (k == 1) return; → 最小的那个盘子永远搬不动,少一堆步骤

9 这 2ⁿ 步是省不掉的

递归版慢吗?n = 20 要跑一百多万步,确实不快。但请分清楚是谁在慢

steps.cpp
传说里的汉诺塔是 64 层。跑一下看看那个年份 —— 顺便,宇宙年龄大约 138 亿年。
输入(stdin)
输出
点「运行 ▶」看结果
★ 慢的是答案本身,不是算法

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) = 0f(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递归版循环版
400.13 秒0.003 秒
440.74 秒0.003 秒
461.96 秒0.003 秒
485.17 秒0.003 秒
5013.6 秒0.003 秒
⚠ 请盯着这个事实看三秒

答案只是一个数字,f(50) = 12586269025。循环版三毫秒就给出来了。

汉诺塔慢得有道理 —— 它要输出一百万行。可斐波那契输出只有一个数字,凭什么要跑 13 秒?

这里的 2ⁿ 和汉诺塔的 2ⁿ,性质完全不同

11 慢在哪:把重复次数数出来

不要猜,数出来。下面这份给每个 fib(k) 装了个计数器:

fibTrace.cpp过程演示
先跑 20,看那张表。然后改成 30、40 各跑一次,只看最后三行的数字变化。
输入(stdin)
输出
点「运行 ▶」看结果

n = 20 的结果(截取):

  k    被调用次数
-----  ----------
 20             1
 19             1
 18             2
 17             3
 16             5
 15             8
 14            13
 ...
  1          6765
  0          4181

调用次数本身又是一串斐波那契数 —— 很漂亮,也很吓人。全表汇总:

n需要算的不同值实际调用次数白算的比例
101117793.8%
202121 89199.90%
30312 692 53799.9988%
4041331 160 28199.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 —— 边界才是出事的地方。

必踩的坑,试一个:

  • 边界写成 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
配套练习
  • 洛谷 P1228 地毯填补问题 —— 和汉诺塔同一个套路:切成四块,其中三块想办法变成同一个小问题。想通了代码很短
  • 洛谷 P1255 数楼梯 —— 斐波那契本尊。递归会 TLE —— 先用递推过掉,高精度部分慢慢写
  • 洛谷 P1464 Function —— 照着题意直接写递归会跑不完,正好体会「子问题重叠」。加个数组就过了
  • 洛谷 P1096 Hanoi 双塔问题 —— NOIP1998。汉诺塔的变形,先推出公式,再写高精度。想不出来可以先跳过
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

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