学习自:https://labuladong.gitee.io/algo/3/25/85/
动态规划专题二
一、0-1背包问题
给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?
需要注意的是dp数组的含义,因为我们需要描述出背包的性质:物品数 + 重量,所以得用二维DP数组来描述,dp[i][j]代表前 i 个物品,在背包容量为 j 的情况下,能够装下的最大价值。
再来看状态转移方程,对于每一个物品有两种状态,装它、不装它,这两种情况我们要取最大值,dp[i][j]= max(dp[i - 1][j], dp[i - 1][j - wt[i]] + val[i])。
base case:当物品数 = 0 或 背包当前容量 = 0时,价值都 = 0。
背包问题的关键就在于:base case的考虑,以及是否选择当前物品的考虑。
1.1数字组合(简单)
首先考虑用一维数组还是二维,因为需要记录整数个数、需要凑成的和,所以选用二维数组,dp[i][j]表示,前 i 个数,凑成和为 j 的组合方式。
考虑和为0的情况,那么对所有 i 而言,dp[i][0] = 1,什么数都不拿,就一种情况能凑成0(给出的数都是正整数,并且每个数只能用一次)。
状态转移方程,对于某个数具有两种状态:取它、不取它,我们需要的结果应该是两种情况的加和,因为可能取它能凑成和,不取它也能凑成,就属于不同的情况。dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]。如果,nums[i]大于 j ,那么当前的dp[i][j]只能取决于之前的dp[i - 1][j]。
属于是背包问题中,求解装满背包的方案数问题,在背包问题中可能考察:最值 和 方案数问题,都需要掌握。
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
int n, t;
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
t = scan.nextInt();
// dp[i][j],前 i 个数,和为 j 的组合方式
// 对当前数有两种状态:选它,不选它
// dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]
int[] nums = new int[n];
for (int i = 0; i < n; i++) {
nums[i] = scan.nextInt();
}
int[][] dp = new int[n + 1][t + 1];
// 不管有几个数,如果需要凑成的数 = 0,那么组合方式都 = 1
for (int i = 0; i <= n ; i++) {
dp[i][0] = 1;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= t; j++) {
if (nums[i - 1] <= j) {
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
System.out.println(dp[n][t]);
}
}
1.2分割等和子集(中等)
分成两个和相等的子集(只包含正整数),那么两个子集的和 = sum(nums) / 2,我们只用判断nums能否凑出 sum / 2即可,一旦可以,那么另一个子集也可以,因为数组的和是固定的。
因为需要存储数的个数 和 子集和,所以需要二维DP数组,dp[i][j],前 i 个数能否凑成和为 j。
dp[i][0],对所有 i 而言,都为true,0都能凑成。
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]],因为对一个数而言,可以取或者不取,我们只需要凑成 j 即可,所以可能不取它能凑成,也可能取它才能凑成。
当 nums[i] > j时,dp[i][j] = dp[i - 1][j],当前数只能不选。
class Solution {
public boolean canPartition(int[] nums) {
// 分成两堆,每堆的和都一样
int sum = 0;
int n = nums.length;
for (int i = 0; i < n; i++) {
sum += nums[i];
}
// 和为奇数,不可能
if (sum % 2 != 0) {
return false;
}
sum /= 2;
// 类似于0-1背包问题,但这里必须恰好凑出 sum/2
// 如果前n个数可以凑出 sum/2,那么另外一个子集也可以凑出 sum/2
boolean[][] dp = new boolean[n + 1][sum + 1];
// dp[i][j] 表示,前i个数,能否凑出j
for (int i = 0; i <= n; i++) {
dp[i][0] = true;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= sum; j++) {
if (nums[i - 1] <= j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][sum];
}
}
1.3最后一块石头的重量Ⅱ(中等)(改编题)
整体观念,就是把石头分成两堆,让两堆的差最小,怎么才能最小呢? 我们看 sum / 2,尽可能让一堆石头凑成 sum / 2,这样另一堆石头相减的差才最小。所以本题和上一题是一样的。遇到分两堆的问题都可以往这方面思考,每次选两个实际还是分两堆。
class Solution {
public int lastStoneWeightII(int[] stones) {
int n = stones.length;
int sum = 0;
for (int i = 0; i < n; i++) {
sum += stones[i];
}
int m = sum / 2;
// 整体思考,尽可能让石头凑成石头重量的一半,这样剩下的另一半与其相减剩下的重量最小
// 粉碎就是相当于减法
boolean[][] dp = new boolean[n + 1][m + 1];
// dp[i][j]代表前i个数,能否凑成j(和)
for (int i = 0; i <= n; i++) {
dp[i][0] = true;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (stones[i - 1] <= j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - stones[i - 1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
int max = 0;
for (int j = 0; j <= m; j++) {
if (dp[n][j]) {
max = Math.max(max, j);
}
}
return sum - max - max;
}
}
1.4※目标和(中等)
按照前面题目的思路,还是拆分成两坨,一整个集合分为负数集合和正数集合,假设负数的绝对值之和=neg,那么正数的和=sum - neg,sum - neg - neg = target,所以neg = (sum - target) / 2,我们只需要让前n个数凑出neg即可,凑出neg的这几个数的符号就是 - ,剩下的数的符号就是 +。
同时要注意,neg不可能小于0,也不可能为小数,所以这两种情况都返回0。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int num : nums) {
sum += num;
}
// 为负数的绝对值的和为neg,那么正数的和 = sum - neg(注意nums[i]都是>=0,但target可以小于0)
// sum - neg - neg = target
// neg = (sum - target) / 2
int diff = sum - target;
// 负数绝对值的和不可能小于0,同时sum - target必须得能被2整除,不能整除的话说明是小数,不可能是小数
if (diff < 0 || diff % 2 != 0) {
return 0;
}
int n = nums.length, neg = diff / 2;
int[][] dp = new int[n + 1][neg + 1];
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= neg; j++) {
if (j >= nums[i - 1]) {
dp[i][j] = dp[i - 1][j - nums[i - 1]] + dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][neg];
}
}
1.5一和零(中等)(正常的多维0-1背包)
终于遇到了正常的0-1背包问题,不用再脑筋急转弯拆分成两堆,不易不易。把一个个子串看成一个个物品,m和n就是背包的容量,问背包在给定容量下最多能装下多少物品?
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
// 每个二进制串看成一个个物品
// 0 和 1 的个数看成背包的容量
int len = strs.length;
int[][][] dp = new int[len + 1][m + 1][n + 1];
// dp[i][j][k],前 i 个串,在有m个0和n个1的条件下最多能够装下的子串个数
for (int i = 1; i <= len; i++) {
String tmp = strs[i - 1];
int one = check(tmp);
int zero = tmp.length() - one;
// 要注意 0 和 1 的个数从0开始遍历!
for (int j = 0; j <= m; j++) {
for (int k = 0; k <= n; k++) {
if (zero <= j && one <= k) {
// 题目是求最长长度
dp[i][j][k] = Math.max(dp[i - 1][j - zero][k - one] + 1, dp[i - 1][j][k]);
} else {
dp[i][j][k] = dp[i - 1][j][k];
}
}
}
}
return dp[len][m][n];
}
static int check(String str) {
int cnt = 0;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == '1') {
cnt++;
}
}
// 统计串中1的个数
return cnt;
}
}
注意如果题目中要求的是求方案数、种数:dp[i - 1][j] + dp[i - 1][j - nums[i]],如果求最值:max(dp[i - 1][j], dp[i - 1][j - nums[i]] + val[i])
三维数组 转 二维数组,进行空间压缩:
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int len = strs.length;
// dp[i][j][k]
// 考虑前 i 个字符串,子集中最多有m个0,n个1
int[][] dp = new int[m + 1][n + 1];
// 根据转移方程的特性,设计for循环的遍历顺序
for (int i = len; i >= 1; i--) {
String cur = strs[i - 1];
int one = getOne(cur);
int zero = cur.length() - one;
for (int j = m; j >= 0; j--) {
for (int k = n; k >= 0; k--) {
if (j >= zero && k >= one) {
dp[j][k] = Math.max(dp[j][k], dp[j - zero][k - one] + 1);
}
}
}
}
return dp[m][n];
}
int getOne(String str) {
int cnt = 0;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == '1') cnt++;
}
return cnt;
}
}
二、完全背包问题
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
1.6零钱兑换(中等)
这道题就是完全背包问题,硬币可以重复使用。一定要注意0-1背包和完全背包的状态转移方程的区别:主要是能够放下物品时有区别
01
背
包
:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
j
−
w
[
i
]
]
+
v
[
i
]
)
完
全
背
包
:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
w
[
i
]
]
+
v
[
i
]
)
01背包:dp[i][j] = max(dp[i - 1][j], dp[i -1][j-w[i]]+v[i])\\ 完全背包:dp[i][j] = max(dp[i - 1][j],dp[i][j-w[i]] + v[i])
01背包:dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])完全背包:dp[i][j]=max(dp[i−1][j],dp[i][j−w[i]]+v[i])
完全背包如果可以放下第 i 个物品,它还能选择继续放第 i 个物品,而不用回到第 i - 1个物品上。
题目中要求最小值,所以数组应该初始化为无穷大,不然会影响后续状态转移。
注意:在进行数组压缩时,从dp[i-1][j]到dp[i][j]过渡时,dp[i][j]还是dp[i-1][j]的值,但由于另一半需要dp[i][j-w[i]]的值,这就需要 i、j 都从正向进行更新。
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
// 完全背包,dp[i][j],前 i个硬币组成 j金额所需最少硬币数
int[][] dp = new int[n + 1][amount + 1];
// 因为求最小值,要用最大值填(注意二维数组得用for填)
for (int i = 0; i <= n; i++) {
Arrays.fill(dp[i], amount + 1);
// 凑成金额0都只需要0个硬币
dp[i][0] = 0;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++) {
if (j >= coins[i - 1]) {
// 注意能放下时,继续考虑 i 而不是考虑 i - 1
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - coins[i - 1]] + 1);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][amount] == amount + 1 ? -1 : dp[n][amount];
}
}
1.7零钱兑换Ⅱ(中等)
和上一题一样,都是完全背包问题,只不过这题求的是方案数。 考虑金额 j ,dp[i][j] = dp[i - 1][j] + dp[i][j -coins[i]],因为它可能由前 i - 1个硬币组成 j,如果是01背包,后面应该加上dp[i -1][j - coins[i]],第 i 个硬币只能用一次,必须要考虑前 i - 1个硬币了;但是这里是完全背包,第 i 个硬币可以一直用,所以继续考虑前 i 个硬币能否凑成 j,就是dp[i][j - coinst[i]]。
求方案数就是两个状态求和,求极值就是两个状态求max或min。求最大值时要记得数组初始化为无穷大。
class Solution {
public int change(int amount, int[] coins) {
// 完全背包问题
int n = coins.length;
int[][] dp = new int[n + 1][amount + 1];
// dp[i][j]前 i 个硬币,组成 j金额的方案数
// 无论有几个硬币,组成 0 金额的方案都是 1
for (int i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++) {
if (coins[i - 1] <= j) {
// 注意是完全背包
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][amount];
}
}
1.8买书(简单)
还是完全背包问题,书可以多买,钱必须得花完(全部用来买书),当输入15时,结果是0。
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
int n;
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
// 完全背包问题,求方案数就是两个状态相加
// 记录书的价格
int[] price = new int[] {10, 20, 50, 100};
int[][] dp = new int[5][n + 1];
// dp[i][j],前 i 本书,金额为 j 时,购书方案
for (int i = 0; i <= 4 ; i++) {
dp[i][0] = 1;
}
for (int i = 1; i <= 4 ; i++) {
for (int j = 1; j <= n ; j++) {
if (price[i - 1] <= j) {
// 完全背包,还能转移到 i 的情况
dp[i][j] = dp[i - 1][j] + dp[i][j - price[i - 1]];
} else {
// 不能放第 i 本书,那就看前 i - 1本书
dp[i][j] = dp[i - 1][j];
}
}
}
System.out.println(dp[4][n]);
}
}
※三、排列还是组合?
1.9※组合总和Ⅳ
给了n个数,给了目标和,数可以无限拿,欸?不就是完全背包?
这么想就错了,这道题是全排列动态规划问题(排列强调位置的不同,组合不考虑不同位置的情况),压根不是完全背包问题(题解中有用完全背包问题的一维数组实现的,但这有点生搬硬套的意味),更好地解释如下:
可以翻翻上面的题目,我们求的都是组合数,没有求排列数!!!
先看看爬楼梯,再回到这道题就知道怎么做了!
class Solution {
public int climbStairs(int n) {
if (n <= 2 ) {
return n;
}
int[] ans = new int[n + 1];
ans[1] = 1;
ans[2] = 2;
for (int i = 3; i <= n; i++) {
ans[i] = ans[i - 1] + ans[i - 2];
}
return ans[n];
}
}
爬到第 n 阶台阶,可以看成爬到第 n - 1阶 + 第 n - 2阶的方案数和。
上面是用递推公式的解法,也可以用下面一种写法:
class Solution {
public int climbStairs(int n) {
if (n <= 2 ) {
return n;
}
int[] ans = new int[n + 1];
ans[1] = 1;
ans[2] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= 2; j++) {
if (j <= i) {
ans[i] += ans[i - j];
}
}
}
return ans[n];
}
}
回到本题中,类似于爬楼梯,但是每次可以走nums[i]的步数,需要target阶梯爬到楼顶,第 i 个阶梯,可以由 i - nums[j] 的台阶 + nums[j]爬上来!
class Solution {
public int combinationSum4(int[] nums, int target) {
int n = nums.length;
// 类似于爬楼梯,但是每次可以走nums[i]的步数,需要target阶梯爬到楼顶
// 问能爬到楼顶的方案数
int[] dp = new int[target + 1];
// 对于每种可能爬上的台阶数,至少都有一种方案
// 当然这里也可以直接令dp[0] = 1,加和出来的结果一样
for (int i = 0; i < n; i++) {
if (nums[i] <= target) {
dp[nums[i]] = 1;
}
}
// 第 i 个阶梯,可以由 i - nums[j] 的台阶 + nums[j]爬上来!
for (int i = 1; i <= target; i++) {
for (int j = 0; j < n; j++) {
if (nums[j] <= i) {
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
}
涉及排列和组合的问题,一般都是题目中要求“种类数”,如果是求最值就不用管这个问题,用排列、组合求都可以。(用组合的话考虑的情况更少,所以一般是组合,组合问题就是普通的完全背包问题。遇到排列问题,要学会转换为爬楼梯问题。)
四、二维DP压缩一维DP
针对完全背包问题(组合问题),可以简化二维DP数组为一维,这种想法其实在做很多题的时候就有感觉了,例如最开始的零钱兑换问题,如果没有学过完全背包,直接想法都是声明一个一维DP数组,dp[i]表示 i 金额的组合方式(看题目是排列还是组合)。
对于完全背包问题的一维DP数组解法,一定要注意内外遍历的顺序,如果是求最值不影响,影响的是求种类、方案数。
自己还是更喜欢二维DP数组,更好理解,没有必要压缩为一维数组,不方便理解,还必须要记这些东西,把动态规划限制住了。
1.10完全平方数(中等)
本题是完全背包题目,每个完全平方数可以多次取,背包容量是n,物品是一个个完全平方数,属于求组合类型,一维DP数组应该先遍历物品再遍历背包容量(也就是最普通的情况),由于求的是最值,所以组合数、排列数都可以求,遍历顺序也就不影响了。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
// dp[i] 整数 i 所需的最少数量
Arrays.fill(dp, n);
dp[0] = 0;
for (int i = 1; i <= Math.sqrt(n); i++) { // 先遍历物品
for (int j = 1; j <= n; j++) { // 再遍历背包
if (i * i <= j) {
dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
}
}
}
return dp[n];
}
}
二维DP数组压缩为一维DP数组的实质,就是把dp[i][j]的 i 去掉,就是不存储物品了,只存储背包容量,但是for循环遍历时,还是要遍历物品和背包,01背包的背包容量要从大到小遍历,完全背包的背包容量要从小到大遍历,它们的物品都从小到大遍历,注意完全背包内外循环遍历不同时,一个代表组合数、一个代表排列数。
1.11单词拆分(中等)
这道题也是一道完全背包问题,并且是求排列数,因为s串要求顺序一致。
用一维DP,外层遍历背包,内层遍历物品。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 单词可以重复使用:完全背包问题
// 物品:每个单词,背包:s
int s_len = s.length();
int size = wordDict.size();
boolean[] dp = new boolean[s_len + 1];
dp[0] = true;
// 先遍历背包再遍历物品,因为字符串中的单词有顺序要求
// 背包能放下物品不光光是大小合适,还需要字符串相等!
for (int i = 1; i <= s_len; i++) { // 背包
for (int j = 1; j <= size; j++) { // 物品
int len = wordDict.get(j - 1).length();
if (i >= len && wordDict.get(j - 1).equals(s.substring(i - len, i))) {
dp[i] = dp[i] || dp[i - len];
}
}
}
}
}
在解决较难问题时,一维DP数组的好处也得以体现,题目不是简单的背包问题,而是有多重面具的,这时候使用一维DP数组更好处理问题。
五、多重背包
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?
每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
public void testMultiPack1(){
List<Integer> weight = new ArrayList<>(Arrays.asList(1, 3, 4));
List<Integer> value = new ArrayList<>(Arrays.asList(15, 20, 30));
List<Integer> nums = new ArrayList<>(Arrays.asList(2, 3, 2));
int bagWeight = 10;
for (int i = 0; i < nums.size(); i++) {
while (nums.get(i) > 1) { // 把物品展开为i
weight.add(weight.get(i));
value.add(value.get(i));
nums.set(i, nums.get(i) - 1);
}
}
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));
}
System.out.println(Arrays.toString(dp));
}
}