把「已经算过的答案」记下来别再算第二遍 —— 这是你第一次真正意义上「优化算法」,也是动态规划的前身
| 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 级或 2 级,问爬到第 n 级有多少种走法。
想一下「最后一步」这件事:
n-1 级迈 1 级上来的 → 前面有 dp[n-1] 种走法n-2 级迈 2 级上来的 → 前面有 dp[n-2] 种走法两条路互不重叠,加起来就是全部,于是:
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 循环,从头推到底 |
| 思考方向 | 自顶向下:把大问题拆小 | 自底向上:从小问题堆到大 |
| 速度 | 慢(函数调用有代价) | 快 |
| 符合直觉吗 | ✅ 更符合人类思路 | ⚠️ 有时要想半天顺序 |
| 会不会重复算 | ❌ 会,这是今天的重点 | ✅ 天然不会 |
今天的策略是:先用递归想清楚转移方程,再用记忆化(或递推)实现它。
还是斐波那契 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 | 答案 | 裸递归调用次数 | 记忆化调用次数 | 差距 |
|---|---|---|---|---|
| 10 | 55 | 177 | 19 | 约 9 倍 |
| 20 | 6765 | 21891 | 39 | 约 561 倍 |
| 25 | 75025 | 242785 | 49 | 约 4955 倍 |
| 30 | 832040 | 2692537 | 59 | 约 4.6 万倍 |
| 40 | 102334155 | 331160281 | 79 | 约 419 万倍 |
裸递归的调用次数是指数级暴涨的(大约是 2 × Fib(n+1) - 1),n 每加 1 就乘 1.6 倍左右。
n=40 要 3.3 亿次调用,n=50 就是 400 亿次 —— 电脑跑一天也算不完。
而记忆化呢?调用次数几乎等于 n(n=40 只要 79 次)。这不是「快一点」,是「从算不完变成瞬间出结果」。
裸递归把「子问题的答案」算一遍就扔了,下次遇到同样的子问题只能重算。
解法:算完就存起来。这就是今天全部的内容。
把上面那个裸递归改成记忆化,对比着看:
// ---------- 裸递归 ---------- 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)); // ★ 别忘了初始化成「没算过」 ... }
| 标记值 | 能不能用 | 原因 |
|---|---|---|
| 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; // ★ 存缓存
}
记忆化 = 递归的写法 + 数组的速度。
它保留了「把大问题拆成小问题」这种最符合直觉的思考方式,又把重复计算彻底消灭掉。
数组大小 = 状态空间的大小,也就是「参数的所有可能取值组合数」。
| 参数长什么样 | 用什么数组 | 大小怎么算 |
|---|---|---|
| 一个参数,范围 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。养成写全局的习惯。
这是今天 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
如果先查数组 memo[a][b][c] 再判断 a > 20,那输入 1000 1000 1000 时就是数组越界,轻则答案乱,重则直接崩溃。
规则:凡是「参数可能超出数组下标范围」的题,一定要先做边界判断,再做数组访问。
三个理由:
[-2⁶³, 2⁶³-1],用 int 读进来当场就错了(今天的 P1464 就是这样)。long long 更不容易出意外。只要拿不准,就用 long long。它占 8 字节,多花的那点内存几乎永远不是瓶颈,但省下来的调试时间是实打实的。
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 会强制刷新缓冲区,输出量一大就慢得离谱。换成 "\n"。
P1464 有 10⁵ 行输入输出,这一条能差出几百毫秒。
拿爬楼梯举例,转移方程都是 dp[i] = dp[i-1] + dp[i-2],初值都是 dp[1]=1, dp[2]=2。
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);
}
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 都是规规矩矩的递推 —— 正好两种写法各练一遍。
拿 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):
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Cn | 1 | 1 | 2 | 5 | 14 | 42 | 132 | 429 | 1430 |
| n | 10 | 12 | 14 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|
| Cn | 16796 | 208012 | 2674440 | 35357670 | 129644790 | 477638700 |
算法 A(递推,推荐):把「最后一个出栈的是谁」当作分类标准。设最后一个出栈的数是第 j+1 个进栈的,那么:
j 个数先进先出 → 方案数 f[j]i-1-j 个数进进出出 → 方案数 f[i-1-j]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」这种开头,基本就是它了。
下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。
题意:给定一个三参数的递归函数 w(a,b,c),按四条规则计算它的值。输入若干组 (a,b,c),每组输出一行 w(a, b, c) = 答案,读到 -1 -1 -1 结束。
四条规则按顺序判断(顺序是关键):
a ≤ 0 或 b ≤ 0 或 c ≤ 0 → 返回 1a > 20 或 b > 20 或 c > 20 → 返回 w(20, 20, 20)a < b 并且 b < c → 返回 w(a,b,c-1) + w(a,b-1,c-1) - w(a,b-1,c)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) = 121 21 21 → w(21, 21, 21) = 1048576,和 20 20 20 → 1048576 完全相同 ✅ 验证了截断规则15 15 15 → 32768,单次调用(含进程启动)约 36 ~ 43 ms —— 几乎全是启动开销,真正计算的部分小到测不出来20 20 20)→ 输出 10⁵ 行,总耗时 448 ~ 501 ms(三次运行),末行 w(20, 20, 20) = 1048576>20 再判 ≤0,w(30,-1,0) 就错了。必须严格按题面从上到下。long long。memo[20][20][20] 的下标只到 19,访问 memo[20][20][20] 就越界。要么开 21,要么开 25 留余量。w(a, b, c) = ans,逗号后面有空格、等号两边有空格,一个都不能少。"\n"。这题的最大答案其实只有 1048576(= 2²⁰),int 也放得下,中间也不会溢出。
但我们仍然统一用 long long:一是输入本身必须用 long long 读,二是「不确定就用 long long」是一条不需要思考的好习惯。
这不是必须,但是推荐。
题意:题面给了一个看起来很吓人的公式
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)有精度误差。(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 正确输出)pow(sqrt(5), ...),结果浮点误差导致答案差 0.01。直接用斐波那契递推最稳。if(n == 0)。cout << f1 输出 8 是错的,要输出 8.00。题意:蜂房排成一条锯齿形的编号链(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 必然溢出。
#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 系统学,今天先感受一下)
思路:用一个 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:
377 与样例完全一致377 与样例完全一致高精度版极限测试(真实输出,与 .NET BigInteger 的真值逐位比对完全一致):
1 1000 → 共 209 位:
43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875
(对比:long long 版在 1 93 就已经变成负数了)
dp[m+1] 也是 1。都设成 0 会输出 0。m+2 开始到 n 结束。写成从 1 开始推会在 m 很大时出错。s = 10 时要留 0 进 1,只取 s % 10 不更新 carry 会丢位。题目本身只要求你「先感受一下」高精度(Day 30 会系统学)。
建议今天:先用 long long 版把递推关系彻底想明白(这是今天真正的考点),然后把高精度版抄一遍、跑通 209 位的那个测试,知道「原来大数是这样一位一位加的」就够了。
题意:有一个操作数序列 1, 2, …, n,栈 A 的深度大于 n。你可以做两种操作:
问:通过任意组合这些操作,一共能得到多少种不同的输出序列?
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。f[25] 够用(n ≤ 18),但注意别写成 f[20] 再访问 f[18] —— 那正好卡在边界上,容易出错。* 在前 / 在后,写成 c / i * (n+i) 会丢精度。| # | 错误写法 | 后果 | 正确写法 |
|---|---|---|---|
| 1 | memo 用 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" |
| 8 | P2437 只用 long long | n−m+1≥93 的测试点全 WA | 必须高精度加法 |
| 9 | 卡特兰数忘设 f[0]=1 | 全表算出来都是 0 | 空方案数 = 1 |
| 10 | 浮点公式硬算 P1720 | 精度误差导致末位差 0.01 | 整数递推 + 最后转 double |
答:因为很多题的合法答案就是 0(例如「方案数为 0」「差值为 0」)。如果用 0 当哨兵, 程序会把「答案是 0」误判成「还没算过」,于是又递归下去算一遍,形成无限递归 → 栈溢出 RE。
所以哨兵必须选一个「合法答案不可能取到」的值。本题答案都是正整数,用 −1 就安全。
答:因为规则 2 规定「只要有一个参数 > 20,就返回 w(20,20,20)」—— 所有超出 20 的参数在进入数组访问之前就被截断成 20了。
规则 1 又保证 ≤ 0 的参数直接返回 1、不进数组。所以实际会被当作下标使用的值只有 1 ~ 20, 开 21 个(下标 0~20)刚好够。开 25 留点余量更保险。
w(a,b,c) 的规则 1 和规则 2 调换顺序会怎样?答:输入 30 -1 0 时,先判 > 20 会命中(30 > 20),于是返回 w(20,20,20) = 1048576;
而正确顺序应该先命中 b ≤ 0,返回 1。答案从一个正确的 1 变成了错误的 1048576。
这就是题面特意写「请按照最上面的条件来算」的原因。
答: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 对得上)
题目: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)
DFS 入门:枚举所有方案
今天你学会了「把算过的答案记下来」。明天要换个方向:当答案不是一个数、而是一大堆具体方案时怎么办?
比如「输出 1~n 的所有全排列」「从 n 个数里选 k 个的所有组合」—— 这类题要用深度优先搜索(DFS), 也就是「一条路走到黑,走不通就退回来换一条路」。
明天会学到三个框架:选/不选、下标递增避免重复的组合、带访问标记的排列。 今天记忆化里那句「算过就存、下次直接拿」的思想,其实在 DFS 的剪枝里也会再遇到一次。
小提醒:明天的 P1157 和 P1706 都会用到 <iomanip> 里的 setw() 控制输出宽度,
今天有空可以先瞄一眼这个函数。