diff --git a/problems/0377.组合总和Ⅳ.md b/problems/0377.组合总和Ⅳ.md index 6849bb3b..812ad08f 100644 --- a/problems/0377.组合总和Ⅳ.md +++ b/problems/0377.组合总和Ⅳ.md @@ -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) 大家会感觉很像,但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。 @@ -21,7 +25,15 @@ dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导 因为只要得到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数组如何初始化 @@ -35,7 +47,35 @@ dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导 所以将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 { diff --git a/problems/0509.斐波那契数.md b/problems/0509.斐波那契数.md index 5fbd55d2..e8f19546 100644 --- a/problems/0509.斐波那契数.md +++ b/problems/0509.斐波那契数.md @@ -1,3 +1,10 @@ + +用简单题来把动态规划的解题思路练一遍。 + +题目已经把动态规划最难的一步给我们了:状态转移方程 dp[i] = dp[i-1] +dp[i-2]; + +dp[i]含义:斐波那契数列中第i个数值。 + ``` class Solution { public: @@ -13,3 +20,5 @@ public: } }; ``` + +拓展可以顺便把 70. 爬楼梯 这个做了 diff --git a/problems/0746.使用最小花费爬楼梯.md b/problems/0746.使用最小花费爬楼梯.md index 76d93d4e..12126ffe 100644 --- a/problems/0746.使用最小花费爬楼梯.md +++ b/problems/0746.使用最小花费爬楼梯.md @@ -85,21 +85,21 @@ public: class Solution { public: int minCostClimbingStairs(vector& cost) { - vector dp(cost.size()); int dp0 = cost[0]; int dp1 = cost[1]; for (int i = 2; i < cost.size(); i++) { - dp[i] = min(dp0, dp1) + cost[i]; + int dpi = min(dp0, dp1) + cost[i]; 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(1) 当然我不建议这么写,能写出版本一就可以了,直观简洁! diff --git a/problems/动态规划理论基础.md b/problems/动态规划理论基础.md index 5ac6fcf0..9c71c3a2 100644 --- a/problems/动态规划理论基础.md +++ b/problems/动态规划理论基础.md @@ -30,12 +30,13 @@ 状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。 -**对于动态规划问题,我将拆解为如下四步曲,这四步都搞清楚了,才能说把动态规划真的掌握了!** +**对于动态规划问题,我将拆解为如下五步曲,这四步都搞清楚了,才能说把动态规划真的掌握了!** * 确定dp数组以及下标的含义 * 确定递推公式 * dp数组如何初始化 * 确定遍历顺序 +* 举例推导dp数组 一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢? @@ -69,11 +70,13 @@ **这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了**。 -我来举一个例子:一些同学可能代码通过不了,都会把代码抛到出来问:我这里代码都已经和题解一模一样的,为什么通过不了呢? +这也是我为什么在动规五步曲里强调距离推导dp数组的重要性。 + +举个例子:一些同学可能代码通过不了,都会把代码抛到出来问:我这里代码都已经和题解一模一样的,为什么通过不了呢? 发出这样的问题之前,其实可以自己先思考这三个问题: -* 这道题目我推导状态转移公式了么? +* 这道题目我举例推导状态转移公式了么? * 我打印dp数组的日志了么? * 打印出来了dp数组和我想的一样么? diff --git a/problems/背包总结篇.md b/problems/背包总结篇.md index 834423fd..69bf96f9 100644 --- a/problems/背包总结篇.md +++ b/problems/背包总结篇.md @@ -1,4 +1,6 @@ + + 组合问题公式:dp[i] += 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) @@ -6,6 +8,7 @@ True、False问题公式:dp[i] = dp[i] or dp[i-num] 接下来讲一下背包问题的判定 背包问题具备的特征:给定一个target,target可以是数字也可以是字符串,再给定一个数组nums,nums中装的可能是数字,也可能是字符串,问:能否使用nums中的元素做各种排列组合得到target。 + 背包问题技巧: 1.如果是0-1背包,即数组中的元素不可重复使用,nums放在外循环,target在内循环,且内循环倒序; for num in nums: diff --git a/problems/背包理论基础01背包-1.md b/problems/背包理论基础01背包-1.md index e3528086..e054c370 100644 --- a/problems/背包理论基础01背包-1.md +++ b/problems/背包理论基础01背包-1.md @@ -1,6 +1,7 @@ leetcode上没有纯01背包的问题,都是需要转化为01背包的题目,所以我先把通过纯01背包问题,把01背包原理讲清楚,后序讲解leetcode题目的时候,重点就是如何转化为01背包问题了。 -## 01 背包 + +# 01 背包 有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。**每件物品只能用一次**,求解将哪些物品装入背包里物品价值总和最大。 @@ -30,7 +31,7 @@ leetcode上没有纯01背包的问题,都是需要转化为01背包的题目 以下讲解和图示中出现的数字都是以这个例子为例。 -* 确定dp数组以及下标的含义 +## 确定dp数组以及下标的含义 对于背包问题,有一种写法, 是使用二维数组,即**dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少**。 @@ -40,7 +41,18 @@ leetcode上没有纯01背包的问题,都是需要转化为01背包的题目 **要时刻记着这个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数组的定义吻合,否则到递推公式的时候就会越来越乱**。 @@ -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背包很重要,后面在讲解滚动数组的时候,还会用到倒叙遍历来保证物品使用一次!** -初始化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[][] = dp[][] **应该这样手动推动一下** - } -} -``` 来看一下对应的dp数组的数值,如图: @@ -144,6 +139,21 @@ for(int i = 1; i < weight.size(); i++) { // 遍历物品 主要就是自己没有动手推导一下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数组里数值完整表现出来,精简一下可以是: ``` @@ -155,7 +165,7 @@ for(int i = 1; i < weight.size(); i++) { // 遍历物品 } ``` -完整测试代码: +## 完整代码 ```C++ void 01bagProblem() { diff --git a/problems/背包理论基础01背包-2.md b/problems/背包理论基础01背包-2.md index cb657d51..42171a08 100644 --- a/problems/背包理论基础01背包-2.md +++ b/problems/背包理论基础01背包-2.md @@ -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遍历的时候不用倒叙呢?** diff --git a/problems/背包问题理论基础.md b/problems/背包问题理论基础.md index 3e2d77ab..1b836aa3 100644 --- a/problems/背包问题理论基础.md +++ b/problems/背包问题理论基础.md @@ -10,10 +10,72 @@ 完全背包和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循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。(可能说的就是组合或者排列了) @@ -29,7 +91,6 @@ 力扣上面没有多重背包的题目。 # 总结 - # 总结