理论基础
完全背包问题
在完全背包 问题中,每种物品都有无限个,我们可以选择任意个数 (包括不选),放入一个容量为 W W W 的背包中。我们希望在不超过容量的情况下,最大化背包内物品的总价值。
完全背包:二维
1. 定义dp数组
dp[i][j]
表示在前 i
种物品中(每种可以无限使用),容量为 j
的背包能取得的最大价值。
2. 状态转移公式
在考虑当前物品 i
,背包容量为 j
时,依旧有两个选择:
- 不选当前物品 →
dp[i - 1][j]
- 选当前物品 →
dp[i][j - w[i]] + v[i]
这里和0-1背包最大的区别就是,0-1背包 是根据上一行的值进行状态转移的,而完全背包 是从当前行进行状态转移的,因为在后者的语境下物品可以被选择无限次 !也就是选了当前物品i
之后,还可以再选它。因此转移思路可以写为如下代码:
cpp
dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i])
3. 初始化dp数组
因为状态转移需要用到前一行,所以需要初始化选择物品0
的情况,能放多少就放多少。
cpp
for (int j = weight[0]; j <= bagSize; j++) {
dp[0][j] = (j / weight[0]) * value[0];
}
4. 代码实践
cpp
int completeKnapsack2D(int bagSize, const vector<int>& weight, const vector<int>& value) {
int n = weight.size();
vector<vector<int>> dp(n, vector<int>(bagSize + 1, 0));
for (int j = weight[0]; j <= bagSize; j++) {
dp[0][j] = (j / weight[0]) * value[0];
}
for (int i = 1; i < n; i++) {
for (int j = 0; j <= bagSize; j++) {
if (j < weight[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
}
}
}
return dp[n - 1][bagSize];
}
完全背包:一维
可以根据上述的二维数组优化成滚动数组的写法,如下:
cpp
int completeKnapsack1D(int bagSize, const vector<int>& weight, const vector<int>& value) {
int n = weight.size();
vector<int> dp(bagSize + 1, 0);
for (int j = weight[0]; j <= bagSize; j++) {
dp[j] = (j / weight[0]) * value[0];
}
for (int i = 1; i < n; i++) {
for (int j = weight[i]; j <= bagSize; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
}
return dp[bagSize];
}
有一点很重要,这里必须正序遍历 !二维完全背包问题的状态转移依赖的值包含了当前行和上一行,我们需要在遍历的时候保证"当前物品可以被重复选择"这一特性。
在一维的方法中,当遍历到容量 j
,我们在用 dp[j - weight]
来更新 dp[j]
,而这个 dp[j - weight]
,必须是**这一轮已经更新过的结果。**也就是说,它需要我们已经考虑了当前物品被使用过的状态,只有正序遍历可以实现这一点!
我们遍历 j = weight[i]
到 bagSize
:
cpp
for (int j = weight[i]; j <= bagSize; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
在这个过程中:
dp[j - weight[i]]
是容量更小的状态。- 如果
dp[j - weight[i]]
是在 这一轮已经被当前物品i
更新过的,那么这就代表它可能已经放了 1 个、2 个... 当前物品。 - 现在再放一个当前物品,就相当于「我又放了一个」,这代表我们重复使用了当前物品。
如果是倒序,那么又回到了0-1背包问题:此时dp[j - weight]
是上一轮的旧值,它不包含"选了当前物品的状态",所以只能选一次,不能重复使用,刚好满足0-1背包的需要。
cpp
for (int i = 0; i < n; i++) {
for (int j = bagweight; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
最后总结一下:完全背包 正序遍历时,dp[j - weight[i]]
可能已经包含了当前物品的若干次选择,所以能继续"再来一次";而倒序时只用旧值,不能重复选择。列了个表格,便于进一步记忆和比较:
特点 | 0-1 背包 | 完全背包(unbounded) |
---|---|---|
每件物品数量 | 只能选一次 | 可以选多次(无限) |
状态转移 | 只能用一次的状态 | 可以重复使用 |
遍历顺序 | j 倒序遍历 |
j 正序遍历 |
题目
力扣 518 零钱兑换 II
本问题是一个组合 问题,所以外层遍历物品,内层遍历容量。对于每个金额j
,枚举有哪些组合方式能够凑齐这个金额,与顺序无关。代码如下:
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<uint64_t> dp(amount + 1, 0);
dp[0] = 1;
for(int i = 0; i < coins.size(); i++){
for(int j = coins[i]; j <= amount; j++){
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
此外,有两个点,第一是初始化只需要初始化j = 0
,组成零也就是都不选择,只有一种写法。第二是vector
里的类型,int
会溢出,因此写成uint64_t
也就是64位无符号。这里也复习一下:
类型 | 位数 | 最大值 |
---|---|---|
int |
32 位 | 约 21 亿(2³¹ - 1) |
long long / int64_t |
64 位有符号 | 约 9 × 10¹⁸ |
uint64_t |
64 位无符号 | 约 18 × 10¹⁸(2⁶⁴ - 1) |
力扣 377 组合总数 IV
本问题核心是排列 ,不同的排列方式单独算次数,因此外层遍历容量,内层遍历物品。也就是说对于每一个j
,每次都从头开始尝试所有的数,以此得到不同的顺序。
cpp
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<uint64_t> dp(target + 1, 0);
dp[0] = 1;
for(int j = 1; j <= target; j++){
for(int i = 0; i < nums.size(); i++){
if(j >= nums[i]) dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
};
例如dp[j]
中包含了:
dp[j - 1]
+ 用1
结尾dp[j - 2]
+ 用2
结尾
这样 [1,2]
和 [2,1]
被视为不同路径 ,即排列。
总结
外层循环 | 内层循环 | 问题 | 说明 |
---|---|---|---|
构造方式(物品) | 状态容量(目标) | 组合 | 每种构造路径只被考虑一次 |
状态容量(目标) | 构造方式(物品) | 排列 | 对每种状态尝试所有构造顺序 |
卡码 57 爬楼梯(进阶)AC
本题也是一个排列问题,可以把楼梯总数 n n n 看作背包容量,将 [ 1 , m ] [1,m] [1,m] 看作物体的重量,得到代码如下:
cpp
#include<iostream>
#include<vector>
using namespace std;
int main(){
int m, n;
while (cin >> n >> m){
vector<int> dp(n + 1, 0);
dp[0] = 1;
for(int j = 1; j <= n; j++){
for(int i = 1; i <= m; i++){
if(j >= i) dp[j] += dp[j - i];
}
}
cout<<dp[n]<<endl;
}
return 0;
}