背包问题的经典解法:动态规划
总结,背包问题
- 状态转移时,关注此时装还是不装当前位置的物品
- 如果可以一个物品 i 可以无限取用,关注 i 所在行
- 如果一个物品 i 只能取用一次,关注 i - 1 的上一行
一、背包问题I
找最大价值,每个物品只能放一次
1、题目描述
背包问题I
在 n 个物品中挑选若干物品装入背包,最多能装多满?
假设背包的大小为m,每个物品的大小为Ai(每个物品只能选择一次且物品大小均为正整数),A数组中是各个物品的大小
数组A[3, 4, 5, 8],m = 12,输出:9,解释:装 4、5
数组A[2, 3, 5, 7],m = 12,输出:12,解释:装 5、7
2、解题丝路
状态:
- F[ i ][ j ] 表示 [ 0, i ] 中挑选若干物品,装到容量为 j 的背包中,能达到的最大收纳量(最大价值)(j 的范围是 [ 0, m])
状态转移方程:
- 如果不装 A[ i ],此时的最大收纳量 就是 j 容量的背包装 [ 0, i - 1] 物品的最大收纳量,F[ i ][ j ] = F[ i - 1][ j ]
- 如果 A[ i ] > j,说明根本不可能装 i ,F[ i ][ j ] = F[ i - 1][ j ];
- 如果 A [ i ] <= j,说明可以装 i ;如果不装 i ,最大收纳量就是F[ i - 1][ j ];如果装 i ,就要腾出 A[ i ] 容量,最大收纳量 = 当前容量 j 去除 A[ i ] 容量,剩余容量装 [ 0, i - 1] 物品的最大收纳量 + A[ i ],即 F[ i - 1][ j - A[i] ] + A[ i ];综上,F[ i ][ j ] = max( F[ i - 1][ j ], F[ i - 1][ j - A[i] ] + A[ i ])
初始值:
- 针对第一个物品(i = 0),如果容量 j >= 此物品的容量A[ i ],可放入,F[ 0 ][ j ] = A[ 0 ]
最终解:
- 返回 F[ A.length - 1 ][ m ]
3、代码炸裂
public int backPack(int m, int[] a) {
int n = a.length;
int[][] array = new int[n][m + 1];
for (int i = 0; i <= m; i++) {
if(i >= a[0]){
array[0][i] = a[0];
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= m; j++) {
if(a[i] > j){
array[i][j] = array[i - 1][j];
} else {
array[i][j] = Math.max(array[i - 1][j], array[i - 1][j - a[i]] + a[i]);
}
}
}
return array[n - 1][m];
}
4、空间优化
F[ i ][ j ]层的值,只与 F[ i - 1][ k ] (k <= j)的值有关,可以使用一维数组,注意 j 要从后向前遍历
public int backPack(int m, int[] a) {
int n = a.length;
int[] array = new int[m + 1];
for (int i = 0; i <= m; i++) {
if(i >= a[0]){
array[i] = a[0];
}
}
for (int i = 1; i < n; i++) {
// 注意:此处 j 必须逆序遍历
for (int j = m; j >= a[i]; j--) {
array[j] = Math.max(array[j], array[j - a[i]] + a[i]);
}
}
return array[m];
}
5、更换初始值
多设置一行,其体积为 0,这一行就作为初始值, F[ 0 ][ j ] = 0
public int backPack(int m, int[] a) {
int n = a.length;
int[] array = new int[m + 1];
for (int i = 1; i <= n; i++) {
// 注意:此处 j 必须逆序遍历
for (int j = m; j >= a[i - 1]; j--) {
array[j] = Math.max(array[j], array[j - a[i - 1]] + a[i - 1]);
}
}
return array[m];
}
背包II
背包问题II,赋予物品价值,求最大价值,与背包I相同
public int backPackII(int m, int[] a, int[] v) {
int n = a.length;
int[][] array = new int[n][m + 1];
for (int i = 0; i <= m; i++) {
if(i >= a[0]){
array[0][i] = v[0];
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= m; j++) {
if(a[i] > j){
array[i][j] = array[i - 1][j];
} else {
array[i][j] = Math.max(array[i - 1][j], array[i - 1][j - a[i]] + v[i]);
}
}
}
return array[n - 1][m];
}
三、背包问题III
找最大价值,每个物品可以无限放
1、题目描述
背包问题III
给定 n 种物品, 每种物品都有无限个. 第 i 个物品的体积为 A[i], 价值为 V[i].
再给定一个容量为 m 的背包. 问可以装入背包的最大价值是多少?
A = [2, 3, 5, 7]
V = [1, 5, 2, 4]
m = 10
输出: 15
解释: 装入3、3、3, 总价值 15.
2、解题丝路
状态:
- F[ i ][ j ] 表示 [ 0, i ] 中挑选若干物品(同一个物品可以多次装入),装到容量为 j 的背包中,能达到的最大价值(j 的范围是 [ 0, m])
状态转移方程:
关于 F[ i ][ j ]
- A[ i ] > j,说明根本不可能装 i,F[ i ][ j ] = F[ i - 1 ][ j ]
- A[ i ] <= j,说明可以装 i,
- 背包中不装 i (一个 i 也没有),最大价值 = F[ i - 1 ][ j ]
- 此时再装一个 i (可能已经有 i ,也可能没有 i),最大价值 = F[ i - 1][ j - A[i] ] + V[ i ])
- F[ i ][ j ] = max(F[ i - 1 ][ j ],F[ i - 1][ j - A[i] ] + V[ i ]))
初始值:
- 针对第一个物品(i = 0),计算最多的个数,再 * V[ 0 ]
最终解:
- 返回 F[ A.length - 1 ][ m ]
3、代码哗啦
public int backPackIII(int[] a, int[] v, int m) {
int n = a.length;
int[][] array = new int[n][m + 1];
for (int i = 0; i <= m; i++) {
int count = i / a[0];
array[0][i] = count * v[0];
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= m; j++) {
if(a[i] > j){
array[i][j] = array[i - 1][j];
} else {
array[i][j] = Math.max(array[i - 1][j], array[i][j - a[i]] + v[i]);
}
}
}
return array[n - 1][m];
}
4、空间优化
空间优化,F[ i ][ j ] 只和 F[ i ][ k ](k < j )、F[ i - 1][ j ]有关,可以通过一维数组,进行空间优化,j 从前向后遍历
同时可以更换初始值,多设置一行,其体积为 0,价值为 0,这一行就作为初始值, F[ 0 ][ j ] = 0
public int backPackIII(int[] a, int[] v, int m) {
int n = a.length;
int[] array = new int[m + 1];
for (int i = 1; i <= n; i++) {
for (int j = a[i - 1]; j <= m; j++) {
array[j] = Math.max(array[j], array[j - a[i - 1]] + v[i - 1]);
}
}
return array[m];
}
四、背包问题IV
两种情况
- 放满,每个物品可以无限放
- 放满,每个物品可以只能放一次
1、题目描述
背包问题IV(无限放)
给出 n 个物品, 以及一个数组, nums[i]代表第i个物品的大小, 保证大小均为正数并且没有重复, 正整数 target 表示背包的大小, 找到能填满背包的方案数。
每一个物品可以使用无数次
输入: nums = [2,3,6,7] 和 target = 7
输出: 2
解释: 方案有: [7] [2, 2, 3]
给出 n 个物品, 以及一个数组, nums[i] 代表第i个物品的大小, 保证大小均为正数, 正整数 target 表示背包的大小, 找到能填满背包的方案数。
每一个物品只能使用一次
A [1,2,3,3,7], m = 7
返回 2
结果的集合为:[7][1,3,3]
2、解题丝路
将目光聚焦到容量上
状态:
- F[ i ][ j ] 表示 [0,i] 物品中选取若干物品(无线放或只能放一次),可以放满 j 容量的方案数
状态转移方程
- 关于 F[ i ][ j ],可以使用 [ 0,i - 1 ]物品装满 j 容量,即可以将F[ i ][ j ] 赋初值, F[ i ][ j ] = F[ i - 1 ][ j ](这些方案中都不包括 i )
- A[ i ] > j,说明不可能装 i,只能使用 [ 0,i - 1 ]物品装满 j 容量, F[ i ][ j ]不变
- A[ i ] = j,说明可以只用一个 i 装满 j 容量,F[ i ][ j ] ++;
- A[ i ] > j,说明可以装 i:
- 如果可以重复选,F[ i ][ j - A[ i ] ]的每种方案中添加一个 i,即可装满 j 容量,F[ i ][ j ] += F[ i ][ j - A[ i ] ]
如果是不可以重复选,那么就只能是在F[ i - 1 ][ j - A[ i ] ]的每种方案中添加一个 i, 即可装满 j 容量,F[ i ][ j ] += F[ i - 1 ][ j - A[ i ] ]
初始值
- 可重复选时,针对第一个物品(i = 0),是 A[ i ] 倍数的容量 j 才能放满,F[ 0 ][ j ] = 1
- 不可重复选时,针对第一个物品(i = 0),只有与 A[ i ] 相等的容量 j 才能放满,F[ 0 ][ j ] = 1
返回结果
- F[ A.length - 1 ][ m ]
3、代码结局
- 每个物品可以无限放
public int backPackIV(int[] nums, int target) {
int n = nums.length;
int[][] array = new int[n][target + 1];
for (int i = 0; i <= target; i++) {
if(nums[0] <= i && i % nums[0] == 0){
array[0][i] = 1;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= target; j++) {
array[i][j] = array[i - 1][j];
if(nums[i] == j){
array[i][j]++;
} else if(nums[i] < j){
array[i][j] += array[i][j - nums[i]];
}
}
}
return array[n - 1][target];
}
初始值设置,多设置一行,其体积为 0,F[ 0 ][ 0 ] = 1, (0 填满 0 只 1 种方案)
空间优化,F[ i ][ j ] 只和 F[ i ][ k ](k < j )、F[ i - 1][ j ]有关,可以通过一维数组,进行空间优化,j 从前向后遍历
public static int backPackIV3(int[] nums, int target) {
int n = nums.length;
int[] array = new int[target + 1];
array[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = nums[i - 1]; j <= target; j++) {
if(nums[i - 1] <= j){
array[j] += array[j - nums[i - 1]];
}
}
}
return array[target];
}
- 每个物品可以只能放一次
public int backPackV(int[] nums, int target) {
int n = nums.length;
int[][] array = new int[n][target + 1];
for (int i = 0; i <= target; i++) {
if(nums[0] == i){
array[0][i] = 1;
break;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= target; j++) {
array[i][j] = array[i - 1][j];
if(nums[i] == j){
array[i][j]++;
}
if(nums[i] < j){
array[i][j] += array[i - 1][j - nums[i]];
}
}
}
return array[n - 1][target];
}
public int backPackV2(int[] nums, int target) {
int n = nums.length;
int[] array = new int[target + 1];
array[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = target; j >= 0; j--) {
if(nums[i - 1] <= j){
array[j] += array[j - nums[i - 1]];
}
}
}
return array[target];
}
五、相似题目
1、分割等和子集
分割等和子集
与背包问题V相似
通过总和取半确定背包容量,nums就是物品的容量,判断是否可以放满背包(每个物品只能放一次)
public boolean canPartition(int[] nums) {
int val = 0;
for (int num : nums) {
val += num;
}
// 总和是奇数无法分割
if(val % 2 == 1){
return false;
}
// 要达到的目标值,即背包的容量;数组 nums 就是各个物品的重量
int target = val / 2;
boolean[] array = new boolean[target + 1];
for (int i = 0; i <= target; i++) {
if(nums[0] == i){
array[i] = true;
}
}
for (int i = 1; i < nums.length; i++) {
for (int j = target; j >= 0; j--) {
if(nums[i] <= j){
array[j] = array[j] || array[j - nums[i]];
}
}
}
return array[target];
}
2、一和零
每个物品放一次,找最大数目,物品体积、背包容积要求多样化
1、题目描述
一和零
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
输入: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。
与背包问题I相似
每个字符串中0的数目、1的数目就类似于物品的体积,允许子集中最多m个0、n个1就类似于背包的容量,最多可以放物品的数量(每个物品只能放一次)
2、解题丝路
定义状态:
- F[ i ][ j ][ k ]表示背包容量为 j 个0,k 个1,从[ 0,i ]物品中选取,可放入物品的最多数目
状态转移:
- 对于 i 字符串,考虑当前容量放不放 i
- 如果 i 中 0 的个数(zero) > j,或 1 的个数(one) > k,没法放入 i,F[ i ][ j ][ k ] = F[ i - 1 ][ j ][ k ]
- 否则,可以放入 i,先腾地方,F[ i ][ j ][ k ] = max(F[ i - 1 ][ j ][ k ],F[ i - 1 ][ j - zero ][ k - one ] + 1 );
初始状态:
- 可以多设置一行,为空字符串,F[ 0 ][ j ][ k ] = 0
最终结果:
- F[ len ][ m ][ n ]
3、代码解释
- 三维数组
public int findMaxForm(String[] strs, int m, int n) {
int len = strs.length;
int[][][] array = new int[len + 1][m + 1][n + 1];
for (int i = 1; i <= len; i++) {
// count[0] 表示 str[i - 1] 中 0 的数量;count[1] 表示 str[i - 1]中 1 的数量
int[] count = countZeroAndOne(strs[i - 1]);
for (int j = 0; j <= m; j++) {
for (int k = 0; k <= n; k++) {
array[i][j][k] = array[i - 1][j][k];
if(count[0] <= j && count[1] <= k){
array[i][j][k] = Math.max(array[i - 1][j][k], array[i - 1][j - count[0]][k - count[1]] + 1);
}
}
}
}
return array[len][m][n];
}
// 计算字符串中 0 和 1 的数量
private int[] countZeroAndOne(String s) {
int[] count = new int[2];
for (char ch : s.toCharArray()) {
count[ch - '0']++;
}
return count;
}
- 二维数组,背包容量就是[ 0,m ]与[ 0,n ]的组合
public static int findMaxFormII(String[] strs, int m, int n) {
int len = strs.length;
int[] ones = new int[len]; // 记录每个字符串中 1 的个数
int[] zeros = new int[len]; // 记录每个字符串中 0 的个数
for (int i = 0; i < len; i++) {
String s = strs[i];
for (char ch : s.toCharArray()) {
if(ch == '1'){
ones[i]++;
} else {
zeros[i]++;
}
}
}
int col = (m + 1) * (n + 1);
int[][] array = new int[len][col];
for (int i = 0; i < col; i++) {
int sz = i/(n + 1);
int so = i%(n + 1);
if( sz >= zeros[0] && so >= ones[0]){
array[0][i] = 1;
}
}
for (int i = 1; i < len; i++) {
for (int j = 0; j < col; j++) {
int sz = j/(n + 1);
int so = j%(n + 1);
if(sz < zeros[i] || so < ones[i]){
array[i][j] = array[i - 1][j];
} else {
array[i][j] = Math.max(array[i - 1][j], array[i - 1][j - (zeros[i] * (n + 1) + ones[i])] + 1);
}
}
}
return array[len - 1][col - 1];
}
3、目标和
每个物品只能且必须操作一次,正好放满,求方案数
1、题目描述
目标和
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 ,
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
输入: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
与方案V类似,将nums看作是各物品的体积,target看作是背包容量
题目前提:数组中的数 >= 0
target 与 -target 的方案数相同,所以取 target = abs( target )
2、题目分析,解法一
状态:
- F[ i ][ j ] 表示[ 0,i ]的每件物品均操作(或装入(+),或取出(-)(不管有没有装入过))过,填满 j - sum 容量的方案数
- 背包的容量边界是[ -sum,sum ]
状态转移: 关于 F[ i ][ j ]
- i 物品必须参与操作,要么装入(+),要么取出(-)
- 装入,如果 j - sum - nums[ i ] >= -sum,可以容纳 num[ i ],F[ i ][ j ] += F[ i - 1 ][ j - nums[ i ] ]
- 取出,如果 j - sum + nums[ i ] <= sum,可以从背包中去除num[ i ],F[ i ][ j ] += F[ i - 1 ][ j + nums[ i ] ]
初始值:
- 遍历 容量 k ∈ [ -sum,sum ],当 nums[ 0 ] = k时,F[ 0 ][ k + sum ] = 1
- 如果num[ 0 ] = 0,nums[ 0 ] = k时,F[ 0 ][ k + sum ] = 2,这两种方案可以是 -0、0
返回结果:
- F[ nums.length - 1 ][ target ]
3、代码处理
public int findTargetSumWays(int[] nums, int target) {
target = Math.abs(target);
int val = 0;
for (int num : nums) {
val += num;
}
int[][] array = new int[nums.length][val*2 + 1];
for (int i = -val; i <= val; i++) {
if (Math.abs(i) == nums[0]) {
if (nums[0] == 0) {
// -0 = 0,+0 = 0
array[0][i + val] = 2;
} else {
array[0][i + val] = 1;
}
}
}
for (int i = 1; i < nums.length; i++) {
for (int j = -val; j <= val; j++) {
if(j - nums[i] >= -val){
array[i][j + val] += array[i - 1][j - nums[i] + val];
}
if(j + nums[i] <= val){
array[i][j + val] += array[i - 1][j + nums[i] + val];
}
}
}
return array[nums.length - 1][target + val];
}
4、其他解法,简单高效
题目前提:数组中的数 >= 0
数组总和是 sum(>0),取负号的所有数的总和是 neg(> 0),则不取符号的所有数的总和是 sum - neg,
串联所有数,得到表达式:(sum-neg) - neg = target,
neg = (sum - target) / 2,如果 sum - target < 0 或 sum - target 为奇数,不可能得到要求的 target,直接返回 0,
从nums中选若干物品,放到容量为 neg 的背包(每件物品放一次)中,问恰好放满一共有几种方案(背包问题V一模一样)
4、零钱兑换
每个物品可以无限放,正好放满的所需的最小个数
1、题目描述
零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
此题目与背包问题V相似,但此处是求最少的硬币个数
硬币的面额就是物品的体积,amount就是背包容积
2、题目分析
状态:
- F[ i ][ j ] 表示 [ 0,i ]的硬币组成 j 的最小硬币个数(无法组成为 -1)
状态转移:
- coins[ i ] > j,或 F[ i ][ j - coins] == -1,说明不能放 i 硬币,此时 F[ i ][ j ] = F[ i - 1 ][ j ]
- coins[ i ] <= j,且 F[ i ][ j - coins ] != -1,说明可以放 i 硬币,此时可以选择不放或放(放了就要在原硬币个数上 + 1),F[ i ][ j ] = max( F[ i - 1 ][ j ],F[ i ][ j - coins[ i ]] + 1)
初始状态:
- 针对 coins[ 0 ],赋值 F[ 0 ][ j ](0 <= j <= amount)
返回结果:
- F[ coins.length - 1 ][ amount ]
3、代码show
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int[][] array = new int[n][amount + 1];
for (int i = 0; i <= amount; i++) {
if(i % coins[0] == 0){
array[0][i] = i / coins[0];
} else {
array[0][i] = -1;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j <= amount; j++) {
array[i][j] = array[i - 1][j];
if(coins[i] <= j && array[i][j - coins[i]] != -1){
if(array[i - 1][j] == -1){
array[i][j] = array[i][j - coins[i]] + 1;
} else {
array[i][j] = Math.min(array[i - 1][j], array[i][j - coins[i]] + 1);
}
}
}
}
return array[n - 1][amount];
}
4、空间优化 + 更换初始值
- F[ i ][ j ] 依赖于 F[ i ][ k ](0 <= k <= j)及 F[ i - 1 ][ j ],可使用一维数组进行优化
- 多设置一行,其面额为 0,这一行就作为初始值,F[ 0 ][ 0 ] = 0; F[ 0 ][ j ] = -1 ( j > 0)
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int[] array = new int[amount + 1];
// 设置初始值
for (int i = 1; i <= amount; i++) {
array[i] = -1;
}
// 1 <= i <= n, i表示第i个硬币(coins[i - 1]), 遍历所有的硬币
for (int i = 1; i <= n; i++) {
for (int j = coins[i - 1]; j <= amount; j++) {
if(array[j - coins[i - 1]] != -1){
if(array[j] == -1){
array[j] = array[j - coins[i - 1]] + 1;
} else {
array[j] = Math.min(array[j], array[j - coins[i - 1]] + 1);
}
}
}
}
return array[amount];
}
千年帝都,盛世长安