前言:
算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,计划用60天的时间刷完。
内容包括了面试常见的10类题目,分别是:数组,链表,哈希表,字符串,栈与队列,二叉树,回溯算法,贪心算法,动态规划,单调栈。
博客记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。
如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)
目录
完全背包基础知识
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode518. 零钱兑换
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode377.组合总和IV
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。
在下面的讲解中,我依然举这个例子:
背包最大重量为4。
物品为:
每件商品都有无限个!问背包能背的物品最大价值是多少?
本题不再按照动态规划五部曲来讲解,而是直接讲解完全背包和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背包dp状态图如下:每层的 j 逆序遍历。
完全背包dp状态图如下:每层的 j 正序遍历。
在01背包中,二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!
因为dp[j] 是根据 下标j之前所对应的 dp[j] 计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。
要理解这个区别,最好的办法还是举例子,然后模拟状态转移的过程;
完全背包中 先遍历物品再遍历背包
// 完全背包
// 先遍历物品,再遍历背包
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(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;
}
遍历背包容量在外层循环,遍历物品在内层循环,状态如图:
看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])
01背包中 如果先逆序遍历背包,再遍历物品呢?
// 01背包
// 先遍历背包,再遍历物品
for(int j = bagWeight; j >= 0; 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;
}
dp状态转移如图:
求出来是:背包可装下的最大的单件物品的价值
# 先遍历物品,再遍历背包
def test_complete_pack1():
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
dp = [0]*(bag_weight + 1)
for i in range(len(weight)):
for j in range(weight[i], bag_weight + 1):
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
print(dp[bag_weight])
# 先遍历背包,再遍历物品
def test_complete_pack2():
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
dp = [0]*(bag_weight + 1)
for j in range(bag_weight + 1):
for i in range(len(weight)):
if j >= weight[i]: dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
print(dp[bag_weight])
if __name__ == '__main__':
test_complete_pack1()
test_complete_pack2()
时间复杂度:O(N*bagSize);
其中N为物品个数,bagSize为背包容量,两层for loop需要遍历的;
空间复杂度:O(bagSize);
其中bagSize为背包容量,dp数组的大小为bagSize。
细心的同学可能发现,**全文我说的都是对于纯完全背包问题,其for循环的先后循环是可以颠倒的!**但如果题目稍稍有点变化,就会体现在遍历顺序上。
如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。
这个区别,我将在后面讲解具体leetcode题目中给大家介绍,因为这块如果不结合具题目,单纯的介绍原理估计很多同学会越看越懵!
最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后在问,两个for循环的先后是否可以颠倒?为什么? 这个简单的完全背包问题,估计就可以难住不少候选人了。
Reference: 代码随想录 (programmercarl.com)
本篇学习时间:90分钟。
链接:518. 零钱兑换 II - 力扣(LeetCode)
这是一道典型的背包问题,一看到钱币数量不限,就知道这是一个完全背包。
但本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!
注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?
例如示例一:
5 = 2 + 2 + 1
5 = 2 + 1 + 2
这是一种组合,都是 2 2 1。
如果问的是排列数,那么上面就是两种排列了。
组合不强调元素之间的顺序,排列强调元素之间的顺序。 其实这一点我们在讲解回溯算法专题的时候就讲过了哈。
那我为什么要介绍这些呢,因为这和下文讲解遍历顺序息息相关!
dp[j]:凑成总金额j的货币组合数为dp[j]
dp[j] 就是所有的dp[j - coins[i]](考虑coins[i]的情况)相加。
所以递推公式:dp[j] += dp[j - coins[i]];
这个递推公式大家应该不陌生了,我在讲解01背包题目的时候在这篇494. 目标和 (opens new window)中就讲解了,求装满背包有几种方法,
公式都是:dp[j] += dp[j - nums[i]];
回顾 LeetCode494.目标和 的推导:
有哪些来源可以推出dp[j]呢?
只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
dp[0]
首先dp[0]一定要为1,dp[0] = 1是 递归公式的基础。如果dp[0] = 0 的话,后面所有推导出来的值都是0了。
那么 dp[0] = 1 有没有含义,其实既可以说 凑成总金额0的货币组合数为1,也可以说 凑成总金额0的货币组合数为0,好像都没有毛病。
但题目描述中,也没明确说 amount = 0 的情况,结果应该是多少。这里我认为题目描述还是要说明一下,因为后台测试数据是默认,amount = 0 的情况,组合数为1的;
非零下标dp[ j ]
下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]。
本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?
我在**动态规划:关于完全背包,你该了解这些! (opens new window)**中讲解了完全背包的两个for循环的先后顺序都是可以的。
但本题就不行了!
因为纯完全背包求得装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!而本题要求凑成总和的组合数,元素之间明确要求没有顺序。所以纯完全背包是能凑成总和就行,不用管怎么凑的。本题是求凑出来的方案个数,且每个方案个数是为组合数。那么本题,两个for循环的先后顺序可就有说法了。
外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)—— 组合数
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
dp[j] += dp[j - coins[i]];
}
}
假设题意:amount = 5;coins = [ 1,2,5 ]
情况1下的dp数组状态:
初始化 dp[0] = 1 ;非零下标:dp[j] = 0
dp[1] = dp[1] + dp[0] = 0+1 = 1;放的是 [1]
dp[2] = dp[2] + dp[1] = 0+1 = 1; 放的是[1,1]
(其中dp[2]是代表之前遍历上一个i的时候的方案,然后加上dp[1]意思是,组成dp[1]的方案,额外加上一个1组成新的方案)
dp[3] = dp[3] + dp[2] = 0 +1 = 1;放的是[1,1,1]
dp[4] = dp[4] + dp[3] = 0+1 = 1;放的是[1,1,1,1]
dp[5] = dp[5] + dp[4] = 0+1 = 1;放的是[1,1,1,1,1]
因为物品i 是一个个遍历的,放了1再放2,不会出现顺序不同但是元素相同的组合,这种遍历顺序中dp[j]计算的是组合数。
外层for循环遍历背包(金钱总额),内层for遍历物品(钱币)—— 排列数
for (int j = 0; j <= amount; j++) { // 遍历背包容量
for (int i = 0; i < coins.size(); i++) { // 遍历物品
if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
}
}
假设题意:amount = 5;coins = [ 1,2,5 ]
情况2下的dp数组状态:
初始化 dp[0] = 1 ;非零下标:dp[j] = 0
背包容量中的每个值都是经过每个物品计算的,会包含元素相同但是顺序不同的情况,此时dp[j]中算出来的就是排列数。
输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:
最后红色框dp[amount]为最终结果。
# 动态规划-背包问题-完全背包应用
# time:O(N*amount);space:O(amount)
# step1. 确定dp[j]含义:装满容量为j的背包有dp[j]种方法
# step2. 递推公式: dp[j] += dp[j-coins[i]]
# step3. 初始化 dp[0] = 1;其余dp[j] = 0
# step4. 遍历顺序 因为是完全背包,所以背包容量正序遍历;
# 又因为是求组合问题,所以先遍历物品,再遍历背包
# step5. 打印dp数组
class Solution(object):
def change(self, amount, coins):
"""
:type amount: int
:type coins: List[int]
:rtype: int
"""
dp = [0]*(amount+1)
dp[0] = 1
# 遍历物品
for i in range(len(coins)):
# 遍历背包
for j in range(amount+1):
if j >= coins[i]:
dp[j] += dp[j-coins[i]]
return dp[amount]
时间复杂度:O(N*amount);
其中N为物品个数,amount为背包容量,两层for loop需要遍历的;
空间复杂度:O(amount);
其中amount为背包容量,dp数组的大小为amount。
本题的递推公式,其实我们在**494. 目标和 (opens new window)**中就已经讲过了,而难点在于遍历顺序!
在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
Reference:代码随想录 (programmercarl.com)
本题学习时间:90分钟。
链接:377. 组合总和 Ⅳ - 力扣(LeetCode)
本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列!
弄清什么是组合,什么是排列很重要。组合不强调顺序,(1,5)和(5,1)是同一个组合。排列强调顺序,(1,5)和(5,1)是两个不同的排列。
大家在公众号里学习回溯算法专题的时候,一定做过这两道题目**回溯算法:39.组合总和 (opens new window)和回溯算法:40.组合总和II (opens new window)**会感觉这两题和本题很像!
但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。如果本题要把排列都列出来的话,只能使用回溯算法爆搜。
dp[i]: 凑成目标正整数为i的排列个数为dp[i]
dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导出来。
因为只要得到nums[j],排列个数dp[i - nums[j]],就是dp[i]的一部分。
求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];
因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,这样递归其他dp[i]的时候才会有数值基础。
至于dp[0] = 1 有没有意义呢?
其实没有意义,所以我也不去强行解释它的意义了,因为题目中也说了:给定目标值是正整数! 所以dp[0] = 1是没有意义的,仅仅是为了推导递推公式。
至于非0下标的dp[i]应该初始为多少呢?
初始化为0,这样才不会影响dp[i]累加所有的dp[i - nums[j]]。
个数可以不限使用,说明这是一个完全背包。得到的集合是排列,说明需要考虑元素之间的顺序。
本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了。
在**动态规划:518.零钱兑换II (opens new window)**中就已经讲过了。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。
我们再来用示例中的例子推导一下:
如果代码运行处的结果不是想要的结果,就把dp[i]都打出来,看看和我们推导的一不一样。
# 动态规划-背包问题-完全背包应用
# time:O(N*target);space:O(target)
# step1. dp[j]含义:组成总和为j的数,有dp[j]种排列方式
# step2. 递推公式:dp[j] += dp[j-nums[i]]
# step3. 初始化 dp[0] = 1;dp[j] =0
# step4. 遍历顺序,先背包容量(target);后物品
# step5. 打印dp
class Solution(object):
def combinationSum4(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
dp = [0]*(target+1)
dp[0] = 1
# 先遍历背包容量
for j in range(target+1):
# 再遍历物品
for i in range(len(nums)):
if j >= nums[i]:
dp[j] += dp[j-nums[i]]
return dp[target]
时间复杂度:O(N*target);
其中N为物品个数,target为背包容量,两层for loop需要遍历的;
空间复杂度:O(target);
其中target为背包容量,dp数组的大小为target。
Reference:代码随想录 (programmercarl.com)
本题学习时间:30分钟。
本篇学习时间为4小时,总结字数9000+;本篇先介绍了完全背包的基础知识,和01背包不同的是遍历顺序背包是正序,而且纯完全背包的for loop可以颠倒;然后LeetCode518. 零钱兑换II 是装满背包的方法数目,求组合;LeetCode377.组合总和IV也是装满背包的方法数目,但是是求排列。(求推荐!)