三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

动态规划选数问题解析:从洛谷P15800到背包问题优化

动态规划选数问题解析:从洛谷P15800到背包问题优化

1. 项目概述:洛谷P15800动态规划题目解析

这道来自洛谷平台的P15800题目,是GESP202603六级认证考试中的一道经典动态规划问题。题目要求从给定数组中选取若干个数,使其满足特定条件(如和等于目标值、数量限制等)。这类"选数"问题在实际编程竞赛和算法面试中出现频率极高,是检验考生动态规划掌握程度的试金石。

我在刷题过程中发现,许多初学者面对这类题目时容易陷入暴力搜索的思维定式。实际上,通过合理的状态设计和转移方程优化,这类问题的时间复杂度可以从指数级降到多项式级别。以本题为例,合理运用动态规划可以将时间复杂度从O(2^n)优化到O(n*sum),其中n为数字个数,sum为目标和。

2. 动态规划解题思路拆解

2.1 问题建模与状态定义

首先需要明确题目要求的具体条件。典型的选数问题可能要求:

  • 选取数字的和恰好等于目标值
  • 选取数字的数量不超过/恰好等于k个
  • 数字可以重复选取或不可重复选取

以基础版本为例,假设题目要求从数组nums中选取若干数,使它们的和恰好等于target。我们可以定义dp[i][j]表示考虑前i个数时,能否凑出和j。这种二维状态定义是解决背包类问题的通用方法。

注意:在实际编码时,为了优化空间复杂度,通常会使用滚动数组技巧将二维dp压缩为一维。但在初学阶段,建议先写出完整的二维状态转移方程,确保理解正确后再进行空间优化。

2.2 状态转移方程推导

对于每个数字nums[i],我们有两种选择:

  1. 不选这个数:dp[i][j] = dp[i-1][j]
  2. 选这个数(如果j >= nums[i]):dp[i][j] = dp[i-1][j-nums[i]]

最终的转移方程为: dp[i][j] = dp[i-1][j] || (j >= nums[i] ? dp[i-1][j-nums[i]] : false)

初始化条件: dp[0][0] = true (前0个数凑出和0是可行的) dp[0][j] = false for j > 0 (前0个数无法凑出任何正数和)

2.3 空间优化技巧

观察到dp[i]只依赖于dp[i-1],可以使用一维数组滚动更新:

vector<bool> dp(target+1, false); dp[0] = true; for(int num : nums){ for(int j = target; j >= num; j--){ dp[j] = dp[j] || dp[j - num]; } }

这里内层循环需要倒序遍历,避免同一个数字被重复使用(如果是完全背包问题,即数字可重复使用,则需要正序遍历)。

3. 完整代码实现与解析

3.1 C++标准解法

#include <iostream> #include <vector> using namespace std; bool canSum(vector<int>& nums, int target) { vector<bool> dp(target + 1, false); dp[0] = true; for (int num : nums) { for (int j = target; j >= num; j--) { dp[j] = dp[j] || dp[j - num]; } } return dp[target]; } int main() { int n, target; cin >> n >> target; vector<int> nums(n); for (int i = 0; i < n; i++) { cin >> nums[i]; } cout << (canSum(nums, target) ? "YES" : "NO") << endl; return 0; }

3.2 代码关键点解析

  1. dp数组初始化:大小为target+1,因为需要考虑和为0到target的所有情况
  2. 外层循环:遍历每个数字,逐步考虑是否选择该数字
  3. 内层循环:从target倒序检查到当前数字值,避免重复使用
  4. 状态转移:dp[j] = dp[j] || dp[j-num] 表示当前和j可以通过不选或选当前数字达到

3.3 复杂度分析

  • 时间复杂度:O(n*target),其中n为数字个数
  • 空间复杂度:O(target),使用了一维dp数组

4. 变种问题与扩展思考

4.1 计算方案总数

如果题目要求计算达到目标和的方案数,只需修改状态转移方程:

dp[j] += dp[j - num];

初始化时dp[0]=1,其余为0。

4.2 限制选取数字个数

增加一维状态表示已选数字个数:

dp[i][k][j] // 前i个数选k个凑出和j

转移方程相应扩展,空间复杂度变为O(k*target)。

4.3 输出具体方案

需要额外记录路径信息,通常有两种方法:

  1. 使用二维数组记录每个状态的前驱
  2. 在dp完成后逆向回溯找出所选数字

5. 常见错误与调试技巧

5.1 初始化错误

  • 错误示例:忘记初始化dp[0]=true
  • 现象:所有结果都为false
  • 检查:打印dp数组初始状态

5.2 循环顺序错误

  • 错误示例:内层循环正序遍历
  • 现象:数字被重复计算(完全背包效果)
  • 修正:严格倒序遍历(01背包)或正序遍历(完全背包)

5.3 边界条件处理

  • 数字含负数:需要偏移处理,将可能的负和映射到正索引
  • 大target值:可能超出内存限制,需要考虑剪枝或其他算法

6. 洛谷平台提交注意事项

  1. 输入输出格式:严格匹配题目要求,包括换行符等细节
  2. 数据范围:预先计算所需内存,避免MLE(内存超出限制)
  3. 特殊测试用例
    • 空数组
    • target为0
    • 所有数字都大于target
  4. 时间复杂度估算:对于n=100,target=1e4的情况,O(n*target)=1e6,在C++中完全可接受

7. 动态规划学习建议

  1. 从背包问题入手:01背包、完全背包、多重背包是动态规划的经典模型
  2. 画状态转移表:对于二维dp问题,手工填写小规模例子的dp表有助于理解
  3. 分步调试:在IDE中单步执行,观察dp数组的变化过程
  4. 对比记忆化搜索:递归+记忆化的实现方式有时更直观,有助于理解状态定义

我在最初学习动态规划时,曾花费整整一周时间专门练习各种背包问题变种。建议初学者至少完成以下题目序列:

  • 洛谷P1048 采药(基础01背包)
  • 洛谷P1616 疯狂的采药(完全背包)
  • 洛谷P1064 金明的预算方案(依赖背包)
  • 本题P15800(综合应用)

动态规划的精髓在于"状态定义"和"无后效性"。一旦设计出正确的状态表示,问题就解决了一大半。在实际比赛中,我通常会先在草稿纸上明确写出:dp数组的含义、转移方程、初始条件和最终答案的位置,确认无误后再开始编码。

← 返回列表