C++递归优化策略
较难11递归跑得慢?给它配一张“借书记录单”!
你有没有遇到过这样的烦恼:写了一个递归程序,明明思路很清晰,可运行起来却像老牛拉破车,算个不大的数都要等上好几秒?别急,这不是你的程序有错,而是递归有个“健忘症”——它总把算过的结果忘掉,一次又一次地重算,白白浪费了时间。
今天我们就来学一个超实用的技巧:记忆化(Memoization),就像给递归配上一张“借书记录单”,让它记住算过的结果,再也不会重复劳动,速度一下子飞起来!
1. 递归的“健忘症”——重复计算
先看看递归是怎么犯“健忘症”的。假设我们写一个求斐波那契数列的函数 fib(n),定义是:
- fib(0) = 0,fib(1) = 1
- fib(n) = fib(n-1) + fib(n-2)
用递归写出来就是:
#include <iostream>
using namespace std;
int fib(int n) {
if (n <= 1) return n; // 第0项是0,第1项是1
return fib(n-1) + fib(n-2); // 自己调自己
}
这个代码看起来很简单,但跑起来就“露馅”了。我们来数一数当 n=5 时,fib(3) 会被调用几次?
调用图大概是这样:
fib(5)
├─fib(4)
│ ├─fib(3)
│ │ ├─fib(2)
│ │ │ ├─fib(1)
│ │ │ └─fib(0)
│ │ └─fib(1)
│ └─fib(2)
│ ├─fib(1)
│ └─fib(0)
└─fib(3)
├─fib(2)
│ ├─fib(1)
│ └─fib(0)
└─fib(1)
看到没?fib(3) 被算了 2 次,fib(2) 被算了 3 次,fib(1) 被算了 5 次!这就像你去图书馆借书,第一次查到了《哈利波特》在第3排第2格,结果下次你又要借同一本书,却忘了它在哪,从头把整个图书馆找一遍。写作业时,老师让你算1加到100,你算完1+2+3+...+50,过一会儿又让你算1+2+3+...+50,你难道要重新加一遍?多累呀!
这种重复计算就是递归效率低下的主要原因,尤其是当 n 变大时(比如 n=40),计算量会爆炸式增长,你的电脑可能卡住好几秒才算出来。
2. 给递归戴上“备忘录”——记忆化搜索
怎么解决呢?很简单:把算过的结果记下来,下次再要的时候,直接掏出来用,不用再算一遍。
就像你去图书馆借书,带一张“借书记录单”:
- 第一次查到“《哈利波特》在第3排”,你写在单子上:
书:哈利波特 → 位置:第3排 - 下次再借这本书,你先翻记录单,哦,在第3排,直接去拿!
在程序里,我们用一个数组(或者别的容器)当“记录单”。数组的每个位置对应一个 n,存放已经算好的结果。初始时,我们给每个位置放一个“还没算过”的标记(比如 -1),一旦算过,就把结果存进去。
改造前的代码(未优化)
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2); // 每次都要重新算
}
改造后的代码(记忆化)
#include <iostream>
using namespace std;
const int MAX = 100; // 假设最多算到100项
int memo[MAX]; // 记录单数组,每个元素存fib(n)的结果
// 初始化函数:把所有记录设为 -1(表示还没算过)
void initMemo() {
for (int i = 0; i < MAX; i++) {
memo[i] = -1;
}
}
int fib(int n) {
if (n <= 1) return n; // 边界情况:fib(0)=0, fib(1)=1
if (memo[n] != -1) return memo[n]; // 如果记录单上已经有结果,直接拿
// 否则,算一遍,存进记录单
memo[n] = fib(n-1) + fib(n-2);
return memo[n];
}
int main() {
initMemo(); // 在使用前先初始化记录单
cout << "fib(50) = " << fib(50) << endl;
return 0;
}
注意这里的细节: 为什么用 -1 而不是 0 来初始化?
因为 fib(0) = 0,如果初始值用 0,那么 memo[0] 是 0,程序会误以为已经算过了,直接返回 0 没错,但 memo[其他] 如果也是 0,就会出错。比如 fib(2) = 1,结果还没算时 memo[2]=0,程序误以为 fib(2)=0 就返回错了。所以我们要用一个不可能出现的值(比如 -1)来表示“还没算”。
优化之后,每个 fib(n) 只会被真正计算一次,后面的调用直接查表。原来算 fib(40) 可能要等好几秒,现在瞬间出结果,连眨眼都不需要!
3. 再举一个例子:爬楼梯问题
小明爬楼梯,每次可以走1级或2级台阶。请问到第10级台阶有多少种不同的走法?
这个问题也可以用递归想:
- 走到第
n级台阶的走法 = 走到第n-1级的走法 + 走到第n-2级的走法(因为最后一步要么跨1级,要么跨2级)。 - 边界:走到第0级只有1种走法(不动),走到第1级只有1种走法(跨1级)。
如果不优化,直接写递归:
int climbStairs(int n) {
if (n <= 1) return 1; // 到第0级或第1级都只有1种
return climbStairs(n-1) + climbStairs(n-2); // 重复计算严重
}
同样的问题:n=40 时,重复计算的次数多到让你怀疑人生。
用记忆化优化:
#include <iostream>
using namespace std;
const int MAX = 100; // 最多算到100级
int memo[MAX]; // 记录单
void initMemo() {
for (int i = 0; i < MAX; i++) {
memo[i] = -1;
}
}
int climbStairs(int n) {
if (n <= 1) return 1; // 边界
if (memo[n] != -1) return memo[n]; // 查记录单
memo[n] = climbStairs(n-1) + climbStairs(n-2); // 算一次存下来
return memo[n];
}
int main() {
initMemo();
cout << "到第10级台阶有 " << climbStairs(10) << " 种走法" << endl; // 输出89
cout << "到第40级台阶有 " << climbStairs(40) << " 种走法" << endl; // 很快出结果
return 0;
}
你看,加上“记录单”后,原本算 n=40 可能要十几秒的程序,现在跑得飞快!就像你写作业时,把算过的题目答案记在小本子上,后面遇到类似的直接抄,省时又省力。
4. 新手常见错误
刚开始用记忆化时,容易掉进这些坑里,看看你踩过几个?
错误1:用0初始化记录单
int memo[100] = {0}; // 全部初始化为0
// 然后判断 if (memo[n] != 0) ...
后果: 如果 fib(0) = 0 是正确结果,那么 memo[0] 永远为0,程序会以为它还没算过,导致递归进入死循环?其实不会死循环,但会重复计算 fib(0) 很多次,浪费一点时间。更严重的是,如果某个中间结果恰好也是0(比如 fib(2)=1 不会为0,但其他递归可能产生0结果),就会误判为没算过,导致重复计算或逻辑错误。解决方法: 用一个不可能出现的值(如 -1)初始化,或单独开一个布尔数组记录是否算过。
错误2:忘记初始化记录单
int memo[MAX]; // 没有初始化,里面是垃圾值
后果: 随机判断 if (memo[n] != -1),可能永远不成立,也可能意外成立,导致结果错误或程序崩溃。一定要在使用前把记录单全部设成同一个标记值。
错误3:记录单大小不够
const int MAX = 10; // 结果你要算 fib(50)
后果: 数组越界,程序可能崩溃或输出奇怪的值。根据问题规模设置足够大的数组(比如 1000 或更多),或者用动态数组(vector)。
错误4:边界条件写错
比如爬楼梯问题,n <= 1 返回 1,有人写成 n == 1 返回 1,忘记处理 n=0 的情况,导致递归一直减到负数才停。仔细检查边界。
5. 完整可运行示例:斐波那契 + 爬楼梯
下面是一个完整的程序,包含了两个记忆化递归函数,你可以直接复制到编译器里运行,感受一下速度差异。
#include <iostream>
using namespace std;
const int MAX = 1000; // 数组最大容量
// ---------- 斐波那契 ----------
int fibMemo[MAX]; // 斐波那契的记录单
void initFib() {
for (int i = 0; i < MAX; i++) {
fibMemo[i] = -1; // -1表示还没算
}
}
int fib(int n) {
if (n <= 1) return n; // 边界:fib(0)=0, fib(1)=1
if (fibMemo[n] != -1) return fibMemo[n]; // 查记录单
fibMemo[n] = fib(n-1) + fib(n-2); // 算一次,存起来
return fibMemo[n];
}
// ---------- 爬楼梯 ----------
int climbMemo[MAX]; // 爬楼梯的记录单
void initClimb() {
for (int i = 0; i < MAX; i++) {
climbMemo[i] = -1;
}
}
int climbStairs(int n) {
if (n <= 1) return 1; // 到第0或第1级都是1种
if (climbMemo[n] != -1) return climbMemo[n];
climbMemo[n] = climbStairs(n-1) + climbStairs(n-2);
return climbMemo[n];
}
int main() {
initFib();
initClimb();
cout << "=== 记忆化递归演示 ===" << endl;
cout << "fib(50) = " << fib(50) << endl; // 瞬间出结果
cout << "爬楼梯到第40级: " << climbStairs(40) << " 种走法" << endl;
// 你可以试试去掉记忆化的版本,算 fib(40) 就很慢
return 0;
}
运行结果(会很快显示):
=== 记忆化递归演示 ===
fib(50) = 12586269025
爬楼梯到第40级: 165580141 种走法
对比一下,如果去掉记忆化,算 fib(40) 可能就要好几秒,更别说 fib(50) 了。
6. 相关指引
记忆化递归是动态规划(Dynamic Programming, DP)的亲戚,你可以认为记忆化就是“自顶向下的动态规划”。动态规划还有另一种写法叫“自底向上”,直接用循环从小的 n 开始递推,不需要递归,用数组存结果,速度更快也更省空间。
想要继续学习?
- 试试用记忆化解决“数塔问题”“走迷宫”等题目。
- 搜索“C++ 动态规划入门”,了解更多优化技巧。
- 如果觉得递归很慢,还可以把递归改成迭代(循环),比如斐波那契可以直接用
for循环从第2项算到第n项,既简单又高效。
递归虽然好用,但别忘了给它带上“备忘录”——记忆化,这样你的程序才能跑得又快又好!快去给你的递归程序配一张“借书记录单”吧!
例题精讲
以下哪个是C++编译器进行尾递归优化的必要条件?
使用记忆化优化递归时,所有递归函数都可以通过添加缓存数组来获得性能提升。
完成记忆化斐波那契函数:
int fib(int n, int memo[]) {
if (n <= 1) return n;
if (memo[n] != -1) return ___;
memo[n] = fib(n-1, memo) + fib(n-2, memo);
return ___;
}