完全背包问题是动态规划中的经典问题之一,与0/1背包问题类似但有一个关键区别:每种物品可以选取无限次。这个问题在实际中有广泛应用,比如资源分配、投资组合优化等场景。
我们先明确几个基本概念:
与0/1背包只能选一次不同,完全背包中每种物品可以选0次、1次...直到超过背包容量。这个特性使得它的状态转移方程推导更加复杂但同时也更有趣。
设f[i][j]表示前i种物品装入容量为j的背包的最大价值。对于第i种物品,我们面临的选择是:选0个、选1个...选k个(直到k*vol[i] > j)。
因此,状态转移可以表示为:
code复制f[i][j] = max{
f[i-1][j], // 不选第i种物品
f[i-1][j-vol[i]] + val[i], // 选1个
f[i-1][j-2*vol[i]] + 2*val[i], // 选2个
...
f[i-1][j-k*vol[i]] + k*val[i] // 选k个
}
这个表达式看起来非常冗长,我们需要找到简化的方法。
观察上面的表达式,我们发现其中存在重复计算。令j' = j - vol[i],则有:
code复制f[i][j'] = max{
f[i-1][j'],
f[i-1][j'-vol[i]] + val[i],
f[i-1][j'-2*vol[i]] + 2*val[i],
...
f[i-1][j'-(k-1)*vol[i]] + (k-1)*val[i]
}
现在我们在等式两边都加上val[i]:
code复制f[i][j'] + val[i] = max{
f[i-1][j'] + val[i],
f[i-1][j'-vol[i]] + 2*val[i],
f[i-1][j'-2*vol[i]] + 3*val[i],
...
f[i-1][j'-(k-1)*vol[i]] + k*val[i]
}
神奇的事情发生了:这个结果正好对应原始表达式中选1个、选2个...选k个的情况。因此我们可以将原始表达式简化为:
code复制f[i][j] = max(f[i-1][j], f[i][j-vol[i]] + val[i])
这个简化大大降低了计算复杂度,从需要考虑k种情况变为只需要比较两种情况。
观察二维状态转移方程,我们发现f[i][j]只依赖于:
这意味着我们可以将二维数组优化为一维数组,只需要确保在计算f[j]时,f[j-vol[i]]已经被更新过。
这与0/1背包问题的遍历顺序形成鲜明对比:
核心代码实现:
cpp复制for(int i=0; i<n; i++) {
for(int j=vol[i]; j<=V; j++) { // 正序是关键
f[j] = max(f[j], f[j-vol[i]] + val[i]);
}
}
假设背包容量V=8,有3种物品:
计算过程如下:
初始化:f[0]=0, f[1..8]=0
处理物品1(vol=2):
处理物品2(vol=3):
处理物品3(vol=4):
最终结果:f[8]=12
正序遍历时,当计算f[j]时,f[j-vol[i]]可能已经包含了当前物品的选取,因此相当于允许重复选择。这与现实生活中的"先拿一个,再拿一个"的过程是一致的。
常见错误包括:
建议的防御性编程:
cpp复制vector<int> f(V+1, 0); // 初始化为0
for(int i=0; i<n; i++) {
if(vol[i] > V) continue; // 跳过装不下的物品
for(int j=vol[i]; j<=V; j++) {
f[j] = max(f[j], f[j-vol[i]] + val[i]);
}
}
调试时可以输出中间结果:
cpp复制cout << "Processing item " << i << " (vol=" << vol[i] << ", val=" << val[i] << ")" << endl;
for(int j=0; j<=V; j++) {
cout << "f[" << j << "]=" << f[j] << " ";
}
cout << endl;
在实际编码比赛中,完全背包问题有几个容易出错的点值得注意:
遍历顺序混淆:我曾经多次因为把完全背包和0/1背包的遍历顺序搞反而失分。一个好的记忆方法是:完全背包是"无限供应",所以可以"正着拿";0/1背包是"只有一个",所以要"倒着拿"。
初始化的陷阱:如果题目要求"恰好装满",初始化方式完全不同。我建议在代码模板中加入清晰的注释说明不同场景的初始化要求。
空间优化的思维:从二维到一维的优化不是简单的技巧,而是对问题本质的理解。我建议初学者先写出二维版本,确保理解正确后再进行优化。
调试技巧:对于复杂的背包问题,我通常会先在小样例上手工计算,然后用打印语句验证每个物品处理后的状态数组,这样可以快速定位逻辑错误。
最后提醒一点:虽然一维实现简洁高效,但在解决一些变种问题时(如需要记录具体方案),可能需要回退到二维实现。理解两者的等价性和转换方法非常重要。