ACM 一个月学习计划 · DAY 17

Day 17 · 递推与记忆化

把「已经算过的答案」记下来别再算第二遍 —— 这是你第一次真正意义上「优化算法」,也是动态规划的前身

今日目标:会写递推式、会给递归加缓存、能说清记忆化和递推的关系
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及/提高-
题目来源:洛谷 P1464 / P1720 / P2437 / P1044
← Day 16 · 递归入门:自己调用自己

⏱今日时间安排

0 - 25 min搞懂「递推」:从已知推未知(第 1 节)
25 - 45 min看清裸递归为什么慢(第 2 节,有实测数据)
45 - 70 min记忆化模板 + 四个实现细节(第 3、4 节)
70 - 90 min卡特兰数速成(第 6 节,P1044 要用)
90 - 180 min做 4 道题,每题限时 30 分钟
180 - 210 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

Day 16 你学会了「函数自己调用自己」。递归写起来很爽,但有一个致命毛病:同一个子问题会被重复算很多遍。

今天要做的事情只有一句话:算过的答案存进数组,下次直接拿。这一招叫「记忆化搜索」,学会它你就摸到了动态规划的门。

1递推:从已知推未知

先看一个不需要任何技巧的例子

爬楼梯:一次只能迈 1 级或 2 级,问爬到第 n 级有多少种走法。

想一下「最后一步」这件事:

两条路互不重叠,加起来就是全部,于是:

🎯 这就是「状态转移方程」

dp[n] = dp[n-1] + dp[n-2]

递推的三要素(每道递推题都逃不出这三样)

要素要回答的问题爬楼梯的例子
状态dp[i] 到底表示什么?爬到第 i 级的方案数
初值最小规模的情况是什么?(手算出来)dp[1] = 1,dp[2] = 2
转移大状态怎么由小状态得到?dp[i] = dp[i-1] + dp[i-2]
⚠️ 顺序不能乱

递推用循环写,循环方向必须保证「算 dp[i] 时它依赖的东西已经算好了」。
上面这个式子里 dp[i] 依赖 dp[i-1] 和 dp[i-2],都是更小的下标,所以 i 必须从小到大推。

如果哪天你写出 dp[i] = dp[i+1] + dp[i+2],那就得反过来从大到小推,或者改成递归。

递推和递归的对比

递归递推
写法函数调自己,有栈的开销一个 for 循环,从头推到底
思考方向自顶向下:把大问题拆小自底向上:从小问题堆到大
速度慢(函数调用有代价)快
符合直觉吗✅ 更符合人类思路⚠️ 有时要想半天顺序
会不会重复算❌ 会,这是今天的重点✅ 天然不会

今天的策略是:先用递归想清楚转移方程,再用记忆化(或递推)实现它。

2裸递归到底慢在哪(有实测数据)

手画一下 fib(5) 的调用树

还是斐波那契 f(n) = f(n-1) + f(n-2),写一个不带缓存的递归:

long long f(int n){
    if(n <= 1) return n;
    return f(n - 1) + f(n - 2);
}

把 f(5) 展开看一眼:

f(5)
├── f(4)
│   ├── f(3)
│   │   ├── f(2)          ← 第 1 次算
│   │   │   ├── f(1)
│   │   │   └── f(0)
│   │   └── f(1)
│   └── f(2)              ← 第 2 次算(明明刚算过!)
│       ├── f(1)
│       └── f(0)
└── f(3)                  ← 整个 f(3) 又算一遍!
    ├── f(2)              ← 第 3 次算
    └── f(1)

f(2) 被算了 3 次,f(3) 被算了 2 次。n 只有 5 就已经这样了,n 大一点会怎样?

我实际跑出来的调用次数

下面这张表是真跑出来的(fib_experiment.cpp,计数版递归 vs 记忆化):

n答案裸递归调用次数记忆化调用次数差距
105517719约 9 倍
2067652189139约 561 倍
257502524278549约 4955 倍
30832040269253759约 4.6 万倍
4010233415533116028179约 419 万倍
🚨 看清楚这张表的规律

裸递归的调用次数是指数级暴涨的(大约是 2 × Fib(n+1) - 1),n 每加 1 就乘 1.6 倍左右。
n=40 要 3.3 亿次调用,n=50 就是 400 亿次 —— 电脑跑一天也算不完。

而记忆化呢?调用次数几乎等于 n(n=40 只要 79 次)。这不是「快一点」,是「从算不完变成瞬间出结果」。

🎯 根因:同一个子问题被反复求解

裸递归把「子问题的答案」算一遍就扔了,下次遇到同样的子问题只能重算。
解法:算完就存起来。这就是今天全部的内容。

3记忆化搜索:递归 + 一个缓存数组

改动只有三行

把上面那个裸递归改成记忆化,对比着看:

// ---------- 裸递归 ----------
long long f(int n){
    if(n <= 1) return n;
    return f(n - 1) + f(n - 2);
}

// ---------- 记忆化搜索 ----------
long long memo[105];                       // ★ 新增:缓存数组

long long f(int n){
    if(memo[n] != -1) return memo[n];      // ★ 新增 1:算过就直接返回
    if(n <= 1) return memo[n] = n;         // ★ 新增 2:边界答案也存进去
    return memo[n] = f(n - 1) + f(n - 2);  // ★ 新增 3:算完立刻存
}

int main(){
    memset(memo, -1, sizeof(memo));        // ★ 别忘了初始化成「没算过」
    ...
}

为什么用 −1 当「没算过」的标记?

标记值能不能用原因
0❌ 不行很多题的答案真的就是 0(比如方案数为 0),你会把「答案是 0」误判成「没算过」,然后无限递归下去
−1✅ 推荐只要答案不可能是负数就没问题(本题答案全是正整数)
INF / 1e18✅ 也行答案不可能到这么大时可以用
⚠️ 这个坑非常隐蔽

写记忆化的时候,if(memo[x] != 0) return memo[x]; 是最常见的错误写法。
如果某道题的正确答案恰好是 0,你就会陷入死循环,或者栈溢出 RE。
记住:哨兵值一定要选一个「合法答案不可能取到」的值。

💡 「初始化」这一步不能省

全局数组默认是 0,不是 −1。所以必须在 main 里显式地把整个 memo 刷成 −1:

memset(memo, -1, sizeof(memo));

另一个办法是写个循环 for(int i=0;i<N;i++) memo[i] = -1;,效果一样。

如果你不想每次手动初始化,可以把含义反过来:memo 全 0 表示「没算过」,用一个 bool vis[] 单独记「算过没有」。这样连 memset 都省了(全局数组天然是 0)。

记忆化的通用模板(背下来)

类型 f(参数...){
    if(参数越界或非法) return 特殊值;        // 先处理边界
    if(memo[参数] != -1) return memo[参数];  // ★ 查缓存
    ...用递归算出 res...
    return memo[参数] = res;                 // ★ 存缓存
}
🎯 一句话记住

记忆化 = 递归的写法 + 数组的速度。

它保留了「把大问题拆成小问题」这种最符合直觉的思考方式,又把重复计算彻底消灭掉。

4记忆化的四个实现细节(今天的题全踩得到)

细节 1:数组要开多大?

数组大小 = 状态空间的大小,也就是「参数的所有可能取值组合数」。

参数长什么样用什么数组大小怎么算
一个参数,范围 0 ~ n一维 memo[n+5]n+5
两个参数,范围 0 ~ n二维 memo[n+5][n+5](n+5)²
三个参数,范围 0 ~ 20三维 memo[21][21][21]21³ = 9261,很小
💡 多维数组的写法

long long memo[21][21][21]; 声明在全局(函数外面),这样自动全 0,而且不受栈空间限制。
如果写在函数里面,21³ 个 long long ≈ 74 KB 还撑得住,但再大一点(比如 100³)就会爆栈 RE。养成写全局的习惯。

细节 2:参数越界要先「夹」到合法范围

这是今天 P1464 的核心套路。题目说「a、b、c 只要有一个超过 20,就当作 (20,20,20) 处理」。

那数组根本不需要开到 10⁹ —— 因为超过 20 的参数永远不会真的去查数组,只会被打包成 (20,20,20):

if(a > 20 || b > 20 || c > 20) return w(20, 20, 20);   // ★ 先截断,再查数组
if(memo[a][b][c] != -1) return memo[a][b][c];        // 这里 a,b,c 保证在 1~20
⚠️ 顺序颠倒就是 RE

如果先查数组 memo[a][b][c] 再判断 a > 20,那输入 1000 1000 1000 时就是数组越界,轻则答案乱,重则直接崩溃。
规则:凡是「参数可能超出数组下标范围」的题,一定要先做边界判断,再做数组访问。

细节 3:返回值类型用 long long

三个理由:

💡 一个判断口诀

只要拿不准,就用 long long。它占 8 字节,多花的那点内存几乎永远不是瓶颈,但省下来的调试时间是实打实的。

细节 4:多组输入的读入方式

P1464 的输入是「若干行,读到 -1 -1 -1 结束」,标准写法:

long long a, b, c;
while(cin >> a >> b >> c){                    // 读到 EOF 自动停
    if(a == -1 && b == -1 && c == -1) break;   // ★ 哨兵行,退出
    cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
}
⚠️ 多组输入时别用 endl

endl 会强制刷新缓冲区,输出量一大就慢得离谱。换成 "\n"。
P1464 有 10⁵ 行输入输出,这一条能差出几百毫秒。

5同一道题,两种写法

拿爬楼梯举例,转移方程都是 dp[i] = dp[i-1] + dp[i-2],初值都是 dp[1]=1, dp[2]=2。

写法 A:记忆化搜索(自顶向下)

long long memo[105];

long long dp(int i){
    if(i == 1) return 1;
    if(i == 2) return 2;
    if(memo[i] != -1) return memo[i];
    return memo[i] = dp(i - 1) + dp(i - 2);
}

写法 B:递推(自底向上)

long long dp[105];
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n; i++){
    dp[i] = dp[i - 1] + dp[i - 2];   // ★ 从前往后推
}
对比项记忆化搜索递推
代码量稍多(要写递归函数)少(一个循环)
要不要想顺序✅ 不用,递归自动处理依赖❌ 要想清楚循环方向
会不会算多余状态只算用得到的全部状态都算一遍
运行速度略慢(函数调用开销)✅ 更快
递归深度风险⚠️ 状态多时会爆栈✅ 没有
🎯 推荐流程

第 1 步:用递归把状态和转移方程想清楚(这一步最贴合人脑)。
第 2 步:加 memo 数组 → 能过题就交。
第 3 步:如果 TLE 或者爆栈,再改写成递推。

今天的 P1464 只能用记忆化(因为它有 3 个参数、规则是分段的,写成递推很别扭), P1720 / P2437 / P1044 都是规规矩矩的递推 —— 正好两种写法各练一遍。

6卡特兰数:P1044 背后的数列

先讲题:n 个数进栈,能弹出多少种不同的序列?

拿 n = 3 举例,入栈顺序固定是 1 2 3。可以得到的输出序列有:

1 2 3  1 3 2  2 1 3  2 3 1  3 2 1

一共 5 种(注意 3 1 2 是不可能出现的,因为 3 出来时 1、2 还在栈里,2 一定压在 1 上面,必须先出 2)。

这个数列就是大名鼎鼎的卡特兰数(Catalan number):

n012345678
Cn112514421324291430
n101214161718
Cn16796208012267444035357670129644790477638700

两种算法

算法 A(递推,推荐):把「最后一个出栈的是谁」当作分类标准。设最后一个出栈的数是第 j+1 个进栈的,那么:

f[0] = 1;
for(int i = 1; i <= n; i++)
    for(int j = 0; j < i; j++)
        f[i] += f[j] * f[i - 1 - j];   // ★ 卡特兰数递推式

算法 B(组合数公式):Cn = C(2n, n) / (n+1),代码只有四行。

long long c = 1;
for(int i = 1; i <= n; i++)
    c = c * (n + i) / i;      // 先算出 C(2n, n)
cout << c / (n + 1) << endl;  // 再除以 n+1
💡 为什么 c = c * (n+i) / i 每步都能整除

因为 c * (n+i) / i 算的其实是 C(n+i, i),而组合数一定是整数,所以中途不会出现小数。
但乘除顺序不能换!写成 c / i * (n+i) 就会因为整数除法丢精度而算错。

📌 卡特兰数还能数什么(知道就行)

n 对括号的合法配对方案数、n 个节点的不同二叉树个数、凸多边形三角剖分数 …… 全是卡特兰数。 以后看到「方案数是 1 1 2 5 14 42」这种开头,基本就是它了。

7四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P1464 [PacNW 1999] Function 普及- · 记忆化搜索模板题

题意:给定一个三参数的递归函数 w(a,b,c),按四条规则计算它的值。输入若干组 (a,b,c),每组输出一行 w(a, b, c) = 答案,读到 -1 -1 -1 结束。

四条规则按顺序判断(顺序是关键):

  1. 只要 a ≤ 0 或 b ≤ 0 或 c ≤ 0 → 返回 1
  2. 否则,只要 a > 20 或 b > 20 或 c > 20 → 返回 w(20, 20, 20)
  3. 否则,如果 a < b 并且 b < c → 返回 w(a,b,c-1) + w(a,b-1,c-1) - w(a,b-1,c)
  4. 否则 → 返回 w(a-1,b,c) + w(a-1,b-1,c) + w(a-1,b,c-1) - w(a-1,b-1,c-1)
⚠️ 数据范围(这题最坑的地方)

输入的数在 [-9223372036854775808, 9223372036854775807] 之间,也就是 完整的 long long 范围。
输入行数 T ≤ 10⁵。

结论 1:a、b、c 必须用 long long 读!用 int 读 9223372036854775807 当场就废了。

结论 2:因为规则 2 会把所有大于 20 的参数压到 20,所以 memo 数组只需要 [21][21][21]。不需要也不允许按输入值开数组。

🚨 题面特别强调的坑

「例如 w(30,-1,0) 又满足条件 1 又满足条件 2,请按照最上面的条件来算,答案为 1。」

所以代码里 必须先判断 ≤ 0,再判断 > 20。写反了,w(30,-1,0) 就会被当成 w(20,20,20),答案变成 1048576,直接 WA。

思路

这就是一个标准的记忆化搜索。三个参数的范围被规则压到 1~20,状态总数只有 20³ = 8000 个 —— 但因为裸递归的分支是 3~4 路,重复量巨大,不加缓存必 TLE。

加上 memo[a][b][c] 之后,每个状态最多真正计算一次,剩下的全是 O(1) 查表。

样例输入
1 1 1
2 2 2
-1 -1 -1
样例输出
w(1, 1, 1) = 2
w(2, 2, 2) = 4

完整代码

#include <iostream>
#include <cstring>
using namespace std;

// memo[a][b][c] 存已经算过的答案,-1 表示还没算过
// 因为 a,b,c 超过 20 会先被截断到 20,所以只需要开 0..20
long long memo[21][21][21];

long long w(long long a, long long b, long long c){
    // 第 1 条规则:只要有一个 <= 0 就返回 1(必须放在最前面判断)
    if(a <= 0 || b <= 0 || c <= 0) return 1;
    // 第 2 条规则:只要有一个 > 20 就全部截断到 20
    if(a > 20 || b > 20 || c > 20) return w(20, 20, 20);

    // ★ 记忆化的核心:算过就直接取,不再递归
    if(memo[a][b][c] != -1) return memo[a][b][c];

    long long res;
    if(a < b && b < c){
        res = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
    }else{
        res = w(a - 1, b, c) + w(a - 1, b - 1, c)
            + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
    }

    memo[a][b][c] = res;   // ★ 存下来,下次 O(1) 取用
    return res;
}

int main(){
    memset(memo, -1, sizeof(memo));   // -1 的二进制是全 1,long long 也适用

    long long a, b, c;                // ★ 必须 long long,输入范围是整个 long long
    while(cin >> a >> b >> c){
        if(a == -1 && b == -1 && c == -1) break;
        cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出

w(1, 1, 1) = 2
w(2, 2, 2) = 4

完全一致(注意等号两边各有一个空格)

补充边界测试(真实输出):

  • 30 -1 0 → w(30, -1, 0) = 1 ✅ 验证了「先判 ≤0」这条规则
  • -5 -5 -5 → w(-5, -5, -5) = 1
  • 21 21 21 → w(21, 21, 21) = 1048576,和 20 20 20 → 1048576 完全相同 ✅ 验证了截断规则
  • 15 15 15 → 32768,单次调用(含进程启动)约 36 ~ 43 ms —— 几乎全是启动开销,真正计算的部分小到测不出来
  • 压力测试:10⁵ 组查询(全部为 20 20 20)→ 输出 10⁵ 行,总耗时 448 ~ 501 ms(三次运行),末行 w(20, 20, 20) = 1048576

易错点

  • 规则顺序写反:先判 >20 再判 ≤0,w(30,-1,0) 就错了。必须严格按题面从上到下。
  • 用 int 读输入:输入可能到 ±9.2×10¹⁸,int 直接溢出。必须 long long。
  • memo 数组开小了:memo[20][20][20] 的下标只到 19,访问 memo[20][20][20] 就越界。要么开 21,要么开 25 留余量。
  • 忘了初始化成 −1:全局数组默认是 0,那么「没算过」和「答案是 0」就分不清了。
  • 输出格式:必须是 w(a, b, c) = ans,逗号后面有空格、等号两边有空格,一个都不能少。
  • 用 endl 换行:10⁵ 行输出会慢很多,用 "\n"。
💡 关于 long long 结果类型的一点诚实说明

这题的最大答案其实只有 1048576(= 2²⁰),int 也放得下,中间也不会溢出。
但我们仍然统一用 long long:一是输入本身必须用 long long 读,二是「不确定就用 long long」是一条不需要思考的好习惯。 这不是必须,但是推荐。

P1720 月落乌啼算钱(斐波那契数列) 入门 · 递推 + 精度

题意:题面给了一个看起来很吓人的公式

Fn = [ ((1+√5)/2)n − ((1−√5)/2)n ] / √5

让你输入 n,输出 Fn,保留两位小数。

🎯 关键洞察:这个公式就是斐波那契通项公式

它有个名字叫比内公式。算出来的 Fn 恰好就是斐波那契数列的第 n 项:

F0=0, F1=1, F2=1, F3=2, F4=3, F5=5, F6=8 …

样例输入 6 输出 8.00 —— 正是 F6 = 8。所以不用碰 sqrt 和 pow,老老实实递推就行。

⚠️ 数据范围与溢出红线

对于所有数据:0 ≤ n ≤ 48。

F48 = 4807526976,而 int 上限是 2147483647 —— 整整超出一倍多。

所以答案必须是 long long。用 int 会在 n ≥ 47 的时候变成负数。

思路

用「滚动两个变量」的递推:只保留 F(i−2) 和 F(i−1),一路往后推。这样连数组都不用开。

最后输出时把 long long 转成 double 用 printf("%.2f") 打两位小数。因为答案本身是整数,所以小数部分永远是 .00。

💡 为什么先算整数再转 double,而不是直接用公式算浮点

浮点数(double)有精度误差。(1+√5)/2 本身是个无限不循环小数,n 次方之后再相减、再除以 √5,误差会被放大。
能精确算的整数就不要用浮点算。先用整数递推算出准确值,最后一步再转成浮点输出。

样例输入
6
样例输出
8.00

完整代码

#include <cstdio>
#include <iostream>
using namespace std;

int main(){
    int n;
    cin >> n;

    // F(n) 就是斐波那契数列:F(0)=0, F(1)=1
    // n <= 48 时 F(48) = 4807526976,已经超过 int 上限,必须用 long long
    long long f0 = 0, f1 = 1;   // f0 = F(i-2), f1 = F(i-1)

    if(n == 0){
        printf("0.00\n");
        return 0;
    }

    for(int i = 2; i <= n; i++){
        long long t = f0 + f1;   // F(i) = F(i-1) + F(i-2)
        f0 = f1;
        f1 = t;
    }

    // 题目要求保留两位小数,答案本身是整数,所以小数为 .00
    printf("%.2f\n", (double)f1);
    return 0;
}
✅ 实测结果

官方样例 6 → 8.00 完全一致

边界测试(真实输出):

  • 0 → 0.00 (F0 = 0,最小值)
  • 47 → 2971215073.00 (已经超过 int 上限 2147483647,用 int 必爆)
  • 48 → 4807526976.00 (最大值,long long 正确输出)

易错点

  • 用 int 存答案:n = 47 开始就溢出成负数了。这是本题第一号杀手。
  • 被公式吓住:看到 √5 和 n 次方就想去写 pow(sqrt(5), ...),结果浮点误差导致答案差 0.01。直接用斐波那契递推最稳。
  • n = 0 没特判:如果写成「先循环再输出」,n=0 时 f0/f1 的处理容易出错。老老实实写个 if(n == 0)。
  • 输出格式:必须保留两位小数,写成 cout << f1 输出 8 是错的,要输出 8.00。
P2437 蜜蜂路线 普及- · 递推 + 高精度

题意:蜂房排成一条锯齿形的编号链(1、2、3……),蜜蜂只能从小编号爬到大编号的相邻蜂房。问从蜂房 m 爬到蜂房 n(m < n)有多少条不同路线。

思路:又是一只「兔子」

先看图找规律。仔细观察蜂房的连接方式会发现:编号为 i 的蜂房,只能直接通到 i+1 和 i+2。

那么「到达 i 号蜂房的路线数」dp[i],只能从这两个方向来:

  • 从 i-1 迈一步过来 → dp[i-1] 条
  • 从 i-2 跳两步过来 → dp[i-2] 条
dp[i] = dp[i - 1] + dp[i - 2]      // 又是斐波那契!

初值:起点 dp[m] = 1(站在起点算 1 种走法),dp[m+1] = 1(只能一步迈过去)。

所以答案就是 F(n − m + 1)(斐波那契的写法是 F(1)=1, F(2)=1)。

⚠️ 数据范围藏着大坑

对于 100% 的数据:1 ≤ m, n ≤ 1000。

n − m + 1 最大能到 1000,而 F(1000) 是一个 209 位数!

long long 最大只能存到约 9.2×10¹⁸,也就是 F(92) 左右。从 F(93) 开始,long long 必然溢出。

第一个版本:long long 递推(能过样例,但过不了全部数据)

#include <iostream>
using namespace std;

// 蜜蜂只能从小编号爬到相邻的大编号蜂房,
// 所以 dp[i] = dp[i-1] + dp[i-2],这就是斐波那契
// 起点 dp[m] = 1,dp[m+1] = 1,答案就是 dp[n] = F(n-m+1)
int main(){
    int m, n;
    cin >> m >> n;

    long long a = 1, b = 1;   // a = dp[i-2], b = dp[i-1];第一步时两者都是 1

    for(int i = m + 2; i <= n; i++){
        long long t = a + b;
        a = b;
        b = t;
    }

    cout << b << endl;
    return 0;
}
⚠️ 这个版本的实测能力边界(我真实测了)

1 92 → 7540113804746346429 ✅ 正确(这是 long long 能表达的最大斐波那契项)
1 93 → -6246583658587674878 ❌ 溢出成负数了!

用 .NET 的 BigInteger 算出的真值是 12200160415121876738,确实超出了 long long 范围。

所以想把这道题 AC,必须上高精度。(高精度会在 Day 30 系统学,今天先感受一下)

第二个版本:高精度加法(可以 AC)

思路:用一个 vector<int> 从低位到高位存每一位数字,然后手动模拟小学竖式加法。

#include <iostream>
#include <vector>
using namespace std;

// 高精度加法:用 vector 存每一位(下标 0 是最低位),手动处理进位
vector<int> addBig(const vector<int>& A, const vector<int>& B){
    vector<int> C;
    int carry = 0;                            // 进位
    for(size_t i = 0; i < A.size() || i < B.size() || carry; i++){
        int s = carry;
        if(i < A.size()) s += A[i];
        if(i < B.size()) s += B[i];
        C.push_back(s % 10);                  // 本位留下
        carry = s / 10;                       // 进位带走
    }
    return C;
}

int main(){
    int m, n;
    cin >> m >> n;

    vector<int> a(1, 1), b(1, 1);   // a = dp[i-2], b = dp[i-1]

    for(int i = m + 2; i <= n; i++){
        vector<int> t = addBig(a, b);
        a = b;
        b = t;
    }

    // 从最高位往低位输出
    for(int i = (int)b.size() - 1; i >= 0; i--) cout << b[i];
    cout << endl;
    return 0;
}
样例输入
1 14
样例输出
377
✅ 实测结果

官方样例 1 14:

  • long long 版 → 377 与样例完全一致
  • 高精度版 → 377 与样例完全一致

高精度版极限测试(真实输出,与 .NET BigInteger 的真值逐位比对完全一致):

1 1000 → 共 209 位:
43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875

(对比:long long 版在 1 93 就已经变成负数了)

易错点

  • 只写 long long 版就交:样例能过,但 n − m + 1 ≥ 93 的测试点全部 WA。这题必须高精度。
  • 初值给错:起点要设为 1(站在起点算一种走法),dp[m+1] 也是 1。都设成 0 会输出 0。
  • 循环边界:循环应该从 m+2 开始到 n 结束。写成从 1 开始推会在 m 很大时出错。
  • 高精度输出方向:vector 里下标 0 是最低位,输出必须从后往前,倒着输出就得到镜像数了。
  • 忘记处理进位:s = 10 时要留 0 进 1,只取 s % 10 不更新 carry 会丢位。
💡 今天怎么写这道题?

题目本身只要求你「先感受一下」高精度(Day 30 会系统学)。
建议今天:先用 long long 版把递推关系彻底想明白(这是今天真正的考点),然后把高精度版抄一遍、跑通 209 位的那个测试,知道「原来大数是这样一位一位加的」就够了。

P1044 [NOIP 2003 普及组] 栈 普及/提高- · 卡特兰数

题意:有一个操作数序列 1, 2, …, n,栈 A 的深度大于 n。你可以做两种操作:

  1. 把操作数序列头端的数压进栈(push)
  2. 把栈栈顶的数弹到输出序列尾端(pop)

问:通过任意组合这些操作,一共能得到多少种不同的输出序列?

⚠️ 数据范围

1 ≤ n ≤ 18。n = 18 时答案是 477638700,没有超过 int,但已经接近 5 亿。
中间算乘法时 f[j] * f[i-1-j] 也可能很大,统一用 long long。

思路:按「最后一个出栈的是谁」分类

设 f[i] 表示「i 个数进栈能得到的输出序列数」。现在来想 i 个数的情况。

不管过程怎么乱,最后出栈的那一个数一定是某个特定的数。假设它是第 j+1 个进栈的 (j 从 0 到 i−1),那么整个过程被它切成互不干扰的两段:

  • 它进栈之前,已经有 j 个数进栈并且全部先出去了 → 有 f[j] 种
  • 它进栈之后,剩下的 i−1−j 个数进进出出,最后才轮到它 → 有 f[i-1-j] 种

两段相互独立,乘起来;j 的所有取值加起来,就是全部:

🎯 卡特兰数递推式

f[0] = 1, f[i] = Σj=0..i−1 f[j] × f[i−1−j]

手算一遍前几项验证一下:f[1]=1, f[2]=2, f[3]=5 —— 和题目样例 n=3 → 5 对上了。

样例输入
3
样例输出
5

完整代码

#include <iostream>
using namespace std;

// 卡特兰数:f[i] = Σ(j=0..i-1) f[j] * f[i-1-j],边界 f[0] = 1
// n <= 18 时 Catalan(18) = 477638700,用 long long 保险
int main(){
    int n;
    cin >> n;

    long long f[25] = {0};
    f[0] = 1;                       // ★ 空序列算 1 种,这是递推的种子

    for(int i = 1; i <= n; i++){
        for(int j = 0; j < i; j++){
            f[i] += f[j] * f[i - 1 - j];   // ★ 两段方案数相乘
        }
    }

    cout << f[n] << endl;
    return 0;
}
✅ 实测结果

官方样例 3 → 5 完全一致

补充测试(真实输出):1 → 1、2 → 2、10 → 16796、18 → 477638700(最大值,long long 正确容纳)

我还用另一种算法(组合数公式 C(2n,n)/(n+1))独立跑了一遍:

long long c = 1;
for(int i = 1; i <= n; i++)
    c = c * (n + i) / i;      // 先算出 C(2n, n)
cout << c / (n + 1) << endl;  // 再除以 n+1

输出 1 / 2 / 5 / 16796 / 477638700,与递推版逐个一致。两种算法互相印证,说明结果是对的。

易错点

  • f[0] 忘了置 1:这样 f[1] 算出来就是 0,后面全废。空序列(一个数都不进)也算 1 种方案。
  • 双重循环边界写错:内层是 j < i(不是 j <= i),因为是 i−1−j,j 最大只能到 i−1。
  • 用 int 存 f 数组:虽然 n=18 的答案 477638700 刚好塞得下 int,但习惯上用 long long 更稳(很多卡特兰数题 n 稍大就爆)。
  • 越界:f[25] 够用(n ≤ 18),但注意别写成 f[20] 再访问 f[18] —— 那正好卡在边界上,容易出错。
  • 组合数公式版乘除顺序颠倒:必须 * 在前 / 在后,写成 c / i * (n+i) 会丢精度。

✗今日常见错误汇总

#错误写法后果正确写法
1memo 用 0 当「没算过」答案是 0 的状态会被反复重算,甚至死循环用 −1 当哨兵
2忘了 memset(memo,-1,...)全局数组全是 0,等价于「所有状态都算过了」,答案直接错main 开头初始化
3先查数组再判边界memo[1000][1000][1000] 越界 → RE先判边界再查数组
4规则顺序写反(P1464)w(30,-1,0) 答案错严格按题面从上到下判
5用 int 读大数 / 存答案溢出变成负数(P1720 的 n≥47、P1464 的输入)一律 long long
6多维 memo 开在函数里数组太大 → 爆栈 RE声明为全局数组
7输出用 endl(多组数据)10⁵ 行输出明显变慢用 "\n"
8P2437 只用 long longn−m+1≥93 的测试点全 WA必须高精度加法
9卡特兰数忘设 f[0]=1全表算出来都是 0空方案数 = 1
10浮点公式硬算 P1720精度误差导致末位差 0.01整数递推 + 最后转 double

✓自测:你真的会了吗

第 1 题:为什么记忆化用 −1 当「没算过」的标记,而不是 0?

答:因为很多题的合法答案就是 0(例如「方案数为 0」「差值为 0」)。如果用 0 当哨兵, 程序会把「答案是 0」误判成「还没算过」,于是又递归下去算一遍,形成无限递归 → 栈溢出 RE。

所以哨兵必须选一个「合法答案不可能取到」的值。本题答案都是正整数,用 −1 就安全。

第 2 题:P1464 里 memo 数组为什么开 [21][21][21] 就够了?

答:因为规则 2 规定「只要有一个参数 > 20,就返回 w(20,20,20)」—— 所有超出 20 的参数在进入数组访问之前就被截断成 20了。

规则 1 又保证 ≤ 0 的参数直接返回 1、不进数组。所以实际会被当作下标使用的值只有 1 ~ 20, 开 21 个(下标 0~20)刚好够。开 25 留点余量更保险。

第 3 题:把 w(a,b,c) 的规则 1 和规则 2 调换顺序会怎样?

答:输入 30 -1 0 时,先判 > 20 会命中(30 > 20),于是返回 w(20,20,20) = 1048576; 而正确顺序应该先命中 b ≤ 0,返回 1。答案从一个正确的 1 变成了错误的 1048576。

这就是题面特意写「请按照最上面的条件来算」的原因。

第 4 题:P1044 的 n=4 答案是多少?(自己手算)

答:14。

用递推式算:f[0]=1, f[1]=1, f[2]=2, f[3]=5
f[4] = f[0]·f[3] + f[1]·f[2] + f[2]·f[1] + f[3]·f[0]
     = 1×5 + 1×2 + 2×1 + 5×1 = 14 ✅(和卡特兰数列 1,1,2,5,14 对得上)

第 5 题(手写代码):给一个裸递归函数加记忆化

题目:g(n) = g(n-1) + 2*g(n-3),g(0)=1, g(1)=1, g(2)=2,n ≤ 1000。写出记忆化版本。

long long memo[1005];

long long g(int n){
    if(n == 0) return 1;
    if(n == 1) return 1;
    if(n == 2) return 2;
    if(memo[n] != -1) return memo[n];          // ★ 查缓存
    return memo[n] = g(n-1) + 2 * g(n-3);      // ★ 算完就存
}

int main(){
    memset(memo, -1, sizeof(memo));            // ★ 别忘了初始化
    ...
}

★ 注意边界(n=0/1/2)必须在查缓存之后或同时处理好。这里把边界写在查缓存之前也没问题, 因为边界状态不参与缓存,代价是每次都要多判两下 —— 想更规范可以写成 if(memo[n]!=-1) return memo[n]; 放在最前面, 再让边界也走 return memo[n] = ...。

▤今日速查卡(截图存手机)

【递推三要素】
  1. 状态:dp[i] 表示什么?
  2. 初值:最小规模手算出来
  3. 转移:dp[i] = ?(循环方向要保证依赖已算好)

【记忆化搜索模板】
  类型 f(参数){
      if(边界) return 边界值;
      if(memo[参数] != -1) return memo[参数];   // ★ 查缓存
      res = ...递归...
      return memo[参数] = res;                  // ★ 存缓存
  }
  main 里: memset(memo, -1, sizeof(memo));

【记忆化四条铁律】
  1. 哨兵值选合法答案取不到的值(推荐 -1)
  2. 先判边界 / 先截断,再访问数组
  3. 返回值一律 long long
  4. memo 一律声明成全局数组

【斐波那契滚动写法】
  long long a = 1, b = 1;         // dp[i-2], dp[i-1]
  for(int i = m + 2; i <= n; i++){
      long long t = a + b;
      a = b;  b = t;
  }
  // 答案 = b = F(n - m + 1)

【卡特兰数】
  f[0] = 1;
  for(i = 1..n) for(j = 0..i-1) f[i] += f[j] * f[i-1-j];
  // 或:c = C(2n,n);  答案 = c / (n+1)
  // 前几项:1 1 2 5 14 42 132 429 1430 4862

【P1464 规则顺序(不能调换)】
  1) a<=0 || b<=0 || c<=0  →  1
  2) a>20 || b>20 || c>20  →  w(20,20,20)
  3) a<b && b<c  →  w(a,b,c-1)+w(a,b-1,c-1)-w(a,b-1,c)
  4) 否则        →  w(a-1,b,c)+w(a-1,b-1,c)+w(a-1,b,c-1)-w(a-1,b-1,c-1)

【溢出红线】
  int      上限 2147483647     (F(47)=2971215073 已超)
  long long 上限 约 9.2e18     (F(93) 已超)
  答案可能很大 → 高精度(Day 30)

→明天预告 · Day 18

DFS 入门:枚举所有方案

今天你学会了「把算过的答案记下来」。明天要换个方向:当答案不是一个数、而是一大堆具体方案时怎么办?

比如「输出 1~n 的所有全排列」「从 n 个数里选 k 个的所有组合」—— 这类题要用深度优先搜索(DFS), 也就是「一条路走到黑,走不通就退回来换一条路」。

明天会学到三个框架:选/不选、下标递增避免重复的组合、带访问标记的排列。 今天记忆化里那句「算过就存、下次直接拿」的思想,其实在 DFS 的剪枝里也会再遇到一次。

小提醒:明天的 P1157 和 P1706 都会用到 <iomanip> 里的 setw() 控制输出宽度, 今天有空可以先瞄一眼这个函数。