让函数调用它自己 —— 代码一下子变得很短,但「出口」写错就会当场炸掉
| 0 - 30 min | 搞懂「递归三要素」,把阶乘的两行代码想透(第 1-3 节) |
| 30 - 50 min | 亲手制造一次栈溢出,再理解递归 vs 循环(第 4-5 节) |
| 50 - 160 min | 做 4 道题,每题限时 30 分钟 |
| 160 - 190 min | 对照本文题解,把没做出来的补掉 |
| 190 - 220 min | 写题解笔记 + 做自测 + 背速查卡 |
Day 15 我们写的东西全都是「一个 for 循环从头扫到尾」,数据是被动地被处理。
今天换一种完全不同的思路:让函数在里面再调用一次自己,把大问题交给「缩小一号的同一个问题」。
你会发现有些问题用递归写出来只有三五行,而且几乎就是把数学定义抄了一遍。
今天第 3 题 P5461 会把「一个大方块切成四块」反复切下去 —— 这就是分治, 也是后面学 DFS、归并排序、线段树的共同套路。今天第 4 题 P1028 则会让你第一次感受到 「递归写对了但很慢」,为明天的记忆化埋个伏笔。
查字典的时候,你想知道「递归」是什么意思,字典上写着:「见『递推』」。
于是你去查「递推」,字典上写着:「见『递归』」…… 然后你就永远查不完了。
这个段子其实精确地描述了递归的两个面:自己调用自己是它的力量,而没有终点就是它的死穴。
递归就是:一个函数,在它自己里面调用它自己。
听起来像废话,但关键在于每次调用时问题要变小一点,小到最后有个「不用再问别人就能直接回答」的情况。
一队人,你想知道自己排第几,但看不见前面。怎么办?
这个过程有两个明显特征:问问题是一层层往前传的,答案是一层层往后回来的。递归也是这样 —— 先「一路递归进去」, 再「一路返回出来」。这一点第 3 节会详细画图。
用递归求「1 到 n 的和」,就是上面那个报数过程:
int sum(int n){
if(n == 1) return 1; // ★ 出口:第一个人直接报 1
return n + sum(n - 1); // 递推:我是 n,加上前面那些人的和
}
短短两行。如果换成循环,你要写 for、要维护一个累加变量、要注意初值 —— 递归把「重复」这件事交给了函数调用本身。
| # | 要素 | 它回答什么问题 | 忘了他会怎样 |
|---|---|---|---|
| ① | 出口(base case) | 什么情况下直接返回,不再递归? | 无限递归 → 栈溢出 RE(今天第 4 节有实测) |
| ② | 递推关系 | f(n) 怎么由「更小的子问题」算出来? | 算不出结果,或者算错 |
| ③ | 参数含义 | 每一层的参数代表什么? | 传参传错方向、边界差一,答案永远差一点 |
题目:求 n! = 1 × 2 × 3 × … × n。
| 要素 | 内容 |
|---|---|
| ① 出口 | if(n <= 1) return 1; —— 到 1 就没有更小的了,直接回答 1 |
| ② 递推关系 | f(n) = n * f(n-1) —— 想算 3!,先问 2! 是多少,再乘个 3 |
| ③ 参数含义 | 参数 n 表示「要算谁的阶乘」,每递归一层,n 就小 1 |
long long fact(int n){
if(n <= 1) return 1; // ① 出口
return (long long)n * fact(n - 1); // ② 递推关系(③ 参数 n 每层减 1)
}
光有 if 还不够,得保证递归的每一步都在朝出口靠近。
上面 fact(n-1) 里 n 每次减 1,肯定有一天会变成 1 → 安全。
但如果你不小心写成 fact(n)(忘了减 1),n 永远是原来那个数,就到不了出口 —— 结果是死递归、栈溢出。
写递归时养成一个习惯:问自己「每递归一层,哪个东西变小了?」答不上来就说明有 bug。
#include <iostream>
using namespace std;
long long fact(int n){
if(n <= 1) return 1;
return (long long)n * fact(n - 1);
}
int main(){
int n;
cin >> n;
cout << fact(n) << endl;
return 0;
}
初学递归最容易懵的地方是:「代码明明是从上往下执行的,函数自己调用自己,那后面的代码什么时候跑?」
答案藏在「函数调用栈」里。每次函数调用,系统都会给它分配一小块内存(叫栈帧),用来记住「我是谁、我算到哪了、返回之后要接着干什么」。 递归调用就是往这个栈上一层层往上压;返回的时候从最上面一层层弹下来。
所以递归的顺序是:先进后出(就像往箱子里叠盘子,最后放进去的最先拿出来)。
3 * fact(2) 这一层,必须先把 fact(2) 算出来才能做乘法 —— 所以它得「停在那里等」。
| 步骤 | 发生了什么 | 调用栈(左边是底,右边是顶) |
|---|---|---|
| 1 | main 调用 fact(3) | main → fact(3) |
| 2 | fact(3) 要算 3 * fact(2),先暂停,去调用 fact(2) | main → fact(3) → fact(2) |
| 3 | fact(2) 要算 2 * fact(1),也暂停,去调用 fact(1) | main → fact(3) → fact(2) → fact(1) |
| 4 | fact(1) 命中出口(n ≤ 1),直接返回 1 | main → fact(3) → fact(2) (fact(1) 弹出) |
| 5 | fact(2) 拿到 1,算出 2 × 1 = 2,返回 2 | main → fact(3) (fact(2) 弹出) |
| 6 | fact(3) 拿到 2,算出 3 × 2 = 6,返回 6 | main (fact(3) 弹出) |
注意第 4~6 步:返回的顺序和调用的顺序正好反过来。这就是「先进后出」。
把每一层调用画成一个节点,向下画出它又调用了谁,就是一棵「递归树」。
树画出来之后,三件事一眼就能看清:树有多深(决定会不会爆栈)、树有多少个节点(决定跑多快)、
有没有重复的节点(决定要不要上记忆化,第 8 节讲)。
fact(3) 的递归树其实就是一条链:fact(3) → fact(2) → fact(1)。
这不是「可能会出错」,而是必然出事。因为每递归一层就要在栈上占一块内存,栈的大小是有限的(本机上默认约 2 MB), 一层层压下去总有压满的时候 —— 这时程序会以「栈溢出」直接崩溃。
#include <cstdio>
long long f(int n){
return n * f(n - 1); // ★ 少了 if(n <= 1) return 1; 这一行出口
}
int main(){
printf("%lld\n", f(5));
return 0;
}
实测结果(本机 MinGW g++ 6.3.0):
· 用 -O0(不做优化)编译运行 → 立刻崩溃退出,退出码 0xC00000FD,
也就是 Windows 的 STATUS_STACK_OVERFLOW(栈溢出)。
· 用 -O2 编译运行 → 这次没崩,但程序一直转不停(我给它设了 120 秒上限,它没停下来)。
原因是编译器把这个递归优化成了死循环。
两种情况在评测机上都会表现为 RE 或 TLE,一分都拿不到。所以:出口不是可选项,是必须项。
写一个「递归到指定层数就返回」的函数,然后不断加大层数看它什么时候崩:
| 递归层数 | 10000 | 30000 | 40000 | 45000 | 100000 |
|---|---|---|---|---|---|
| 结果 | ✅ 正常 | ✅ 正常 | ✅ 正常 | ❌ 栈溢出 | ❌ 栈溢出 |
(本机 MinGW g++ 6.3.0,-O0 编译,一个最简单的递归函数)
具体数字会随编译器、优化选项、函数里有多少局部变量、以及具体平台而变,但量级就是几万层。
这对我们的实际影响:
· 今天 4 道题的递归深度最大只有 20 层(P5743)或 10 层(P5461),完全安全;
· 但以后做 DFS 的时候,如果图/树有 105 个点连成一条链,递归深度就会到 105 —— 那就可能爆栈了,
到时候要么改写成循环 + 手工栈,要么把递归深度控制住。
| 递归 | 循环 | |
|---|---|---|
| 代码长度 | 短,常常就是把数学定义抄一遍 | 通常更长,要自己维护变量 |
| 运行速度 | 慢(每次调用都要压栈、传参、返回) | 快 |
| 内存占用 | 每层都占一块栈空间,深度大就爆栈 | 只占几个变量 |
| 深度限制 | 有,本机实测约 4 万层 | 没有 |
| 适合什么 | 树/图的遍历、分治、回溯;问题定义本身就是递归的 | 简单的重复累加;一眼能看出规律的 |
| 今天的例子 | P5461 分治、P5743 倒推 | P5739 阶乘、P1028 递推 |
能用循环简单表达的,优先用循环。
比如求阶乘:循环写起来也就是三行,还不用担心爆栈。递归真正无可替代的地方是 「不知道要循环多少层、每一层还要分成好几个分支」的场合 —— 那时候循环根本写不出来,递归几行就搞定。 今天第 3 题 P5461 就是这种。
顺带说一句:P5739 的题目里专门写了一句「挑战:尝试不使用循环语句完成这个任务」, 就是为了逼你用一次递归。把它当成一次练习,不是让你以后什么都用递归。
递归最漂亮的用法叫分治,三个步骤:
今天第 3 题 P5461 赦免战俘 就是标准的分治:一个 2n×2n 的方阵, 左上角那一块直接「处理完」(全部赦免),剩下三块各自又是个同样形状的小方阵 —— 于是递归下去。
比如 P5461 的函数签名是 solve(x, y, size):
「我要处理左上角在 (x, y)、边长为 size 的那个正方形」。
想清楚这句话,递归就不会写错 —— 每一层的参数只是把「同一句话」里的数字换成了更小的那块。这就是第 2 节说的「参数含义」。
今天第 2 题 P5743 猴子吃桃 是个很有意思的例子:题目把「过程」讲了一遍,但问的是「最开始有多少」。
从第 1 天往第 n 天正着推,你会发现推不下去 —— 因为你不知道第 1 天有多少个桃子,而每天的吃法都依赖前一天剩下的数量。
题目告诉了最后的结果:第 n 天早上剩 1 个。这是唯一的「已知条件」,那就从它出发往回倒推。
设某天早上有 y 个桃子。猴子吃了「一半多一个」,也就是吃掉 y/2 + 1 个,剩下:
剩下的 = y − (y/2 + 1) = y/2 − 1
反过来解 y:y = (剩下的 + 1) × 2
所以只要知道「今天早上剩多少」,就能算出「昨天早上有多少」—— 一路推到第 1 天,就是答案。
用 n = 4 手推一遍(第 4 天早上剩 1 个):
| 往回推的步 | 计算 | 那一天早上有多少个 |
|---|---|---|
| 第 4 天 | 题目已知 | 1 |
| 第 3 天 | (1 + 1) × 2 | 4 |
| 第 2 天 | (4 + 1) × 2 | 10 |
| 第 1 天 | (10 + 1) × 2 | 22 |
所以最初买了 22 个 —— 正好是样例输出。
「多吃了一个」意味着剩下的比一半少一个,所以倒推回去要先把少掉的那个加回来,再乘 2。
写成 (剩下 - 1) * 2 就错了。记不住的话,把 n = 4 代进去手算一遍:
(1 - 1) × 2 = 0,而正确答案是 4 —— 一眼就能发现错在哪。小数据手算是最快的验错手段。
先看今天第 4 题 P1028 数的计算 的递归关系。设 f(x) 表示「以 x 开头的合法数列有多少个」,那么:
f(x) = 1 + f(1) + f(2) + ... + f(x/2)
↑
只有 x 自己那一个数列
看起来没问题,但把 f(6) 的递归树画出来,问题就暴露了:
f(6) ├── f(1) // 直接命中出口 ├── f(2) ── f(1) // ← f(1) 又算了一遍 └── f(3) ── f(1) // ← f(1) 又又算了一遍 f(6) = 1 + f(1) + f(2) + f(3) = 1 + 1 + 2 + 2 = 6
f(1) 被算了 3 次!x 小的时候无所谓,但 x 一大,重复量会指数级爆炸。
我给纯递归版加了一个计数器数了数调用次数:
| n | 10 | 20 | 30 | 40 | 50 |
|---|---|---|---|---|---|
| 答案 | 14 | 60 | 166 | 390 | 786 |
| 递归调用次数 | 14 | 60 | 166 | 390 | 786 |
两行数字一模一样 —— 因为递归树上的每一个节点就对应一个合法数列。
而本题 n 可以到 1000,答案 f(1000) = 1981471878(下面题卡里有实测)。也就是说纯递归要做将近 20 亿次函数调用, 必然超时。
既然 f(1) 会被反复问,那第一次算完就把答案写进一个数组 memo[1],下次再问直接读数组,不再往下递归。
这个技巧叫记忆化搜索,就是「递归 + 缓存」。正式内容在 Day 17,今天的 P1028 我会给出「递推」写法 (从小到大把答案填进数组,本质上就是不让它重复算),并且也附上记忆化递归版给你先感受一下。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。
题意:求 n! = 1 × 2 × 3 × … × n。题目额外加了一句挑战:尝试不使用循环语句(for、while)完成。
1 ≤ n ≤ 12。
★ 为什么上限这么小?因为 13! = 6227020800 已经超过 int 的范围了,12! = 479001600 还在里面。
所以本题用 int 也能过 —— 但养成习惯,乘法相关的中间结果一律用 long long,不吃亏。
这题就是第 2 节那个例子的原题,直接套三要素:
| 要素 | 内容 |
|---|---|
| ① 出口 | if(n <= 1) return 1; (写成 ≤ 1 而不是 == 1,这样连 0! = 1 都顺带处理了) |
| ② 递推关系 | f(n) = n × f(n-1) |
| ③ 参数含义 | n 表示「要算谁的阶乘」,每层减 1,一定会在 n = 1 时停下 |
递归深度最大 12 层,完全不用担心栈的问题。
3
6
#include <iostream>
using namespace std;
long long fact(int n) {
if (n <= 1) return 1; // 出口:0! = 1! = 1
return (long long)n * fact(n - 1); // 递推关系:n! = n * (n-1)!
}
int main() {
int n;
cin >> n;
cout << fact(n) << endl;
return 0;
}
官方样例 3 → 输出 6 完全一致
边界 n = 1 → 输出 1 正确
最大数据 n = 12 → 输出 479001600 正确(12! = 479001600,手算核对无误)
fact(n)):参数永远不变,到不了出口。int 还真的去乘大数:本题 n ≤ 12 侥幸没事,但换成 13 就溢出了。用 long long。if(n == 0) return 1; 只处理 0 的情况:那 n = 1 时还会继续递归到 fact(0),能对但多绕一层。写 n <= 1 最稳。题意:小猴买了若干桃子。第一天吃掉这些桃子的一半再多一个,之后每天都吃掉当天剩余的一半再多一个。 到了第 n 天早上,只剩下 1 个桃子了。问:小猴一开始买了多少个桃子?
1 ≤ n ≤ 20。
★ 20 天每天「翻倍再 +2」,答案的量级是 220 左右(实测 n = 20 时是 1572862),
int 装得下,但用 long long 更放心。递归深度最大 20 层,非常安全。
正着推推不动(不知道第 1 天有多少),所以从最后一天往回倒推,推导过程见第 7 节:
昨天的桃子数 = (今天的桃子数 + 1) × 2
第 n 天早上剩 1 个(已知)→ 倒推 n−1 次 → 得到第 1 天早上的数量,就是最初买的桃子数。
三要素对照:
| 要素 | 内容 |
|---|---|
| ① 出口 | if(n == 1) return 1; 只过 1 天就剩 1 个,那买的就是 1 个 |
| ② 递推关系 | peaches(n) = (peaches(n-1) + 1) * 2 |
| ③ 参数含义 | 「如果第 n 天早上剩 1 个桃子,那么第 1 天早上有多少个」 —— 这句话一定要在心里说清楚 |
验算 n = 4:peaches(1) = 1 → peaches(2) = 4 → peaches(3) = 10 → peaches(4) = 22。
4
22
#include <iostream> using namespace std; // 含义:如果第 n 天早上剩 1 个,那么第 1 天早上有多少个桃子 long long peaches(int n) { if (n == 1) return 1; // 出口:只过 1 天,剩的就是买的 return (peaches(n - 1) + 1) * 2; // 倒推:昨天 = (今天 + 1) * 2 } int main() { int n; cin >> n; cout << peaches(n) << endl; return 0; }
官方样例 4 → 输出 22 完全一致
边界 n = 1 → 输出 1 正确
最大数据 n = 20 → 输出 1572862。这个值可以手算核对:倒推式子展开后是
3 × 219 − 2 = 1572864 − 2 = 1572862,完全吻合。
另外我还用「循环倒推」写了一份对照代码(x = 1; 重复 n-1 次: x = (x+1)*2;),
编译运行后 n = 4 输出 22、n = 1 输出 1、n = 20 输出 1572862,和递归版逐项相同。
peaches(n-1) 一直推到 peaches(1)。n == 0:那 n = 1 时会去调 peaches(0),算出 (1+1)×2 = 4,答案就错了。出口必须是 n == 1。题意:有 2n × 2n 个人站成方阵。每轮把正方形均分成四块,左上那一块的所有人全部赦免, 剩下三块各自继续做同样的事,直到方阵无法再分(只剩 1 个人)。输出每个人的命运:0 表示被赦免,1 表示不被赦免。
2n × 2n(n ≤ 10),也就是最大 1024 × 1024 = 约 106 个格子。
★ 两点注意:
① 格子数到 106,所以每个格子只能做 O(1) 的处理,不能在里面写循环;
② 输出也有 106 个字符,用 putchar 比 cout << endl 快得多。
标准的分治。先想清楚「函数在处理什么」:
solve(x, y, size):「我要处理左上角在 (x, y)、边长为 size 的那个正方形」。
三要素对照:
| 要素 | 内容 |
|---|---|
| ① 出口 | size == 1:只剩一个人,他没有被赦免,把这一格标成 1,然后返回 |
| ② 递推关系 | 边长砍一半得 h = size/2;左上那块整体赦免(什么都不用做),
另外三块各自 solve 一次 |
| ③ 参数含义 | (x, y) 是左上角坐标,size 是边长。三块子方阵的左上角分别是
(x, y+h)、(x+h, y)、(x+h, y+h) |
因为数组是全局的 char 数组,默认值全是 0。而 0 正好代表「被赦免」。
所以左上角那一块连赋值都不用写 —— 不用刷、不用管,它天然就是对的。 既省代码又省时间。这也是为什么我把「赦免 = 0」和「数组初值 = 0」这两件事对上。
如果反过来让 0 表示不被赦免,那就得写个循环把左上角刷成 1,代码更多、还更慢。
另外可以算一下工作量:递归树的节点数是 1 + 3 + 9 + … + 3n = (3n+1 − 1) / 2。
n = 10 时约 8.9 万个节点,每个节点只做 O(1) 的事 —— 非常快。递归深度只有 10 层,不会爆栈。
3
0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 1 1 0 0 0 1 0 0 0 1 0 0 1 1 0 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1
#include <cstdio> const int MAXN = 1030; char a[MAXN][MAXN]; // 全局数组自动全是 0(0 = 被赦免) // 处理左上角为 (x,y)、边长为 size 的正方形 void solve(int x, int y, int size) { if (size == 1) { // 出口:分到只剩一个人 → 没被赦免 a[x][y] = 1; return; } int h = size / 2; // 左上那一块整体都是 0,不用管,直接跳过 solve(x, y + h, h); // 右上 solve(x + h, y, h); // 左下 solve(x + h, y + h, h); // 右下 } int main() { int n; scanf("%d", &n); int size = 1 << n; // 2 的 n 次方 solve(1, 1, size); for (int i = 1; i <= size; i++) { for (int j = 1; j <= size; j++) { putchar('0' + a[i][j]); putchar(j == size ? '\n' : ' '); } } return 0; }
官方样例 3 → 输出的 8×8 矩阵与官方样例逐行逐字符完全一致
边界 n = 1 → 输出
0 1
1 1 —— 只有左上角被赦免,其余三个人留下,符合题意。
最大数据 n = 10 → 输出 1024×1024 = 1048576 个数,耗时约 32 ms。
我用另一个程序数了一遍:里面 1 的个数正好是 59049 = 310,
和「每个方阵留下 3 块、递归 n 层」的理论值完全吻合;而且没有任何非法字符。
另外我还写了一份不用递归的对照程序(对每个格子逐层判断「我是不是落在左上那一块」), 在 n = 1~7 上和递归版输出完全一致,交叉验证了两边的正确性。
size == 0:这样 size = 1 时还会继续分,而 1/2 = 0,会无限递归下去 → 栈溢出。出口必须是 size == 1。(x, y+h),下面那块是 (x+h, y)。cout 且每个数都 endl:106 次刷新缓冲区,会明显变慢。用 putchar 或者至少用 '\n'。putchar(j == size ? '\n' : ' ') 就不会多。char a[1030][1030] 约 1 MB。注意这是全局数组,如果写成 main 里的局部数组可能会有栈问题。题意:给定正整数 n,按下面的方式构造数列:
问:一共有多少个合法的数列?
比如 n = 6,合法的数列有:6、6,1、6,2、6,3、6,2,1、6,3,1,共 6 个。
对于全部的测试点,保证 1 ≤ n ≤ 103。
★ 注意:本题题面是洛谷修订过的(原 NOIP 2001 的题面描述和数据不符,洛谷改了题面让它和数据一致), 所以直接搜老题解可能会看到不一样的表述,以本题面为准。
★ 另一个关键点:答案会很大。实测 n = 1000 时答案是 1981471878,
而 int 的上限是 2147483647 —— 只差一点点就爆了。所以必须用 long long。
设 f[x] = 「以 x 开头的合法数列有多少个」。
从 x 出发,末尾能接的第一个数 i 可以是 1 到 x/2 之间的任意正整数。
一旦接上了 i,后面还能接什么,就完全由 i 决定了 —— 而「以 i 开头的合法数列个数」正好就是 f[i]。
再加上「只有 x 自己」这一个数列,就得到递推式:
f[x] = 1 + f[1] + f[2] + ... + f[x/2]
因为右边只用到比 x 小的下标,所以可以从 x = 1 开始从小到大一路填表填到 n,不需要递归。
因为递归会把 f(1)、f(2) 这些小子问题重复算无数遍(见第 8 节的递归树)。
而递推是从小往大算:算 f[x] 的时候,f[1] … f[x/2] 早就算好放在数组里了,
直接拿来用,每个值只算一次。这就是「用数组记答案」的思想 —— 明天 Day 17 的核心。
复杂度:外层 n 次,内层平均 x/2 次,总共约 n²/4 = 25 万次加法(n = 1000),实测 35 ms,非常轻松。
6
6
#include <iostream> using namespace std; const int MAXN = 1005; long long f[MAXN]; // ★ 答案能到 19 亿,必须 long long int main() { int n; cin >> n; for (int x = 1; x <= n; x++) { f[x] = 1; // 只有 x 自己这一个数列 for (int i = 1; i <= x / 2; i++) { f[x] += f[i]; // 后面接上所有以 i 开头的合法数列 } } cout << f[n] << endl; return 0; }
如果你就想用递归写,那就必须加缓存,否则必超时:
long long memo[1005];
bool vis[1005];
long long f(int x) {
if (vis[x]) return memo[x]; // ★ 算过就直接取,不再往下递归
vis[x] = true;
long long r = 1; // 只有 x 自己
for (int i = 1; i <= x / 2; i++) r += f(i);
return memo[x] = r; // 存起来,下次直接用
}
int main() { int n; cin >> n; cout << f(n) << endl; return 0; }
实测:n = 1000 时输出 1981471878,和上面的递推版完全一致。
写法几乎没变,只多了三行缓存,速度就从「跑不完」变成瞬间出结果。
long long f(int x){
long long r = 1;
for(int i = 1; i <= x/2; i++) r += f(i); // 没有任何缓存
return r;
}
这份代码结果是对的(n = 6 输出 6),但代价是:递归树的节点数正好等于答案。
实测调用次数:n = 10 时 14 次、n = 20 时 60 次、n = 50 时 786 次 ……
而 n = 1000 时答案是 1981471878,也就是要做将近 20 亿次函数调用,必然 TLE。
官方样例 6 → 输出 6 完全一致
最大数据 n = 1000 → 输出 1981471878,耗时 35 ms。
同结果交叉验证:记忆化递归版输出也是 1981471878,两边一致。
正确性再验证:我另外写了个「暴力枚举所有合法数列」的 DFS 程序(真的把每个数列都构造一遍并计数),
在 n = 1 到 20 上和递推版逐个 n 对比,答案完全相同:
1, 2, 2, 4, 4, 6, 6, 10, 10, 14, 14, 20, 20, 26, 26, 36, 36, 46, 46, 60
(n = 6 时是 6,和官方样例也对得上。)
int 存答案:n = 1000 的答案 1981471878 已经贴到 int 上限(2147483647),
稍有不慎就溢出成负数。必须 long long。f[x] = 1 这一句:会漏掉「只有 x 自己」那个数列,答案偏小。x-1 或 x/2 + 1:题目说「不能超过最后一项的一半」,
所以是 i <= x/2,整数除法自动向下取整。x = 1 时 1/2 = 0,循环一次都不执行 → f[1] = 1,正确。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
忘记写出口 if(...) return ...; |
无限递归 → 栈溢出(实测退出码 0xC00000FD,或编译器优化成死循环一直跑) | 写递归前先默念三要素,第一条就是出口 |
递归调用时参数没变小,写成 f(n) |
到不了出口,一样爆栈 | 确认每层都在朝出口靠近,如 f(n-1) |
出口写成 size == 0(P5461) |
size = 1 时 1/2 = 0,永远分不完 → 栈溢出 | 出口是 size == 1 |
| 倒推题里把「+1」写成「−1」(P5743) | 答案完全错,n = 4 会输出 0 | 昨天 = (今天 + 1) × 2;不确定就小数据手算 |
| 递归 + 没有缓存就交(P1028) | 调用次数 = 答案,n = 1000 要 20 亿次调用 → TLE | 改成递推(从小到大填表)或加记忆化数组 |
答案用 int 存(P1028) |
1981471878 贴着 int 上限,容易溢出 | 用 long long |
| 分治时把「左上角那一块」也递归了(P5461) | 本该被赦免的人变成不被赦免,整幅图全错 | 左上块不递归,只处理另外三块 |
| 递归深度开到几万层以上 | 爆栈(本机实测约 4 万层是上限) | 评估深度;太深就改循环或手工栈 |
106 次输出用小规模写法(cout << ... << endl) |
反复刷新缓冲区,明显变慢 | 用 putchar / '\n',避免 endl |
把大数组开在 main 里(如 char a[1030][1030]) |
局部变量占栈空间,1 MB 可能直接把栈挤爆 | 大数组开成全局变量(全局在静态区) |
用递归求「n 的各位数字之和」(比如 1357 → 1+3+5+7 = 16),三要素分别是什么?
答:
① 出口:if(n == 0) return 0;(没有数字了,和为 0)
② 递推关系:f(n) = n % 10 + f(n / 10)(最后一位 + 前面所有位的和)
③ 参数含义:n 表示「还要拆哪个数」;每层 n/10 都更小,最终会变成 0 → 到得了出口
下面的函数,g(4) 返回多少?
int g(int n){
if(n <= 1) return 1;
return n + g(n - 2);
}
答:4 + g(2) = 4 + (2 + g(0)) = 4 + 2 + 1 = 7。
★ 注意 g(0):0 ≤ 1,命中出口返回 1。
调用链是 g(4) → g(2) → g(0),共 3 层。这里能看出一个要点:参数不一定每次只减 1,
只要保证「一定会落到出口」就行(n 每次减 2,奇偶两种情况都能到 ≤ 1)。
int f(int n){
if(n == 1) return 1;
return n * f(n - 2);
}
要求算 f(4)。
答:到不了出口,会栈溢出。
f(4) → f(2) → f(0) → f(−2) → … 参数一路变负,永远碰不到 n == 1。
正确写法是把出口放宽成 if(n <= 1) return 1;,或者把递推改成 f(n-1)。
教训:光有出口还不够,得保证「一定会到达」。
P1028 如果写成纯递归,为什么 n = 1000 一定过不了?请用「递归树节点数」来解释。
答:因为递归树上的每个节点都对应一个合法数列,所以节点总数 = 答案。
n = 1000 时答案是 1981471878,也就是要做将近 20 亿次函数调用。
计算机每秒大约能做 108 次函数调用级别的工作,20 亿次至少要几十秒 —— 远超时限。
★ 实测数据也印证了这一点:n = 10 时调用 14 次(答案 14)、n = 50 时调用 786 次(答案 786),两边永远相等。
仿照 P5461,如果一个正方形的边长是 8,它的四块子正方形的边长是多少?四块的左上角坐标(设大正方形左上角是 (1,1))分别是?
答:边长 h = 8/2 = 4。
左上 (1, 1)、右上 (1, 5)、左下 (5, 1)、右下 (5, 5)。
★ 规律:右 = 列加 h,下 = 行加 h。P5461 里左上那块不用递归,所以只调用 (1,5)、(5,1)、(5,5) 这三块。
【递归三要素】(写之前先默念) ① 出口 → if(什么情况) return 直接答案; ② 递推关系 → f(n) 怎么由更小的子问题算出 ③ 参数含义 → 每层参数代表什么,哪一步在变小 // 忘出口 = 栈溢出;参数不缩小 = 到不了出口 【递归的模板形状】 返回类型 f(参数){ if(出口条件) return 出口答案; // ★ 永远写在最前面 ...调用 f(更小的参数)... return 结果; } 【递归求阶乘 / 数字拆位和】 long long fact(int n){ if(n <= 1) return 1; return (long long)n * fact(n - 1); } int digitSum(int n){ if(n == 0) return 0; return n % 10 + digitSum(n / 10); } 【分治模板(P5461 那种「切四块」)】 void solve(int x, int y, int size){ if(size == 1){ ...处理这一个格子...; return; } int h = size / 2; // 左上块按题意处理(P5461 里是赦免,不用写代码) solve(x, y + h, h); // 右上 solve(x + h, y, h); // 左下 solve(x + h, y + h, h); // 右下 } 【倒推(P5743 猴子吃桃)】 知道「今天剩多少」,求「昨天有多少」: 昨天 = (今天 + 1) * 2 // 「多吃一个」→ 倒推要先 +1 再 ×2,别搞反 【递推 / 记忆化(P1028)】 // 递推:从小到大填表,每个值只算一次 for(x = 1; x <= n; x++){ f[x] = 1; for(i = 1; i <= x/2; i++) f[x] += f[i]; } // 记忆化:递归 + 缓存 if(vis[x]) return memo[x]; vis[x] = true; ... return memo[x] = 结果; 【铁律】 递归第一行永远是出口 大数组开全局,别开在 main 里 递归深度上限约几万层(本机实测约 4 万) 能一眼看出规律的,优先用循环 答案可能很大的题(P1028)→ long long
递推与记忆化搜索
今天你已经提前尝到了甜头:P1028 用纯递归会算 20 亿次,加一个数组记答案就瞬间出结果。
明天会把这件事讲透:递推(从小规模推大规模,用循环填表)和记忆化搜索(递归 + 缓存, 写法和普通递归一样,只在开头加一句判断)。它们就是动态规划(DP)的前身 —— 后面第 25 天的线性 DP,本质上就是在这两个东西上加一层「怎么划分状态」。
明天的题目是 P1464 Function、P1720 月落乌啼算钱、P2437 蜜蜂路线、P1044 栈, 都是「不加记忆化就过不去」的经典题,正好把今天的遗憾补上。