动态规划背包问题
1. 引言
动态规划是一种解决复杂问题的方法,它可以将一个问题分解为若干个子问题,然后利用子问题的最优解来构造原问题的最优解。动态规划的核心思想是避免重复计算,即将已经求解过的子问题的结果保存起来,以便后续使用。
背包问题是一类经典的动态规划问题,它描述了一个背包有一定的承重上限,而有若干个物品,每个物品有自己的重量和价值,如何选择装入背包的物品,使得背包内物品的总价值最大。背包问题有很多变种,例如01背包、完全背包、多重背包等,它们都可以用动态规划的方法来求解。
本文将介绍动态规划背包问题的基本概念和思路,并以leetcode里的题目作为案例,给出JAVA实现的答案。本文将涵盖以下几种类型的背包问题:
- 01背包问题:每种物品只有一个,可以选择放或不放。
- 完全背包问题:每种物品有无限个,可以选择放任意个。
- 多重背包问题:每种物品有有限个,可以选择放任意个但不能超过给定的数量。
- 混合三种背包问题:每种物品可能属于以上三种情况之一。
- 二维费用的背包问题:每种物品除了重量还有另一种费用,背包也有相应的限制。
- 分组的背包问题:物品分为若干组,每组只能选择一个物品放入背包。
- 有依赖的背包问题:物品之间存在依赖关系,例如要放某个物品必须先放另一个物品。
leetcode 上相应的练习题
- 01背包问题:每种物品只有一个,可以选择放或不放。
- LeetCode 416. 分割等和子集 https://leetcode-cn.com/problems/partition-equal-subset-sum/
- LeetCode 494. 目标和 https://leetcode-cn.com/problems/target-sum/
- LeetCode 474. 一和零 https://leetcode-cn.com/problems/ones-and-zeroes/
- 完全背包问题:每种物品有无限个,可以选择放任意个。
- LeetCode 322. 零钱兑换 https://leetcode-cn.com/problems/coin-change/
- LeetCode 518. 零钱兑换 II https://leetcode-cn.com/problems/coin-change-2/
- LeetCode 377. 组合总和 Ⅳ https://leetcode-cn.com/problems/combination-sum-iv/
- 多重背包问题:每种物品有有限个,可以选择放任意个但不能超过给定的数量。
- LeetCode 暂无
2. 0-1背包
2.1 题目 1 416. 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:数组不能分割成两个元素和相等的子集。
解析
这道题可以转化为一个0-1背包问题,即从数组中选取若干个数,使得它们的和等于数组总和的一半。
如果数组总和是奇数,则直接返回false。
如果是偶数,则定义一个二维数组dp[i][j]
- 二维数组有两种 定义 解法:
- dp[i][j]表示从前i个数中选取若干个数,使得它们的和不超过j的最大值, 则状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]] + nums[i])
- dp[i][j]表示前i个数能否组成和为j的子集,它是一个布尔值,如果为true,表示可以组成,如果为false,表示不可以组成。则状态转移方程为 dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i-1]]
解法一 参照 :7-012-(LeetCode- 416) 分割等和子集
解法二:如下
二维数组dp[i][j]表示从前i个数中选取若干个数,使得它们的和不超过j的最大值。则状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]] + nums[i])
其中,dp[i-1][j]表示不选第i个数,dp[i-1][j-nums[i]] + nums[i]表示选第i个数。最后判断dp[n][sum/2]是否等于sum/2即可。
代码
class Solution {
public boolean canPartition(int[] nums) {
// 数组总和
int sum = 0;
for (int num : nums) {
sum += num;
}
// 如果总和是奇数,则不能分割成两个相等的子集
if (sum % 2 == 1) {
return false;
}
// 数组长度
int n = nums.length;
// dp数组
int[][] dp = new int[n + 1][sum / 2 + 1];
// 初始化第一行为0
for (int j = 0; j <= sum / 2; j++) {
dp[0][j] = 0;
}
// 遍历物品
for (int i = 1; i <= n; i++) {
// 遍历容量
for (int j = 0; j <= sum / 2; j++) {
// 不选第i个数
dp[i][j] = dp[i - 1][j];
// 如果容量大于等于第i个数,可以选择选或不选
if (j >= nums[i - 1]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - nums[i - 1]] + nums[i - 1]);
}
}
}
// 判断是否能分割成两个相等的子集
return dp[n][sum / 2] == sum / 2;
}
}
复杂度
- 时间复杂度:O(n * sum),其中n是数组长度,sum是数组总和,需要遍历二维dp数组。
- 空间复杂度:O(n * sum),需要创建一个二维dp数组。
2.2 题目 2 LeetCode 494. 目标和 https://leetcode-cn.com/problems/target-sum/
题目:
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
示例 1:
输入:nums = [1,1,1,1,1], target = 3 输出:5 解释:一共有 5 种方法让最终目标和为 3 。 -1 + 1 + 1 + 1 + 1 = 3 +1 - 1 + 1 + 1 + 1 = 3 +1 + 1 - 1 + 1 + 1 = 3 +1 + 1 + 1 - 1 + 1 = 3 +1 + 1 + 1 + 1 - 1 = 3
示例 2:
输入:nums = [1], target = 1 输出:1
解法:参照 7-013-(LeetCode- 494) 目标和
2.3 题目 3 LeetCode 474. 一和零 https://leetcode-cn.com/problems/ones-and-zeroes/
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
示例 1:
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3 输出:4 解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。 其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
示例 2:
输入:strs = ["10", "0", "1"], m = 1, n = 1 输出:2 解释:最大的子集是 {"0", "1"} ,所以答案是 2 。
动态规划-01背包问题 :474. 一和零
3. 完全背包
3.1 题目1:LeetCode 322. 零钱兑换 https://leetcode-cn.com/problems/coin-change/
3.2 题目2:动态规划 完全背包问题 -游戏最大伤害
和 LeetCode 322 是类似的一道题
3.3 题 目3:LeetCode 518. 零钱兑换 II
3.4 题目 518. 零钱兑换 II
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 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
解析
这道题可以转化为一个完全背包问题,即从coins数组中选取若干个数,使得它们的和等于amount。定义一个一维数组dp[j]表示容量为j的背包可以装入的硬币组合数。则状态转移方程为:
dp[j] = dp[j] + dp[j - coins[i]]
其中,dp[j]表示不选第i种硬币,dp[j - coins[i]]表示选第i种硬币。注意这里是加号,因为要求所有可能的组合数。最后返回dp[amount]即可。
代码
class Solution {
public int change(int amount, int[] coins) {
// dp数组
int[] dp = new int[amount + 1];
// 初始化
dp[0] = 1;
// 遍历物品
for (int coin : coins) {
// 遍历容量,正序
for (int j = coin; j <= amount; j++) {
// 状态转移方程
dp[j] += dp[j - coin];
}
}
// 返回结果
return dp[amount];
}
}
复杂度
时间复杂度:O(n * amount),其中n是coins数组长度,amount是总金额,需要遍历一维dp数组。
空间复杂度:O(amount),需要创建一个一维dp数组。
3.5 题目4:LeetCode 377. 组合总和 Ⅳ https://leetcode-cn.com/problems/combination-sum-iv/
动态规划-背包问题-完全背包问题:leetcode 377. 组合总和 Ⅳ
4. 多重背包
4.1 题目 322. 零钱兑换
给你一个整数数组 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
解析
这道题可以转化为一个多重背包问题,即从coins数组中选取若干个数,使得它们的和等于amount,且每种硬币有有限个。定义一个一维数组dp[j]表示容量为j的背包可以装入的最少硬币个数。则状态转移方程为:
dp[j] = min(dp[j], dp[j - coins[i]] + 1)
其中,dp[j]表示不选第i种硬币,dp[j - coins[i]] + 1表示选第i种硬币。注意这里是最小号,因为要求最少的硬币个数。最后判断dp[amount]是否等于初始值,如果是则返回-1,否则返回dp[amount]。
代码
class Solution {
public int coinChange(int[] coins, int amount) {
// dp数组
int[] dp = new int[amount + 1];
// 初始化为最大值
Arrays.fill(dp, Integer.MAX_VALUE - 1);
// 初始条件
dp[0] = 0;
// 遍历物品
for (int coin : coins) {
// 遍历容量,正序
for (int j = coin; j <= amount; j++) {
// 状态转移方程
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
// 返回结果,判断是否有解
return dp[amount] == Integer.MAX_VALUE - 1 ? -1 : dp[amount];
}
}
复杂度
时间复杂度:O(n * amount),其中n是coins数组长度,amount是总金额,需要遍历一维dp数组。
空间复杂度:O(amount),需要创建一个一维dp数组。
5. 总结
背包问题是一类常见的动态规划问题,它的核心思想是定义一个合适的状态表示和状态转移方程,然后根据问题的要求进行初始化和遍历。在解决背包问题时,需要注意以下几点:
- 根据物品的数量和重复性选择合适的背包类型,如0-1背包、完全背包或多重背包。
- 根据问题的要求选择合适的状态属性,如最大值、最小值、方案数或具体方案。
- 根据状态转移方程选择合适的遍历顺序,如正序或逆序。
- 根据状态压缩的可能性选择合适的空间优化方法,如降维或滚动数组。