二维费用0/1背包问题:从NASA食物计划到动态规划实践 1. 从NASA食物计划到背包问题一个有趣的建模案例第一次看到洛谷P1507这道题目时我被它独特的背景设定吸引了——NASA的航天食物计划竟然和经典的背包问题产生了联系。这让我想起2015年SpaceX为国际空间站运送补给时的真实场景每个货舱都有严格的重量和体积限制而不同食物提供的营养价值和占用空间各不相同。工程师们需要在这些约束条件下做出最优选择。这道题目正是将这种现实问题抽象化为计算机科学中的二维费用0/1背包问题。与传统的背包问题不同这里需要考虑两个维度的限制条件通常是重量和体积同时每个物品只能选择放入或不放入0/1性质。这种模型广泛应用于资源分配、投资组合优化等场景。2. 理解二维费用0/1背包问题的本质2.1 问题形式化定义给定n种食物每种食物有三个属性质量w_i体积v_i卡路里c_i背包的两个容量限制最大承重W最大体积V目标 选择若干食物装入背包使得总质量 ≤ W总体积 ≤ V总卡路里最大化每种食物最多选一次0/1性质2.2 与经典背包问题的对比传统0/1背包问题只需要考虑单一约束通常是重量状态转移方程为dp[j] max(dp[j], dp[j-w[i]] v[i])而二维费用背包需要同时跟踪两个维度的消耗因此状态表示需要扩展为三维dp[i][j][k] 表示前i个物品重量不超过j体积不超过k时的最大价值在实际编程中为了优化空间复杂度我们通常使用滚动数组技巧将三维降为二维dp[j][k] max(dp[j][k], dp[j-w[i]][k-v[i]] c[i])3. 动态规划解法详解3.1 状态定义与初始化我们定义dp[j][k]表示当背包重量限制为j、体积限制为k时能够获得的最大卡路里值。初始化时dp[0][0] 0其余位置初始化为-∞表示不可达状态因为我们需要精确满足重量和体积的限制。vectorvectorint dp(W1, vectorint(V1, -INF)); dp[0][0] 0;注意有些问题允许不超过限制即可此时可以初始化为0。但本题通常理解为必须严格不超过因此采用-∞初始化更安全。3.2 状态转移方程对于每个食物i我们需要逆序更新dp数组这是0/1背包的经典优化避免重复选择for(int i 0; i n; i) { for(int j W; j w[i]; j--) { for(int k V; k v[i]; k--) { if(dp[j-w[i]][k-v[i]] ! -INF) { dp[j][k] max(dp[j][k], dp[j-w[i]][k-v[i]] c[i]); } } } }3.3 结果提取最终答案是在所有可能的j≤W和k≤V组合中dp[j][k]的最大值int ans 0; for(int j 0; j W; j) { for(int k 0; k V; k) { ans max(ans, dp[j][k]); } } cout ans endl;4. 算法优化与边界处理4.1 空间优化技巧虽然我们使用了二维dp数组但实际上可以进一步优化空间。观察到每次更新只依赖于左上方向的值因此可以像经典背包问题一样使用逆序更新for(int i 0; i n; i) { for(int j W; j w[i]; j--) { for(int k V; k v[i]; k--) { if(dp[j-w[i]][k-v[i]] ! -INF) { dp[j][k] max(dp[j][k], dp[j-w[i]][k-v[i]] c[i]); } } } }4.2 输入数据的预处理在实际编程竞赛中处理输入数据时常会遇到坑点。对于本题重量和体积的限制可能为0虽然实际中不太可能物品的重量或体积可能超过背包限制这类物品可以直接过滤掉卡路里值可能为负数虽然题目通常不会这样设计建议的预处理代码vectortupleint,int,int items; // w, v, c for(int i 0; i n; i) { int w, v, c; cin w v c; if(w W v V) { // 过滤无效物品 items.emplace_back(w, v, c); } } n items.size(); // 更新有效物品数量4.3 时间复杂度分析该算法的时间复杂度为O(nWV)其中n是物品数量W是最大承重V是最大体积当W和V较大时如1e4级别这个算法可能不够高效。但在竞赛中通常n≤100W,V≤500这样的复杂度是可以接受的。5. 完整代码实现与测试用例5.1 完整C代码#include iostream #include vector #include algorithm #include climits using namespace std; const int INF INT_MIN; int main() { int W, V; cin W V; int n; cin n; vectortupleint,int,int items; for(int i 0; i n; i) { int w, v, c; cin w v c; if(w W v V) { items.emplace_back(w, v, c); } } n items.size(); vectorvectorint dp(W1, vectorint(V1, -INF)); dp[0][0] 0; for(const auto [w, v, c] : items) { for(int j W; j w; j--) { for(int k V; k v; k--) { if(dp[j-w][k-v] ! -INF) { dp[j][k] max(dp[j][k], dp[j-w][k-v] c); } } } } int ans 0; for(int j 0; j W; j) { for(int k 0; k V; k) { ans max(ans, dp[j][k]); } } cout ans endl; return 0; }5.2 测试用例设计好的测试用例应该覆盖各种边界情况基本测试用例10 10 3 5 5 100 6 6 120 4 4 80预期输出180选择第一和第三个物品边界测试0 0 1 0 0 50预期输出50虽然不实际但测试代码鲁棒性无解情况5 5 2 6 6 100 7 7 120预期输出0所有物品都超限完全利用空间10 10 2 10 10 200 5 5 100预期输出200选择单个物品比组合更优6. 常见错误与调试技巧6.1 初始化错误常见错误是将dp数组初始化为0而不是-INF。这会导致算法可能违反重量或体积限制因为0表示任何超限的状态都有0卡路里。调试技巧打印出dp数组的中间状态检查是否在未满足条件的情况下获得了正值。6.2 更新顺序错误在0/1背包问题中内层循环必须逆序更新否则会变成完全背包问题物品可以重复选择。这是新手常犯的错误。// 错误写法正序更新 for(int j w[i]; j W; j) { for(int k v[i]; k V; k) { // 这会允许重复选择物品 } }6.3 结果提取不完整有些同学只在dp[W][V]处取值忽略了可能在较小重量/体积下获得更高卡路里的情况。正确的做法是遍历整个dp数组找最大值。6.4 输入处理遗漏没有过滤掉那些单独就超过背包限制的物品导致数组越界或错误结果。务必在读取输入时就进行过滤。7. 问题变种与扩展思考7.1 多维费用背包本题是二维但可以扩展到更多维度。例如考虑重量限制体积限制价格限制运输时间限制此时dp数组的维度也会相应增加但核心思路不变。7.2 分组背包变种如果食物分为若干类别如主食、蔬菜、水果每类只能选有限数量就变成了分组背包问题。这需要额外的状态来记录类别选择情况。7.3 背包问题与贪心算法有些同学可能会想为什么不先计算单位重量体积的卡路里密度然后贪心选择这在分数背包中有效但在0/1背包中可能得不到最优解。例如背包限制W10, V10 物品1w6,v6,c60密度10 物品2w5,v5,c55密度11 物品3w5,v5,c54密度10.8贪心会选择物品2物品3109但最优解是物品1物品2115。7.4 记忆化搜索实现除了递推的dp解法也可以用递归记忆化来实现。虽然通常效率略低但有时更直观int memo[101][101][101]; // i, j, k int dfs(int i, int j, int k) { if(i n) return 0; if(memo[i][j][k] ! -1) return memo[i][j][k]; int res dfs(i1, j, k); // 不选 if(j w[i] k v[i]) { res max(res, dfs(i1, j-w[i], k-v[i]) c[i]); // 选 } return memo[i][j][k] res; }8. 实际应用与学习建议二维费用背包问题在现实中有着广泛应用投资组合优化资金限制风险限制生产计划原材料限制时间限制运输调度载重限制空间限制对于想深入学习动态规划的同学我建议从经典0/1背包开始彻底理解状态设计和转移方程逐步增加维度二维费用、分组背包等对比记忆化搜索和递推dp的实现差异尝试自己出一些变种题目并解决洛谷上的P1507是一个很好的二维费用背包入门题。通过这道题我们不仅学习了一个算法模板更重要的是培养了将实际问题抽象为计算模型的能力——这正是计算机科学最迷人的地方之一。