This commit is contained in:
youngyangyang04
2020-12-26 18:55:25 +08:00
parent e41d36812c
commit f0ada5141c
8 changed files with 167 additions and 40 deletions

View File

@ -1,9 +1,13 @@
# 思路 # 思路
本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列! 本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,**其实就是求排列!**
弄清什么是组合,什么是排列很重要。 弄清什么是组合,什么是排列很重要。
组合不强调顺序,(1,5)和(5,1)是同一个组合。
排列强调顺序,(1,5)和(5,1)是两个不同的排列。
大家在学习回溯算法系列的时候,一定做过这两道题目[回溯算法39.组合总和](https://mp.weixin.qq.com/s/FLg8G6EjVcxBjwCbzpACPw)和[回溯算法40.组合总和II](https://mp.weixin.qq.com/s/_1zPYk70NvHsdY8UWVGXmQ) 大家在学习回溯算法系列的时候,一定做过这两道题目[回溯算法39.组合总和](https://mp.weixin.qq.com/s/FLg8G6EjVcxBjwCbzpACPw)和[回溯算法40.组合总和II](https://mp.weixin.qq.com/s/_1zPYk70NvHsdY8UWVGXmQ)
大家会感觉很像,但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。 大家会感觉很像,但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。
@ -21,7 +25,15 @@ dp[i]考虑nums[j])可以由 dp[i - nums[j]]不考虑nums[j] 推导
因为只要得到nums[j]排列个数dp[i - nums[j]]就是dp[i]的一部分。 因为只要得到nums[j]排列个数dp[i - nums[j]]就是dp[i]的一部分。
所以递归公式: dp[i] += dp[i - nums[j]]; 那题目中的示例来例子target=4那么dp[4] 是求和为4的排列个数这个排列个数一定等于以元素3为结尾的排列的个数以元素2为结尾的排列的个数以元素1为结尾的排列的个数 之和!
以元素3为结尾的排列的个数就是dp[1]dp[4 - 3]以元素2为结尾的排列的个数就是dp[2]dp[4 - 2]以元素1为结尾的排列的个数就是dp[3]dp[4 - 1])。
dp[4] 就等于 dp[1]dp[2]dp[3]之和
所以dp[i] 就是 dp[i - nums[j]]之和而nums[j]就是 1 2 3。
此时不难理解递归公式为dp[i] += dp[i - nums[j]];
* dp数组如何初始化 * dp数组如何初始化
@ -35,7 +47,35 @@ dp[i]考虑nums[j])可以由 dp[i - nums[j]]不考虑nums[j] 推导
所以将target放在外循环将nums放在内循环内循环从前到后遍历。 所以将target放在外循环将nums放在内循环内循环从前到后遍历。
C++代码如下: 本题要求的是排列那么这个for循环嵌套的顺序可以有说法了。
需要把target放在外循环将nums放在内循环为什么呢
还是拿本题示例来举例只有吧遍历target放在外面dp[4] 才能得到 以元素3为结尾的排列的个数dp[1]以元素2为结尾的排列的个数就是dp[2]以元素1为结尾的排列的个数就是dp[3] 之和。
那么以元素3为结尾的排列的个数dp[1] 其实就已经包含了元素1了而以元素1为结尾的排列的个数就是dp[3]也已经包含了元素3。
所以这两个就算成了两个集合了,即:排列。
如果把遍历nums放在外循环遍历target的作为内循环的话呢
举一个例子计算dp[4]的时候,结果集只有 (1,3) 这样的集合,不会有(3,1)这样的集合因为nums遍历放在外层3只能出现在1后面
所以本题遍历顺序最终遍历顺序target放在外循环将nums放在内循环内循环从前到后遍历。
* 举例来推导dp数组
我们再来用示例中的例子推导一下:
dp[0] = 1
dp[1] = dp[0] = 1
dp[2] = dp[1] + dp[0] = 2
dp[3] = dp[2] + dp[1] + dp[0] = 4
dp[4] = dp[3] + dp[2] + dp[1] = 7
如果代码运行处的结果不是想要的结果就把dp[i]都打出来,看看和我们推导的一不一样。
经过以上的分析C++代码如下:
``` ```
class Solution { class Solution {

View File

@ -1,3 +1,10 @@
用简单题来把动态规划的解题思路练一遍。
题目已经把动态规划最难的一步给我们了:状态转移方程 dp[i] = dp[i-1] +dp[i-2];
dp[i]含义:斐波那契数列中第i个数值。
``` ```
class Solution { class Solution {
public: public:
@ -13,3 +20,5 @@ public:
} }
}; };
``` ```
拓展可以顺便把 70. 爬楼梯 这个做了

View File

@ -85,21 +85,21 @@ public:
class Solution { class Solution {
public: public:
int minCostClimbingStairs(vector<int>& cost) { int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size());
int dp0 = cost[0]; int dp0 = cost[0];
int dp1 = cost[1]; int dp1 = cost[1];
for (int i = 2; i < cost.size(); i++) { for (int i = 2; i < cost.size(); i++) {
dp[i] = min(dp0, dp1) + cost[i]; int dpi = min(dp0, dp1) + cost[i];
dp0 = dp1; // 记录一下前两位 dp0 = dp1; // 记录一下前两位
dp1 = dp[i]; dp1 = dpi;
} }
return min(dp[cost.size() - 1], dp[cost.size() - 2]); return min(dp0, dp1);
} }
}; };
``` ```
* 时间复杂度O(n) * 时间复杂度O(n)
* 空间复杂度O(n) * 空间复杂度O(1)
当然我不建议这么写,能写出版本一就可以了,直观简洁! 当然我不建议这么写,能写出版本一就可以了,直观简洁!

View File

@ -30,12 +30,13 @@
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。 状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。
**对于动态规划问题,我将拆解为如下步曲,这四步都搞清楚了,才能说把动态规划真的掌握了!** **对于动态规划问题,我将拆解为如下步曲,这四步都搞清楚了,才能说把动态规划真的掌握了!**
* 确定dp数组以及下标的含义 * 确定dp数组以及下标的含义
* 确定递推公式 * 确定递推公式
* dp数组如何初始化 * dp数组如何初始化
* 确定遍历顺序 * 确定遍历顺序
* 举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢? 一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?
@ -69,11 +70,13 @@
**这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了** **这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了**
我来举一个例子:一些同学可能代码通过不了,都会把代码抛到出来问:我这里代码都已经和题解一模一样的,为什么通过不了呢? 这也是我为什么在动规五步曲里强调距离推导dp数组的重要性。
举个例子:一些同学可能代码通过不了,都会把代码抛到出来问:我这里代码都已经和题解一模一样的,为什么通过不了呢?
发出这样的问题之前,其实可以自己先思考这三个问题: 发出这样的问题之前,其实可以自己先思考这三个问题:
* 这道题目我推导状态转移公式了么? * 这道题目我举例推导状态转移公式了么?
* 我打印dp数组的日志了么 * 我打印dp数组的日志了么
* 打印出来了dp数组和我想的一样么 * 打印出来了dp数组和我想的一样么

View File

@ -1,4 +1,6 @@
<img src='../pics/416.分割等和子集1.png' width=600> </img></div>
组合问题公式:dp[i] += dp[i-num] 组合问题公式:dp[i] += dp[i-num]
True、False问题公式:dp[i] = dp[i] or dp[i-num] True、False问题公式:dp[i] = dp[i] or dp[i-num]
最大最小问题公式:dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1) 最大最小问题公式:dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)
@ -6,6 +8,7 @@ True、False问题公式:dp[i] = dp[i] or dp[i-num]
接下来讲一下背包问题的判定 接下来讲一下背包问题的判定
背包问题具备的特征给定一个targettarget可以是数字也可以是字符串再给定一个数组numsnums中装的可能是数字也可能是字符串能否使用nums中的元素做各种排列组合得到target。 背包问题具备的特征给定一个targettarget可以是数字也可以是字符串再给定一个数组numsnums中装的可能是数字也可能是字符串能否使用nums中的元素做各种排列组合得到target。
背包问题技巧: 背包问题技巧:
1.如果是0-1背包即数组中的元素不可重复使用nums放在外循环target在内循环且内循环倒序 1.如果是0-1背包即数组中的元素不可重复使用nums放在外循环target在内循环且内循环倒序
for num in nums: for num in nums:

View File

@ -1,6 +1,7 @@
leetcode上没有纯01背包的问题都是需要转化为01背包的题目所以我先把通过纯01背包问题把01背包原理讲清楚后序讲解leetcode题目的时候重点就是如何转化为01背包问题了。 leetcode上没有纯01背包的问题都是需要转化为01背包的题目所以我先把通过纯01背包问题把01背包原理讲清楚后序讲解leetcode题目的时候重点就是如何转化为01背包问题了。
## 01 背包
# 01 背包
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i]得到的价值是value[i] 。**每件物品只能用一次**,求解将哪些物品装入背包里物品价值总和最大。 有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i]得到的价值是value[i] 。**每件物品只能用一次**,求解将哪些物品装入背包里物品价值总和最大。
@ -30,7 +31,7 @@ leetcode上没有纯01背包的问题都是需要转化为01背包的题目
以下讲解和图示中出现的数字都是以这个例子为例。 以下讲解和图示中出现的数字都是以这个例子为例。
* 确定dp数组以及下标的含义 ## 确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即**dp[i][j] 表示从下标为[0-i]的物品里任意取放进容量为j的背包价值总和最大是多少**。 对于背包问题,有一种写法, 是使用二维数组,即**dp[i][j] 表示从下标为[0-i]的物品里任意取放进容量为j的背包价值总和最大是多少**。
@ -40,7 +41,18 @@ leetcode上没有纯01背包的问题都是需要转化为01背包的题目
**要时刻记着这个dp数组的含义下面的一些步骤都围绕这dp数组的含义进行的**如果哪里看懵了就来回顾一下i代表什么j又代表什么。 **要时刻记着这个dp数组的含义下面的一些步骤都围绕这dp数组的含义进行的**如果哪里看懵了就来回顾一下i代表什么j又代表什么。
* dp数组如何初始化 ## 确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取放进容量为j的背包价值总和最大是多少。
那么可以有两个方向推出来dp[i][j]
* 由dp[i - 1][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数组的定义吻合否则到递推公式的时候就会越来越乱**
@ -65,18 +77,8 @@ dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价
**很明显,红框的位置就是我们要求的结果** **很明显,红框的位置就是我们要求的结果**
* 确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取放进容量为j的背包价值总和最大是多少。 ## 确定遍历顺序
那么可以有两个方向推出来dp[i][j]
* 由dp[i - 1][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]);
* 确定遍历顺序
确定递归公式之后,还要确定遍历顺序。 确定递归公式之后,还要确定遍历顺序。
@ -118,18 +120,11 @@ for (int j = weight[0]; j <= bagWeight; j++) {
**所以一定要倒叙遍历保证物品0只被放入一次这一点对01背包很重要后面在讲解滚动数组的时候还会用到倒叙遍历来保证物品使用一次** **所以一定要倒叙遍历保证物品0只被放入一次这一点对01背包很重要后面在讲解滚动数组的时候还会用到倒叙遍历来保证物品使用一次**
初始化dp数组之后就可以先遍历物品在遍历背包然后使用公式推导了代码如下
``` ## 举例推导dp数组
// 遍历过程
for(int i = 1; i < weight.size(); i++) { // 遍历物品 dp[][] = dp[][] **应该这样手动推动一下**
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数组的数值如图 来看一下对应的dp数组的数值如图
<img src='../pics/动态规划-背包问题4.png' width=600> </img></div> <img src='../pics/动态规划-背包问题4.png' width=600> </img></div>
@ -144,6 +139,21 @@ for(int i = 1; i < weight.size(); i++) { // 遍历物品
主要就是自己没有动手推导一下dp数组的演变过程如果推导明白了代码写出来就算有问题只要把dp数组打印出来对比一下和自己推导的有什么差异很快就可以发现问题了。 主要就是自己没有动手推导一下dp数组的演变过程如果推导明白了代码写出来就算有问题只要把dp数组打印出来对比一下和自己推导的有什么差异很快就可以发现问题了。
## 遍历过程代码
初始化dp数组之后就可以先遍历物品在遍历背包然后使用公式推导了代码如下
```
// 遍历过程
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数组里数值完整表现出来精简一下可以是 遍历过程的代码其实优化的我是为了把dp数组里数值完整表现出来精简一下可以是
``` ```
@ -155,7 +165,7 @@ for(int i = 1; i < weight.size(); i++) { // 遍历物品
} }
``` ```
完整测试代码 ## 完整代码
```C++ ```C++
void 01bagProblem() { void 01bagProblem() {

View File

@ -91,7 +91,8 @@ dp[2] = dp[2 - weight[0]] + value[0] = 15 dp数组已经都初始化为0
dp[1] = dp[1 - weight[0]] + value[0] = 15 dp[1] = dp[1 - weight[0]] + value[0] = 15
通过这个例子大家应该理解了为什么倒叙遍历可以保证数组只放入一次! 所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
**那么问题又来了为什么二维dp遍历的时候不用倒叙呢** **那么问题又来了为什么二维dp遍历的时候不用倒叙呢**

View File

@ -10,10 +10,72 @@
完全背包和01背包问题唯一不同的地方就是每种物品有无限件。 完全背包和01背包问题唯一不同的地方就是每种物品有无限件。
**终点讲解两个for顺序的问题完全背包就可以换顺序了因为不需要从后向前遍历了** 关于完全背包和01背包的差别还是好好好讲一讲。
同样因为leetcode上没有纯完全背包问题都是需要完全背包的各种应用需要转化成完全背包问题所以我这里还是以纯完全背包问题进行讲解理论和原理。讲leetcode题目的时候则侧重于讲如何转化为完全背包问题。
程序为何成立的道理。 首先在回顾一下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]);
}
}
```
在讲解01背包中已经讲解了为什么正序遍历物品就可以添加多次。
相信很多同学看网上的文章,关于完全背包介绍基本就到为止了。
其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环?
这个问题很多题解都避而不谈,大家都默认 遍历物品在外层,遍历背包容量在内层,好像本应该如此一样,那么为什么呢?
难道就不能,遍历背包容量在外层,遍历物品在内层?
接下来我们好好分析一下,让大家学个通透!
把对n的历遍放到第一层循环这样才能避免把[1,5]、[5,1]算作两条路径。因为你限制了1,5的顺序
到了i=5之后不可能在发生5,1的情况产生。
对于方式二把对n的历遍放在第二层对于任意的一个状态v,都可能历遍每一种硬币,会导致重复冗余的问题。
如果想加深理解,建议最好把两种方式都实现一下,单步执行查看
背包问题技巧:
1.如果是0-1背包即数组中的元素不可重复使用nums放在外循环target在内循环且内循环倒序
for num in nums:
for i in range(target, nums-1, -1):
2.如果是完全背包即数组中的元素可重复使用nums放在外循环target在内循环。且内循环正序。
for num in nums:
for i in range(nums, target+1):
3.如果组合问题需考虑元素之间的顺序需将target放在外循环将nums放在内循环。
for i in range(1, target+1):
for num in nums:
值得一提的是上面的伪代码中两层for循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。(可能说的就是组合或者排列了) 值得一提的是上面的伪代码中两层for循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。(可能说的就是组合或者排列了)
@ -29,7 +91,6 @@
力扣上面没有多重背包的题目。 力扣上面没有多重背包的题目。
# 总结 # 总结
<img src='../pics/416.分割等和子集1.png' width=600> </img></div>
# 总结 # 总结