1. P1464 Function题目解析
P1464是一道经典的递归与记忆化搜索练习题,题目要求我们实现一个特定的递归函数并优化其性能。这个函数通常具有以下形式:
cpp复制int w(int a, int b, int c) {
if(a <= 0 || b <= 0 || c <= 0)
return 1;
if(a > 20 || b > 20 || c > 20)
return w(20, 20, 20);
if(a < b && b < c)
return w(a, b, c-1) + w(a, b-1, c-1) - w(a, b-1, c);
return w(a-1, b, c) + w(a-1, b-1, c) + w(a-1, b, c-1) - w(a-1, b-1, c-1);
}
这个函数的特殊之处在于它包含了多个递归分支,直接暴力递归会导致大量的重复计算,时间复杂度呈指数级增长。当输入参数较大时(比如a=15, b=15, c=15),普通的递归实现可能需要几分钟甚至更长时间才能完成计算。
2. 递归优化策略分析
2.1 递归树的可视化
让我们以w(2,2,2)为例分析递归调用过程:
code复制w(2,2,2)
├── w(1,2,2)
│ ├── w(0,2,2) = 1
│ ├── w(0,1,2) = 1
│ ├── w(0,2,1) = 1
│ └── w(0,1,1) = 1
├── w(1,1,2)
│ ├── w(0,1,2) = 1
│ ├── w(0,0,2) = 1
│ ├── w(0,1,1) = 1
│ └── w(0,0,1) = 1
├── w(1,2,1)
│ ├── w(0,2,1) = 1
│ ├── w(0,1,1) = 1
│ ├── w(0,2,0) = 1
│ └── w(0,1,0) = 1
└── w(1,1,1)
├── w(0,1,1) = 1
├── w(0,0,1) = 1
├── w(0,1,0) = 1
└── w(0,0,0) = 1
可以看到即使对于这样小的输入,递归调用已经非常复杂,而且存在大量重复计算(如w(0,1,1)被计算了4次)。
2.2 时间复杂度分析
未经优化的递归实现时间复杂度为O(3^n),因为每个函数调用可能产生3-4个新的递归调用。对于n=20,这将产生约3.5亿次调用,显然不可行。
3. 记忆化搜索实现
3.1 记忆化存储设计
我们需要一个三维数组来存储已经计算过的结果:
cpp复制int memo[21][21][21]; // 因为a,b,c超过20时会被截断为20
初始化时将所有元素设为-1,表示未计算。每次计算w(a,b,c)前先检查memo[a][b][c],如果已经计算过则直接返回存储的值。
3.2 完整实现代码
cpp复制#include <iostream>
#include <cstring>
using namespace std;
int memo[21][21][21];
int w(int a, int b, int c) {
if(a <= 0 || b <= 0 || c <= 0)
return 1;
if(a > 20 || b > 20 || c > 20)
return w(20, 20, 20);
if(memo[a][b][c] != -1)
return memo[a][b][c];
int result;
if(a < b && b < c)
result = w(a, b, c-1) + w(a, b-1, c-1) - w(a, b-1, c);
else
result = 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] = result;
return result;
}
int main() {
memset(memo, -1, sizeof(memo));
int a, b, c;
while(cin >> a >> b >> c) {
if(a == -1 && b == -1 && c == -1) break;
printf("w(%d, %d, %d) = %d\n", a, b, c, w(a,b,c));
}
return 0;
}
3.3 性能对比
| 输入规模 | 普通递归时间 | 记忆化搜索时间 |
|---|---|---|
| (10,10,10) | ~5秒 | <1毫秒 |
| (15,15,15) | >5分钟 | ~1毫秒 |
| (20,20,20) | 无法完成 | ~2毫秒 |
4. 动态规划解法
虽然记忆化搜索已经足够高效,但我们也可以使用自底向上的动态规划方法:
4.1 DP表初始化
cpp复制int dp[21][21][21];
for(int i=0; i<=20; i++) {
for(int j=0; j<=20; j++) {
for(int k=0; k<=20; k++) {
if(i == 0 || j == 0 || k == 0)
dp[i][j][k] = 1;
}
}
}
4.2 填表顺序
按照a,b,c从小到大的顺序填充DP表:
cpp复制for(int a=1; a<=20; a++) {
for(int b=1; b<=20; b++) {
for(int c=1; c<=20; c++) {
if(a < b && b < c)
dp[a][b][c] = dp[a][b][c-1] + dp[a][b-1][c-1] - dp[a][b-1][c];
else
dp[a][b][c] = dp[a-1][b][c] + dp[a-1][b-1][c] + dp[a-1][b][c-1] - dp[a-1][b-1][c-1];
}
}
}
4.3 查询处理
对于查询(a,b,c),处理边界条件后直接返回dp[a][b][c]即可。
5. 边界条件与特殊处理
在实际编码中需要注意以下几点:
- 输入参数可能为负数,需要先处理为0
- 参数超过20时需要先截断为20
- 记忆化数组的大小只需21x21x21,因为超过20的参数会被截断
- 使用memset初始化记忆化数组时,-1的二进制表示是全1,适合作为未计算的标记
6. 算法复杂度分析
6.1 空间复杂度
两种优化方法都需要O(n^3)的存储空间,对于本题n=20,空间需求很小(约40KB)。
6.2 时间复杂度
- 记忆化搜索:每个状态只计算一次,O(n^3)
- 动态规划:明确的三重循环,O(n^3)
虽然两种方法的理论复杂度相同,但在实际中:
- 记忆化搜索只计算实际需要的状态
- 动态规划计算所有可能状态,但常数因子更小
7. 实际编码技巧
- 使用全局数组而非传递引用,简化代码
- 用memset初始化记忆化数组比循环更快
- 在递归函数开头统一处理边界条件
- 对于在线判题系统,可以预先计算好整个DP表,然后直接处理查询
- 输出格式要严格符合题目要求,包括空格和标点
8. 类似题目推荐
- 斐波那契数列的递归优化
- 背包问题的记忆化搜索实现
- 网格路径计数问题
- 最长公共子序列问题
- 矩阵链乘法问题
这些题目都可以通过类似的记忆化或动态规划技术大幅提升性能。
