01背包
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
举例:背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
二维动态规划
dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大为dp[i][j]。
那么可以有两个方向推出来dp[i][j]:
- 不放物品i: 由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
- 放物品i: 由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值。
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagweight = 4;
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
遍历顺序的解释
由于dp[i][j]是由dp[i - 1][j], dp[i - 1][j - weight[i]]得出来的,根据递推方向,所以i要从小到大,j要从小到大。
那么背包和物品的遍历顺序呢? 在二维数组中先遍历背包还是先遍历物品没有区别。
需要注意的是,二维数组需要考虑jj < weight[i]的情况(和上一轮数值相等,手动赋值),而一维数组直接沿用上一轮的数值,不必考虑。
一维动态数组(滚动数组)
dp[j]为容量为 j 的背包所背的最大价值。
dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值。
所以递归公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
总体代码如下:
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
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]);
}
}
}
遍历顺序的解释
1.i、j大小的遍历顺序:
1.1 i的遍历顺序和二维一致
1.2 j的遍历顺序 一维数组和二维数组不一样的地方在于,二维数组中通过为dp[i-1][j-weight[i]来推导dp[i][j],在一维数组中没有i这个维度,而是重复利用一维数组的元素。 如果j从小到大遍历,当需要dp[i-1][j-weight[i]]时,用到的dp[j-weight[i]]已经不是i-1这一轮的数值了,而是被覆盖为i这一轮的数值,这样其实就是重复放了物品i !
例如:
物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!
2.背包和物品的遍历顺序:
再看看两个嵌套for循环的顺序,代码中是先遍历物品,嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。例如求dp[4]时,j=4,i=0,需要由max(dp[4], dp[4 - weight[0]] + value[0]),即max(dp[4],dp[3] +value[0])由于先遍历的背包容量,因此此时的dp[3],dp[4]还没有求,仍然为初始值0,此时最大值必然为value[0],即每个dp[j]相当于只放了一个物品,因此不能先遍历背包容量。
总结
二维数组遍历顺序没有要求,但需要考虑j < weight[i]的情况
一维数组遍历背包时必须从大到小遍历,否则会导致重复选取物品; 必须先遍历物品,再遍历背包, 否则会导致每个dp[j]只放一个物品。一维数组不用考虑j < weight[i]的情况,直接沿用上一轮的情况。
完全背包有
N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
其中每件商品都有无数个。问背包能背的物品最大价值是多少?
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
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]);
}
}
}
遍历顺序的解释
1.物品和背包的遍历顺序:
对于纯完全背包问题,先遍历物品再遍历背包和先遍历背包,后遍历物品都可以。
2.i、j大小遍历顺序
01背包内嵌的循环 j(一维数组) 是从大到小遍历,为了保证每个物品仅被添加一次。而完全背包的物品是可以添加多次的,所以要从小到大去遍历。
完全背包的排列组合问题
由于完全背包问题中的物品可以重复拿取,因此会产生拿取顺序的问题,组合问题即不考虑拿取先后顺序的问题,而排序问题需要考虑拿取的先后顺序。此外,如果求的是每个排列组合中的个数,则元素的顺序不重要,既可以有顺序,也可以没有顺序。
组合问题
如leecode518题,
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例 :
输入: amount = 5, coins = [1, 2, 5] 输出: 4 解释: 有四种方式可以凑成总金额: 5=5 5=2+2+1 5=2+1+1+1 5=1+1+1+1+1
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount+1,0);
dp[0]=1;
//如果先遍历背包就编程排列问题,而不是组合问题
/*//以下为先遍历背包
这会导致出现{1,2}与{2,1},而先遍历物品,物品i增加后不会回头重复添加
for(int j=·;j<=amount;++j){//先遍历背包
for(int i=0;i<coins.size();++i){//先遍历物品
if(j>=coins[i]) dp[j]+=dp[j-coins[i]];
}
}
*/
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];
}
};
排列问题
例如Leecode377.组合总和Ⅳ:
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
题目数据保证答案符合 32 位整数范围。
请注意,顺序不同的序列被视作不同的组合。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> 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] < INT_MAX - dp[j - nums[i]]){
dp[j]+=dp[j-nums[i]];
}
}
}
return dp[target];
}
};
个数问题
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例 1: 输入:coins = [1, 2, 5], amount = 11 输出:3 解释:11 = 5 + 5 + 1
// 版本一
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包
if (dp[j - coins[i]] != INT_MAX) { // 如果dp[j - coins[i]]是初始值则跳过
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
if (dp[amount] == INT_MAX) return -1;
return dp[amount];
}
}
// 版本二
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i <= amount; i++) { // 遍历背包
for (int j = 0; j < coins.size(); j++) { // 遍历物品
if (i - coins[j] >= 0 && dp[i - coins[j]] != INT_MAX ) {
dp[i] = min(dp[i - coins[j]] + 1, dp[i]);
}
}
}
if (dp[amount] == INT_MAX) return -1;
return dp[amount];
}
}
可以看到,求排列组合中元素最小个数而非求排列组合的数量,此时先遍历背包还是先遍历物品都可以。