椰程信奥·互动课件 专题12·递归:递下去,归回来
10:00 1 / 17
🧭 椰程信奥 · CSP复赛专题系列

专题12 · 递归

递归不是"函数调自己",而是把大问题拆成同类小问题的那一条思路。
本讲把"递"与"归"拆成两个可观察的阶段,搞懂调用栈怎么长、怎么退, 再用放苹果、上台阶、十进制转二进制三道题练出写递归的手感。

放苹果(递归分类) 台阶问题(记忆化优化) 调用栈 · 三要素 · 尾递归
路线图

递归全景(对应视频 23′27″)

递归 = 自己调用自己,靠「变小的同类问题」往下走,再一路返回递:把问题变小f(5)f(4)f(3)f(2)f(1)归:拿到小答案,拼出大答案三要素:① 递归式(怎么变小) ② 终止条件(什么时候停) ③ 返回值(带什么回来)

🔁 概念与栈 0–8′

"递"下去、"归"回来两个阶段;调用栈如何实现;三要素:终止条件、递归调用、返回结果。

🧩 四道题型 8–20′

放苹果(分类递归)、小球摆放(错位分球)、上台阶(记忆化)、十进制转二进制(回溯输出)。

⚡ 优化 20–23′

普通递归 vs 尾递归;重复计算的识别;递归 → 递推 的改写时机。

本讲最重要的一句话:写递归时脑子里要有"栈"这根线 —— 每调一次自己,栈里就多一层;每 return 一次,栈就退一层。递归的深度就是栈的高度。
动画①

sum(n) = n + sum(n-1):看它怎么"下去又回来"

点「递一层」把问题推小;到底后点「归一层」把答案带回来。

n =
点「递一层」开始。注意每一层都在等下一层的答案。

"递"阶段发生了什么

每层把自己的现场(参数、局部变量、返回地址)压进调用栈,然后带着更小的参数往下走。 此时谁都没有算出答案。

"归"阶段发生了什么

碰到终止条件后,最深层先算出答案,逐层弹栈,每层拿到下层的返回值再算自己的、再返回。 答案是从最深处往外长出来的。

3D🎲 递归 = 递下去压栈,归回来弹栈🖱 拖拽旋转 · 双击复位
为什么用 3D:递归最难的是「还没算完就往下走了」。把调用链摆成往深处延伸的立体链,递与归两个方向不再混淆。
动画②

栈帧:递归的物理载体

每一格就是一个栈帧。看看栈能堆多高,以及堆爆了会怎样。

栈是空的。每层递归 = 压入一个栈帧。

要素① 终止条件

没有它 → 无限递归 → 栈溢出 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)。 竞赛里更稳的做法是直接写成循环,别依赖优化。

✅ 什么时候递归最好用: 问题的结构是树形 / 可以自然分类(如放苹果、树的遍历、回溯)时,递归代码最短最清楚; 如果只是一条链(如求和、阶乘、斐波那契),循环通常更好。
例题1

放苹果:按"有没有空盘"一分就通

看递归树怎么展开:每个节点分出两支,直到撞上终止条件。

样例
点「展开一步」看 f(7,3) 如何拆成两支。

分类的依据

支①(有空盘):至少一个盘子空着 → 等价于把 m 个苹果放进 n−1 个盘子。
支②(无空盘):每个盘子至少 1 个 → 每盘各拿走 1 个,变成 m−n 个苹果放 n 个盘子。

为什么不会漏、不会重

"至少有一个空盘"与"一个空盘都没有"互斥且穷尽, 所以两支相加不重不漏 —— 这就是递归分类题的核心检查点。

例题1

放苹果 · 完整代码

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 分支算错。
坑③ 用 int: 虽然本题 m,n ≤ 10 答案很小,但养成习惯用 long long 更稳。
✅ 复杂度:m,n ≤ 10,递归树很小,直接跑毫无压力。 若 m,n 变大,就要加记忆化(见下一题)。
例题2

重复计算:递归最大的敌人

观察 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] 记住算过的值:算之前先查,算完之后记下。

目标:算 f(10)(每次走 1 或 2 阶)。点「算一步」。
已计算节点:0

记忆化三行改动

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。
例题2

台阶问题 · 三种写法

① 纯递归(超时)

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 取余:余数要倒着输出 —— 递归天然就帮你倒过来了。

十进制
点「下一步」:先一路除到 0(递),再倒着把余数吐出来(归)。

代码

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 分, 加起来往往就是一等奖和二等奖的分界。

第 1 档 特判 5~10 分 约 2 分钟 第 2 档 暴力 20~30 分 约 8 分钟 第 3 档 特殊性质 40~60 分 约 15 分钟 第 4 档 正解 100 分 约 25 分钟

① 特判档:先别想算法,看数据范围表

题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行, 就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。

② 暴力档:按题意最直白地写一遍

多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。

③ 特殊性质档:题目里那句「若……」

常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。

④ 正解档:思路定了,就赢了八成

考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。

考场纪律:每档设时间上限,到点还没调通就 立刻提交当前版本,保住已有分数,再往上冲。最常见的翻车是"正解写了 50 分钟没过,连暴力分都没交上去"。
拿分

本讲 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,或递推 + 前缀和优化

50 分钟时间盒(一题的节奏):
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查 freopen 与文件名。
课堂上别让学生"从头想正解"。先要求每个人 15 分钟内交出第 1、2 档, 再放开冲正解。这个顺序一换,平均分通常能涨一大截——因为它杜绝了"想了 40 分钟,交上去 0 分"。
当堂检测

椰程信奥当堂测评

① 放苹果 f(3, 2) 的结果是?

② 记忆化用 0 表示"没算过",隐患是什么?

③ 判断该不该把递归改成循环,关键看?

椰程锦囊 · 写递归的四步 + 三问
四步:定状态 → 找终止 → 找分类 → 写返回;
写完必问三句:① 每条路都能撞到终止吗?② 规模真的变小了吗?③ 分支不重不漏吗?
再加一条工程判断:深度上千就别递归,改递推。💡

目录