动态规划——背包问题
在算法问题中,背包问题是一类经典的动态规划问题,它们的核心思想是选择一组物品,满足某个条件或目标。背包问题不仅限于物理意义上的“背包”和“物品”,其概念可以扩展到许多实际场景,如资金分配、时间管理、资源优化等。
背包问题可以定义为:给定一个背包容量(target
)和一组物品(nums
),能否按某种方式选取nums
中的元素,使其总和或总重量等于target
?
sum/2
等)。背包问题可以根据选择方式和问题类型进行分类。
通过将选择方式与问题类型结合,可以得到如下常见的背包问题类型:
解决背包问题的基本方法是动态规划。解题的核心是设置一个dp
数组,记录每种状态下的最优解,然后通过遍历物品和背包容量来更新dp
。
dp[i][j]
表示从前i
个物品中选择不超过重量j
的最大价值。dp[j]
表示容量为j
的背包能放下的最大价值。以经典的0/1背包问题为例,最基础的二维动态规划代码如下:
int[][] dp = new int[n + 1][target + 1];
// 遍历物品
for (int i = 1; i <= n; i++) {
// 遍历背包容量
for (int j = 0; j <= target; j++) {
if (j >= weight[i - 1]) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
一维动态规划的简化代码如下:
int[] dp = new int[target + 1];
for (int i = 0; i < n; i++) {
for (int j = target; j >= weight[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
根据背包问题的分类,解题时可以选择合适的遍历顺序和状态转移方程:
状态转移方程的写法也因问题类型不同而有所变化:
dp[i] = max/min(dp[i], dp[i-nums]+1)
。dp[i] = dp[i] || dp[i-num]
。dp[i] += dp[i-num]
。通过几个例题来具体说明如何应用背包问题的分类与模板:
注意:
1、背包容量target和物品nums的类型可能是数,也可能是字符串
2、target可能题目已经给出(显式),也可能是需要我们从题目的信息中挖掘出来(非显式)(常见的非显式target比如sum/2等)
3、选取方式有常见的一下几种:每个元素选一次/每个元素选多次/选元素进行排列组合
那么对应的背包问题就是下面我们要讲的背包分类
首先先了解一下原始背包问题的解题思路和代码:
最开始的背包问题是二维动态规划
import java.util.ArrayList;
import java.util.List;
public class KnapsackProblem {
public static void knapsack() {
List<Integer> weight = new ArrayList<>(List.of(1, 3, 4)); // 各个物品的重量
List<Integer> value = new ArrayList<>(List.of(15, 20, 30)); // 对应的价值
int bagWeight = 4; // 背包最大能放下多少重的物品
// 二维数组:状态定义:dp[i][j]表示从0-i个物品中选择不超过j重量的物品的最大价值
int[][] dp = new int[weight.size() + 1][bagWeight + 1];
// 初始化:第一列都是0,第一行表示只选取0号物品最大价值
for (int j = bagWeight; j >= weight.get(0); j--) {
dp[0][j] = dp[0][j - weight.get(0)] + value.get(0);
}
// weight数组的大小 就是物品个数
for (int i = 1; i < weight.size(); i++) { // 遍历物品(第0个物品已经初始化)
for (int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight.get(i)) { // 背包容量已经不足以拿第i个物品了
dp[i][j] = dp[i - 1][j]; // 最大价值就是拿第i-1个物品的最大价值
} else { // 背包容量足够拿第i个物品
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight.get(i)] + value.get(i));
}
}
}
System.out.println(dp[weight.size() - 1][bagWeight]); // 输出最大价值
}
public static void main(String[] args) {
knapsack();
}
}
二维代码可以进行优化,去除选取物品的那一层,简化为一维背包
// 一维
//状态定义:dp[j]表示容量为j的背包能放下东西的最大价值
import java.util.ArrayList;
import java.util.List;
public class KnapsackProblemOneDimensional {
public static void testOneDimensionalBagProblem() {
List<Integer> weight = new ArrayList<>(List.of(1, 3, 4)); // 各个物品的重量
List<Integer> value = new ArrayList<>(List.of(15, 20, 30)); // 对应的价值
int bagWeight = 4; // 背包最大能放下多少重的物品
// 初始化
int[] dp = new int[bagWeight + 1];
for (int i = 0; i < weight.size(); i++) { // 遍历物品
for (int j = bagWeight; j >= weight.get(i); j--) {
// 遍历背包容量(逆序)
dp[j] = Math.max(dp[j], dp[j - weight.get(i)] + value.get(i)); // 不取或者取第i个
}
}
System.out.println(dp[bagWeight]); // 输出最大价值
}
public static void main(String[] args) {
testOneDimensionalBagProblem();
}
}
分类解题模板
背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程,
根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类我们确定状态转移方程的写法
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
3、组合背包:外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要三重循环:外循环背包bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
然后是问题分类的模板:
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];
这样遇到问题将两个模板往上一套大部分问题就可以迎刃而解
1049. 最后一块石头的重量 II
//01背包一维数组要倒着遍历
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for (int x : stones) {
sum += x;
}
int target = sum / 2;
int[] dp = new int[sum + 1];
for (int i = 0; i < stones.length; i++) {
for (int j = 0; j <= target; j++) {
if (j >= stones[i]) {
dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
}
return Math.abs(sum - 2 * dp[target]);
}
}
322. 零钱兑换
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE / 2);
dp[0] = 0;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
}
}
int ans = dp[amount];
return ans == Integer.MAX_VALUE / 2 ? -1 : ans;
}
}
416. 分割等和子集
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int x : nums) {
sum += x;
}
if (sum % 2 != 0) {
return false;
}
sum /= 2;
int[] dp = new int[sum + 1];
for (int i = 0; i < nums.length; i++) {
for (int j = sum; j >= nums[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
if (dp[sum] == sum) {
return true;
}
}
return false;
}
}
494. 目标和
//相当于台阶问题,注意要定义 dp[0] = 1
class Solution {
public int findTargetSumWays(int[] nums, int target) {
for (int x : nums) {
target += x;
}
if(target < 0 || target % 2 != 0){
return 0;
}
target /= 2;
int[] dp = new int[target + 1];
dp[0] = 1;
for (int i = 0; i < nums.length; i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
}
279. 完全平方数
//其中要注意顺序是可以随便排列的,因此需要先遍历容量,再遍历每个物品
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for (int i = 1; i <= target; i++) {
for (int num : nums) {
if (i >= num) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
}
1155. 掷骰子等于目标和的方法数
//分组背包问题
class Solution {
public int numRollsToTarget(int n, int k, int target) {
int mod = (int)1e9 + 7;
int[][] dp = new int[n + 1][target + 1];
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= target; j++) {
for (int q = 1; q <= k; q++) {
if (j >= q) {
dp[i][j] = (dp[i - 1][j - q] + dp[i][j]) % mod;
}
}
}
}
return dp[n][target];
}
}
518. 零钱兑换 II
//完全背包的组合数问题
class Solution {
public int change(int amount, int[] coins) {
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];
}
}
背包问题看似千变万化,但它们的本质都是在给定条件下选择物品的最优问题。通过掌握背包问题的分类和解题模板,我们可以迅速识别并解决相关问题。希望这篇文章能帮助大家更好地理解和应用背包问题。