目录
标准0-1背包问题
二维dp数组01背包
一维dp数组01背包(滚动数组)
416.分割等和子集
1049.最后一块石头的重量||
494.目标和
474.一和零
背包问题的分类
标准0-1背包问题
46. 携带研究材料(第六期模拟笔试)
时间限制:5.000S 空间限制:128MB
题目描述
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。
小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
输入描述
第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。
第二行包含 M 个正整数,代表每种研究材料的所占空间。
第三行包含 M 个正整数,代表每种研究材料的价值。
输出描述
输出一个整数,代表小明能够携带的研究材料的最大价值。
输入示例
6 1
2 2 3 1 5 2
2 3 1 5 4 3
输出示例
5
提示信息
小明能够携带 6 种研究材料,但是行李空间只有 1,而占用空间为 1 的研究材料价值为 5,所以最终答案输出 5。
数据范围:
1 <= N <= 5000
1 <= M <= 5000
研究材料占用空间和价值都小于等于 1000
二维dp数组01背包
背包问题给出的物品和背包问题容量这两个参数,可以作为递推的规则
依然动规五部曲分析一波。
确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
只看这个二维数组的定义,大家一定会有点懵,看下面这个图:
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。
确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dp[i][j],
不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:
在看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
代码初始化如下:
for (int j = 0 ; j < weight[0]; j++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。
初始-1,初始-2,初始100,都可以!
但只不过一开始就统一把dp数组统一初始为0,更方便一些。
如图:
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
费了这么大的功夫,才把如何初始化讲清楚,相信不少同学平时初始化dp数组是凭感觉来的,但有时候感觉是不靠谱的。
确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
那么问题来了,先遍历 物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解。
那么我先给出先遍历物品,然后遍历背包重量的代码。
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)
例如这样:
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
为什么也是可以的呢?
要理解递归的本质和递推的方向。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:
再来看看先遍历背包,再遍历物品呢,如图:
大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导!
但先遍历物品再遍历背包这个顺序更好理解。
其实背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了。
举例推导dp数组
来看一下对应的dp数组的数值,如图:
最终结果就是dp[2][4]。
建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。
做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!
很多同学做dp题目,遇到各种问题,然后凭感觉东改改西改改,怎么改都不对,或者稀里糊涂就改过了。
主要就是自己没有动手推导一下dp数组的演变过程,如果推导明白了,代码写出来就算有问题,只要把dp数组打印出来,对比一下和自己推导的有什么差异,很快就可以发现问题了。
import java.util.*;
public class Main {
public static void main(String[] args) {
// 初始化一个Scanner对象用于读取用户输入
Scanner sc = new Scanner(System.in);
// 读取背包的容量N和物品种类M
int M = sc.nextInt(); // 物品种类
int N = sc.nextInt(); // 背包容量
// 初始化两个数组,分别用于存储物品的价值和重量
int[] values = new int[M];
int[] weights = new int[M];
// 读取每种物品的重量
for(int i = 0; i < M; i++) {
weights[i] = sc.nextInt();
}
// 读取每种物品的价值
for(int i = 0; i < M; i++) {
values[i] = sc.nextInt();
}
// 初始化动态规划数组dp,其中dp[i][j]表示前i个物品放入容量为j的背包中所能获得的最大价值
//容量可以为0,故这里定义为N + 1
int[][] dp = new int[M][N + 1];
// 对于第一个物品,如果其重量小于等于当前背包容量,则直接将其价值赋给dp数组对应位置
for(int i = weights[0]; i <= N; i++) {
dp[0][i] = values[0];
}
// 动态规划过程
for(int i = 1; i < dp.length; i++){ // 遍历所有物品
for(int j = 1; j < dp[0].length; j++){ // 遍历所有背包容量
// 如果当前物品重量大于背包容量,则当前背包无法装入该物品,价值与除去这个物品的情况一样
if(weights[i] > j) {
dp[i][j] = dp[i-1][j];
} else {
// 如果当前物品重量小于等于背包容量,则比较放入和不放入该物品两种情况下的价值,取最大值
//放入的情况是除去当前物品,容量减少该物品能放入的最大价值加该物品的价值
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i]);
}
}
}
// 输出最大价值,即dp数组的最后一个元素
System.out.println(dp[M - 1][N]);
}
}
一维dp数组01背包(滚动数组)
对于背包问题其实状态都是可以压缩的。
在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。
dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
一定要时刻记住这里i和j的含义,要不然很容易看懵了。
动规五部曲分析如下:
确定dp数组的定义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
一维dp数组的递推公式
dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?
dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。
dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,
所以递归公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。
那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
一维dp数组遍历顺序
代码如下:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
为什么呢?
倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
通过递归公式以及上边的分析来说,在原来的二维数组中dp[j]的值是由上方及左上方的值决定的,换到一维数组时,就由dp[j](上一层的值)和左侧上一层的值决定,如果正序遍历,则左边的值已经变成了本层的dp[j],所以要倒序遍历。
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
那么问题又来了,为什么二维dp数组遍历的时候不用倒序呢?
因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!
(如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)
再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
(这里如果读不懂,就再回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)
所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。
举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
import java.util.*;
public class Main {
public static void main(String[] args) {
// 创建一个Scanner对象,用于从控制台读取用户输入
Scanner sc = new Scanner(System.in);
// 从用户处读取背包的总容量N和物品种类数量M
int M = sc.nextInt(); // M表示物品种类的数量
int N = sc.nextInt(); // N表示背包的总容量
// 初始化两个数组,分别用于存储每种物品的价值和重量
int[] values = new int[M]; // 物品价值数组
int[] weights = new int[M]; // 物品重量数组
// 读取每种物品的重量,并存入weights数组
for (int i = 0; i < M; i++) {
weights[i] = sc.nextInt();
}
// 读取每种物品的价值,并存入values数组
for (int i = 0; i < M; i++) {
values[i] = sc.nextInt();
}
// 初始化动态规划数组dp,用于存储每个容量下能装入物品的最大价值
// 由于背包容量可以为0到N,故数组长度为N+1
int[] dp = new int[N + 1];
// 动态规划过程,从后往前遍历背包容量,确保每个物品只被考虑一次
for (int i = 0; i < M; i++) { // 遍历每一种物品
for (int j = N; j >= weights[i]; j--) { // 从最大容量开始遍历到当前物品重量
// 如果当前物品重量小于等于当前背包容量,则比较放入和不放入该物品两种情况下的价值
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
// 输出最大价值,即dp数组在容量为N时的值
System.out.println(dp[N]);
// 关闭Scanner对象,释放资源
sc.close();
}
}
416.分割等和子集
416. 分割等和子集
中等
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:数组不能分割成两个元素和相等的子集。
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
class Solution {
public boolean canPartition(int[] nums) {
// 假设物品的重量和价值相等,因此这里直接使用原数组作为重量和价值数组
int[] weight = nums;
int[] value = nums;
int sum = 0;
// 计算数组总和
for(int i = 0; i < nums.length; i++){
sum += nums[i];
}
// 如果总和为奇数,则无法平均分割,直接返回false
if(sum % 2 == 1){
return false;
}
// 初始化动态规划数组dp,长度为总和的一半加1
// dp[j]表示容量为j的背包所能装下的最大价值
int dp[] = new int[sum/2 + 1];
// 遍历每个物品
for(int i = 0; i < nums.length;i++){
// 从背包的最大容量开始向前遍历
for(int j = dp.length - 1; j >= 0; j--){
// 如果当前背包容量j大于等于当前物品的重量weight[i]
if(j >= weight[i]){
// 更新dp[j],比较当前物品放入背包和不放入背包两种情况下的最大价值
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
}
// 如果dp数组中最大容量的价值等于总和的一半,说明可以将数组平均分割成两个子集,使得它们的和相等
if(dp[dp.length - 1] == sum/2){
return true;
}
// 否则无法平均分割
return false;
}
}
class Solution {
public boolean canPartition(int[] nums) {
// 假设物品的重量和价值相等,因此这里直接使用原数组作为重量和价值数组
int[] weight = nums;
int[] values = nums;
// 获取数组长度
int len = nums.length;
// 如果数组为空,则无法分割成两个子集,直接返回false
if(len == 0){
return false;
}
// 计算数组的总和
int sum = 0;
for (int num : nums){
sum += num;
}
// 如果总和是奇数,则无法平均分割成两个子集,直接返回false
if(sum % 2 == 1){
return false;
}
// 目标值,即分割后每个子集应达到的和
int target = sum / 2;
// 初始化动态规划数组dp,dp[i][j]表示考虑前i个物品时,背包容量为j时的最大价值
int[][] dp = new int[nums.length][target + 1];
// 处理第一个物品
// 如果背包容量j大于等于第一个物品的重量,则放入第一个物品
for(int j = nums[0]; j <= target; j++){
dp[0][j] = nums[0];
}
// 处理剩余物品
for(int i = 1; i < len; i++){
for(int j = 0; j <= target; j++){
// 如果当前背包容量j小于当前物品的重量,则无法放入该物品,最大价值与前一个物品相同
if (j < nums[i]) {
dp[i][j] = dp[i - 1][j];
} else {
// 如果当前背包容量j大于等于当前物品的重量,则比较放入和不放入该物品两种情况下的最大价值
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + values[i]);
}
}
}
// 如果考虑所有物品后,背包容量为target时的最大价值等于target,说明可以分割成两个和相等的子集
return dp[len - 1][target] == target;
}
}
1049.最后一块石头的重量||
1049. 最后一块石头的重量 II
中等
提示
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0
。
示例 1:
输入:stones = [2,7,4,1,8,1] 输出:1 解释: 组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1], 组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1], 组合 2 和 1,得到 1,所以数组转化为 [1,1,1], 组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
示例 2:
输入:stones = [31,26,33,21,40] 输出:5
提示:
1 <= stones.length <= 30
1 <= stones[i] <= 100
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了。(和分割等和子集相似)
本题物品的重量为stones[i],物品的价值也为stones[i]。
对应着01背包里的物品重量weight[i]和 物品价值value[i]。
接下来进行动规五步曲:
确定dp数组以及下标的含义
dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背最大重量为dp[j]。
可以回忆一下01背包中,dp[j]的含义,容量为j的背包,最多可以装的价值为 dp[j]。
相对于 01背包,本题中,石头的重量是 stones[i],石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”
确定递推公式
01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
本题则是:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
一些同学可能看到这dp[j - stones[i]] + stones[i]中 又有- stones[i] 又有+stones[i],看着有点晕乎。
大家可以再去看 dp[j]的含义。
dp数组如何初始化
既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和。
把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。
接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);中dp[j]才不会初始值所覆盖。
代码为:
vector<int> dp(15001, 0);
确定遍历顺序
如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
代码如下:
for (int i = 0; i < stones.size(); i++) { // 遍历物品
for (int j = target; j >= stones[i]; j--) { // 遍历背包
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
举例推导dp数组
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
最后dp[target]里是容量为target的背包所能背的最大重量。
那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。
在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的。
那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]。
class Solution {
public int lastStoneWeightII(int[] stones) {
// 计算所有石头的总重量
int sum = 0;
for (int i : stones) {
sum += i;
}
// 目标重量为总重量的一半,因为我们需要将石头分成尽可能接近的两堆
int target = sum >> 1;
// 初始化dp数组,dp[j]表示容量为j的背包所能装下的石头的最大重量
int[] dp = new int[target + 1];
// 遍历每块石头
for (int i = 0; i < stones.length; i++) {
// 逆序遍历目标重量,从大到小更新dp数组,保证每个石头只被使用一次
for (int j = target; j >= stones[i]; j--) {
// 对于当前石头,有两种选择:放入背包或不放入背包
// 放入背包:则背包的总重量为之前背包的总重量加上当前石头的重量
// 不放入背包:则背包的总重量不变
// 取两种情况中的较大值作为当前背包的总重量
dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
//int[] weight = stones
//int[] values = stones
}
}
// 最后一堆石头的重量为总重量减去两堆中较大堆的重量(即dp[target]),因为dp[target]是两堆中较大堆的重量
// 所以,剩余一堆的重量为 sum - 2 * dp[target]
// 题目要求返回的是最后剩下的石头的重量,即两堆石头重量的差值
return sum - 2 * dp[target];
}
}
class Solution {
public int lastStoneWeightII(int[] stones) {
// 计算所有石头的总重量
int sum = 0;
for (int s : stones) {
sum += s;
}
// 目标重量为总重量的一半,向下取整
int target = sum / 2;
// 初始化二维dp数组,dp[i][j]表示前i个物品放入容量为j的背包所能达到的最大重量
int[][] dp = new int[stones.length][target + 1];
// 对于第一个石头,初始化其对应的dp列
// 当背包容量j大于等于stones[0]时,可以放入该石头
for (int j = stones[0]; j <= target; j++) {
dp[0][j] = stones[0];
}
// 遍历每个石头
for (int i = 1; i < stones.length; i++) {
// 遍历背包的每个容量
for (int j = 1; j <= target; j++) {
// 如果当前背包容量j大于等于当前石头stones[i]的重量
if (j >= stones[i]) {
// 选择放入当前石头,此时背包的重量为前i-1个物品在容量为j-stones[i]的背包中的最大重量加上当前石头的重量
// 或者选择不放入当前石头,此时背包的重量为前i-1个物品在容量为j的背包中的最大重量
// 取两种情况中的较大值作为当前背包的最大重量
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);
} else {
// 如果当前背包容量j小于当前石头stones[i]的重量,则无法放入该石头,背包的最大重量与前i-1个物品在容量为j的背包中的最大重量相同
dp[i][j] = dp[i - 1][j];
}
}
}
// 打印出背包的最大重量,便于调试(可选)
System.out.println(dp[stones.length - 1][target]);
// 两堆石头重量差值的最小值即为总重量减去两倍的背包最大重量
// 因为背包最大重量是使得两堆石头重量差值最小的那堆石头的重量
return sum - 2 * dp[stones.length - 1][target];
}
}
494.目标和
494. 目标和
中等
给你一个非负整数数组 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
提示:
1 <= nums.length <= 20
0 <= nums[i] <= 1000
0 <= sum(nums[i]) <= 1000
-1000 <= target <= 1000
假设加法的总和为x,那么减法对应的总和就是sum - x。
所以我们要求的是 x - (sum - x) = target
x = (target + sum) / 2
此时问题就转化为,装满容量为x的背包,有几种方法。
这里的x,就是bagSize,也就是我们后面要求的背包容量。
大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响。
这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:
(C++代码中,输入的S 就是题目描述的 target)
if ((S + sum) % 2 == 1) return 0; // 此时没有方案
同时如果 S的绝对值已经大于sum,那么也是没有方案的。
(C++代码中,输入的S 就是题目描述的 target)
if (abs(S) > sum) return 0; // 此时没有方案
再回归到01背包问题,为什么是01背包呢?
因为每个物品(题目中的1)只用一次!
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。
本题则是装满有几种方法。其实这就是一个组合问题了。
确定dp数组以及下标的含义
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。
下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来
确定递推公式
有哪些来源可以推出dp[j]呢?
只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
dp数组如何初始化
这里看做组合问题,如果背包容量为1的话,那么只有一种方式就是往背包里什么都不放,所以dp[0] = 1;
确定遍历顺序
对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
class Solution {
// 公共方法,用于计算数组中和为目标值的组合数
public int findTargetSumWays(int[] nums, int target) {
//left是加和,right是减和
//left - right = target
//left + right = sum
//right = sum - left
//left = (target + sum) / 2
// 计算数组的总和
int sum = 0;
for(int i = 0; i < nums.length; i++){
sum += nums[i];
}
// 如果目标值的绝对值大于数组的总和,那么不可能有任何组合的和等于目标值
if(Math.abs(target) > sum){
return 0;
}
// 如果目标值与数组总和之和是奇数,那么也不可能有任何组合的和等于目标值(因为和的一半必须是整数)
if((target + sum) % 2 == 1){
return 0;
}
// 计算目标值与数组总和之和的一半,这就是我们要在数组中找出其组合和为多少的数值
int size = (target + sum) / 2;
// 初始化动态规划数组,dp[i]表示和为i的组合数
int dp[] = new int[size + 1];
// 初始条件:和为0的组合有一种,即不选任何数字
dp[0] = 1;
// 遍历数组中的每个数字
for(int i = 0; i < nums.length; i++){
// 从大到小遍历可能的和,这样可以保证在计算dp[j]时,dp[j - nums[i]]的值是已经计算好的
for(int j = size; j >= nums[i]; j--){
//j >= nums[i]的时候下边的式子才有意义
// dp[j]的值等于它自身(不选当前数字)与dp[j - nums[i]](选择当前数字)的和
dp[j] += dp[j - nums[i]];
}
}
// 返回和为size的组合数,即目标值与数组总和之和的一半的组合数
return dp[size];
}
}
474.一和零
474. 一和零
中等
给你一个二进制字符串数组 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 。
提示:
1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i]
仅由'0'
和'1'
组成1 <= m, n <= 100
本题中strs 数组里的元素就是物品,每个物品都是一个!
而m 和 n相当于是一个背包,两个维度的背包。
理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包。
但本题其实是01背包问题!
只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。
开始动规五部曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
确定递推公式
dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。
dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。
然后我们在遍历的过程中,取dp[i][j]的最大值。
所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。
这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。
dp数组如何初始化
01背包的dp数组初始化为0就可以。
因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。
确定遍历顺序
外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!
那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。
代码如下:
for (string str : strs) { // 遍历物品
int oneNum = 0, zeroNum = 0;
for (char c : str) {
if (c == '0') zeroNum++;
else oneNum++;
}
for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
for (int j = n; j >= oneNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
有同学可能想,那个遍历背包容量的两层for循环先后循序有没有什么讲究?
没讲究,都是物品重量的一个维度,先遍历哪个都行!
举例推导dp数组
以输入:["10","0001","111001","1","0"],m = 3,n = 3为例
最后dp数组的状态如下所示:
class Solution {
// 定义一个公共方法,用于找到最大形式的字符串数量
public int findMaxForm(String[] strs, int m, int n) {
// 初始化一个二维动态规划数组dp,其维度为(m+1)x(n+1)。
// dp[i][j]表示容量为i个'0'和j个'1'的子集中可以形成的最大字符串数。
int[][] dp = new int[m + 1][n + 1];
// 遍历字符串数组中的每一个字符串
for(String s : strs){
// 初始化计数器,用于统计当前字符串中'0'和'1'的数量
int zeroNum = 0;
int oneNum = 0;
// 遍历当前字符串的每一个字符
for(int k = 0; k < s.length(); k ++){
// 获取当前字符
char a = s.charAt(k);
// 根据字符类型增加对应的计数器
if(a == '0'){
zeroNum++;
}else{
oneNum++;
}
}
// 从最大容量开始递减,比该字符串中的0和1的范围大才有更新的意义
for(int i = m; i >= zeroNum; i--){
for(int j = n; j >= oneNum; j--){
// 更新dp数组,比较当前状态与添加当前字符串后的状态哪个更优
dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
// 返回最终结果,即在容量为m个'0'和n个'1'的情况下可以形成的最大字符串数
return dp[m][n];
}
}