文章目录
- 一、前言
- 二、01背包问题
- 2.1 认识01背包问题
- 2.2 二维数组处理01背包问题
- 2.3 一维数组处理01背包问题(滚动数组)
- 三、完全背包问题
- 3.1 认识完全背包
- 3.2 完全背包问题带来的两个问题
- 3.2.1 背包和完全背包唯一不同就是体现在遍历顺序上
- 3.2.2 为什么遍历物品在外层循环,遍历背包容量在内层循环?
- 3.3 代码模板:完全背包问题
- 四、01背包问题:分割等和子集 +
- 4.1 分割等和子集
- 4.2 最后一块石头的重量 II
- 五、01背包问题:目标和
- 5.1 目标和
- 5.2 一和零
- 六、尾声
一、前言
二、01背包问题
2.1 认识01背包问题
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这是标准的背包问题,以至于很多同学看了这个自然就会想到背包,甚至都不知道暴力的解法应该怎么解了。
这样其实是没有从底向上去思考,而是习惯性想到了背包,那么暴力的解法应该是怎么样的呢?
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),这里的n表示物品数量。
所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
在下面的讲解中,我举一个例子:
背包最大重量为4。
物品为:
问背包能背的物品最大价值是多少?
以下讲解和图示中出现的数字都是以这个例子为例。
2.2 二维数组处理01背包问题
依然动规五部曲分析一波。
第一步,确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
只看这个二维数组的定义,大家一定会有点懵,看下面这个图:
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。
第二步,确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dp[i][j],
- 由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]
- 由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的物品的时候,各个容量的背包所能存放的最大价值。代码如下:
// 倒叙遍历
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0]; // 初始化i为0时候的情况
}
大家应该发现,这个初始化为什么是倒叙的遍历的?正序遍历就不行么?
正序遍历还真就不行,dp[0][j]表示容量为j的背包存放物品0时候的最大价值,物品0的价值就是15,因为题目中说了**每个物品只有一个!**所以dp[0][j]如果不是初始值的话,就应该都是物品0的价值,也就是15。
但如果一旦正序遍历了,那么物品0就会被重复加入多次!例如代码如下:
// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
例如dp[0][1] 是15,到了dp[0][2] = dp[0][2 - 1] + 15; 也就是dp[0][2] = 30 了,那么就是物品0被重复放入了。
所以一定要倒叙遍历,保证物品0只被放入一次!这一点对01背包很重要,后面在讲解滚动数组的时候,还会用到倒叙遍历来保证物品使用一次!
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。
如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + 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]; // 这个是为了展现dp数组里元素的变化
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数组打印出来,对比一下和自己推导的有什么差异,很快就可以发现问题了。
完整测试代码
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 二维数组
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
// 初始化
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
// 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]);
}
}
cout << dp[weight.size() - 1][bagWeight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
以上遍历的过程也可以这么写:
// 遍历过程
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j - weight[i] >= 0) {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
这么写打印出来的dp数据这就是这样:
空出来的0其实是用不上的,版本一 能把完整的dp数组打印出来。
2.3 一维数组处理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[j]]推导出来,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[j - weight[i]] + value[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就可以了,如果题目给的价值有负数,那么非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只被放入一次!,在动态规划:关于01背包问题,你该了解这些!中讲解二维dp数组初始化dp[0][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 来遍历背包,最终得到结果如下:
一维dp01背包完整测试代码
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
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]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
一维数组无需对第一行初始化
三、完全背包问题
3.1 认识完全背包
从01背包问题到完全背包问题,如下图:
完全背包是由01背包稍作变化而来,完全背包的物品数量是无限的,即 01背包问题是一个物品只能用一次,完全背包问题是一个物品可以用无数次(所以完全背包问题没有太大现实意义)。
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
在下面的讲解中,我依然举这个例子:
背包最大重量为4。
物品为:
每件商品都有无限个!
问背包能背的物品最大价值是多少?
3.2 完全背包问题带来的两个问题
3.2.1 背包和完全背包唯一不同就是体现在遍历顺序上
首先在回顾一下01背包的核心代码
// 01背包
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]);
}
}
我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:
// 完全背包:先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j < bagWeight ; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
3.2.2 为什么遍历物品在外层循环,遍历背包容量在内层循环?
这个问题很多题解关于这里都是轻描淡写就略过了,大家都默认 遍历物品在外层,遍历背包容量在内层,好像本应该如此一样,那么为什么呢?
难道就不能遍历背包容量在外层,遍历物品在内层?
01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一位dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。只要保证下标j之前的dp[j]都是经过计算的就可以了。
先遍历被背包在遍历物品,代码如下:
// 完全背包:先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cout << endl;
}
3.3 代码模板:完全背包问题
完整的C++测试代码如下:
// 先遍历物品,在遍历背包
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
// 先遍历背包,再遍历物品
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
四、01背包问题:分割等和子集 +
两个题目共同点:
物体有 重量 和 价值,背包有 总重量 和 总价值,这是对应的
每增加一个物体,背包总空间减少 weight[i]
每增加一个物体,背包总价值增加 value[i]
4.1 分割等和子集
题目链接:416. 分割等和子集
如何将问题转换为01背包问题?
背包问题,大家都知道,有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
背包问题有多种背包方式,常见的有:01背包、完全背包、多重背包、分组背包和混合背包等等。
要注意题目描述中商品是不是可以重复放入。
即一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的。
要明确本题中我们要使用的是01背包,因为元素我们只能用一次。
回归主题:首先,本题要求集合里能否出现总和为 sum / 2 的子集。
那么来一一对应一下本题,看看背包问题如果来解决。
只有确定了如下四点,才能把01背包问题套到本题上来。
- 背包的体积为sum / 2
- 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
- 背包如何正好装满,说明找到了总和为 sum / 2 的子集。
- 背包中每一个元素是不可重复放入。
以上分析完,我们就可以套用01背包,来解决这个问题了。动态规划代码如下:
class Solution {
public boolean canPartition(int[] nums) {
// 1 <= nums.length <= 200 物体个数最多为200个
// 1 <= nums[i] <= 100
int sum=0;
int[] dp=new int[20001];// 一维dp数组
// dp[i]表示 背包总容量是i,最大可以凑成i的子集总和为dp[i]。
for (int i=0;i<nums.length;i++){
sum = sum + nums[i];
}
if (sum %2 ==1) return false;
int target = sum/2; //
for (int i=0;i<nums.length;i++){ // nums数组长度就是物品数量
for (int j=target;j>=nums[i];j--){ // 一定要大于等于nums[i] 否则减去重量,就是负数了
dp[j] = Math.max(dp[j],dp[j-nums[i]]+nums[i]); // 不选择,选择(减去重量,加上价值)
}
}
if (dp[target] == target) return true; // 为一半,返回true,否则返回为false
return false;
}
}
可以写成二维数组,更好理解,如下:
class Solution {
public boolean canPartition(int[] nums) {
// 1 <= nums.length <= 200 物体个数最多为200个
// 1 <= nums[i] <= 100
int sum = 0;
int[][] dp = new int[nums.length + 1][20001]; // sum 最大为20000 下面就不用20000了,用 sum 和 target(sum的一半)
// 一维dp数组 改为 二维dp数组 第二维度还是背包总容量+1,只是第一个维度改为物体数量+1
// dp[i]表示 背包总容量是i,最大可以凑成i的子集总和为dp[i]。
for (int i = 0; i < nums.length; i++) {
sum = sum + nums[i];
}
if (sum % 2 == 1) return false;
int target = sum / 2;
// 二维数组要初始化第一行
for (int j = target; j >= nums[0]; j--) {
dp[0][j] = dp[0][j - nums[0]] + nums[0];
}
// 因为用到了 i-1 ,这里 i 从1开始,又因为这里 i 从1开始,所以要初始化第一行
for (int i = 1; i < nums.length; i++) { // 物体在数组里面,所以从0开始,[0,length-1] nums数组长度就是物品数量
for (int j = target; j >= 0; j--) { // 一定要大于等于nums[i] 否则减去重量,就是负数了
if (j>=nums[i])
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]); // 不选择,选择(减去重量,加上价值)
else
dp[i][j] = dp[i - 1][j];
}
}
// i 范围 [0,length-1] 所以这里的第一个维度是 length-1
if (dp[nums.length - 1][target] == target) return true; // 为一半,返回true,否则返回为false
return false;
}
}
二维数组不管是正序j还是倒序j,这里不能这么写 for (int j=target;j>=nums[i];j--)
理由:会无法覆盖到dp数组左下角,如
输入:[1,5,10,6]
输出:false
预期结果:true
二维数组可以正序遍历
class Solution {
public boolean canPartition(int[] nums) {
// 1 <= nums.length <= 200 物体个数最多为200个
// 1 <= nums[i] <= 100
int sum = 0;
int[][] dp = new int[nums.length + 1][20001]; // sum 最大为20000 下面就不用20000了,用 sum 和 target(sum的一半)
// 一维dp数组 改为 二维dp数组 第二维度还是背包总容量+1,只是第一个维度改为物体数量+1
// dp[i]表示 背包总容量是i,最大可以凑成i的子集总和为dp[i]。
for (int i = 0; i < nums.length; i++) {
sum = sum + nums[i];
}
if (sum % 2 == 1) return false;
int target = sum / 2;
// 二维数组要初始化第一行
for (int j = target; j >= nums[0]; j--) {
dp[0][j] = dp[0][j - nums[0]] + nums[0];
}
// 因为用到了 i-1 ,这里 i 从1开始,又因为这里 i 从1开始,所以要初始化第一行
for (int i = 1; i < nums.length; i++) { // 物体在数组里面,所以从0开始,[0,length-1] nums数组长度就是物品数量
for (int j = 0; j <= target; j++) { // 一定要大于等于nums[i] 否则减去重量,就是负数了
if (j>=nums[i])
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]); // 不选择,选择(减去重量,加上价值)
else
dp[i][j] = dp[i - 1][j];
}
}
// i 范围 [0,length-1] 所以这里的第一个维度是 length-1
if (dp[nums.length - 1][target] == target) return true; // 为一半,返回true,否则返回为false
return false;
}
}
4.2 最后一块石头的重量 II
题目链接:1049. 最后一块石头的重量 II
一维数组
class Solution {
public int lastStoneWeightII(int[] stones) {
int[] dp=new int[15001];
int sum=0;
for (int i=0;i<stones.length;i++)
sum = sum + stones[i];
int target = sum/2;
for (int i=0;i<stones.length;i++){ // stones [0,length-1]
for (int j=target;j>=stones[i];j--)
dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);
}
return sum-dp[target]-dp[target];
}
}
可以改成二维数组
class Solution {
public int lastStoneWeightII(int[] stones) {
int[][] dp = new int[stones.length + 1][15001];
int sum = 0;
for (int i = 0; i < stones.length; i++)
sum = sum + stones[i];
int target = sum / 2;
for (int j = target; j >= stones[0]; j--)
dp[0][j] = Math.max(dp[0][j], dp[0][j - stones[0]] + stones[0]);
for (int i = 1; i < stones.length; i++) { // stones [0,length-1]
for (int j = target; j >= 0; j--){
if (j >= stones[i])
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);
else dp[i][j] = dp[i - 1][j];
}
}
return sum - dp[stones.length-1][target] - dp[stones.length-1][target];
}
}
一维数组改成二维数组
1、新增第一个维度是 物体 数量
2、对于 i=0 初始化
3、第一个for循环 i 从1开始,第二个for循环可以反向,也可以正向
4、循环内部要if else 分别处理
5、最后返回,第一个维度为 物体数量 最后一个,第二个维度表示 容量 target
五、01背包问题:目标和
5.1 目标和
题目描述:494. 目标和
根据题目抽象成01背包问题,本题要如何使表达式结果为target,
既然为target,那么就一定有 left组合 - right组合 = target。
left + right等于sum,而sum是固定的。
公式来了, left - (sum - left) = target -> left = (target + sum)/2 。
target是固定的,sum是固定的,left就可以求出来。
此时问题就是在集合nums中找出和为left的组合。
动态规划01背包问题
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum =0;
for (int i=0;i<nums.length;i++)
sum = sum + nums[i];
if (target > sum ) return 0;
if (( target + sum )%2==1) return 0;
int bigSize = ( target + sum )/2;
int[] dp=new int[bigSize+1]; // int数组初始化全部为0 为什么数组长度为 bigSize+1,因为内循环变量的时候 int j=bigSize;i>=0; 一共 [0,bigSize] bigSize+1 个
dp[0]=1; // 这个是迭代的基础,对于计算总数的时候
for(int i=0;i<nums.length;i++){
for (int j=bigSize;j>=nums[i];j--) { // 虽然j-- 很笨,但是只能这么用 不断减少背包容量
dp[j] = dp[j] + dp[j-nums[i]]; // j-nums[i] 不能数组越界 计算总数的都是这个dp状态方程
}
}
return dp[bigSize]; // dp长度为 bigSize+1 ,这就是最后一个元素
}
}
一维数组改成二维数组
1、新增第一个维度是 物体 数量; 先dp[i][0]=1
业务初始化,而后才是二维dp数组的模板初始化 i=0
2、对于 i=0 初始化 初始化要按照一维数组的条件来,是 for (int j = bigSize; j >= nums[0]; j--)
,也可以是 for (int j = bigSize; j >= 0; j--) if...else...
一个意思;
3、第一个for循环 i 从1开始,第二个for循环可以反向,也可以正向
4、循环内部要if else 分别处理 状态方程第一维,是 dp[i][j] = dp[i-1][j] + dp[i-1][j - nums[i]];
不是 dp[i][j] = dp[i][j] + dp[i][j - nums[i]];
5、最后返回,第一个维度为 物体数量 最后一个,第二个维度表示 容量 target
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i = 0; i < nums.length; i++)
sum = sum + nums[i];
if (target > sum) return 0;
if ((target + sum) % 2 == 1) return 0;
int bigSize = (target + sum) / 2; // 得到背包重量容量 背包价值就是target,不用管了,只要管好背包重量容量就好
int[][] dp = new int[nums.length][bigSize + 1]; // int数组初始化全部为0 为什么数组长度为 bigSize+1,因为内循环变量的时候 int j=bigSize;i>=0; 一共 [0,bigSize] bigSize+1 个
// 取代 dp[0]=1;
for (int i = 0; i < nums.length; i++)
dp[i][0] = 1;
// 初始化第一行不是取代 dp[0]=1; 这个是迭代的基础,对于计算总数的时候 装满容量为0的背包,有1种方法,就是装0件物品
for (int j = bigSize; j >= nums[0]; j--) {
dp[0][j] = dp[0][j] + dp[0][j - nums[0]]; // j-nums[i] 不能数组越界 计算总数的都是这个dp状态方程
}
for (int i = 1; i < nums.length; i++) {
for (int j = bigSize; j >= 0; j--) { // 虽然j-- 很笨,但是只能这么用 不断减少背包容量
if (j >= nums[i])
dp[i][j] = dp[i-1][j] + dp[i-1][j - nums[i]]; // j-nums[i] 不能数组越界 计算总数的都是这个dp状态方程
else
dp[i][j] = dp[i-1][j]; // 必须用上一行控制下一行
}
}
return dp[nums.length - 1][bigSize]; // dp长度为 bigSize+1 ,这就是最后一个元素
}
}
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i = 0; i < nums.length; i++)
sum = sum + nums[i];
if (target > sum) return 0;
if ((target + sum) % 2 == 1) return 0;
int bigSize = (target + sum) / 2; // 得到背包重量容量 背包价值就是target,不用管了,只要管好背包重量容量就好
int[][] dp = new int[nums.length][bigSize + 1]; // int数组初始化全部为0 为什么数组长度为 bigSize+1,因为内循环变量的时候 int j=bigSize;i>=0; 一共 [0,bigSize] bigSize+1 个
for (int i = 0; i < nums.length; i++)
dp[i][0] = 1;
// 初始化第一行不是取代 dp[0]=1; 这个是迭代的基础,对于计算总数的时候 装满容量为0的背包,有1种方法,就是装0件物品
for (int j = bigSize; j >= 0; j--) {
if (j >= nums[0])
dp[0][j] = dp[0][j] + dp[0][j - nums[0]]; // j-nums[i] 不能数组越界 计算总数的都是这个dp状态方程
else
dp[0][j] = dp[0][j];
}
for (int i = 1; i < nums.length; i++) {
for (int j = bigSize; j >= 0; j--) { // 虽然j-- 很笨,但是只能这么用 不断减少背包容量
if (j >= nums[i])
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]; // j-nums[i] 不能数组越界 计算总数的都是这个dp状态方程
else
dp[i][j] = dp[i - 1][j]; // 必须用上一行控制下一行
}
}
return dp[nums.length - 1][bigSize]; // dp长度为 bigSize+1 ,这就是最后一个元素
}
}
问题1:一维dp数组的含义和二维dp数组的含义?
回答1:
一维dp数组:填满j(包括j)这么大容积的包,有dp[i]种方法;
二维dp数组:使用 下标为[0~i]
的 nums[i] 能够凑满j(包括j)这么大容量的包,有dp[i][j]
种方法。
问题2:dp状态方程的理解?
回答2:
一维:dp[j] = dp[j] + dp[j-nums[i]];
对于容量为j背包,之前的方法数是dp[j],现在来了num[i],所以容量数 是 原来的dp[j] (j 容量的背包中不放入num[i])+ dp[j-nums[i]] (j 容量的背包中放入num[i])。
二维:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
对于容量为j背包,之前的方法数是dp[i-1][j],现在来了num[i],所以容量数 是 原来的dp[i-1][j] (j 容量的背包中不放入num[i])+ dp[i-1][j-nums[i]] (j 容量的背包中放入num[i])。
问题3:dp[0]的初始化为1 ?
回答3:从递归公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递归结果将都是0。dp[0] = 1,理论上也很好解释,装满容量为0的背包,有1种方法,就是装0件物品。dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。
问题4:dp数组为什么设置为 bigSize+1 ?
回答4:因为内循环变量的时候 int j=bigSize;j>=0;j--
,所以, 一共 [0,bigSize] bigSize+1 个。
问题5:bigSize的意思?
回答5:bigSize的意义就是背包重要容量,就是为正数的数字的数字和。
5.2 一和零
题目描述:474.一和零
抽象成01背包问题,彻底理解这个题目,一个图就好了,如下:
问题1:二维dp数组的含义?
回答1:dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
问题2:dp状态方程的理解?
回答2:
二维:dp[i][j]=Math.max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
两个维度都是从一个一个字符来判断,当前的结果数存放在dp[i][j]里面,对于新来的 zeroNum oneNum,有两个状态供选择,
状态1:当结果中不选取 zeroNum oneNum, 结果就是仍然是 dp[i][j]
状态2:当结果中选取 zeroNum oneNum ,结果为 dp[i-zeroNum][j-oneNum]+1
两个状态、两种情况中选择比较大的。
对比标志的状态方程 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。所以,这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。
问题3:dp数组为什么设置为 m+1 n+1 ?
回答3:因为遍历的时候要用到 [m,0] [n,0]
问题4:为什么两层循环要用到 [m,0] [n,0]
回答4:因为对于新来的字符串数组的字符串,要全部更新dp数组。
动态规划01背包问题
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] dp=new int[m+1][n+1]; // 全部初始化为0
for (String str:strs) {
char[] chars=str.toCharArray();
int zeroNum=0;int oneNum=0;
for (char ch:chars){
if ('0'==ch)
zeroNum++;
else
oneNum++;
}
for(int i=m;i>=zeroNum;i--){ // 遍历范围从 [m,0] 所以定义长度为m+1
for (int j=n;j>=oneNum;j--){ // 遍历范围从 [n,0] 所以定义长度为n+1
dp[i][j]=Math.max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
}
}
}
return dp[m][n];
}
}
换成二维数组,现在已经是二维数组了…
换成三维数组太复杂,不涉及。
六、尾声
一文解析动态规划中的背包问题,完成了。
天天打码,天天进步!!