This commit is contained in:
programmercarl
2024-08-29 20:39:18 +08:00
parent 1609759238
commit 574cef48b3
30 changed files with 1686 additions and 236 deletions

View File

@@ -49,7 +49,7 @@
* [动态规划关于01背包问题你该了解这些](https://programmercarl.com/背包理论基础01背包-1.html)
* [动态规划关于01背包问题你该了解这些滚动数组](https://programmercarl.com/背包理论基础01背包-2.html)
如果跟着「代码随想录」一起学过[回溯算法系列](https://programmercarl.com/回溯总结.html)的录友,看到这道题,应该有一种直觉,就是感觉好像回溯法可以搜出来。
如果跟着「代码随想录」一起学过[回溯算法系列](https://programmercarl.com/回溯总结.html)的录友,看到这道题,应该有一种直觉,就是感觉好像回溯法可以搜出来。
事实确实如此,下面我也会给出相应的代码,只不过会超时。
@@ -118,9 +118,7 @@ public:
也可以使用记忆化回溯,但这里我就不在回溯上下功夫了,直接看动规吧
### 动态规划
如何转化为01背包问题呢。
### 动态规划 二维dp数组
假设加法的总和为x那么减法对应的总和就是sum - x。
@@ -132,7 +130,7 @@ x = (target + sum) / 2
这里的x就是bagSize也就是我们后面要求的背包容量。
大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响。
大家看到`(target + sum) / 2` 应该担心计算的过程中向下取整有没有影响。
这么担心就对了例如sum是5target是2 的话其实就是无解的,所以:
@@ -147,8 +145,6 @@ if ((target + sum) % 2 == 1) return 0; // 此时没有方案
if (abs(target) > sum) return 0; // 此时没有方案
```
再回归到01背包问题为什么是01背包呢
因为每个物品题目中的1只用一次
这次和之前遇到的背包问题不一样了之前都是求容量为j的背包最多能装多少。
@@ -157,59 +153,260 @@ if (abs(target) > sum) return 0; // 此时没有方案
1. 确定dp数组以及下标的含义
dp[j] 表示:填满j包括j这么大容的包有dp[j]种方法
先用 二维 dp数组求解本题dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j包括j这么大容的包有dp[i][j]种方法
其实也可以使用二维dp数组来求解本题dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j包括j这么大容量的包有dp[i][j]种方法。
下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来,这个我在[动态规划关于01背包问题你该了解这些滚动数组](https://programmercarl.com/背包理论基础01背包-2.html)也有介绍。
01背包为什么这么定义dp数组我在[0-1背包理论基础](https://www.programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-1.html)中 确定dp数组的含义里讲解过。
2. 确定递推公式
有哪些来源可以推出dp[j]呢?
我们先手动推导一下,这个二维数组里面的数值。
只要搞到nums[i]凑成dp[j]就有dp[j - nums[i]] 种方法。
先只考虑物品0如图
例如dp[j]j 为5
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240808161747.png)
* 已经有一个1nums[i] 的话,有 dp[4]种方法 凑成 容量为5的背包
* 已经有一个2nums[i] 的话,有 dp[3]种方法 凑成 容量为5的背包。
* 已经有一个3nums[i] 的话,有 dp[2]种方法 凑成 容量为5的背包
* 已经有一个4nums[i] 的话,有 dp[1]种方法 凑成 容量为5的背包
* 已经有一个5 nums[i])的话,有 dp[0]种方法 凑成 容量为5的背包
这里的所有物品都是题目中的数字1
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
装满背包容量为0 的方法个数是1即 放0件物品。
所以求组合类问题的公式,都是类似这种:
装满背包容量为1 的方法个数是1即 放物品0。
装满背包容量为2 的方法个数是0目前没有办法能装满容量为2的背包。
接下来 考虑 物品0 和 物品1如图
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240808162052.png)
装满背包容量为0 的方法个数是1即 放0件物品。
装满背包容量为1 的方法个数是2即 放物品0 或者 放物品1。
装满背包容量为2 的方法个数是1即 放物品0 和 放物品1。
其他容量都不能装满所以方法是0。
接下来 考虑 物品0 、物品1 和 物品2 ,如图:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240808162533.png)
装满背包容量为0 的方法个数是1即 放0件物品。
装满背包容量为1 的方法个数是3即 放物品0 或者 放物品1 或者 放物品2。
装满背包容量为2 的方法个数是3即 放物品0 和 放物品1、放物品0 和 物品 2、 放物品1 和 物品2。
装满背包容量为3的方法个数是1即 放物品0 和 物品1 和 物品2。
通过以上举例,我们来看 dp[2][2] 可以有哪些方向推出来。
如图红色部分:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240808163312.png)
dp[2][2] = 3即 放物品0 和 放物品1、放物品0 和 物品 2、放物品1 和 物品2 如图所示,三种方法:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240826111946.png)
**容量为2 的背包,如果不放 物品2 有几种方法呢**
有 dp[1][2] 种方法,即 背包容量为2只考虑物品0 和 物品1 ,有 dp[1][2] 种方法,如图:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240826112805.png)
**容量为2 的背包, 如果放 物品2 有几种方法呢**
首先 要在背包里 先把物品2的容量空出来 装满 刨除物品2容量 的背包 有几种方法呢?
刨除物品2容量后的背包容量为 1。
此时装满背包容量为1 有 dp[1][1] 种方法,即: 不放物品2背包容量为1只考虑物品 0 和 物品 1有 dp[1][1] 种方法。
如图:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240826113043.png)
有录友可能疑惑,这里计算的是放满 容量为2的背包 有几种方法那物品2去哪了
在上面图中你把物品2补上就好同样是两种方法。
dp[2][2] = 容量为2的背包不放物品2有几种方法 + 容量为2的背包不放物品2有几种方法
所以 dp[2][2] = dp[1][2] + dp[1][1] ,如图:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240826113258.png)
以上过程,抽象化如下:
* **不放物品i**即背包容量为j里面不放物品i装满有dp[i - 1][j]中方法。
* **放物品i**先空出物品i的容量背包容量为j - 物品i容量放满背包有 dp[i - 1][j - 物品i容量] 种方法。
本题中物品i的容量是nums[i]价值也是nums[i]。
递推公式dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
考到这个递推公式,我们应该注意到,`j - nums[i]` 作为数组下标,如果 `j - nums[i]` 小于零呢?
说明背包容量装不下 物品i所以此时装满背包的方法值 等于 不放物品i的装满背包的方法dp[i][j] = dp[i - 1][j];
所以递推公式:
```CPP
if (nums[i] > j) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
```
dp[j] += dp[j - nums[i]]
3. dp数组如何初始化
先明确递推的方向,如图,求解 dp[2][2] 是由 上方和左上方推出。
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240826115800.png)
那么二维数组的最上行 和 最左列一定要初始化,这是递推公式推导的基础,如图红色部分:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240827103507.png)
关于dp[0][0]的值在上面的递推公式讲解中已经讲过装满背包容量为0 的方法数量是1即 放0件物品。
那么最上行dp[0][j] 如何初始化呢?
dp[0][j]只放物品0 把容量为j的背包填满有几种方法。
只有背包容量为 物品0 的容量的时候方法为1正好装满。
其他情况下,要不是装不满,要不是装不下。
所以初始化dp[0][nums[0]] = 1 其他均为0 。
表格最左列也要初始化dp[i][0] : 背包容量为0 放物品0 到 物品i装满有几种方法。
都是有一种方法就是放0件物品。
即 dp[i][0] = 1
4. 确定遍历顺序
在明确递推方向时,我们知道 当前值 是由上方和左上方推出。
那么我们的遍历顺序一定是 从上到下,从左到右。
因为只有这样,我们才能基于之前的数值做推导。
例如下图,如果上方没数值,左上方没数值,就无法推出 dp[2][2]。
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240827105427.png)
那么是先 从上到下 ,再从左到右遍历,例如这样:
```CPP
for (int i = 1; i < nums.size(); i++) { // 行,遍历物品
for (int j = 0; j <= bagSize; j++) { // 列,遍历背包
}
}
```
还是先 从左到右,再从上到下呢,例如这样:
```CPP
for (int j = 0; j <= bagSize; j++) { // 列,遍历背包
for (int i = 1; i < nums.size(); i++) { // 行,遍历物品
}
}
```
**其实以上两种遍历都可以** 但仅针对二维DP数组是这样的
这一点我在 [01背包理论基础](https://www.programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-1.html)中的 遍历顺序部分讲过。
这里我再画图讲一下以求dp[2][2]为例,当先从上到下,再从左到右遍历,矩阵是这样:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240827110933.png)
当先从左到右,再从上到下遍历,矩阵是这样:
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240827111013.png)
这里大家可以看出,无论是以上哪种遍历,都不影响 dp[2][2]的求值,用来 推导 dp[2][2] 的数值都在。
5. 举例推导dp数组
输入nums: [1, 1, 1, 1, 1], target: 3
bagSize = (target + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240827111612.png)
这么大的矩阵,我们是可以自己手动模拟出来的。
在模拟的过程中,既可以帮我们寻找规律,也可以帮我们验证 递推公式加遍历顺序是不是按照我们想象的结果推进的。
最后二维dp数组的C++代码如下:
```CPP
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (abs(target) > sum) return 0; // 此时没有方案
if ((target + sum) % 2 == 1) return 0; // 此时没有方案
int bagSize = (target + sum) / 2;
vector<vector<int>> dp(nums.size(), vector<int>(bagSize + 1, 0));
// 初始化最上行
if (nums[0] <= bagSize) dp[0][nums[0]] = 1;
// 初始化最左列,最左列其他数值在递推公式中就完成了赋值
dp[0][0] = 1;
int numZero = 0;
for (int i = 0; i < nums.size(); i++) {
if (nums[i] == 0) numZero++;
dp[i][0] = (int) pow(2.0, numZero);
}
// 以下遍历顺序行列可以颠倒
for (int i = 1; i < nums.size(); i++) { // 行,遍历物品
for (int j = 0; j <= bagSize; j++) { // 列,遍历背包
if (nums[i] > j) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
}
}
return dp[nums.size() - 1][bagSize];
}
};
```
### 动态规划 一维dp数组
将二维dp数组压缩成一维dp数组我们在 [01背包理论基础滚动数组](https://programmercarl.com/背包理论基础01背包-2.html) 讲过滚动数组,原理是一样的,即重复利用每一行的数值。
既然是重复利用每一行,就是将二维数组压缩成一行。
dp[i][j] 去掉 行的维度,即 dp[j]表示填满j包括j这么大容积的包有dp[j]种方法。
2. 确定递推公式
二维DP数组递推公式 `dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];`
去掉维度i 之后,递推公式:`dp[j] = dp[j] + dp[j - nums[i]]` ,即:`dp[j] += dp[j - nums[i]]`
**这个公式在后面在讲解背包解决排列组合问题的时候还会用到!**
3. dp数组如何初始化
从递推公式可以看出在初始化的时候dp[0] 一定要初始为1因为dp[0]是在公式中一切递推结果的起源如果dp[0]是0的话递推结果将都是0。
这里有录友可能认为从dp数组定义来说 dp[0] 应该是0也有录友认为dp[0]应该是1。
其实不要硬去解释它的含义,咱就把 dp[0]的情况带入本题看看应该等于多少。
如果数组[0] target = 0那么 bagSize = (target + sum) / 2 = 0。 dp[0]也应该是1 也就是说给数组里的元素 0 前面无论放加法还是减法,都是 1 种方法。
所以本题我们应该初始化 dp[0] 为 1。
可能有同学想了,那 如果是 数组[0,0,0,0,0] target = 0 呢。
其实 此时最终的dp[0] = 32也就是这五个零 子集的所有组合情况但此dp[0]非彼dp[0]dp[0]能算出32其基础是因为dp[0] = 1 累加起来的。
dp[j]其他下标对应的数值也应该初始化为0从递推公式也可以看出dp[j]要保证是0的初始值才能正确的由dp[j - nums[i]]推导出来。
在上面 二维dp数组中我们讲解过 dp[0][0] 初始为1这里dp[0] 同样初始为1 ,即装满背包为0的方法有一种放0件物品。
4. 确定遍历顺序
在[动态规划关于01背包问题你该了解这些滚动数组](https://programmercarl.com/背包理论基础01背包-2.html)中我们讲过对于01背包问题一维dp的遍历nums放在外循环target在内循环且内循环倒序
在[动态规划关于01背包问题你该了解这些滚动数组](https://programmercarl.com/背包理论基础01背包-2.html)中,我们系统讲过对于01背包问题一维dp的遍历。
遍历物品放在外循环,遍历背包在内循环,且内循环倒序(为了保证物品只使用一次)。
5. 举例推导dp数组
@@ -221,7 +418,9 @@ dp数组状态变化如下
![](https://code-thinking-1253855093.file.myqcloud.com/pics/20210125120743274.jpg)
C++代码如下:
大家可以和 二维dp数组的打印结果做一下对比。
一维DP的C++代码如下:
```CPP
class Solution {
@@ -248,23 +447,51 @@ public:
* 空间复杂度O(m)m为背包容量
### 拓展
关于一维dp数组的递推公式解释也可以从以下维度来理解。 **但还是从二维DP数组到一维DP数组这样更容易理解一些**
2. 确定递推公式
有哪些来源可以推出dp[j]呢?
只要搞到nums[i]凑成dp[j]就有dp[j - nums[i]] 种方法。
例如dp[j]j 为5
* 已经有一个1nums[i] 的话,有 dp[4]种方法 凑成 容量为5的背包。
* 已经有一个2nums[i] 的话,有 dp[3]种方法 凑成 容量为5的背包。
* 已经有一个3nums[i] 的话,有 dp[2]种方法 凑成 容量为5的背包
* 已经有一个4nums[i] 的话,有 dp[1]种方法 凑成 容量为5的背包
* 已经有一个5 nums[i])的话,有 dp[0]种方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
所以求组合类问题的公式,都是类似这种:
```
dp[j] += dp[j - nums[i]]
```
## 总结
此时 大家应该不禁想起,我们之前讲过的[回溯算法39. 组合总和](https://programmercarl.com/0039.组合总和.html)是不是应该也可以用dp来做啊
是的如果仅仅是求个数的话就可以用dp但[回溯算法39. 组合总和](https://programmercarl.com/0039.组合总和.html)要求的是把所有组合列出来,还是要使用回溯法搜的。
可以求如果仅仅是求个数的话就可以用dp但[回溯算法39. 组合总和](https://programmercarl.com/0039.组合总和.html)要求的是把所有组合列出来,还是要使用回溯法搜的。
本题还是有点难度,大家也可以记住,在求装满背包有几种方法的情况下,递推公式一般为:
本题还是有点难度,理解上从二维DP数组更容易理解做题上直接用一维DP更简洁一些。
大家可以选择哪种方式自己更容易理解。
在后面得题目中,在求装满背包有几种方法的情况下,递推公式一般为:
```CPP
dp[j] += dp[j - nums[i]];
```
后面我们在讲解完全背包的时候,还会用到这个递推公式!
我们在讲解完全背包的时候,还会用到这个递推公式!
## 其他语言版本
@@ -359,13 +586,6 @@ class Solution {
}
}
// 打印dp数组
// for(int i = 0; i < nums.length; i++) {
// for(int j = 0; j <= left; j++) {
// System.out.print(dp[i][j] + " ");
// }
// System.out.println("");
// }
return dp[nums.length - 1][left];
@@ -656,51 +876,3 @@ public class Solution
<img src="../pics/网站星球宣传海报.jpg" width="1000"/>
</a>
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (abs(target) > sum) return 0; // 此时没有方案
if ((target + sum) % 2 == 1) return 0; // 此时没有方案
int bagSize = (target + sum) / 2;
vector<vector<int>> dp(nums.size(), vector<int>(bagSize + 1, 0));
if (nums[0] <= bagSize) dp[0][nums[0]] = 1;
dp[0][0] = 1;
int numZero = 0;
for (int i = 0; i < nums.size(); i++) {
if (nums[i] == 0) numZero++;
dp[i][0] = (int) pow(2.0, numZero);
}
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j <= bagSize; j++) {
if (nums[i] > j) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
}
}
for (int i = 0; i < nums.size(); i++) {
for (int j = 0; j <= bagSize; j++) {
cout << dp[i][j] << " ";
}
cout << endl;
}
return dp[nums.size() - 1][bagSize];
}
};
1 1 0 0 0
1 2 1 0 0
1 3 3 1 0
1 4 6 4 1
1 5 10 10 5
初始化 如果没有0 dp[i][0] = 1; 即所有元素都不取
用元素 取与不取来举例