完全背包理论基础
文章链接:完全背包理论基础
视频链接:带你学透完全背包问题!
完全背包问题的定义
有N
件物品和一个最多能背重量为W
的背包。第i
件物品的重量是weight[i],得到的价值是value[i]
。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
每件商品有无限个,问背包能背的物品的最大价值是多少?
与01背包的核心区别
先看01背包遍历的核心代码
for (int i = 0; i < weight.size(); i++) {
for (int j = bagweight; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
01背包的内循环是从大到小遍历的,目的就是为了保证每个物品只被添加一次;
那么对于完全背包问题的物品可以多次添加,所以要从小到大去遍历,这样的遍历方式使得每个物品可以在更新当前容量 j
的时候重复利用之前已经计算过的结果(也就是说在同一个 i
循环中,dp[j]
可以从 dp[j - weight[i]]
中获得更新,而 dp[j - weight[i]]
可能刚刚在本轮循环中被更新过),从而允许每个物品被多次选取。
//先遍历物品,再遍历背包
for (int i = 0; i < weight.size(); i++) {
for (int j = weight[i]; j <= bagWeight; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
为什么完全背包的循环顺序可以互换?
在0-1背包理论基础(一)、0-1背包理论基础之滚动数组(二)文章中已经指出在01背包问题中,一维dp数组的两个for循环一定是先遍历物品,再遍历背包容量。
在完全背包问题中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!
1. 先遍历物品,再遍历背包容量
for (int i = 0; i < n; i++) { // 遍历物品
for (int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
我们可以看出,每次考虑一个物品,然后更新所有可能的背包容量。由于是正序更新,所以 dp[j]
可以反复从 dp[j - weight[i]]
获取价值,实现了物品的重复选择。状态图展示如下:
2. 先遍历背包容量,再遍历物品
for (int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for (int i = 0; i < n; i++) { // 遍历物品
if (j >= weight[i]) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
}
在这种情况下,每个背包容量都尝试添加所有可能的物品。这样的循环同样可以正常工作,因为每个 dp[j]
都会考虑是否加入每个物品 i
,并且仍然可以通过 dp[j - weight[i]]
反复获得价值,从而实现物品的重复选择。
CPP代码
这里是卡码网第52题问题的答案。
#include <bits/stdc++.h>
using namespace std;
int numsMaterials;
int bagWeight;
void solve () {
vector<int> weights(numsMaterials, 0);
vector<int> values(numsMaterials, 0);
int weight, value;
for (int i = 0; i < numsMaterials; i++) {
int weight, value;
cin >> weight >> value;
weights[i] = weight;
values[i] = value;
}
vector<int> dp(bagWeight + 1, 0);
for (int i = 0; i < numsMaterials; i++) {
for (int j = weights[i]; j<= bagWeight; j++) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
cout << dp[bagWeight] <<endl;
}
int main () {
cin >> numsMaterials >> bagWeight;
solve();
return 0;
}
⭐️518.零钱兑换II
文章链接:518.零钱兑换II
视频链接:装满背包有多少种方法?组合与排列有讲究!| LeetCode:518.零钱兑换II
状态:「错误点」
1. 关于dp[0]应该初始化为1,因为系统后台默认的是dp[0]应该等于1
2. 第一个遍历物品的时候肯定从零开始啊!不知道为什么第一次写的时候从1开始了。
思路
首先可以确定bagWeight=amount=5
,再一个weight=value=coins
。再一个本题和纯完全背包问题还不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!
- 确定dp数组以及下标的含义
dp[j]
:凑成总金额j
的货币组合为dp[j]
- 确定递推公式
dp[j]
就是所有的dp[j-coins[i]]
情况相加。我们已经在这篇文章中讨论过该类问题:494.目标和
- dp数组如何初始化
卡哥文章里写了,后台测试数据是默认,amount = 0 的情况,组合数为1的。
也就是说dp[0]=1
——凑成总金额0的货币组合数为1。
下标非0的dp[j]
初始化为0,这样累计加dp[j - coins[i]]
的时候才不会影响真正的dp[j]
- 确定遍历顺序
对于一个纯背包问题来说,遍历顺序并不重要,因为他是一个排列问题.
但是本题中是一个明显的组合问题,比如说我们可以有一种分配方法是{1, 5}但是绝对不能再有{5, 1}。所以对于遍历顺序而言一定是 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
dp[j] += dp[j - coins[i]];
}
}
- 举例推导dp数组
输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:
CPP代码
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> 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];
}
};
⭐️377. 组合总和 Ⅳ
文章链接:377. 组合总和 Ⅳ
视频链接:装满背包有几种方法?求排列数?| LeetCode:377.组合总和IV
状态:典型的排列问题,其实就是一个区别,就是遍历顺序的问题,只要我们先遍历背包,再遍历物品,就可以把物品进行反复选择,从而得出排列总和为target的个数
首先先明确一下什么是排列,什么是组合:
-
组合不强调顺序,(1,5)和(5,1)是同一个组合。
-
排列强调顺序,(1,5)和(5,1)是两个不同的排列。
我们在写回溯的时候,写过几次组合总和的问题,里面其实本质也是求排列,不过回溯是要求把所有的排列都列出来,而不是求排列总和相等的个数。
如果本题要把排列都列出来的话,只能使用回溯算法爆搜。
思路
- 确定dp数组以及下标的含义
dp[i]
:凑成目标正整数为i
的排列个数为dp[i]
- 确定递推公式
dp[j]
(考虑nums[j]
)可以由 dp[i - nums[j]]
(不考虑nums[j]
) 推导出来。
因为只要得到nums[j]
,排列个数dp[j - nums[i]]
,就是dp[j]
的一部分。
本题还是我们经常谈论的,求装背包有几种方法,递推公式一般都是dp[j] += dp[j - nums[i]]
。
- dp数组如何初始化(dp的初始化非常重要)
在求装满背包的多少种组合问题时,其实就是让dp[0]
初始化为1,这样递归其他dp[i]的时候才会有数值基础。
然后非零下标初始化为0
- 确定遍历顺序
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
- 举例来推导dp数组(当题目不能AC的时候一定要进行尝试)
CPP代码
关于递推公式前的条件判断语句:
一方面是防止下标超过索引下标;
另一方面防止整数溢出。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int i = 0; i <= target; i++) { // 遍历背包
for (int j = 0; j < nums.size(); j++) { // 遍历物品
if (i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]) {
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
//直接讲元素定义成题目规定的uint
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<uint> dp(target + 1, 0);
dp[0] = 1;
for (int j = 0; j <= target; j++) {
for (int i = 0; i < nums.size(); i++) {
if (j >= nums[i])
dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
};
扩展题
还记得我们的爬楼梯吗?
爬楼梯中,如果需要n阶才能爬到楼顶,每次你可以爬 1 或 2 个台阶。有多少种不同的方法可以爬到楼顶呢?
进一步:
如果一次可以爬3、4甚至m个台阶,一共需要爬n阶才能爬到楼顶,又如何求爬到楼顶的方法数呢?
联系到本题来看,
一步可以爬几个台阶,就相当于本题的nums=[1, 2, 3]
,就是一步可以爬1、2、3个台阶,target
就相当于是要target
阶才能爬到楼顶。
也就是装满这个背包(爬到楼顶)有多少种方法,典型的排列问题,和本题是一样一样的。
明天我们就是爬楼梯(进阶版)