1.零钱兑换
力扣题目链接(opens new window)
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例 1:
- 输入:coins = [1, 2, 5], amount = 11
- 输出:3
- 解释:11 = 5 + 5 + 1
示例 2:
- 输入:coins = [2], amount = 3
- 输出:-1
示例 3:
- 输入:coins = [1], amount = 0
- 输出:0
示例 4:
- 输入:coins = [1], amount = 1
- 输出:1
示例 5:
- 输入:coins = [1], amount = 2
- 输出:2
提示:
- 1 <= coins.length <= 12
- 1 <= coins[i] <= 2^31 - 1
- 0 <= amount <= 10^4
思路
在动态规划:518.零钱兑换II (opens new window)中我们已经兑换一次零钱了,这次又要兑换,套路不一样!
题目中说每种硬币的数量是无限的,可以看出是典型的完全背包问题。
动规五部曲分析如下:
- 确定dp数组以及下标的含义
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
- 确定递推公式
凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。
递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
- dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
所以下标非0的元素都是应该是最大值。
- 确定遍历顺序
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。
所以本题并不强调集合是组合还是排列。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
在动态规划专题我们讲过了求组合数是动态规划:518.零钱兑换II (opens new window),求排列数是动态规划:377. 组合总和 Ⅳ (opens new window)。
所以本题的两个for循环的关系是:外层for循环遍历物品,内层for遍历背包或者外层for遍历背包,内层for循环遍历物品都是可以的!
那么我采用coins放在外循环,target在内循环的方式。
本题钱币数量可以无限使用,那么是完全背包。所以遍历的内循环是正序
综上所述,遍历顺序为:coins(物品)放在外循环,target(背包)在内循环。且内循环正序。
- 举例推导dp数组
以输入:coins = [1, 2, 5], amount = 5为例
dp[amount]为最终结果。
总结
动态规划:518.零钱兑换II (opens new window)中求的是组合数,动态规划:377. 组合总和 Ⅳ (opens new window)中求的是排列数。
而本题是要求最少硬币数量,硬币是组合数还是排列数都无所谓!所以两个for循环先后顺序怎样都可以!
public class Coin_Exchange {public int coinChange(int[] coins, int amount) {//接受一个整数数组coins和一个整数amount作为参数,并返回一个整数。int max = Integer.MAX_VALUE;//Java中int类型能表示的最大值。这个值用于表示在初始状态下,任何金额的最小硬币数量是未知的,即一个非常大的数。int[] dp = new int[amount + 1];//初始化一个长度为amount + 1的数组dp,用于存储动态规划的中间结果。数组的索引从0到amount,因此长度需要是amount + 1。for (int j = 0; j < dp.length; j++) {//始化dp数组,将所有元素设置为max值。dp[j] = max;//将dp数组中的每个元素初始化为max,表示在初始状态下,每个金额的最小硬币数量都是未知的。}dp[0] = 0;//将dp[0]设置为0,表示组成金额0所需的硬币数量是0(不需要任何硬币)。for (int i = 0; i < coins.length; i++) {//外层循环,遍历所有可用的硬币面额。for (int j = coins[i]; j <= amount; j++) {//内层循环,从当前硬币面额coins[i]开始,遍历到总金额amount。if (dp[j - coins[i]] != max) {//检查dp[j - coins[i]]是否不等于max,即检查是否已经计算过组成金额j - coins[i]的最小硬币数量。dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);//如果dp[j - coins[i]]不等于max,则更新dp[j]的值。dp[j]表示组成金额j的最小硬币数量,我们取dp[j]和dp[j - coins[i]] + 1的最小值,因为dp[j - coins[i]] + 1表示使用一个面额为coins[i]的硬币来凑成金额j。}}}return dp[amount] == max ? -1 : dp[amount];//最后,检查dp[amount]是否等于max。如果等于max,说明无法用给定的硬币面额组成总金额amount,因此返回-1。否则,返回dp[amount],即组成总金额amount所需的最少硬币数量。}
}
- 时间复杂度:O(amount * coins.length)
- 空间复杂度:O(amount)
2.完全平方数
力扣题目链接(opens new window)
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
示例 1:
- 输入:n = 12
- 输出:3
- 解释:12 = 4 + 4 + 4
示例 2:
- 输入:n = 13
- 输出:2
- 解释:13 = 4 + 9
提示:
- 1 <= n <= 10^4
思路
可能刚看这种题感觉没啥思路,又平方和的,又最小数的。
我来把题目翻译一下:完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?
感受出来了没,这么浓厚的完全背包氛围,而且和昨天的题目动态规划:322. 零钱兑换 (opens new window)就是一样一样的!
动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[j]:和为j的完全平方数的最少数量为dp[j]
- 确定递推公式
dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]。
此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);
- dp数组如何初始化
dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0。
有同学问题,那0 * 0 也算是一种啊,为啥dp[0] 就是 0呢?
看题目描述,找到若干个完全平方数(比如 1, 4, 9, 16, ...),题目描述中可没说要从0开始,dp[0]=0完全是为了递推公式。
非0下标的dp[j]应该是多少呢?
从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖。
- 确定遍历顺序
我们知道这是完全背包,
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
在动态规划:322. 零钱兑换 (opens new window)中我们就深入探讨了这个问题,本题也是一样的,是求最小数!
所以本题外层for遍历背包,内层for遍历物品,还是外层for遍历物品,内层for遍历背包,都是可以的!
- 举例推导dp数组
已输入n为5例,dp状态图如下:
dp[0] = 0 dp[1] = min(dp[0] + 1) = 1 dp[2] = min(dp[1] + 1) = 2 dp[3] = min(dp[2] + 1) = 3 dp[4] = min(dp[3] + 1, dp[0] + 1) = 1 dp[5] = min(dp[4] + 1, dp[1] + 1) = 2
最后的dp[n]为最终结果。
总结
如果大家认真做了昨天的题目动态规划:322. 零钱兑换 (opens new window),今天这道就非常简单了,一样的套路一样的味道。
先遍历物品, 再遍历背包
public class Perfect_Square {//先遍历物品, 再遍历背包public int numSquares1(int n) {int max = Integer.MAX_VALUE;int[] dp = new int[n + 1];//创建一个长度为 n+1 的数组 dp,用于存储从 0 到 n 每个数值最少需要多少个完全平方数来表示。初始时,除了 dp[0] 设置为 0(因为 0 可以用 0 个完全平方数表示),其他所有值都设置为 Integer.MAX_VALUE,表示一个非常大的数,代表初始时我们不知道如何用完全平方数来表示这些数。for (int j = 0; j <= n; j++) {dp[j] = max;}dp[0] = 0;for (int i = 1; i * i <= n; i++) {//外层循环遍历所有可能的完全平方数 i * i(i 从 1 到 sqrt(n)),内层循环遍历所有可能的背包大小 j(从 i * i 到 n)。对于每个 j,我们检查是否可以用更少的完全平方数来表示 j,方法是将 dp[j] 更新为 dp[j - i * i] + 1 和当前 dp[j] 的最小值。for (int j = i * i; j <= n; j++) {dp[j] = Math.min(dp[j], dp[j - i * i] + 1);}}return dp[n];//最后返回 dp[n],即表示 n 的最少完全平方数数量。}}
时间复杂度是 O(n * sqrt(n))
空间复杂度是 O(n)
先遍历背包, 再遍历物品
public class Perfect_Square {//先遍历背包, 再遍历物品public int numSquares2(int n) {int max = Integer.MAX_VALUE;int[] dp = new int[n + 1];for (int j = 0; j <= n; j++) {dp[j] = max;}dp[0] = 0;for (int j = 1; j <= n; j++) {//外层循环遍历所有可能的背包大小 j(j 从 1 到 n),内层循环遍历所有可能的完全平方数 i * i(i 从 1 到 sqrt(j))。对于每个 j,我们检查是否可以用更少的完全平方数来表示 j,方法是将 dp[j] 更新为 dp[j - i * i] + 1 和当前 dp[j] 的最小值。for (int i = 1; i * i <= j; i++) {dp[j] = Math.min(dp[j], dp[j - i * i] + 1);}}return dp[n];}}
时间复杂度是 O(n * sqrt(n))
空间复杂度是 O(n)
3.
本周小结动态规划
周一
动态规划:377. 组合总和 Ⅳ (opens new window)中给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数(顺序不同的序列被视作不同的组合)。
题目面试虽然是组合,但又强调顺序不同的序列被视作不同的组合,其实这道题目求的是排列数!
递归公式:dp[i] += dp[i - nums[j]];
这个和前上周讲的组合问题又不一样,关键就体现在遍历顺序上!
在动态规划:518.零钱兑换II (opens new window)中就已经讲过了。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。
周二
爬楼梯之前我们已经做过了,就是斐波那契数列,很好解,但动态规划:70. 爬楼梯进阶版(完全背包) (opens new window)中我们进阶了一下。
改为:每次可以爬 1 、 2、.....、m 个台阶。问有多少种不同的方法可以爬到楼顶呢?
1阶,2阶,.... m阶就是物品,楼顶就是背包。
每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。
问跳到楼顶有几种方法其实就是问装满背包有几种方法。
此时大家应该发现这就是一个完全背包问题了!
和昨天的题目动态规划:377. 组合总和 Ⅳ (opens new window)基本就是一道题了,遍历顺序也是一样一样的!
周三
动态规划:322.零钱兑换 (opens new window)给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数(每种硬币的数量是无限的)。
这里我们都知道这是完全背包。
递归公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
关键看遍历顺序。
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。
所以本题并不强调集合是组合还是排列。
那么本题的两个for循环的关系是:外层for循环遍历物品,内层for遍历背包或者外层for遍历背包,内层for循环遍历物品都是可以的!
周四
动态规划:279.完全平方数 (opens new window)给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少(平方数可以重复使用)。
如果按顺序把前面的文章都看了,这道题目就是简单题了。 dp[i]的定义,递推公式,初始化,遍历顺序,都是和动态规划:322. 零钱兑换 (opens new window)一样一样的。
要是没有前面的基础上来做这道题,那这道题目就有点难度了。
这也体现了刷题顺序的重要性。
总结
本周的主题其实就是背包问题中的遍历顺序!
我这里做一下总结:
求组合数:动态规划:518.零钱兑换II (opens new window)求排列数:动态规划:377. 组合总和 Ⅳ (opens new window)、动态规划:70. 爬楼梯进阶版(完全背包) (opens new window)求最小数:动态规划:322. 零钱兑换 (opens new window)、动态规划:279.完全平方数
4.单词拆分
力扣题目链接(opens new window)
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:
- 输入: s = "leetcode", wordDict = ["leet", "code"]
- 输出: true
- 解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。
示例 2:
- 输入: s = "applepenapple", wordDict = ["apple", "pen"]
- 输出: true
- 解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
- 注意你可以重复使用字典中的单词。
示例 3:
- 输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
- 输出: false
思路
看到这道题目的时候,大家应该回想起我们之前讲解回溯法专题的时候,讲过的一道题目回溯算法:分割回文串 (opens new window),就是枚举字符串的所有分割情况。
回溯算法:分割回文串 (opens new window):是枚举分割后的所有子串,判断是否回文。
本道是枚举分割所有字符串,判断是否在字典里出现过。
背包问题
单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。
拆分时可以重复使用字典中的单词,说明就是一个完全背包!
动规五部曲分析如下:
- 确定dp数组以及下标的含义
dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。
- 确定递推公式
如果确定dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。
所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true。
- dp数组如何初始化
从递推公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都都是false了。
那么dp[0]有没有意义呢?
dp[0]表示如果字符串为空的话,说明出现在字典里。
但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。
下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。
- 确定遍历顺序
题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。
还要讨论两层for循环的前后顺序。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
我在这里做一个总结:
求组合数:动态规划:518.零钱兑换II (opens new window)求排列数:动态规划:377. 组合总和 Ⅳ (opens new window)、动态规划:70. 爬楼梯进阶版(完全背包) (opens new window)求最小数:动态规划:322. 零钱兑换 (opens new window)、动态规划:279.完全平方数(opens new window)
而本题其实我们求的是排列数,为什么呢。 拿 s = "applepenapple", wordDict = ["apple", "pen"] 举例。
"apple", "pen" 是物品,那么我们要求 物品的组合一定是 "apple" + "pen" + "apple" 才能组成 "applepenapple"。
"apple" + "apple" + "pen" 或者 "pen" + "apple" + "apple" 是不可以的,那么我们就是强调物品之间顺序。
所以说,本题一定是 先遍历 背包,再遍历物品。
- 举例推导dp[i]
以输入: s = "leetcode", wordDict = ["leet", "code"]为例,dp状态如图:
dp[s.size()]就是最终结果。
拓展
关于遍历顺序,再给大家讲一下为什么 先遍历物品再遍历背包不行。
使用用例:s = "applepenapple", wordDict = ["apple", "pen"],对应的dp数组状态如下:
最后dp[s.size()] = 0 即 dp[13] = 0 ,而不是1,因为先用 "apple" 去遍历的时候,dp[8]并没有被赋值为1 (还没用"pen"),所以 dp[13]也不能变成1。
除非是先用 "apple" 遍历一遍,再用 "pen" 遍历,此时 dp[8]已经是1,最后再用 "apple" 去遍历,dp[13]才能是1。
如果大家对这里不理解,建议可以把我上面给的代码,拿去力扣上跑一跑,把dp数组打印出来,对着递推公式一步一步去看,思路就清晰了。
总结
本题和我们之前讲解回溯专题的回溯算法:分割回文串 (opens new window)非常像,所以我也给出了对应的回溯解法。
稍加分析,便可知道本题是完全背包,是求能否组成背包,而且这里要求物品是要有顺序的。
回溯法加记忆化
public class Word_Splitting {private Set<String> set;private int[] memo;public boolean wordBreak1(String s, List<String> wordDict) {memo = new int[s.length()];//memo数组用于记忆化,避免重复计算。set = new HashSet<>(wordDict);//set是一个HashSet,包含字典中的所有单词,用于快速查找。return backtracking(s, 0);}public boolean backtracking(String s, int startIndex) {//从字符串的起始位置开始,尝试所有可能的拆分点。if (startIndex == s.length()) {//如果当前索引startIndex等于字符串长度,说明已经成功拆分了整个字符串,返回true。return true;}if (memo[startIndex] == -1) {//如果memo[startIndex]已经被标记为-1,则表示之前已经计算过从这个位置开始无法找到匹配的单词,直接返回false。return false;}for (int i = startIndex; i < s.length(); i++) {//循环遍历从startIndex开始的所有可能的单词结束位置i。String sub = s.substring(startIndex, i + 1);if (!set.contains(sub)) {//对于每个子字符串sub,检查它是否存在于set中。continue;}boolean res = backtracking(s, i + 1);//如果存在,递归调用backtracking方法,尝试从i + 1位置继续拆分。if (res) return true;//如果递归调用返回true,则说明找到了一种拆分方式,返回true。}memo[startIndex] = -1;//如果遍历完所有可能的拆分点后,没有找到匹配的拆分方式,则将memo[startIndex]标记为-1,表示从这个位置开始无法找到匹配的单词。return false;}}
- 时间复杂度:最坏情况下,这个方法会尝试所有可能的拆分方式,因此时间复杂度为O(n^2 * k),其中n是字符串s的长度,k是单词字典wordDict中单词的平均长度。
- 空间复杂度:O(n),用于存储memo数组。
背包算法
public class Word_Splitting {public boolean wordBreak2(String s, List<String> wordDict) {HashSet<String> set = new HashSet<>(wordDict);boolean[] valid = new boolean[s.length() + 1];//valid数组用于存储每个位置是否可以被拆分。valid[0] = true;//valid[0]初始化为true,因为空字符串可以被认为是一个有效的拆分。for (int i = 1; i <= s.length(); i++) {//从1到s.length()遍历字符串的每个位置。for (int j = 0; j < i && !valid[i]; j++) {//对于每个位置i,检查所有可能的子字符串,看它们是否在字典中,并且它们的开始位置的valid值为true。if (set.contains(s.substring(j, i)) && valid[j]) {valid[i] = true;//如果找到这样的子字符串,则将valid[i]设置为true。}}}return valid[s.length()];//最终,valid[s.length()]的值将告诉我们整个字符串是否可以被拆分。}}
- 时间复杂度:O(n^2),因为对于字符串中的每个位置,我们都需要检查所有可能的子字符串。
- 空间复杂度:O(n),用于存储valid数组。
public class Word_Splitting {public boolean wordBreak3(String s, List<String> wordDict) {boolean[] dp = new boolean[s.length() + 1];//dp数组用于存储每个位置是否可以被拆分。dp[0] = true;//dp[0]初始化为true,表示空字符串可以被拆分。for (int i = 1; i <= s.length(); i++) {//从1到s.length()遍历字符串的每个位置。for (String word : wordDict) {//对于字典中的每个单词,检查它是否可以匹配字符串的一部分,并且这个单词之前的部分是否可以被拆分。int len = word.length();if (i >= len && dp[i - len] && word.equals(s.substring(i - len, i))) {dp[i] = true;//如果找到匹配的单词,则将dp[i]设置为true。break;}}}return dp[s.length()];//最终,dp[s.length()]的值将告诉我们整个字符串是否可以被拆分。}
}
- 时间复杂度:O(n * m * k),其中n是字符串s的长度,m是字典wordDict的大小,k是单词的平均长度。
- 空间复杂度:O(n),用于存储dp数组。
5.动态规划:关于多重背包,你该了解这些!
本题力扣上没有原题,大家可以去卡码网第56题 (opens new window)去练习,题意是一样的。
之前我们已经系统的讲解了01背包和完全背包,如果没有看过的录友,建议先把如下三篇文章仔细阅读一波。
- 动态规划:关于01背包问题,你该了解这些!(opens new window)
- 动态规划:关于01背包问题,你该了解这些!(滚动数组)(opens new window)
- 动态规划:关于完全背包,你该了解这些!
多重背包
对于多重背包,我在力扣上还没发现对应的题目,所以这里就做一下简单介绍,大家大概了解一下。
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?
每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
例如:
背包最大重量为10。
物品为:
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 2 |
物品1 | 3 | 20 | 3 |
物品2 | 4 | 30 | 2 |
问背包能背的物品最大价值是多少?
和如下情况有区别么?
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 1 |
物品0 | 1 | 15 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品2 | 4 | 30 | 1 |
物品2 | 4 | 30 | 1 |
毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。
练习题目:卡码网第56题,多重背包
总结
多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。
至于背包九讲里面还有混合背包,二维费用背包,分组背包等等这些,大家感兴趣可以自己去学习学习,这里也不做介绍了,面试也不会考。
public record Multiple_Knapsack_Problem() {public static void main(String [] args) {Scanner sc = new Scanner(System.in);//使用Scanner类从标准输入读取数据。int bagWeight, n;//bagWeight变量存储背包的容量。n变量存储物品的种类数。bagWeight = sc.nextInt();n = sc.nextInt();int[] weight = new int[n];//weight数组存储每种物品的重量。int[] value = new int[n];//value数组存储每种物品的价值。int[] nums = new int[n];//nums数组存储每种物品的数量。for (int i = 0; i < n; i++) weight[i] = sc.nextInt();//dp数组用于存储动态规划的中间结果,其大小为bagWeight + 1,因为背包容量为bagWeight,数组索引从0开始,所以需要额外一个空间存储容量为0的情况。for (int i = 0; i < n; i++) value[i] = sc.nextInt();for (int i = 0; i < n; i++) nums[i] = sc.nextInt();int[] dp = new int[bagWeight + 1];for (int i = 0; i < n; i++) {//外层循环遍历每种物品。for (int j = bagWeight; j >= weight[i]; j--) {//中层循环从背包的最大容量开始向下遍历到每种物品的重量,这是因为在动态规划中,我们通常从最小的子问题开始解决,但在这个多重背包问题中,我们需要从最大的容量开始,以便能够使用之前计算的结果。for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) {//内层循环处理每种物品的多重选择,即可以选择1个、2个...直到nums[i]个该物品,并且不能超过背包的当前剩余容量。dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i]);//对于每个物品i,我们检查将其放入背包j次(k从1到nums[i])后的价值,并更新dp[j]的值。这个方程意味着对于当前的背包容量j,我们尝试放入k个第i个物品,然后比较放入和不放入这些物品的价值,选择价值最大的方案。}}}System.out.println(dp[bagWeight]);//循环结束后,dp[bagWeight]存储了能够放入背包的最大价值,因此打印出dp[bagWeight]。}
}
- 时间复杂度:O(n * bagWeight * maxNums),其中
n
是物品种类数,bagWeight
是背包容量,maxNums
是物品数量的最大值。 - 空间复杂度:O(bagWeight),只需要一个大小为
bagWeight + 1
的数组来存储动态规划的结果。
6.背包问题总结篇
背包问题是动态规划里的非常重要的一部分,所以我把背包问题单独总结一下,等动态规划专题更新完之后,我们还会在整体总结一波动态规划。
关于这几种常见的背包,其关系如下:
通过这个图,可以很清晰分清这几种常见背包之间的关系。
在讲解背包问题的时候,我们都是按照如下五部来逐步分析,相信大家也体会到,把这五部都搞透了,算是对动规来理解深入了。
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
其实这五部里哪一步都很关键,但确定递推公式和确定遍历顺序都具有规律性和代表性,所以下面我从这两点来对背包问题做一做总结。
背包递推公式
问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:
- 动态规划:416.分割等和子集(opens new window)
- 动态规划:1049.最后一块石头的重量 II(opens new window)
问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:
- 动态规划:494.目标和(opens new window)
- 动态规划:518. 零钱兑换 II(opens new window)
- 动态规划:377.组合总和Ⅳ(opens new window)
- 动态规划:70. 爬楼梯进阶版(完全背包)(opens new window)
问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:
- 动态规划:474.一和零(opens new window)
问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:
- 动态规划:322.零钱兑换(opens new window)
- 动态规划:279.完全平方数(opens new window)
遍历顺序
01背包
在动态规划:关于01背包问题,你该了解这些! (opens new window)中我们讲解二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
和动态规划:关于01背包问题,你该了解这些!(滚动数组) (opens new window)中,我们讲解一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。
一维dp数组的背包在遍历顺序上和二维dp数组实现的01背包其实是有很大差异的,大家需要注意!
完全背包
说完01背包,再看看完全背包。
在动态规划:关于完全背包,你该了解这些! (opens new window)中,讲解了纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
相关题目如下:
- 求组合数:动态规划:518.零钱兑换II(opens new window)
- 求排列数:动态规划:377. 组合总和 Ⅳ (opens new window)、动态规划:70. 爬楼梯进阶版(完全背包)(opens new window)
如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:
- 求最小数:动态规划:322. 零钱兑换 (opens new window)、动态规划:279.完全平方数(opens new window)
对于背包问题,其实递推公式算是容易的,难是难在遍历顺序上,如果把遍历顺序搞透,才算是真正理解了。
总结
这篇背包问题总结篇是对背包问题的高度概括,讲最关键的两部:递推公式和遍历顺序,结合力扣上的题目全都抽象出来了。
而且每一个点,我都给出了对应的力扣题目。
最后如果你想了解多重背包,可以看这篇动态规划:关于多重背包,你该了解这些! (opens new window),力扣上还没有多重背包的题目,也不是面试考察的重点。
如果把我本篇总结出来的内容都掌握的话,可以说对背包问题理解的就很深刻了,用来对付面试中的背包问题绰绰有余!
背包问题总结:
这个图是 代码随想录知识星球 (opens new window)成员:海螺人 (opens new window),所画结的非常好,分享给大家。