专题12 · 递归
递归不是"函数调自己",而是把大问题拆成同类小问题的那一条思路。
本讲把"递"与"归"拆成两个可观察的阶段,搞懂调用栈怎么长、怎么退,
再用放苹果、上台阶、十进制转二进制三道题练出写递归的手感。
递归全景(对应视频 23′27″)
🔁 概念与栈 0–8′
"递"下去、"归"回来两个阶段;调用栈如何实现;三要素:终止条件、递归调用、返回结果。
🧩 四道题型 8–20′
放苹果(分类递归)、小球摆放(错位分球)、上台阶(记忆化)、十进制转二进制(回溯输出)。
⚡ 优化 20–23′
普通递归 vs 尾递归;重复计算的识别;递归 → 递推 的改写时机。
sum(n) = n + sum(n-1):看它怎么"下去又回来"
点「递一层」把问题推小;到底后点「归一层」把答案带回来。
"递"阶段发生了什么
每层把自己的现场(参数、局部变量、返回地址)压进调用栈,然后带着更小的参数往下走。 此时谁都没有算出答案。
"归"阶段发生了什么
碰到终止条件后,最深层先算出答案,逐层弹栈,每层拿到下层的返回值再算自己的、再返回。 答案是从最深处往外长出来的。
栈帧:递归的物理载体
每一格就是一个栈帧。看看栈能堆多高,以及堆爆了会怎样。
要素① 终止条件
没有它 → 无限递归 → 栈溢出 RE。
if (n == 0) return 0;
要素② 递归调用
规模必须严格变小,否则永远到不了底。
return n + sum(n-1);
要素③ 返回结果
每层要把结果交回去;忘了 return 就丢答案。
return ...;
f(100000) 这种深递归会 Stack Overflow —— 这正是台阶问题必须改递推的原因。普通递归 vs 尾递归
// 普通递归:拿到下层结果后,还要再做一次加法 int sum(int n){ if(n == 0) return 0; return n + sum(n-1); // ★ 回来还要算 } // 调用过程:sum(4) → 4 + (3 + (2 + (1 + 0))) // 栈必须保留每一层,因为每层都还"有事没做完"
// 尾递归:把累积结果当参数传下去,回来啥也不做 int sumT(int n, int acc){ if(n == 0) return acc; return sumT(n-1, acc+n); // ★ 直接返回 } // 调用过程:sumT(4,0) → sumT(3,4) → sumT(2,7) // → sumT(1,9) → sumT(0,10) → 10 // 本层已经"无事可做",编译器可复用同一个栈帧
尾递归为什么省
本层的局部变量已经用完了(答案全在 acc 里), 编译器可以复用同一个栈帧(尾调用优化),栈深度保持 O(1)。
但要注意
C++ 不保证做尾调用优化(取决于编译器与 -O2)。 竞赛里更稳的做法是直接写成循环,别依赖优化。
放苹果:按"有没有空盘"一分就通
看递归树怎么展开:每个节点分出两支,直到撞上终止条件。
分类的依据
支①(有空盘):至少一个盘子空着 → 等价于把 m 个苹果放进 n−1 个盘子。
支②(无空盘):每个盘子至少 1 个 → 每盘各拿走 1 个,变成 m−n 个苹果放 n 个盘子。
为什么不会漏、不会重
"至少有一个空盘"与"一个空盘都没有"互斥且穷尽, 所以两支相加不重不漏 —— 这就是递归分类题的核心检查点。
放苹果 · 完整代码
long long f(int m, int n){ // ① 终止条件 if(m == 0 || n == 1) return 1; // ② 盘子比苹果多:多出来的必然是空盘 if(n > m) return f(m, m); // ③ 分类递归:有空盘 + 无空盘 return f(m, n-1) + f(m-n, n); } int main(){ int t; cin >> t; while(t--){ int m, n; cin >> m >> n; cout << f(m, n) << '\n'; } return 0; }
n > m 这条:
会出现 f(m-n, n) 里 m−n 为负 → 无限递归 → RE。
这是本题最高频的失分点。m == 1:
漏掉"没苹果可放"(m = 0)的情况,导致 n > m 分支算错。long long 更稳。重复计算:递归最大的敌人
观察 f(6)(每次走 1 或 2 阶)的递归树,数一数 f(3) 被算了几次。
为什么会重复
f(6) = f(5) + f(4),而 f(5) 里又要算 f(4)、
f(3)……不同分支撞到了同一个子问题,却各自从头算一遍。
重复到什么程度
纯递归算 f(n) 的复杂度是 O(2ⁿ)(实际约 1.618ⁿ)。 n = 40 就要算上万亿次 —— 必须优化。
给递归加一个"备忘录"
memo[i] 记住算过的值:算之前先查,算完之后记下。
记忆化三行改动
long long memo[105]; // 0 表示没算过 long long f(int n){ if(n <= 2) return n; if(memo[n]) return memo[n]; // ① 查 return memo[n] = f(n-1) + f(n-2); // ② 记 }
效果与代价
复杂度从 O(2ⁿ) 降到 O(n):每个子问题只算一次。
代价:多一个数组的空间;且递归深度仍是 n,n 到 1e5 仍会爆栈。
0 表示"没算过",
那么真实答案恰好为 0 的子问题会被反复重算。更稳的做法是用 -1 初始化并判断 != -1。台阶问题 · 三种写法
① 纯递归(超时)
int ways(int i){ if(i == 0) return 1; if(i < 0) return 0; int s = 0; for(int j=1;j<=k;j++) s = (s + ways(i-j)) % MOD; return s; }
思路最清楚,但 O(Kⁿ),N 稍大就跑不出。
② 记忆化(会爆栈)
int memo[100005]; int ways(int i){ if(i == 0) return 1; if(i < 0) return 0; if(memo[i] != -1) return memo[i]; int s = 0; for(int j=1;j<=k;j++) s = (s + ways(i-j)) % MOD; return memo[i] = s; }
复杂度 O(NK),但 N=1e5 时递归 10 万层 → RE。
③ 递推 + 滑动窗口(正解)
f[0] = 1; long long win = 1; for(int i=1;i<=n;i++){ f[i] = win % MOD; win += f[i]; if(i-k >= 0) win -= f[i-k]; } cout << f[n];
没有栈的问题,窗口和把 O(NK) 再降到 O(N)。
% MOD);
② 窗口滑动时先加后减的顺序写反(先算 f[i]、再加 f[i]、最后减 f[i−k]);
③ win 用 int 会溢出(窗口内最多 100 项 × 100003)。"归"的顺序,决定了输出的顺序
除 2 取余:余数要倒着输出 —— 递归天然就帮你倒过来了。
代码
void toBin(int n){ if(n == 0) return; // 终止 toBin(n / 2); // ★ 先递下去 cout << (n % 2); // ★ 回来才输出 }
把 cout 放在递归调用之后,就自动实现了"倒序输出"。
改成正序会怎样
void toBin(int n){ if(n == 0) return; cout << (n % 2); // 先输出 toBin(n / 2); // 再递归 }
输出会完全反过来(13 会输出 1011 的反序 1101)。 体会"语句位置"如何决定结果 —— 这是递归最妙的地方。
写递归的四步套路
① 定状态
函数参数代表什么?
f(m,n) = m 个球放 n 个盒。
② 找终止
什么情况下不用再拆?
m==0 || n==1。
③ 找分类
按什么互斥且穷尽地分成几支?
有空盒 / 无空盒。
④ 写返回
各支结果怎么合并?
return 支① + 支②;
什么时候该把递归改成循环?
| 维度 | 递归 | 递推(循环) |
|---|---|---|
| 代码可读性 | 结构清晰,贴近数学定义 | 需要自己安排计算顺序 |
| 栈空间 | 深度 = 递归层数,深了会爆 | O(1),不受限 |
| 重复计算 | 容易重复,需记忆化 | 自底向上,天然不重复 |
| 适用结构 | 树形 / 分叉 / 回溯 | 链式 / 线性 |
保持递归
放苹果、树的遍历、DFS 回溯、分治(归并/快排)—— 结构本身就是树。
改成递推
上台阶、斐波那契、阶乘——只是一条链,而且深度可能很大。
改写方法
把"从 n 往下拆"反过来写成"从 0 往上填表";
递归式里的 f(n-1) 就是数组的前一项。
递归的易错辨析
① 放苹果里少了 if (n > m) return f(m, m); 会发生什么?
② 台阶问题 N = 10⁵,用记忆化递归能过吗?
③ 十进制转二进制时,想让输出顺序正确,cout << n%2 应该放在?
一道题 100 分是分档给的——不会正解也要先拿保底
复赛一道题的 100 分由十几到二十几个测试点组成, 前面几档专门为「没想出正解的人」准备。四道题各拿 30~40 分, 加起来往往就是一等奖和二等奖的分界。
① 特判档:先别想算法,看数据范围表
题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行,
就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。
② 暴力档:按题意最直白地写一遍
多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。
③ 特殊性质档:题目里那句「若……」
常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。
④ 正解档:思路定了,就赢了八成
考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。
本讲 2 道题,各自的四档怎么走
放苹果 · APPLE
第1档 · 2 分钟m == 0 或 n == 1 → 1
第2档 · 8 分钟
枚举每件苹果放哪个盘子,m,n ≤ 10 稳过
第3档 · 10 分钟
只做 n ≤ 2 的情形(答案就是 m/2 + 1)
第4档 · 15 分钟f(m,n) = f(m,n−1) + (m>=n ? f(m−n,n) : 0)(空一盘 / 每盘先放一个)
台阶问题 · STAIR
第1档 · 2 分钟N == 0 或 N == 1 → 1;K == 1 → 1
第2档 · 8 分钟
递归枚举下一步走几级,N ≤ 20 稳过
第3档 · 10 分钟K ≥ N → 答案 = 2^(N−1)
第4档 · 15 分钟
记忆化 f(n) = Σ f(n−i), i=1..K,或递推 + 前缀和优化
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查
freopen 与文件名。椰程信奥当堂测评
① 放苹果 f(3, 2) 的结果是?
② 记忆化用 0 表示"没算过",隐患是什么?
③ 判断该不该把递归改成循环,关键看?
四步:定状态 → 找终止 → 找分类 → 写返回;
写完必问三句:① 每条路都能撞到终止吗?② 规模真的变小了吗?③ 分支不重不漏吗?
再加一条工程判断:深度上千就别递归,改递推。💡