1. 项目概述:洛谷P15800动态规划题目解析
这道来自洛谷平台的P15800题目,是GESP2026年3月六级认证的真题,考察的核心算法思想是动态规划在组合数学问题中的应用。题目要求从给定的n个正整数中选取若干个数,使得它们的和等于给定的目标值m,计算所有可能的选取方案数。
在实际编程竞赛和算法面试中,这类"选数求和"问题是动态规划的经典应用场景。与简单的暴力枚举相比,动态规划能够将时间复杂度从O(2^n)优化到O(n*m),这在n和m较大时(比如n=100,m=10000)能带来数百万倍的性能提升。
2. 动态规划基础与问题分析
2.1 动态规划的核心思想
动态规划(Dynamic Programming)通过将原问题分解为相对简单的子问题的方式来求解复杂问题。它有三个关键特征:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归算法会反复计算相同的子问题
- 无后效性:当前状态一旦确定,后续决策不受之前决策影响
对于选数问题,我们可以定义dp[i][j]表示考虑前i个数时,和为j的方案数。这个状态定义满足上述三个特征,因此适合用动态规划解决。
2.2 问题输入输出分析
题目典型输入格式:
n m a1 a2 ... an其中:
- n:数字个数 (1 ≤ n ≤ 100)
- m:目标和 (1 ≤ m ≤ 10000)
- ai:每个数字的值 (1 ≤ ai ≤ 1000)
输出为一个整数,表示选取方案数。
例如: 输入:
4 5 1 2 3 4输出:
2解释:有两种方案可以得到和5 (1+4 和 2+3)
3. 动态规划解法详解
3.1 状态转移方程推导
我们定义dp[i][j]为考虑前i个数时,和为j的方案数。状态转移需要考虑两种情况:
- 不选第i个数:方案数等于dp[i-1][j]
- 选第i个数:方案数等于dp[i-1][j-ai](前提是j ≥ ai)
因此,状态转移方程为:
dp[i][j] = dp[i-1][j] + (j >= ai ? dp[i-1][j-ai] : 0)初始条件:
- dp[0][0] = 1 (0个数和为0有1种方案)
- dp[0][j] = 0 for j > 0 (0个数和大于0没有方案)
3.2 空间优化技巧
观察状态转移方程可以发现,dp[i]只依赖于dp[i-1],因此可以将二维数组优化为一维数组,节省空间:
vector<int> dp(m+1, 0); dp[0] = 1; for(int i = 1; i <= n; i++) { for(int j = m; j >= a[i]; j--) { dp[j] += dp[j - a[i]]; } }注意内层循环需要从大到小遍历,避免重复计算(这是背包类问题的常见技巧)。
4. 完整代码实现与解析
4.1 C++实现代码
#include <iostream> #include <vector> using namespace std; int main() { int n, m; cin >> n >> m; vector<int> a(n+1); for(int i = 1; i <= n; i++) { cin >> a[i]; } vector<int> dp(m+1, 0); dp[0] = 1; for(int i = 1; i <= n; i++) { for(int j = m; j >= a[i]; j--) { dp[j] += dp[j - a[i]]; } } cout << dp[m] << endl; return 0; }4.2 代码关键点解析
- 输入处理:使用vector存储数字,从索引1开始更符合问题描述
- 初始化:dp数组大小为m+1,初始化dp[0]=1
- 双重循环:外层遍历数字,内层逆向遍历和
- 状态转移:dp[j] += dp[j-a[i]]实现状态转移
- 输出:最终dp[m]即为答案
5. 算法优化与变种讨论
5.1 时间与空间复杂度分析
- 时间复杂度:O(n*m),两重循环
- 空间复杂度:O(m),使用一维数组优化
5.2 常见变种问题
- 每个数字只能选一次(本题情况)
- 每个数字可以选无限次(完全背包问题)
- 需要输出具体方案而不仅是方案数
- 数字包含负数的情况
- 求最接近m的和(不一定要等于m)
对于变种3(输出具体方案),可以在动态规划后通过回溯法找出所有方案:
void backtrack(int i, int j, vector<int>& path) { if(j == 0) { // 输出方案 for(int num : path) cout << num << " "; cout << endl; return; } if(i == 0 || j < 0) return; // 不选a[i] backtrack(i-1, j, path); // 选a[i] if(j >= a[i] && dp[i-1][j-a[i]] > 0) { path.push_back(a[i]); backtrack(i-1, j-a[i], path); path.pop_back(); } }6. 实战技巧与注意事项
6.1 常见错误与调试技巧
- 数组越界:确保dp数组大小足够(m+1)
- 初始化错误:忘记初始化dp[0]=1
- 循环顺序错误:内层循环必须逆向遍历
- 整数溢出:方案数可能很大,考虑使用long long
调试技巧:可以打印中间dp表,观察状态转移是否正确
6.2 性能优化建议
- 输入优化:对于大规模数据,使用快速输入方法
ios::sync_with_stdio(false); cin.tie(0); - 提前终止:如果某个dp[j]已经不可能达到,可以跳过
- 空间优化:如前述使用一维数组
6.3 测试用例设计
设计测试用例时应考虑:
- 边界情况:n=1,m=1
- 无解情况:所有数字都大于m
- 大数情况:测试整数溢出
- 重复数字:数组中有重复数字
- 极端数据:n=100,m=10000
示例测试用例:
// 样例1 3 5 1 2 3 // 应输出:2 // 样例2 4 6 1 2 3 4 // 应输出:3 (1+2+3, 2+4, 1+5) // 样例3 5 10 2 4 6 8 10 // 应输出:3 (2+8, 4+6, 10)7. 动态规划学习路径建议
对于想系统学习动态规划的选手,建议按照以下顺序进阶:
基础背包问题:
- 01背包(本题类型)
- 完全背包
- 多重背包
线性动态规划:
- 最长上升子序列(LIS)
- 最长公共子序列(LCS)
- 最大子数组和
区间动态规划:
- 矩阵链乘法
- 石子合并
- 最优二叉搜索树
树形动态规划:
- 树的最大独立集
- 树的直径
- 树上背包问题
状态压缩动态规划:
- 旅行商问题(TSP)
- 棋盘覆盖问题
对于GESP六级考生,重点掌握前两类即可应对大多数考题。洛谷题库中有大量分类练习题,建议从"普及-"难度的动态规划题目开始刷起。