ACM 一个月学习计划 · DAY 16

Day 16 · 递归入门:自己调用自己

让函数调用它自己 —— 代码一下子变得很短,但「出口」写错就会当场炸掉

今日目标:写出正确的递归函数,理解为什么「出口」是递归里最重要的东西
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P5739 / P5743 / P5461 / P1028
← Day 15 · 前缀和与差分

⏱今日时间安排

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 则会让你第一次感受到 「递归写对了但很慢」,为明天的记忆化埋个伏笔。

1什么是递归

先讲个段子

查字典的时候,你想知道「递归」是什么意思,字典上写着:「见『递推』」。
于是你去查「递推」,字典上写着:「见『递归』」…… 然后你就永远查不完了。

这个段子其实精确地描述了递归的两个面:自己调用自己是它的力量,而没有终点就是它的死穴。

🎯 一句话定义

递归就是:一个函数,在它自己里面调用它自己。

听起来像废话,但关键在于每次调用时问题要变小一点,小到最后有个「不用再问别人就能直接回答」的情况。

再看个生活里的例子:排队报数

一队人,你想知道自己排第几,但看不见前面。怎么办?

  1. 你问前面那个人:「你是第几?」
  2. 前面那个人也不知道,于是他再问他前面那个……
  3. 问到第一个人时,他说:「我是第 1 个。」—— 这是出口。
  4. 然后答案一个一个传回来:第 1 个告诉第 2 个「你是 2」,第 2 个告诉第 3 个「你是 3」……
  5. 最后你收到了答案。

这个过程有两个明显特征:问问题是一层层往前传的,答案是一层层往后回来的。递归也是这样 —— 先「一路递归进去」, 再「一路返回出来」。这一点第 3 节会详细画图。

写出来长什么样

用递归求「1 到 n 的和」,就是上面那个报数过程:

int sum(int n){
    if(n == 1) return 1;        // ★ 出口:第一个人直接报 1
    return n + sum(n - 1);      // 递推:我是 n,加上前面那些人的和
}

短短两行。如果换成循环,你要写 for、要维护一个累加变量、要注意初值 —— 递归把「重复」这件事交给了函数调用本身。

2递归三要素(背下来,写递归前先默念)

#要素它回答什么问题忘了他会怎样
①出口(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(3)

3 * fact(2) 这一层,必须先把 fact(2) 算出来才能做乘法 —— 所以它得「停在那里等」。

步骤发生了什么调用栈(左边是底,右边是顶)
1main 调用 fact(3)main → fact(3)
2fact(3) 要算 3 * fact(2),先暂停,去调用 fact(2)main → fact(3) → fact(2)
3fact(2) 要算 2 * fact(1),也暂停,去调用 fact(1)main → fact(3) → fact(2) → fact(1)
4fact(1) 命中出口(n ≤ 1),直接返回 1main → fact(3) → fact(2) (fact(1) 弹出)
5fact(2) 拿到 1,算出 2 × 1 = 2,返回 2main → fact(3) (fact(2) 弹出)
6fact(3) 拿到 2,算出 3 × 2 = 6,返回 6main (fact(3) 弹出)

注意第 4~6 步:返回的顺序和调用的顺序正好反过来。这就是「先进后出」。

🎯 想不通递归时,画递归树

把每一层调用画成一个节点,向下画出它又调用了谁,就是一棵「递归树」。
树画出来之后,三件事一眼就能看清:树有多深(决定会不会爆栈)、树有多少个节点(决定跑多快)、 有没有重复的节点(决定要不要上记忆化,第 8 节讲)。

fact(3) 的递归树其实就是一条链:fact(3) → fact(2) → fact(1)。

4忘记写出口会怎样:真的会炸

这不是「可能会出错」,而是必然出事。因为每递归一层就要在栈上占一块内存,栈的大小是有限的(本机上默认约 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,一分都拿不到。所以:出口不是可选项,是必须项。

栈到底能装多少层?我实测了一下

写一个「递归到指定层数就返回」的函数,然后不断加大层数看它什么时候崩:

递归层数10000300004000045000100000
结果✅ 正常✅ 正常✅ 正常❌ 栈溢出❌ 栈溢出

(本机 MinGW g++ 6.3.0,-O0 编译,一个最简单的递归函数)

⚠️ 所以「约几万层」是递归的硬上限

具体数字会随编译器、优化选项、函数里有多少局部变量、以及具体平台而变,但量级就是几万层。

这对我们的实际影响:
· 今天 4 道题的递归深度最大只有 20 层(P5743)或 10 层(P5461),完全安全;
· 但以后做 DFS 的时候,如果图/树有 105 个点连成一条链,递归深度就会到 105 —— 那就可能爆栈了, 到时候要么改写成循环 + 手工栈,要么把递归深度控制住。

5递归 vs 循环:什么时候用哪个

递归循环
代码长度短,常常就是把数学定义抄一遍通常更长,要自己维护变量
运行速度慢(每次调用都要压栈、传参、返回)快
内存占用每层都占一块栈空间,深度大就爆栈只占几个变量
深度限制有,本机实测约 4 万层没有
适合什么树/图的遍历、分治、回溯;问题定义本身就是递归的简单的重复累加;一眼能看出规律的
今天的例子P5461 分治、P5743 倒推P5739 阶乘、P1028 递推
💡 一条实用建议

能用循环简单表达的,优先用循环。

比如求阶乘:循环写起来也就是三行,还不用担心爆栈。递归真正无可替代的地方是 「不知道要循环多少层、每一层还要分成好几个分支」的场合 —— 那时候循环根本写不出来,递归几行就搞定。 今天第 3 题 P5461 就是这种。

顺带说一句:P5739 的题目里专门写了一句「挑战:尝试不使用循环语句完成这个任务」, 就是为了逼你用一次递归。把它当成一次练习,不是让你以后什么都用递归。

6分治:把大问题切成几个「一模一样的小问题」

递归最漂亮的用法叫分治,三个步骤:

  1. 分:把大问题切成几个规模更小、但结构完全相同的子问题;
  2. 治:子问题还大就继续分(这就是递归调用),小到能直接算就直接算(这就是出口);
  3. 合:把子问题的答案拼成原问题的答案。

今天第 3 题 P5461 赦免战俘 就是标准的分治:一个 2n×2n 的方阵, 左上角那一块直接「处理完」(全部赦免),剩下三块各自又是个同样形状的小方阵 —— 于是递归下去。

🎯 分治函数的参数,通常就是「我正在处理哪一块」

比如 P5461 的函数签名是 solve(x, y, size):
「我要处理左上角在 (x, y)、边长为 size 的那个正方形」。

想清楚这句话,递归就不会写错 —— 每一层的参数只是把「同一句话」里的数字换成了更小的那块。这就是第 2 节说的「参数含义」。

7两种方向:正着推和倒着推

今天第 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) × 24
第 2 天(4 + 1) × 210
第 1 天(10 + 1) × 222

所以最初买了 22 个 —— 正好是样例输出。

⚠️ 最容易错的地方:+1 还是 −1

「多吃了一个」意味着剩下的比一半少一个,所以倒推回去要先把少掉的那个加回来,再乘 2。

写成 (剩下 - 1) * 2 就错了。记不住的话,把 n = 4 代进去手算一遍:
(1 - 1) × 2 = 0,而正确答案是 4 —— 一眼就能发现错在哪。小数据手算是最快的验错手段。

8递归的坑:同一件事被算了很多遍

先看今天第 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 一大,重复量会指数级爆炸。

🚨 实测:纯递归的调用次数正好等于答案

我给纯递归版加了一个计数器数了数调用次数:

n1020304050
答案1460166390786
递归调用次数1460166390786

两行数字一模一样 —— 因为递归树上的每一个节点就对应一个合法数列。

而本题 n 可以到 1000,答案 f(1000) = 1981471878(下面题卡里有实测)。也就是说纯递归要做将近 20 亿次函数调用, 必然超时。

🎯 解决办法预告:算过就记下来

既然 f(1) 会被反复问,那第一次算完就把答案写进一个数组 memo[1],下次再问直接读数组,不再往下递归。

这个技巧叫记忆化搜索,就是「递归 + 缓存」。正式内容在 Day 17,今天的 P1028 我会给出「递推」写法 (从小到大把答案填进数组,本质上就是不让它重复算),并且也附上记忆化递归版给你先感受一下。

9四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。

P5739 【深基7.例7】计算阶乘 入门 · 递归热身

题意:求 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,手算核对无误)

易错点

  • 忘记写出口 —— 本题最经典的错误,会栈溢出。
  • 递归时忘了把 n 减 1(写成 fact(n)):参数永远不变,到不了出口。
  • 返回类型用了 int 还真的去乘大数:本题 n ≤ 12 侥幸没事,但换成 13 就溢出了。用 long long。
  • 出口写成 if(n == 0) return 1; 只处理 0 的情况:那 n = 1 时还会继续递归到 fact(0),能对但多绕一层。写 n <= 1 最稳。
P5743 【深基7.习8】猴子吃桃 入门 · 递归倒推

题意:小猴买了若干桃子。第一天吃掉这些桃子的一半再多一个,之后每天都吃掉当天剩余的一半再多一个。 到了第 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,和递归版逐项相同。

易错点

  • 把 +1 写成 −1:最容易犯的错。「多吃一个」→ 剩下的比一半少 1 → 倒推时要先把 1 加回来。 用 n = 4 手算一遍立刻能发现。
  • 循环/递归次数搞错:从第 n 天推回第 1 天,要推 n − 1 次,不是 n 次。 递归写法里这体现为 peaches(n-1) 一直推到 peaches(1)。
  • 出口写成 n == 0:那 n = 1 时会去调 peaches(0),算出 (1+1)×2 = 4,答案就错了。出口必须是 n == 1。
  • 「一半」是整数除法:题目数据保证每天剩余的都是偶数,不用考虑除不尽的情况,但心里要有数。
P5461 赦免战俘 普及- · 递归分治 + 二维数组

题意:有 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。
  • 四个象限少调一块或多调一块:左上角那块不能递归(它已经被赦免了)。如果手滑把左上角也递归了,图案会全错。
  • 三块子方阵的左上角坐标写错:记住边长是 h = size/2,所以右边那块是 (x, y+h),下面那块是 (x+h, y)。
  • 输出用 cout 且每个数都 endl:106 次刷新缓冲区,会明显变慢。用 putchar 或者至少用 '\n'。
  • 行末多打空格:用 putchar(j == size ? '\n' : ' ') 就不会多。
  • 数组要开够:char a[1030][1030] 约 1 MB。注意这是全局数组,如果写成 main 里的局部数组可能会有栈问题。
P1028 [NOIP 2001 普及组] 数的计算 普及- · 递推 / 记忆化

题意:给定正整数 n,按下面的方式构造数列:

  1. 只有一个数 n 的数列是合法的;
  2. 在一个合法数列的末尾加一个正整数,但这个数不能超过该数列最后一项的一半,得到一个新的合法数列。

问:一共有多少个合法的数列?

比如 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,和官方样例也对得上。)

易错点

  • 用纯递归:逻辑对但复杂度是指数级,n = 1000 必 TLE。
  • 用 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,正确。
  • 数组开小:n 最大 1000,数组至少开 1001,开 1005 留点余量。

✗今日常见错误汇总

错误写法后果正确写法
忘记写出口 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 可能直接把栈挤爆 大数组开成全局变量(全局在静态区)

✓自测:你真的会了吗

第 1 题:说出三要素

用递归求「n 的各位数字之和」(比如 1357 → 1+3+5+7 = 16),三要素分别是什么?

答:
① 出口:if(n == 0) return 0;(没有数字了,和为 0)
② 递推关系:f(n) = n % 10 + f(n / 10)(最后一位 + 前面所有位的和)
③ 参数含义:n 表示「还要拆哪个数」;每层 n/10 都更小,最终会变成 0 → 到得了出口

第 2 题:手推递归

下面的函数,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)。

第 3 题:这个递归有什么问题
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)。
教训:光有出口还不够,得保证「一定会到达」。

第 4 题:为什么这题不能用纯递归

P1028 如果写成纯递归,为什么 n = 1000 一定过不了?请用「递归树节点数」来解释。

答:因为递归树上的每个节点都对应一个合法数列,所以节点总数 = 答案。 n = 1000 时答案是 1981471878,也就是要做将近 20 亿次函数调用。
计算机每秒大约能做 108 次函数调用级别的工作,20 亿次至少要几十秒 —— 远超时限。
★ 实测数据也印证了这一点:n = 10 时调用 14 次(答案 14)、n = 50 时调用 786 次(答案 786),两边永远相等。

第 5 题:分治的区间怎么切

仿照 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

→明天预告 · Day 17

递推与记忆化搜索

今天你已经提前尝到了甜头:P1028 用纯递归会算 20 亿次,加一个数组记答案就瞬间出结果。

明天会把这件事讲透:递推(从小规模推大规模,用循环填表)和记忆化搜索(递归 + 缓存, 写法和普通递归一样,只在开头加一句判断)。它们就是动态规划(DP)的前身 —— 后面第 25 天的线性 DP,本质上就是在这两个东西上加一层「怎么划分状态」。

明天的题目是 P1464 Function、P1720 月落乌啼算钱、P2437 蜜蜂路线、P1044 栈, 都是「不加记忆化就过不去」的经典题,正好把今天的遗憾补上。