完全背包
动态规划:完全背包理论基础
本题力扣上没有原题,大家可以去卡码网第52题去练习,题意是一样的。
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。
背包最大重量为4。
物品为:
重量 价值
物品0 1 15
物品1 3 20
物品2 4 30
每件商品都有无限个!
问背包能背的物品最大价值是多少?
01背包和完全背包唯一不同就是体现在遍历顺序上,所以本文就不去做动规五部曲了,我们直接针对遍历顺序经行分析!
关于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背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。
而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:
// 先遍历物品,再遍历背包
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]);
}
}
至于为什么,我在动态规划:关于01背包问题,你该了解这些!(滚动数组) (opens new window)中也做了讲解。
动态规划-完全背包
相信很多同学看网上的文章,关于完全背包介绍基本就到为止了。
其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环?
这个问题很多题解关于这里都是轻描淡写就略过了,大家都默认 遍历物品在外层,遍历背包容量在内层,好像本应该如此一样,那么为什么呢?
难道就不能遍历背包容量在外层,遍历物品在内层?
看过这两篇的话:
就知道了,01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!
因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。
遍历物品在外层循环,遍历背包容量在内层循环,状态如图:
动态规划-完全背包1
遍历背包容量在外层循环,遍历物品在内层循环,状态如图:
动态规划-完全背包2
看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])。
先遍历背包在遍历物品,代码如下:
// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cout << endl;
}
完整的C++测试代码如下:
// 先遍历物品,在遍历背包
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]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
// 先遍历背包,再遍历物品
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 j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
细心的同学可能发现,全文我说的都是对于纯完全背包问题,其for循环的先后循环是可以颠倒的!
但如果题目稍稍有点变化,就会体现在遍历顺序上。
如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。
这个区别,我将在后面讲解具体leetcode题目中给大家介绍,因为这块如果不结合具题目,单纯的介绍原理估计很多同学会越看越懵!
别急,下一篇就是了!
最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后再问,两个for循环的先后是否可以颠倒?为什么? 这个简单的完全背包问题,估计就可以难住不少候选人了
518.零钱兑换II
518.零钱兑换II
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例 1:
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
示例 2:
输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。
示例 3:
输入: amount = 10, coins = [10]
输出: 1
注意,你可以假设:
0 <= amount (总金额) <= 5000
1 <= coin (硬币面额) <= 5000
硬币种类不超过 500 种
结果符合 32 位符号整数
思路
前提:每一种面额的硬币有无限个
要求: 凑出总金额的硬币组合数。有几种方法可以凑出这个总金额
dp[5] = sum(dp[4],dp[3], dp[2],dp[1] + dp[0])
dp[j]:凑出总金额为j的硬币有dp[j]种方法。
dp[j] = dp[j-1] + dp[j-2]+...+dp[1] + dp[j-j]
if count == 0: return 0
dp = [0] * (amount+1)
dp[0] = 1
for coin in coins:
for j in range(amount+1):
if j >= coin:
dp[j] += dp[j-coin]
amount = 5, coins = [1, 2, 5]
dp[0] = 1
coin = 1
j = 1,2,3,4,5
for coin in coins:
for j in range(amount+1):
if j >= coin:
dp[j] =dp[j] + dp[j-1]
dp[1] =dp[1] + dp[0] = 0 + 1=1
dp[2] =dp[2] + dp[1] = 0 + 1=1
dp[3] =dp[3] + dp[2] = 0 + 1=1
dp[4] =dp[4] + dp[3] = 0 + 1=1
dp[5] =dp[5] + dp[4] = 0 + 1=1
coin = 2
j = 2,3,4,5
for coin in coins:
for j in range(amount+1):
if j >= coin:
dp[j] =dp[j] + dp[j-1]
dp[2] =dp[2] + dp[2-2] = 1 + 1 = 2
dp[3] =dp[3] + dp[3-2] = 1 + 1 = 2
dp[4] =dp[4] + dp[4-2] = 1 + 2 = 3
dp[5] =dp[5] + dp[5-2] = 1 + 2 = 3
count = 5
j >= 5
dp[5]=dp[5] + dp[5-5] = 3 + 1 = 4
return dp[5]
周一
动态规划:目标和! 要求在数列之间加入+ 或者 -,使其和为S。
所有数的总和为sum,假设加法的总和为x,那么可以推出x = (S + sum) / 2。
S 和 sum都是固定的,那此时问题就转化为01背包问题(数列中的数只能使用一次): 给你一些物品(数字),装满背包(就是x)有几种方法。
确定dp数组以及下标的含义
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
确定递推公式
dp[j] += dp[j - nums[i]]
注意:求装满背包有几种方法类似的题目,递推公式基本都是这样的。
dp数组如何初始化
dp[0] 初始化为1 ,dp[j]其他下标对应的数值应该初始化为0。
确定遍历顺序
01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
494.目标和
这道题目动态规划:一和零! (opens new window)算有点难度。
不少同学都以为是多重背包,其实这是一道标准的01背包。
这不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。
所以这是一个二维01背包!
确定dp数组(dp table)以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
确定递推公式
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
字符串集合中的一个字符串0的数量为zeroNum,1的数量为oneNum。
dp数组如何初始化
因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。
确定遍历顺序
01背包一定是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!
举例推导dp数组
以输入:["10","0001","111001","1","0"],m = 3,n = 3为例
最后dp数组的状态如下所示:
474.一和零
此时01背包我们就讲完了,正式开始完全背包。
在动态规划:关于完全背包,我们讲解了理论基础。
其实完全背包和01背包区别就是完全背包的物品是无限数量。
递推公式也是一样的,但难点在于遍历顺序上!
完全背包的物品是可以添加多次的,所以遍历背包容量要从小到大去遍历,即:
// 先遍历物品,再遍历背包
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]);
}
}
基本网上题的题解介绍到这里就到此为止了。
那么为什么要先遍历物品,在遍历背包呢? (灵魂拷问)
其实对于纯完全背包,先遍历物品,再遍历背包 与 先遍历背包,再遍历物品都是可以的。
这个细节是很多同学忽略掉的点,其实也不算细节了,相信不少同学在写背包的时候,两层for循环的先后循序搞不清楚,靠感觉来的。
所以理解究竟是先遍历啥,后遍历啥非常重要,这也体现出遍历顺序的重要性!
在文中,我也强调了是对纯完全背包,两个for循环先后循序无所谓,那么题目稍有变化,可就有所谓了。
在动态规划:给你一些零钱,你要怎么凑? (opens new window)中就是给你一堆零钱(零钱个数无限),为凑成amount的组合数有几种。
注意这里组合数和排列数的区别!
看到无限零钱个数就知道是完全背包,
但本题不是纯完全背包了(求是否能装满背包),而是求装满背包有几种方法。
这里在遍历顺序上可就有说法了。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
这里同学们需要理解一波,我在文中也给出了详细的解释,下周我们将介绍求排列数的完全背包题目来加深对这个遍历顺序的理解。
相信通过本周的学习,大家已经初步感受到遍历顺序的重要性!
很多对动规理解不深入的同学都会感觉:动规嘛,就是把递推公式推出来其他都easy了。
其实这是一种错觉,或者说对动规理解的不够深入!
我在动规专题开篇介绍关于动态规划,你该了解这些! (opens new window)中就强调了 递推公式仅仅是 动规五部曲里的一小部分, dp数组的定义、初始化、遍历顺序,哪一点没有搞透的话,即使知道递推公式,遇到稍稍难一点的动规题目立刻会感觉写不出来了。
此时相信大家对动规五部曲也有更深的理解了,同样也验证了Carl之前讲过的:简单题是用来学习方法论的,而遇到难题才体现出方法论的重要性!
377. 组合总和 Ⅳ
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
示例:
nums = [1, 2, 3]
target = 4
所有可能的组合为: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1)
请注意,顺序不同的序列被视作不同的组合。
因此输出为 7。
思路
正整数, 不重复, 找和, 使得和为target的组合数。
target = 1
1
target = 2
1 1
2
target = 3
1 1 1
1 2
2 1
3
target = 4
1 1 1 1
1 2 1
2 1 1
3 + 1
1 + 1 + 2
2 + 2
1 + 3
dp[j] : j的组合个数为dp[j]
dp[j] = dp[j] + dp[j-1] + dp[j-2] + dp[j-3]+...+dp[j-j]
dp = [0] * (target + 1)
dp[0] = 1
for v in nums:
for j in range(0, target+1):
if j >= v:
dp[j] += dp[j-v]
return dp[target]
70. 爬楼梯(进阶版)
70. 爬楼梯(进阶版)
卡码网:57. 爬楼梯(opens new window)
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
输入描述:输入共一行,包含两个正整数,分别表示n, m
输出描述:输出一个整数,表示爬到楼顶的方法数。
输入示例:3 2
输出示例:3
当 m = 2,n = 3 时,n = 3 这表示一共有三个台阶,m = 2 代表你每次可以爬一个台阶或者两个台阶。
此时你有三种方法可以爬到楼顶。
1 阶 + 1 阶 + 1 阶段
1 阶 + 2 阶
2 阶 + 1 阶
思路
这是一个排列问题
dp[j]:爬到 j 有 dp[j] 种方法。
dp[j] = dp[j-1] + dp[j-2] + dp[j-3]+...+dp[j-n]
dp = [0] * (n*1)
dp[0] = 1
for j in range(1, n+1):
for i in range(1, m+1):
if j >= i:
dp[j] += dp[j-i]
return dp[n]
j = 1
只有1
j = 2
dp[2-1] + dp[2-2] = 1 + 1 = 2
j = 3
dp[j-1] + dp[j-2] = dp[2] +dp[1] = 1 + 2 = 3
j = 4
dp[j-1] + dp[j-2] = dp[3] +dp[2] = 3 + 2 = 5