那从今天开始我们就正式进入动态规划的学习哦,这也是我们之前一直都不会的一节知识哈,但是认真想想,递归我们都能干掉了,那么动态规划当然也可以,还是那句话,冲冲冲,勇敢勇敢我的朋友
动态规划的理论基础
那么首先我们来讲解下动态规划的理论基础,动态规划中很重要的是递推公式,当然,并不是说只要找到递推公式动态规划就完了,没有其他需要了解的知识了,我们将动态规划拆解为五部曲,每一步我们都要搞清楚,这有把这五步都搞清楚了,我们对动态规划才能算是真的掌握了
我们做动规的题目,就要严格根据这五步来构建我们的代码,当然,有的同学可能会疑问,为什么要先确定递推公式再考虑初始化呢?因为一些情况是递推公式决定了dp数组要如何初始化。因此我们要先确定递推公式后考虑初始化
当然,现在大家可能还处于一种朦胧状态,不知道到底咋做,没事,后面我们做题目会慢慢对这五步加深理解的,现在我们先记着就行,以后不会了就回来看看理论基础,查漏补缺
接着我们要解决的问题是,动态规划应该如何debug?动规就像递归一样,如果我们只是依葫芦画瓢,凭感觉瞎几把做的话肯定是不行的,这样哪怕通过了自己也是没一点长进的,所以我们要学会debug,以正确的方式来发现自己的错误在哪里,这样来提高自己的知识水平和理解
那么我们debug的三个最大步骤就是
我们做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。如果最后的结果错了,那么我们就要思考到底是哪一步错了,把对应的日志打印下来,然后我们再来查看自己的错误和自己预想的dp数组公式有啥不对,不断确定自己的问题,锻炼自己的能力
OK,那么学完了理论基础之后,我们就起飞喽,正式开始我们的动规冒险吧
斐波那契数
先来一道简单的题目练练手
那么我们就按顺序来解决这题,首先我们要确定dp数组以及其下标的含义,那么在这一题里,dp数组其实就是斐波那契数列的值,而其对应下标就是对应的第几个斐波那契数列的值
然后我们来确定我们的递推公式,这里的递推公式已经给出来了其实,我们可以很显然地看到递归公式是F(n)=F(n-1)+F(n-2),没错其实递推公式就是我们数学里的那种比较形象的公式,而后面就要根据这个递推公式来构造我们的代码
接着我们来确定dp数组应该如何初始化,显然,这里递归公式最起码会用到前两位的值,因此我们最初的两位值是一定要初始化的,不然我们都没有起点的值,如果传入的值就是最初的两位数,那么我们直接返回已知的结果即可
然后我们来确定我们的遍历顺序,我们是要从前往后遍历还是从后往前遍历?这里显然,我们要求第几项的值,我们就从最开始的两位往上推导即可,因此我们肯定是要从前往后遍历的
最后我们来举例推到我们的dp数组,假设我们要找第5位的值,那么我们的数组应该是这样的0 1 1 2 3,这也符合我们的最开始的递推公式,也是我们最终要求的答案
那么进行了上面的步骤之后,确定没问题了,我们就开始构造我们的代码
class Solution {
public int fib(int n) {
if(n<2){
return n;
}
int ans = 0;
int[] next = new int[n+1];
next[0]=0;next[1]=1;
for (int i = 2; i <= n; i++) {
ans=next[i-1]+next[i-2];
next[i]=ans;
}
return ans;
}
}
我们这里首先特殊处理n为初始值的情况,然后我们构建了dp数组,其长度是n+1,因为我们最终是要遍历到n的位置的,因此我们需要给其值+1,接着我们利用公式不断往前递推,到达对应的值时停止递推公式,然后返回我们的记录值即可
当然,我们容易知道本题实际上只需要维护前两个值就可以得到答案,所以我们的代码可以不用构建一个dp数组,只需要维护递推公式中前两位的值,那么我们可以修改我们的代码如下
class Solution {
public int fib(int n) {
if (n < 2) return n;
int a = 0, b = 1, c = 0;
for (int i = 1; i < n; i++) {
c = a + b;
a = b;
b = c;
}
return c;
}
}
不过实际做题的时候我们不推荐使用这种代码,因为我们不差空间,而且这种代码除了装逼之外不知道有什么用,后续我们就不展示这种方式构造的代码了
爬楼梯
初入门径之后,我们再来一题小试牛刀
首先我们来确定dp数组,我们这里的dp数组表示的是爬到对应阶数所拥有的方法数,而对应下标表示的是爬到对应下标所能有的方法数
然后我们来确定我们的递推公式,这里我们的递推公式就没有上一题这么简单了,上一题是直接给出了,我们这里就需要自己推,那么我们就来推导一下
我们假想一下,假设我们要求出第n阶的方法数,那么第n阶可以由第n-1阶跳一步过来,也可以由第n-2阶跳跃两步过来,那么我们容易猜测其递推公式应该是F(n)=F(n-1)+F(n-2),但是我们不保证正确,为此,我们可以自己举例,首先,自己推可以推出来,其对应阶数的数组应该为1 2 3 5 8,认真观察,我们会发现的确符合我们的公式,那么我们就可以认真这个递推公式是正确的
接着我们要确定dp数组如何初始化,我们可以跟我们的上一题一样,先初始化前两位,然后再构造后面的值
遍历顺序显然是从左往右,举例的数组我们已经做过了。
那么到此为止,我们就可以写出我们的代码如下
class Solution {
public int climbStairs(int n) {
int[] next = new int[n+1];
next[0]=1;next[1]=2;
for (int i = 2; i < n; i++) {
next[i]=next[i-1]+next[i-2];
}
return next[n-1];
}
}
我们这里首先让我们的数组最开始的长度就是固定长度的更大一位,由于题目中的数值最低都是一,这样就可以避免发生传入1时发生的数组下标越界异常
然后我们可以每次都返回答案的倒数第二位,这样虽然我们每次创建数组时最后一组都是没有意义的,但是我们可以通过这种方式来避免最开始的特殊处理,不失为一种好方法
使用最小花费爬楼梯
做了前两题之后,我们对动规已经有一个大致印象了,接着我们来做一道提高题
我们同样进行我们的动规五部曲,我们这里dp数组代表的是到达指定台阶时我们的最低花费
接着就是确定递推公式,同样的,前进到n阶只有两种方法,就是n-1阶前进一步或者是n-2阶跳跃两步,那么我们就取这两步的最小值,同时无论是那一步跳跃过来,我们都需要让我们的花费的代价加上当前的下标对应的代价,那么我们可以将我们的递归公式确定为F(n)=min(F(n-1)+F(n-2))+f(n),这里小f代表的是我们的花费数组
初始化当然是初始化前两个,由于我们刚落地时就需要进行当前位置上的花费,因此我们初始化时的值要是对应的解题的花费值
遍历顺序没有疑问,显然是从左往右,从右往左压根用不到我们初始化的值
至于推导dp数组,我们可以拿我们题目中的dp数组来举例,就拿示例二举例,我们推导的dp数组应该是下面的形式
最终我们也可以获得我们的目标值,这就说明我们的逻辑没有问题,不过这里要注意的是,由于我们在倒数第一位和第二位就可以下一步直接到达目标阶梯了,因此这两个阶梯都可以作为终点,所以我们要返回的是这两位的值的最小值,这才是我们的目标答案
那么最后我们可以写入我们的代码如下
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] next = new int[cost.length];
next[0]=cost[0];
next[1]=cost[1];
for (int i = 2; i < cost.length; i++) {
next[i]=Math.min(next[i-1],next[i-2])+cost[i];
}
return Math.min(next[next.length-1],next[next.length-2]);
}
}
我们这里由于最小的输入就是2,都可以保证最初的初始化是有值的,因此不用特别创建一个数组位置了,直接创建对应大小的即可,也可以保证能够处理所有情况
总结
最后我们可以做一个总结,首先,我们的解动规题一定要严格按照五步骤来解题
其次,我们要返回的结果并不是死板的dp的最后一位,有时候也是要返回符合条件的最后两位,具体请看具体分析,总之不要太死板
动态规划的进阶学习
到这里我们就要加深我们动态规划的学习难度了,做好准备
不同路径
这一题刚开始我的思路是创建一个二维数组表示dp,递推公式是每一个位置都可以由四个位置的路径数相加而来,初始化则是初始化第一位的数量为1,遍历顺序是由i到j的自然遍历,但是这个思路只能说是棋差一着,因为四个路径根本没办法处理 但是值得庆幸的是,我们其他的思路都是正确的,这点还是很开心的,其实,我们这题由于我们的机器只能往下或者往右移动,那么到达某个位置的路径数也应该是两个方向的,而这两个方向很自然的就是上方向和左方向之和
我们可以根据这个思路继续构造我们的代码,其他思路不变,那么我们可以推导出我们的dp数组如下
那么我们可以构造我们的代码如下
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
dp[0][0]=1;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
//上可左不可
if(i>0 && j==0){
dp[i][j]+=dp[i-1][j];
}else if(i==0 && j>0){
//上不可左可
dp[i][j]+=dp[i][j-1];
}else if(i != 0){
//上左均可
dp[i][j]+=dp[i-1][j]+dp[i][j-1];
}
}
}
return dp[m-1][n-1];
}
}
不同路径 II
这一题的基本思路跟前面的题目差不多,无非是多一个障碍的判断而已,遇见障碍我们就跳过障碍的判断,这里就不赘述了,那么我们容易构造我们的代码如下
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length,n = obstacleGrid[0].length;
if(obstacleGrid[m-1][n-1]==1 || obstacleGrid[0][0]==1){
return 0;
}
int[][] dp = new int[m][n];
dp[0][0]=1;
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j < dp[i].length; j++) {
//上可左不可
if(i>0 && j==0){
//上必须无障碍物
if(obstacleGrid[i-1][j]!=1){
dp[i][j]+=dp[i-1][j];
}
}else if(i==0 && j>0){
//上不可左可
//左必须无障碍物
if(obstacleGrid[i][j-1]!=1){
dp[i][j]+=dp[i][j-1];
}
}else if(i != 0){
if(obstacleGrid[i-1][j]==1 && obstacleGrid[i][j-1]==1){
//上左均有障碍物,该位置无法到达
continue;
}
//上左均可,但上有障碍物
if(obstacleGrid[i-1][j]==1){
dp[i][j]+=dp[i][j-1];
}else if(obstacleGrid[i][j-1]==1){
//上左均可,但左有障碍物
dp[i][j]+=dp[i-1][j];
}else {
dp[i][j]+=dp[i-1][j]+dp[i][j-1];
}
}
}
}
return dp[m-1][n-1];
}
}
值得一提的是,上面的代码其实还略显臃肿了了,实际上我们还有更加简单的版本,当然,代码上更加复杂就是
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if(obstacleGrid[obstacleGrid.length-1][obstacleGrid[0].length-1]==1){
return 0;
}
int[][] next = new int[obstacleGrid.length][obstacleGrid[0].length];
next[0][0]=1;
for (int i = 0; i < next.length; i++) {
for (int j = 0; j < next[i].length; j++) {
//上左均可,有位置且都无障碍物
if(i-1>=0 && j-1>=0 && obstacleGrid[i-1][j]!=1 && obstacleGrid[i][j-1]!=1){
next[i][j]=next[i-1][j]+next[i][j-1];
//上可左不可,左无位置或者左有障碍物,上必须有位置且无障碍物
}else if(i - 1 >= 0 && (j == 0 || obstacleGrid[i][j - 1] == 1) && obstacleGrid[i-1][j]==0){
next[i][j]=next[i-1][j];
//左可上不可,上无位置或者上有障碍物,左必须有位置且无障碍物
}else if(j-1>=0 && (i==0 || obstacleGrid[i-1][j]==1) && obstacleGrid[i][j-1]==0){
next[i][j]=next[i][j-1];
}
}
}
return next[next.length-1][next[0].length-1];
}
}
整数拆分
这一题就比较重量级了,我们按照我们的五步走,首先确定我们的dp数组的含义,我们的dp数组代表的是与该下标的值相同的正整数的整数拆分之和的最大值
难点在于递推公式,递推公式是最难的,因为我们一下子想不太明白这玩意怎么递推好,我们一步一步来
首先,如果我们想要推导出最开始的对应非初始化的dp数组的值,那么我们必然对其所有的值进行遍历好,比如说,我们要获得3的最大值,那么最简单的想法当然是从1开始遍历并且不断拆分,如何对一个整数进行遍历拆分呢,当然是令这个整数减去另外一个整数再乘于这个整数本身,也就是j * (i - j),就好比如1*(3-1)
但是有时候我们的拆分就不止一个了,比如在图上的第二个例子里,我们10最大的情况就是3+3+4,但是我们上面的显然只拆分了两个,此时我们回忆一下我们的dp数组代表的含义,dp数组代表的是与该下标的值相同的正整数的整数拆分之和的最大值,那我们拆分时,我们就可以用一个整数代表j,也就是用于拆分的整数,而另外的所有拆分整数可以用dp[i-j]表示,其代表该位置被拆分的整数之和的最大值,我们本来需要求的就是最大值,那么我们这里调用dp的最大值来代表那个被拆分的数,当然没有任何问题
也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘
那么最终我们的递推公式就是这样dp[i] = max({dp[i], max((i - j) * j, dp[i - j] * j)})
有的同学可能会疑问为啥这里嵌套了两个max啊?这是因为我们的第一个max取的是我们遍历我们的对应的数的拆分之和的最大值,但是我们还要将这个拆分之和保存到对应的数组上,我们只保存最大值,所以我们在外面还需要再一个max用于再每次比较时保证我们的dp[i]保存的是当前拆分的数的最大值
关于初始化,我们可以初始化只初始n为2时的值,因为1和0,根本就无法拆分,自然也就不需要初始化
接着我们要确定遍历顺序,显然,这题的遍历顺序是从前往后遍历的
最后我们可以推导出我们的dp数组
那么最终我们可以写入我们的代码如下
class Solution {
public int integerBreak(int n) {
int[] dp = new int[n+1];
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i-j; j++) {
dp[i]=Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
}
不同的二叉搜索树
最后我们来看一道真正意义上,这位更是重量级的题目
我们还是按照我们的五步走,首先我们确定dp数组以及其下标含义,我们这里的dp数组表示的是对应下标值节点数量的最多的二叉搜索树数量
接着最重量级的来了,那就是递推公式,直接看我们肯定看不出来,但是我们可以举几个例子,画画图,看看有什么规律
n为1的时候有一棵树,n为2有两棵树,这个是很直观的
来看看n为3的时候,有哪几种情况。
当1为头结点的时候,其右子树有两个节点,看这两个节点的布局,是不是和 n 为2的时候两棵树的布局是一样的啊!
(可能有同学问了,这布局不一样啊,节点数值都不一样。别忘了我们就是求不同树的数量,并不用把搜索树都列出来,所以不用关心其具体数值的差异)
当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,是不是和n为2的时候两棵树的布局也是一样的啊!
当2为头结点的时候,其左右子树都只有一个节点,布局是不是和n为1的时候只有一棵树的布局也是一样的啊!
发现到这里,其实我们就找到了重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。
思考到这里,这道题目就有眉目了。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
如图所示:
当然,这个时候有的同学就要说了,你这啥玩意啊,谁几把想得到啊,还有你怎么知道结果就是要乘啊,还是正好是左边乘于右边,我这看不出来啊
不着急,我们可以来看看下面的解题思路,可以进一步帮助我们理解
*解题思路:假设n个节点存在二叉排序树的个数是G(n),1为根节点,2为根节点,...,n为根节点,当1为根节点时,其左子树节点个数为0,右子树节点个数为n-1,同理当2为根节点时,其左子树节点个数为1,右子树节点为n-2,所以可得G(n) = G(0)G(n-1)+G(1) (n-2)+...+G(n-1)G(0)
当然我承认确实这个有点难看出来,但是这个也就是我们的难点所在,想不到就多学学,下次看到类似的题目就往结构和相乘的思路里想
那么我们可以去抵挡我们的递推公式是dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量],可以简化为dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
接着我们要确定dp数组如何初始化,dp[0]本身是没有意义的,按说根本就不需要初始化,但是我们这里推导出下一个dp的值是让前面的dp的值相乘,因此我们这里需要将其初始化为1,不然我们啥玩意相乘都是0了,这没有意义
然后我们的dp[1]需要设置为1,因为只有一个dp[0]的初始化无法推导出后面的值,最起码也要先把dp[2]推导出来,而推导出dp[2]需要用到dp[0]和dp[1],因此我们需要对其进行初始化
遍历顺序当然是从前往后遍历,这个不需要做过多的说明
最后我们可以举例推导出我们的dp数组如下
那么最终我们可以写入我们的代码如下
class Solution {
public int numTrees(int n) {
int[] dp = new int[n+1];
dp[0]=1;
dp[1]=1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i; j++) {
dp[i]+=dp[j-1]*dp[i-j];
}
}
return dp[n];
}
}
注意我们这里是如何实现dp不断的反复相乘的,我们这里设置j最小为1,这样其j-1的最小值就是0,我们得到dp[0],但是如果只是想要得到dp[0]的话,直接设置j为0就可以了,我们这里设置j=1的还有另外一个作用就是我们的i-j总是可以得到与dp[j-1]对称的dp值,左边不断增大,右边不断减小,这样相乘显然就符合我们的需求
如果我们将j设置为0,那么最开始就会抛出数组越界异常,即使后面不断去调整i的范围,也很难达到同样的效果,只会出现更多的bug,因此我们要好好记住这样获得两边对称的dp值的方式,以后需要的时候就用这个模板
总结
最后我们做一下本次学习的总结,首先,一般来说,如果要求的dp[n],那么我们一般的做法是创建dp数组时令其大小为n+1,然后在对应的dp数组的递推中,让dp下标能更新到n,最后我们返回固定的dp[n]的结果
当然,也有不是这么做的情况,我们主要是具体情况具体分析,如果需要我们使用这种方式,我们就用这种方式即可
01背包问题
本周我们正式来学习背包问题,这也是动态规划里的一道经典题目
背包问题的经典资料当然是:背包九讲。在公众号「代码随想录」后台回复:背包九讲,就可以获得背包九讲的pdf。
但说实话,背包九讲对于小白来说确实不太友好,看起来还是有点费劲的,而且都是伪代码理解起来也吃力。
对于面试的话,其实掌握01背包,和完全背包,就够用了,最多可以再来一个多重背包。
因此我们这里只讲解01背包,至于各种背包问题如何判断,可以看下图
至于背包九讲其其他背包,面试几乎不会问,都是竞赛级别的了,leetcode上连多重背包的题目都没有,所以题库也告诉我们,01背包和完全背包就够用了。
而完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。
所以背包问题的理论基础重中之重是01背包,一定要理解透!
leetcode上没有纯01背包的问题,都是01背包应用方面的题目,也就是需要转化为01背包问题。
所以我先通过纯01背包问题,把01背包原理讲清楚,后续再讲解leetcode题目的时候,重点就是讲解如何转化为01背包问题了。
01背包理论基础
先来看看01背包的问题
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这个问题就是标准的背包问题,我们之前在图解算法中也见过这个类似的问题,我们这里的暴力解法是可以使用回溯法搜索出所有的情况,那么时间复杂度就是,这里的n表示物品数量
当然这个时间复杂度当然是不能接受的,因此我们要使用动态规划对本题进行优化
接着我们来举一个具体的问题例子
背包最大重量为4。
物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
二维dp数组01背包
我们首先来讲解使用二维数组解决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数组的定义就是要取一定容量内的背包最大价值,因此我们会令当然的dp[i][j]=dp[i-1][j],这里保证我们的背包容量不发生变化,但是我们的物品少了一件,就相当于是取上一件的情况,所以我们的递推公式表明的是,我们的二维数组的当前位置一定是通过其上方的第一个位置的值和左方的某个位置的值结合推导而来的
而当我们背包的容量可以放下物品i时,我们就放入物品i到背包中,其对应到代码里的动作就是+value[i],这是当然的,因为我们放入物品i就相当于是我们的背包的价值增加value[i] ,这个很好理解。但是dp[i-1][j-weight[i]]又要怎么解释呢?首先,i-1代表的是我们取其当前能取的物品数量还要少一件时的情况,而j-weight[i]代表的是我们同时要取比当前容量小weight[i]的情况,该情况就是我们没有加入物品i时的背包情况。这很好理解,因为我们没有将对应物品加入背包之前,我们对应的重量必然是当前重量-加入背包的物品重量,我们按照这个公式,所得到的情况就是该物品加入背包前的情况。注意,这里是没有加入物品i时的背包情况,这个背包情况是可能将其他物品全部都没加入的情况的,并不是我们没有加入该物品之前的背包的情况就一定是之前的已经放入了一些物品的背包的情况,这是因为了为了放入该物品,我们不得不将其他物品全部抛出,表达上代码上是直接取没有加入对应的物品时候的背包情况,让我们的背包有足够大的可以加入该物品的容量,然后加入该物品并计算其价值,与之前的加入其他物品的情况两相比较,取最大值
最后我们在最外层还加入了一个max函数判断大小,因为我们不能保证加入了新东西之后就比没加入之前大,因此我们这里还加入了max判断,这样总能保证其最大,有人可能不理解怎么会有这种情况?加入了新东西之后不就该比没加之前大吗?这是因为是存在我们为了加入新东西,放弃装入了某个东西,但是最终新加入的东西的价值还不如之前的价值的情况的,回顾我们的dp数组的定义,我们总是要取价值的最大值到dp数组中,因此我们这里需要做max判断
- 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物品
那么我们就可以初始化我们的数组如下
此时我们的最初的初始化都已经初始好了,而其他内容都是通过最开始初始化的内容,由左方和上方的内容推导出来的,因此其他位置的初始化是爱多少多少,我们将其统一为0,便于我们人类读者的观看
- 确定遍历顺序
在上图中,我们不难看出,我们的问题有两个遍历的维度:物品与背包重量,那我们到底先遍历 物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解,我们这里先讲解前者,之后再来讲解后者
但是我们必须先解决一个问题,就是为什么这两种遍历方式都是可以的呢?这是因为我们的递推公式表明我们的dp数组的值是通过左方和上方的值推导而来的,那无论我们是从左到右推导还是从上到下推导,我们都容易知道我们总是能完成我们的要求,在遍历时在对应的位置总是有对应的左方和上方的值用于给我们的推导,因此我们无论是哪种方式都是完成我们的题目的
- 举例推导dp数组
来看一下对应的dp数组的数值,如图:
最后我们来说一下我们前面所说的,由于为了放入某一件物品,而导致前面的物品没有放入,最后新放入的物品在背包中的价值还不如之前的价值的情况,请看下图
可以看到,我们没放入物品4之前,其价值是20,而此时我们的上方有一个35价值的背包情况,该情况是我们放入了物品0和物品1的背包的情况,这两个物品的重量正好为4,填满了该背包,我们都知道我们的物品2价值30,但是一个重量就是4,那么我们就必须要去其最左方的啥玩意都没放的背包情况,相当于是抛出了背包内的所有物品,然后又放入了物品3,最后我们的背包价值是30,比不得之前的35,因此此时如果我们没有max最大函数的话,那么我们的答案就变成30了,此时就寄了兄弟们,所以我们总是需要max比较,这样来保证我们的dp数组的值的正确性
那么最后我们来看看我们的代码
public class Main {
public static void main(String[] args) {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagsize = 4;
testweightbagproblem(weight, value, bagsize);
}
public static void testweightbagproblem(int[] weight, int[] value, int bagsize){
int wlen = weight.length, value0 = 0;
//定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
int[][] dp = new int[wlen][bagsize + 1];
//初始化
for (int j = weight[0]; j <= bagsize; j++) {
dp[0][j]=value[0];
}
//遍历顺序:先遍历物品,再遍历背包容量
for (int i = 1; i < wlen; i++){
for (int j = 1; j <= bagsize; j++){
if (j < weight[i]){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
//打印dp数组
for (int i = 0; i < wlen; i++){
for (int j = 0; j <= bagsize; j++){
System.out.print(dp[i][j] + " ");
}
System.out.print("\n");
}
}
}
由于力扣没有01背包的题目,所以这里连参数都是我们自己提供的
然后我们来解释下我们的代码,首先我们构造我们的对应的数组,横列数为物品数,而纵列数为重量+1,这是因为我们的物品总是先从0出发而且无法到达最后一位的,而重量是从0出发能够到达最后一位的
然后我们先对我们的数组初始化,先初始化横列第一行,因此我们这里固定i为0,j的出发点是重量最小的起点,结束点自然是最大容量,在只有一个物品的时候要想达到最大价值当然是将该物品装入到背包中,因此我们这里初始化所有符合条件的数组位置的值为第一个物品的价值,这个很好理解。至于纵列第一行,自然只能全是0,因为我们的背包没有重量,那就什么都放不进去,所以只能是0
接着我们开始进行遍历,我们的遍历是先遍历物品,而后遍历背包,我们都是从ij为1的地方开始,这很好理解,因为0的位置都是初始化过的,没必要重新运算,i的结束位置不能到结束点,而y则是可以到达指定的重量位置
我们的迭代逻辑是每次判断当前重量是否小于放入物品的重量,若小于,我们则让当前位置的值等于上一个位置(也就是没有放入物品前的价值)的值,而如果足够,那么我们就判断上一个没有放入该物品的背包的价值和放入之后的背包的价值,取其最大值即可
最后我们将dp数组打印出来,会发现整个dp数组的确如我们所料
下面则是先遍历背包,后遍历物品的代码,本质思路和我们上面差不多,这里就不赘述了
//遍历顺序:先遍历背包容量,再遍历物品
for (int j = 1; j <= bagsize; j++){
for (int i = 1; i < wlen; i++){
if (j < weight[i]){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
这里我们要提另外一个思路,就是我们最开始初始化数组的时候,就可以给我们的物品多初始化一行,那么我们的代码就可以写成这样
public class Main {
public static void main(String[] args) {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagsize = 4;
testweightbagproblem(weight, value, bagsize);
}
public static void testweightbagproblem(int[] weight, int[] value, int bagsize){
int wlen = weight.length, value0 = 0;
//定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
int[][] dp = new int[wlen + 1][bagsize + 1];
//遍历顺序:先遍历物品,再遍历背包容量
for (int i = 1; i <= wlen; i++){
for (int j = 1; j <= bagsize; j++){
if (j < weight[i - 1]){
dp[i][j] = dp[i - 1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
}
}
}
//打印dp数组
for (int i = 0; i <= wlen; i++){
for (int j = 0; j <= bagsize; j++){
System.out.print(dp[i][j] + " ");
}
System.out.print("\n");
}
}
}
我们让最开始的横列数+1,已知j=0时全为0需要初始化为0,而当i为0时,我们这里就认为其没有物品,那么同样也是初始化为0,得益于java的特性,所以我们不需要手动初始化,也就是说,我们完全这样做就可以将初始化的这一步骤在代码上给省略掉了
但是这里要注意的是,我们这里比较重量与物品的大小时,我们这里是需要用其前一个物品进行比较,而不是当前的物品,这其实就相当于我们假定有价值为0的物品放在了第一行,那么我们就可以直接在第二行到结束为止都一直使用我们的递推公式,这样就可以避免额外的初始化操作,我们的代码会变得更加简洁
当然,同时我们的加入物品的操作的代码也是要进行相应的修改的,因为我们加入的前一个物品的重量,因此我们的对应的重量和价值也要改成i-1
滚动数组01背包
我们前面使用二维数组完成了对01背包的动态规划解题,但是其实对于背包问题,其状态都是可以压缩的
在使用二维数组的时候,递推公式:dpi = 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数组的含义,dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。
dp[i][j] 表示从下标为[0-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数组的定义吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。
那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
我们初始化的时候,如果题目给的价值都是正整数,那么我们将他们全部初始化为0,这就相当于是二维数组里我们的第二种写法,一开始就额外初始化一行价值为0的物品,这样我们就一开始就可以直接使用递推公式了,再结合我们java的特性,我们就可以直接在代码上避免初始化了
当然,要注意的是只有题目给的价值都是正整数时,我们才可以使用这种方法,否则是不行的
- 一维dp数组遍历顺序
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
为什么呢?
倒序遍历是为了保证物品i只被放入一次! 。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
举一个例子:物品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]并不会被覆盖!
简单来说,就是二维数组总是依赖上一层的左方的值的,所以我们根本不需要倒序遍历就可以实现我们的需求,那我们没事还倒序遍历啥啊
为了加深理解,我们这里同样也给出二维数组倒序遍历的代码,首先是需要进行初始化的
public class Main {
public static void main(String[] args) {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagsize = 4;
testweightbagproblem(weight, value, bagsize);
}
public static void testweightbagproblem(int[] weight, int[] value, int bagsize){
int wlen = weight.length, value0 = 0;
//定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
int[][] dp = new int[wlen][bagsize + 1];
//初始化
for (int j = weight[0]; j <= bagsize; j++) {
dp[0][j]=value[0];
}
//遍历顺序:先遍历物品,再遍历背包容量
for (int i = 1; i < wlen; i++){
for (int j = bagsize; j >= 0; j--){
if (j < weight[i]){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
//打印dp数组
for (int i = 0; i < wlen; i++){
for (int j = 0; j <= bagsize; j++){
System.out.print(dp[i][j] + " ");
}
System.out.print("\n");
}
}
}
然后是不需要进行初始化的代码
public static void testweightbagproblem(int[] weight, int[] value, int bagsize){
int wlen = weight.length, value0 = 0;
//定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
int[][] dp = new int[wlen+1][bagsize + 1];
//遍历顺序:先遍历物品,再遍历背包容量
for (int i = 1; i <= wlen; i++){
for (int j = bagsize; j >= 0; j--){
if (j < weight[i-1]){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i-1]] + value[i-1]);
}
}
}
接着我们再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
这当然是不可以的,因为我们的一维数组一定要依赖左边的值和上一层的值,而且必须是倒序遍历,那么如果我们先遍历背包,再遍历物品,对应到二维数组中就是我们不断往下更新,但是我们的数组在这里又是一维数组,那么我们如果先遍历背包,对应到一维数组里就是先不断让一维数组的某一个值不断滚动,我们这样做可以总是保证上方的值是推到所需要的值,但是左方的值是无法保证的,因此我们这里不可以先遍历背包再遍历物品,必须先遍历物品再遍历背包,形象一点来说就是在一维数组的遍历中,我们只能从直线遍历,不允许竖线遍历
所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的! 这一点务必要注意
- 举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
是不是就跟我们之前说的一样,用一个一维数组来表示二维数组,很妙是吧
那么最终我们就可以写入我们的一维数组的代码如下,这里我们使用第二种写法,假设有一行价值全为0的物品,这样我们第一行开始就可以使用推导公式
public class Main {
public static void main(String[] args) {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWight = 4;
testWeightBagProblem(weight, value, bagWight);
}
public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){
int wLen = weight.length;
//定义dp数组:dp[j]表示背包容量为j时,能获得的最大价值
int[] dp = new int[bagWeight + 1];
//遍历顺序:先遍历物品,再遍历背包容量
for (int i = 0; i < wLen; i++){
for (int j = bagWeight; j >= weight[i]; j--){
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
//打印dp数组
for (int j = 0; j <= bagWeight; j++){
System.out.print(dp[j] + " ");
}
}
}
可以看到我们的一维数组的写法就比较直观简洁,最重要的是,我们的空间复杂度还下降了一个量级,后续我们的动态规划的题目都使用这种写法
分割等和子集
那么现在我们就来正式来做我们的背包类题目了,第一道题,我们需要解析的认真一些
那么我们首先就要将其转化为背包类的题目,第一步当然是要对本题进行分析,我们注意到描述中不可以重复放入相同数字
那么按照原则一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的。
我们就可以明确本题中我们要使用的是01背包,因为元素我们只能用一次,那么我们确定了01背包之后,我们再确定一遍,本题要求集合里能否出现总和为 sum / 2 的子集
那么来一一对应一下本题,看看背包问题如果来解决
首先背包问题中最为重要的两个点当然是背包的容量,物品的数量以及物品的价值,我们这里可以进行一一对应,首先,物品的数量就是我们nums中的数组中的元素数量,价值则就是对应的数值,最后则是我们的背包的容量,我们本体要求集合中能否出现总和为sum/2的子集,那么我们的总和就是sum/2,同时数组中的每一个元素是不可重复放入的
那么这些步骤我们总结起来就是
- 确定题目中作为物品的数据是什么
- 确定题目中作为物品的元素是否可以重复放入
- 确定题目中作为作为物品价值的数据是什么
- 确定题目中作为背包的体积的价值数据是什么
这些都确定完毕之后,我们就可以来使用我们的动规五部曲来继续分析我们的题目了
- 确定dp数组以及下标的含义
01背包中,dp[j] 表示: 容量为j的背包,所背的物品价值可以最大为dp[j],i则是用于指定可以选取的物品数量的下标
套到本题,dp[j]表示 背包总容量是j,最大可以凑成j的子集总和为dp[j]
- 确定递推公式
01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]
所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
其实这也可以说是01背包问题的一个好处,就是一旦我们确定了对应背包的内容,其公式就比较容易推出来,毕竟换汤不换药是吧
- dp数组如何初始化
从dp[j]的定义来看,首先dp[0]一定是0,之后的第一行的内容直接初始化为对应的背包容量下取不取第一个物品到背包中即可,初始化是都是能取则取,因为问题转化为背包之后就是要求尽可能求最大的价值
不过我们这里采取不用初始化,让第一行就能使用公式的方式来解题,只需要让行数+1就可
- 确定遍历顺序
这个很好理解,我们之前都讲过了,对于物品我们顺序遍历,而对于容量,我们需要倒序遍历,这样才能保证我们不会往背包中重复加入相同的物品
- 举例推导dp数组
dp[j]的数值一定是小于等于j的。
如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,也就是说这么大背包的容量正好可以用物品装满,对应到题目中就是正好有一半的数值可以被分割
用例1,输入[1,5,11,5] 为例,如图:
那么最终我们可以写入我们的代码如下
class Solution {
public boolean canPartition(int[] nums) {
int target = Arrays.stream(nums).sum();
if(target%2==1){
return false;
}
target/=2;
int[] dp = new int[target+1];
for (int i = 0; i < nums.length; i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j]=Math.max(dp[j],dp[j-nums[i]]+nums[i]);
}
}
return dp[target]==target;
}
}
我们这里首先求出总和,然后判断总和是否可以被2整除,如果不可以那就必然不能分,之后我们构建数量+1的dp数组,这样做是为了让我们的背包容量能准确到达其一半,然后我们构建双重for循环,第一重for循环表示我们此时可以选择的物品,第二层则代表我们当前可选择物品下对应的容量的背包下能达到的最大价值,是倒序遍历,因我们这里j总是大于nums[i],是由于滚动数组的特性可以让我们通过在for循环中加条件而做到不用继续特殊判断
注意,我们使用滚动数组时,无论我们是不是使用要初始化的的方式,我们要覆盖的值永远都是dp[j],而i则永远是dp[i],不会是i-1
但是在二维数组里就不一定了,在二维数组中,如果我们使用不用初始化的方式,那么我们的i就会是i-1,请看下面代码
class Solution {
public boolean canPartition(int[] nums) {
int target = Arrays.stream(nums).sum();
if(target%2==1){
return false;
}
target/=2;
int[][] dp = new int[nums.length+1][target+1];
for (int i = 1; i < dp.length; i++) {
for (int j = 1; j <= target; j++) {
if(j<nums[i-1]){
dp[i][j]=dp[i-1][j];
}else {
dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-nums[i-1]]+nums[i-1]);
}
}
}
return target==dp[dp.length-1][dp[0].length-1];
}
}
这份代码同样可以完成工作,但是其由于使用的是双重for循环且不是用初始化的方式,因此其i必须做i-1的处理,这是自然的,因为此时i-1才代表着第一个物品,理所当然的,i应该从1开始遍历
而如果我们在二维数组中使用的是需要初始化的方式,则此时我们的i是则是从0开始,不需要i-1
class Solution {
public boolean canPartition(int[] nums) {
int target = Arrays.stream(nums).sum();
if(target%2==1){
return false;
}
target/=2;
int[][] dp = new int[nums.length][target+1];
for (int j = nums[0]; j <= target; j++) {
dp[0][j]=nums[0];
}
for (int i = 1; i < nums.length; i++) {
for (int j = 1; j <= target; j++) {
if(j<nums[i]){
dp[i][j]=dp[i-1][j];
}else {
dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-nums[i]]+nums[i]);
}
}
}
return target==dp[dp.length-1][dp[0].length-1];
}
}
这也很好理解,因为此时i代表的就是当前的物品,由于第一个物品已经被初始化了,因此我们也不需要使用i-1来代表第一个物品
注意是物品的价值和重量都要同时i-1或者是i,这是统一的,而对于要覆盖的值,在二维数组中都是dp[i][j],对于要上一个没有放任何物品的背包的价值,统一都是dp[i-1][j]
判断条件的缩进
最后我们根据这一题再来解决01背包中的一个问题,那就是为什么在一维数组中,不用进行if判断,而在二维数组中就需要?当然我们的同学很容易想到说这是因为一维数组中我们的指定了我们的背包容量不能小于当前物品的价值,因此避免了所有的当前背包的容量小于当前背包的情况,那么我们在二维数组中也这样做,通过倒序遍历和固定边界来省略掉我们的判断语句行不行?
答案是不行的,这是因为在二维数组中,每一行都是要被下一行使用的,而每一行最初的值都是0,如果我们采用直接在for循环中固定边界的方式,那么左边的值就会直接被忽略,最后就会导致我们需要初始化的背包的值成为了0,这样是会导致我们后面的推导公式发生错误的,因此我们的二维数组中是不可以这样做
那为什么我们的一维数组可以这样做呢?这是因为一维数组即使固定了边界,没有固定边界的地方就是前一个的值,而比边界小的位置其值必然是上一个没放物品的值,这就是利用了滚动数组的特性巧妙设置了边界
实际上,我们也可以在一维数组中加入判断条件而不固定边界,也是可以通过的,同时在二维数组中也可以倒序遍历而保留判断条件来完成题目的,这些东西的本质都是一样的,理解就好
最后一块石头的重量 II
这题其实隐藏着一个隐藏条件,那就是我们可以将我们的石头分成两份尽可能相等的部分,然后他们的差就是我们所求的最小的可能重量。如果这个条件没能看出来,那这题说实话就有点难搞了,因为这样我们先背包四部曲都用不了,因为我们压根就知道我们要怎么将其化为背包问题
知道了这一点之后这题就很好做了,那么我们要做的事情就是将本题提供的数组分为相差最小的两部分,本质上和上一题是一样的,首先我们作为物品的数据就是不同的石头,其元素不可以重复放入,其价值就是石头的重量,而其我们背包的体积就是我们数组的下标
接着我们继续来使用我们的动规五部曲
- 确定dp数组以及下标的含义
dp[j]表示容量为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数组如何初始化
既然 dp[j]中的j表示容量,那么最大容量(重量)就是数组和的一半
- 确定遍历顺序
还是跟以前一样,物品顺序遍历,容量倒序遍历
- 举例推导dp数组
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
那么最终我们可以写入我们的代码如下
class Solution {
public int lastStoneWeightII(int[] stones) {
int target = Arrays.stream(stones).sum();
int sum = target/2;
int[] dp = new int[sum+1];
for (int i = 0; i < stones.length; i++) {
for (int j = sum; j >= stones[i]; j--) {
dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return target-dp[sum]*2;
}
}
我们这里首先求出重量总和,然后求其一半,接着算出最可能满足这一半的物品价值之和,由于我们是总和直接除于2的形式还算出一半的容量的,因此这一半的容量*2的结果必然<=总和,所以我们最后可以直接返回target-dp[sum]*2,这其实也很好理解,我们算出的一半容量的最大值,那么该值与总和的差就是另一半的总和价值,然后拿求出的另一半的总和价值-原先的一半总和的最大价值即是我们所需要的答案
目标和
解开本题的重点在于,如何将这一题化解为一个背包问题,乍一看似乎没有办法分解,但只要仔细观察,我们容易发现left组合 - right组合 = target, 比如在示例一中,其对应的left组合和right组合分别是
| left组合 | right组合 | target |
|---|---|---|
| 4 | 1 | 3 |
| 4 | 1 | 3 |
| 4 | 1 | 3 |
| 4 | 1 | 3 |
每一个组合都是对应不同位置的数组相加最终得到的结果,那么我们的目标显然是要找到这些left组合-right组合=target的总的组合数量
同时我们继续观察,同时我们容易知道在每一个满足条件的组合中,都有left + right==sum,综合上面的式子,我们容易得到left = (target + sum)/2,每求出一个left,必然能得到一个符合条件的式子,那么本题就可以转化为集合nums中找出和为left的组合所有数量
假设加法的总和为x,那么减法对应的总和就是sum - x。
所以我们要求的是 x - (sum - x) = S
x = (S + sum) / 2
此时问题就转化为,装满容量为x背包,有几种方法
同时我们要注意,在问题的转化过程中,有两种情况下其是不会有答案的,第一种情况是向下取整时有余数,此时说明我们的最开始所求的left是不符合题目所需的方案的,我们必须要求含有余数的left才是符合条件的,但是由于我们只有加减两种运算方式,因此我们不可能求到有余数,换言之就是有小数的情况,所以此时无解
第二种情况时我们target的绝对值比sum还大,此时会出现组合数全部相加都无法达到target值或者是创建长度为负数的数组的情况,这些情况都是无解的
然后我们就进行我们的动规五部曲的分析
- 确定dp数组以及下标的含义
首先dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
- 确定递推公式
有哪些来源可以推出dp[j]呢?
不考虑nums[i]的情况下,填满容量为j的背包,有dp[j]种方法。
那么考虑nums[i]的话(只要搞到nums[i]),凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
- 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]。
- 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]。
- 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
- 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
- 已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 dp[5]
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来,也就是说,要求某个位置的组合数,就是将其下所有可能构造成这个组合所代表的组合数都相加起来即可,比如我们这里找dp[5]的方法数,我们这里就是不断考虑新的nums[i]加入的情况下我们的对应的位置所能拥有的组合数,然后将其组合数加入到当前下标中
所以求组合类问题的公式,都是类似这种:dp[j] += dp[j - nums[i]] ,这个公式我们以后也会继续用到
- dp数组如何初始化
初始化的时候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]]推导出来
- 确定遍历顺序
遍历的顺序跟以前一样,容量倒序遍历,物品顺序遍历
- 推导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) {
int sum = Arrays.stream(nums).sum();
int all = target+sum;
if(Math.abs(target)>sum || all%2==1){
return 0;
}
all/=2;
int[] dp = new int[all+1];
dp[0]=1;
for (int i = 0; i < nums.length; i++) {
for (int j = all; j >= nums[i]; j--) {
dp[j]+=dp[j-nums[i]];
}
}
return dp[all];
}
}
一和零
再来看一道稍微有点重量级的题目
对于这一题,同学们刚看到的时候,肯定是没什么思路的,甚至有的同学一位这是一个多重背包,也就是说可以分解成多个背包的问题来解决的问题,这说实话就属于是想多了,我们要知道,我们目前的学习阶段,压根不可能遇上啥多重背包,所以我们肯定要从另外的思路去想
实际上,这题是有两个维度的背包的问题,本题中strs 数组里的元素就是物品,每个物品都是一个,而m 和 n相当于是一个背包,只不过这背包的容积限制有m和n两个,转换为代码上则是对应着二维的背包。
这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品
我个人的理解是,在本题中,我们的背包是分为两个维度的不同背包,这个以前我们的背包只是单纯限制了容量,而这次我们的背包则是限制了两个容量,一个容量装载物品m,另一个容量装载物品n,而我们求的则是在这两种限制之下的背包在指定的物品里所能装载的最大物品数,这样理解就是一个背包的理解方式,简单易懂
那么我们首先要确定背包问题的四部曲,首先题目中的数据是我们的str数组,其次题目中的物品的元素不可以重复放入,接着代表其物品价值的数据则是加入的物品的数量,最后将作为背包的体积的数据则是两个维度的下标
接着我们再来进行我们的动规五部曲
- 确定dp数组(dp table)以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j] 。也就是说,每一个dp[i][j]对应一个能够容纳最多i个0和j个1的背包
- 确定递推公式
dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1,我们每次取其中一个物品时先将其0和1的数量计算出来,这样便于后面背包选择是要装入该物品,因为要选择是否要装入该物品最重要的衡量在于该物品的0和1的数量,所以我们先计算出来
dp[i][j] 如果要加入该物品,那么其公式就可以是 dp[i - zeroNum][j - oneNum] + 1,这里是取其对应的上一个背包,因为一个背包对应去其状态的上一个背包要当前容积减去当前物品的价值,相当于是在背包里去左边的,那么对应到二维的背包里,则是两边的容积都要同样减去对应的价值,相当于是取左上角的某一个背包,而价值则是加入的数量,所以是+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背包! 只不过物品的重量有了两个维度而已
这里我们要重点提一下关于价值的问题,实际上,我们的真正用于判断的数据是对应的物品的01数量,我们的背包也是通过01数量来判断其能否加入的,这个很好理解。因为我们的题目要求返回的是最大子集的数量,所以我们加入的时候就如果满足条件那么就令其加入一个物品,其背包所装的物品数量当然就+1
但是我们真正运算的时候,用于判断背包是否符合条件的,能否装入当前的物品的,则是0和1的数量,其数量减去当前容积对应上一个能否加入该物品的背包,而其加入的价值则是该物品本身的加入,则是+1,之前的题目里,往往我们用于判断的背包能否加入和要加入到背包中的价值是一样的,但是在这里却并不是,这点要注意。这里也告诉我们一件事,那就是并不是所有的题目其物品价值和判断价值都是一致的
- dp数组如何初始化
01背包的dp数组初始化为0就可以了
因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖即可,这其实也很好理解,无论你的背包的容积有多少,最开始如果没有物品,那都只能放入0
- 确定遍历顺序
之前讲到了01背包为什么一定是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!
那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n,我们从后往前遍历,可以让我们的dp数组少一个维度
有同学可能想,那个遍历背包容量的两层for循环先后循序有没有什么讲究?
没讲究,都是物品重量的一个维度,先遍历那个都行!
- 举例推导dp数组
以输入:["10","0001","111001","1","0"],m = 3,n = 3为例
dp数组的状态变化如下所示:
那么最终我们可以写入我们的代码如下
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] dp = new int[m+1][n+1];
for (String str : strs) {
int one = 0;
int zero = 0;
for (int i = 0; i < str.length(); i++) {
if(str.charAt(i)=='0'){
zero++;
}else {
one++;
}
}
for (int i = m; i >= zero; i--) {
for (int j = n; j >= one; j--) {
dp[i][j]=Math.max(dp[i][j],dp[i-zero][j-one]+1);
}
}
}
return dp[m][n];
}
}
上面的代码是倒序遍历的滚动数组的版本,这里我们再放出普通的顺序遍历的版本,当然,下面的代码了解即可,重点还是上面的代码
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][][] dp = new int[strs.length+1][m+1][n+1];
for (int i = 1; i <= strs.length; i++) {
String str = strs[i-1];
int one = 0;
int zero = 0;
for (int j = 0; j < str.length(); j++) {
if(str.charAt(j)=='0'){
zero++;
}else {
one++;
}
}
for (int j = 0; j <= m; j++) {
for (int k = 0; k <= n; k++) {
if(j<zero || k<one){
dp[i][j][k]=dp[i-1][j][k];
}else {
dp[i][j][k]=Math.max(dp[i-1][j][k],dp[i-1][j-zero][k-one]+1);
}
}
}
}
return dp[strs.length][m][n];
}
}
完全背包理论基础
接着我们来学习完全背包问题,完全背包和01背包问题唯一不同的地方就是,每种物品有无限件
同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。
在下面的讲解中,我依然举这个例子:
背包最大重量为4
物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
每件商品都有无限个!
问背包能背的物品最大价值是多少?
我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。
而完全背包的物品是可以添加多次的,所以要从小到大去遍历
dp状态图如下:
那么我们容易构造其滚动数组的代码如下
class Solution {
//先遍历物品,再遍历背包
public static void testCompletePack(){
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWeight = 4;
int[] dp = new int[bagWeight + 1];
for (int i = 0; i < weight.length; i++){ // 遍历物品
for (int j = weight[i]; j <= bagWeight; j++){ // 遍历背包容量
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
for (int maxValue : dp){
System.out.println(maxValue + " ");
}
}
}
简单来说其和之前的滚动数组不同的地方就在于我们这里的遍历顺序是顺序遍历,而之前我们都是倒序遍历的
接着我们再来看看其二维数组实现
class Solution {
public static void testDoubleCompletePack(){
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWeight = 4;
int[][] dp = new int[weight.length+1][bagWeight + 1];
for (int i = 1; i <= weight.length; i++) {
for (int j = 0; j <= bagWeight; j++) {
if(j<weight[i-1]){
dp[i][j]=dp[i-1][j];
}else {
dp[i][j]=Math.max(dp[i-1][j],dp[i][j-weight[i-1]]+value[i-1]);
}
}
}
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j < dp[i].length; j++) {
System.out.print(dp[i][j]+" ");
}
System.out.println();
}
}
}
现在我们来深入讲解一下完全背包的01背包的不同,对于01背包而言,由于其每一个物品都不可以重复拿去,因此其往背包内加入物品的背包必然是其原先的背包,也就是上一个滚动数组的背包(但是不加入),或者是上一个左边的背包加入现在的物品形成的背包,这里有一个限定范围,那就是必然是根据上一个数组的值来构造当前数组的值,也就是拿没有加入当前物品的背包来构造新的背包,这个根据上一个数组就相当于是指定了我们的物品不可重复加入
但是对于完全背包而言又不一样,其是可以重复加入当前物品的,所以决定当前的背包依据的就是同一行的背包,也就是已经加入了新物品的背包,而不是之前没有加入该物品的背包,所以其不要求上一行,而是要求同一行,同样的,因为可能背包的容量压根就不够,所以这里也需要令其在容量不够的时候保持不加入之前的状态,对于滚动数组而言,只要一开始就直接限定容量的范围就可以了,不需要做特别的更新
接着我们来解决最后一个问题,那就是对于完全背包而言,遍历顺序有影响吗?答案是没有影响。这个其实很好理解,我们的完全背包每一个位置的背包都是依赖于其上一个背包和其左边的背包来做推导的,而遍历顺序的倒转并不会打破这个推导规则,因此没有影响
下面是先遍历背包容积后遍历物品的代码
//先遍历背包,再遍历物品
public static void testCompletePack2(){
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWeight = 4;
int[] dp = new int[bagWeight + 1];
for (int j = 0; j <= bagWeight; j++) {
for (int i = 0; i < weight.length; i++) {
if(j>=weight[i]){
dp[j]=Math.max(dp[j],dp[j-weight[i]]+value[i]);
}
}
for (int i : dp) {
System.out.print(i+" ");
}
System.out.println();
}
}
其滚动数组的变化如下
0 0 0 0 0 0 15 0 0 0 0 15 30 0 0 0 15 30 45 0 0 15 30 45 60
可以看到我们的滚动数组变化了六行,而对于先遍历物品后遍历背包容积的代码而言,则只变化了三行,因此,我们平时还是更加推荐各位使用先遍历物品后遍历背包的解题方式的
零钱兑换 II
这题是一道典型的完全背包问题,但本题和纯完全背包不一样,纯完全背包是能否凑成总金额,而本题是要求凑成总金额的个数!
注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?
例如示例一:
5 = 2 + 2 + 1
5 = 2 + 1 + 2
这是一种组合,都是 2 2 1
如果问的是排列数,那么上面就是两种排列了
组合不强调元素之间的顺序,排列强调元素之间的顺序
根据背包四部曲,我们容易分析到本题中,物品是硬币数,而背包的容积则是总金额,背包装入的内容是组合数,物品的价值就是其自身代表的组合数
回归本题,动规五步曲来分析如下:
- 确定dp数组以及下标的含义
dp[j]:凑成总金额j的货币组合数为dp[j]
- 确定递推公式
dp[j] (考虑coins[i]的组合总和) 就是所有的dp[j - coins[i]](不考虑coins[i])相加
所以递推公式:dp[j] += dp[j - coins[i]];
这个其实就是我们之前的经典递推公式了
- dp数组如何初始化
首先dp[0]一定要为1,dp[0] = 1是 递归公式的基础。
从dp[i]的含义上来讲就是,凑成总金额0的货币组合数为1。
下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]
- 确定遍历顺序
之前我们说过,完全背包的遍历顺序是无论是先遍历物品还是先遍历容积都是没有影响的,但是本题却不行
这是因为我们之前的纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!所以我们无论使用何种遍历方式都可以解决问题
本题是求凑出来的方案个数,且每个方案个数是为组合数,组合是不强调顺序的,那么此时两个for循环的先后顺序可就有说法了
我们先来看 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况
代码如下
for (int i = 0; i < coins.length; i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
dp[j] += dp[j - coins[i]];
}
}
假设:coins[0] = 1,coins[1] = 5。
那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。
所以这种遍历顺序中dp[j]里计算的是组合数
我们来看看其滚动数组内的值的变化
1 0 0 0 0 0 1 1 1 1 1 1 1 1 2 2 3 3 1 1 2 2 3 4
可以看到我们这里的变化总是从左往右变化的,最终得到的结果就是我们所求的组合数
如果把两个for交换顺序,代码如下:
for (int j = 0; j <= amount; j++) { // 遍历背包容量
for (int i = 0; i < coins.length; i++) { // 遍历物品
if (j - coins[i] >= 0) {
dp[j] += dp[j - coins[i]];
}
}
}
背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。
此时dp[j]里算出来的就是排列数,我们不妨来看看其滚动数组的变化过程
1 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 1 1 2 0 0 0 1 1 2 3 0 0 1 1 2 3 5 0 1 1 2 3 5 9
可以看到这里滚动数组是从上到下变化的,并最终得到了排列数
其实简单理解来说,可以认为如果我们的推导公式是取最大值,我们就不用在意顺序,而如果是依赖前面的结果的+=推导,那么顺序是有它的意义的
- 举例推导dp数组
这个上面已经推导过了,这里就不赘述了
那么最终我们可以写入我们的代码如下
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0]=1;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
dp[j]+=dp[j-coins[i]];
}
}
return dp[amount]==0 ? 0 : dp[amount];
}
}
当然,我们也还有二维数组的版本,请看代码
class Solution {
public int change(int amount, int[] coins) {
int [][] dp = new int[coins.length+1][amount+1];
dp[0][0] = 1;
for (int i = 1; i <= coins.length; i++) {
for (int j = 0; j <= amount; j++) {
if(j<coins[i-1]){
dp[i][j]=dp[i-1][j];
}else {
dp[i][j]=dp[i-1][j]+dp[i][j -coins[i -1]];
}
}
}
return dp[coins.length][amount];
}
}
组合总和 IV
这一题我们明显可以看到,我们的目标求的是排列,而我们上一题说过,我们先遍历背包容积后遍历物品是求排列,反之则是求组合,那么我们这一题只要使用求排列的做法就迎刃而解了
这里就不搞这么多什么分析了,直接看代码吧,基本思路和上一个差不多,没什么特别值得说的
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0]=1;
for (int j = 0; j <= target; j++) {
for (int i = 0; i < nums.length; i++) {
if(j>=nums[i]){
dp[j]+=dp[j-nums[i]];
}
}
}
return dp[target];
}
}
爬楼梯(进阶版)
上面是普通版本的题目,而进阶的题目则是将本题的条件改为:一步一个台阶,两个台阶,三个台阶,.......,直到 m个台阶。问有多少种不同的方法可以爬到楼顶呢?
显然,这就是一个完全背包问题,阶数就是物品,而楼梯数量则是背包,
没啥特别值得说的,直接看代码吧
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n + 1];
int[] weight = {1,2};
dp[0] = 1;
for (int i = 0; i <= n; i++) {
for (int j = 0; j < weight.length; j++) {
if (i >= weight[j]) dp[i] += dp[i - weight[j]];
}
}
return dp[n];
}
}
零钱兑换
这里我整整有七天在玩没有学习,因为是国庆,还有我自己有病在身,但说实话这实在是太放纵了,就算自己有病在身也不该这样,检查结果不知道的情况下谁也不知道最后到底死不死,既然不知道就优先以不死为前提干活,能读书就多读一点吧,能学一点是一点,学就完了
首先这一题跟之前的零钱兑换的题目几乎一模一样,毫无疑问地是完全背包类题目,但是这一题有所不同的是,其不是求达成该金额的所有组合方法,而是求达成该金额的最小钱数的方法,那么我们本题为了达成题目所需的需求,我们的代码需要进行相应的改动
背包四部曲和之前的差不多,这里就不多提了,我们直接进行动规五部曲吧
- 确定dp数组以及下标的含义
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
- 确定递推公式
得到dp[j](考虑coins[i]),只有一个来源,dp[j - coins[i]](没有考虑coins[i])
凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。
递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]),这个公式的本质是让当前的背包大小和上一个对应的背包放入该物品之后的大小进行比较,取小的背包为当前背包,这里的大小指的是放入的钱数
- dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
所以下标非0的元素都是应该是最大值
- 确定遍历顺序
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数
- 举例推导dp数组
以输入:coins = [1, 2, 5], amount = 5为例
那么最终我们可以写入我们的代码如下
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount+1];
Arrays.fill(dp,Integer.MAX_VALUE);
dp[0]=0;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
if(dp[j-coins[i]]!=Integer.MAX_VALUE){
dp[j]=Math.min(dp[j],dp[j-coins[i]]+1);
}
}
}
return dp[amount]==Integer.MAX_VALUE ? -1 : dp[amount];
}
}
这里我们在循环遍历时加入了不允许往前的背包正好是最大值的判断,这是因为最大值+1会发生数字溢出的问题,当然,如果我们将int改为long,就可以省略这个判断
完全平方数
这题光看题目就知道是和上一题相似的题目了,其分析过程也大同小异,这里就省略了
不过这里值得一提的是,本题只提供了一个数字,并不像之前的题目一样提供一个值和一个数组,我们可以直接使用,因此这里我们需要自己先去构建物品数组
那么我们可以写入我们的代码如下
class Solution {
public int numSquares(int n) {
int[] dp = new int[n+1];
List<Integer> list = new ArrayList<>();
for (int i = 1; i*i <= n; i++) {
list.add(i*i);
}
Arrays.fill(dp,Integer.MAX_VALUE);
dp[0]=0;
for (int i = 0; i < list.size(); i++) {
for (int j = list.get(i); j <= n; j++) {
if(dp[j-list.get(i)]!=Integer.MAX_VALUE){
dp[j]=Math.min(dp[j],dp[j-list.get(i)]+1);
}
}
}
return dp[n];
}
}
可以看到我们这份代码首先将对应的物品加入到了集合中,然后再进行动态规划,这份代码可以通过,但是实际上,我们可以将加入物品这个过程给省略掉,将代码改造如下
class Solution {
public int numSquares(int n) {
int[] dp = new int[n+1];
Arrays.fill(dp,Integer.MAX_VALUE);
dp[0]=0;
for (int i = 1; i*i <= n; i++) {
for (int j = i*i; j <= n; j++) {
if(dp[j-i*i]!=Integer.MAX_VALUE){
dp[j]=Math.min(dp[j],dp[j-i*i]+1);
}
}
}
return dp[n];
}
}
我们这里通过直接指定遍历范围的方式来选取符合要求的物品,效率更高而且代码更加简洁
单词拆分
这一题如果直接看,那显然是一点思路没有的,直接寄了。但是我们都学习过动态规划了,我们就要用动规的思想来解决这题,只要往动规的思路上想,这一题就迎刃而解了
那么我们首先要做的事情就是将这一题转换为一个动规类的题目,当然,这题难就难在这一步,题目只给了对应的字符串数组和目标字符串,那我们要怎么才能将判断是否能组合成目标字符串这一条件转化为动规类题目呢?
首先字符串数组显然是物品,而且在本题中我们的物品可以任意取,那么这题显然是完全背包。接着我们的问题是如果转化S,显然S对应的是背包,但是问题在于怎么转化成背包
其实我们可以将整个字符串根据下标分解为多个子字符串,创建一个对应的布尔类型的数组,该数组每一个位置就代表该下标往左的字符串能否通过右边组合而成,只要该子字符串可以被拼接,我们就将在字符串对应的数组下标赋予true,代表其可以被拼接,这样我们最后只需要返回字符串长度的布尔类型数组的下标的结果即可,这样我们 就将字符串转化为了一个个背包,接着要做的事情是正式的解题
背包四部曲这里就省略了,直接来进行动规五部曲:
- 确定dp数组以及下标的含义
dp[i] : 字符串长度为i的话,dp[i]为true,表示从该位置到最左的字符串可以拆分为一个或多个在字典中出现的单词,简而言之其代表为对应位置所代表的字符串可以通过字典拼接
- 确定递推公式
如果确定dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true,当然这里必须要保证j < i
所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true
这个很好理解,dp[j]==true就保证了j之前的字符串已经出现过了,而当前j~i的字符串存在于字典中就保证当前判断的字符串也在字典中,两个条件满足那么该字符串必然可以通过拼接方式通过字典重新复现
- dp数组如何初始化
从递归公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0]一定要为true,否则递归下去后面都都是false了。
那么dp[0]有没有意义呢?
dp[0]表示如果字符串为空的话,说明出现在字典里。
但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。
下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词
- 确定遍历顺序
如果求组合数就是外层for循环遍历物品,内层for遍历背包
如果求排列数就是外层for遍历背包,内层for循环遍历物品
我们这里无论是使用何种方式,都不会影响到最终结果。尽管我们一般都使用先遍历物品,后遍历背包的方式,但在这里如果采用这种方式,就需要把所有的子串都预先放在一个容器里,不然没法整(这一点如果不理解的话,自己去试着写写对应的代码就理解了),但如果选择先遍历背包后遍历物品就不会有这种问题,因此这里我们选择后者
- 举例推导dp[i]
以输入: s = "leetcode", wordDict = ["leet", "code"]为例,dp状态如图
可以看到我们这里leet对应的字符串4是true,然后leetcode就可以通过对应的4来给自身赋值为true
那么最终我们可以写入我们的代码如下
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> set = new HashSet<>(wordDict);
boolean[] dp = new boolean[s.length()+1];
dp[0]=true;
for (int i = 1; i <= s.length(); i++) {
for (int j = 0; j < i && !dp[i]; j++) {
String substring = s.substring(j, i);
if(set.contains(substring) && dp[j]){
dp[i]=true;
}
}
}
return dp[s.length()];
}
}
这里我们每次遍历物品做的事情都是给当前i所对应的下标赋值为true,那么每次进入判断前我们要求i对应的下标必须为false,因为已经是true了,循环的可能结果也就是将其赋值为true,重复运算没有意义,所以若已经为true则可以直接跳过,实际上将这个要求去除也是不影响我们的算法功能的
由于我们这里无非是判断对应的字符串是否出现在字典中,因此我们选择将字符串数组直接放在Set集合中用于判断
当然有的同学可能会说了,你这题这样搞谁会啊,确实,我承认是有点难,尤其是第一步的转化,但是我们可以学习下思路,以后遇上类似的我们就这样转化了是吧
其实,到此为止我们的动规总体知识就已经全部学完了,剩下的都是些对应的练习题,就不放到这个版面里说了
这一章到结束我们要做的事情是就是做对应的动规练习题,这里我们是必须要把之前学习过的知识都拿出来了不可了,学完了我们动规这一章节就结束了,GOGOGO!!!
打家劫舍
这一题首先我们要对题目进行分析,这里的小偷是不可以连续偷两间房屋的,这种题目我们之前做过类似的,由于小偷不可以偷取连续两间房屋,那么我们基本的思路就是拿其不偷上一间的和偷当前房屋的价值做对比,取其最大的情况,这样就明确了我们递推公式的大体思路了
那么接着我们就来正式解决我们这题,动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]
- 确定递推公式
决定dp[i]的因素就是第i房间偷还是不偷
如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱
如果不偷第i房间,那么dp[i] = dp[i - 1],即考虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
- dp数组如何初始化
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]
从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);
那么我们的初始化就初始化两个,当长度为1时只能偷第一个,为2时就偷两者中最大的一个
- 确定遍历顺序
dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!
要注意的是,并不是所有的动规都可以分解为背包问题的,所以并不是所有的动规我们都需要进行两次遍历,像这一题中,我们只需要一次遍历就可以解决问题了
- 举例推导dp数组
以示例二,输入[2,7,9,3,1]为例
那么最终我们可以写入我们的代码如下
class Solution {
public int rob(int[] nums) {
if(nums.length==1){
return nums[0];
}
int[] dp = new int[nums.length];
dp[0]=nums[0];
dp[1]=Math.max(dp[0],nums[1]);
for (int i = 2; i < dp.length; i++) {
dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);
}
return dp[nums.length-1];
}
}
打家劫舍 II
打家劫舍的进阶题目和原来的题目唯一的不同就是成环了,我们对于成环的题目,最简单的解决方法就是分情况讨论,我们这里可以将题目分为两种情况,第一种情况是考虑偷窃第一位但不考虑偷窃最后一位,第二种情况是不考虑偷窃第一位,但考虑偷窃最后一位,最后分析这两种情况谁大谁小,最终返回最大的值即可,其他解题思路不需要做改动
实际上,上面的两种情况在成环的情况下应该是三种情况的,具体请看下图
但由于情况二和情况三都包含了情况一,因此我们实际上没有必要去特别分出一个情况一来
那么最后我们可以写入我们的代码如下:
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
if(nums.length==2){
return Math.max(nums[0], nums[1]);
}
if(nums.length==3){
return Math.max(nums[2],Math.max(nums[0],nums[1]));
}
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(dp[0], nums[1]);
for (int i = 2; i <= nums.length-2; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
int[] dp2 = new int[nums.length];
dp2[1]=nums[1];
dp2[2]=Math.max(dp2[1],nums[2]);
for (int i = 3; i < nums.length; i++) {
dp2[i]=Math.max(dp2[i-1],dp2[i-2]+nums[i]);
}
return Math.max(dp[nums.length - 2],dp2[nums.length-1]);
}
}
当然,这个代码说实话是有点难看了,我们可以将代码修改如下
class Solution {
public int rob(int[] nums) {
if(nums.length==1){
return nums[0];
}
int left = robRange(nums, 0, nums.length - 2);
int right = robRange(nums, 1, nums.length - 1);
return Math.max(left, right);
}
private int robRange(int[] nums, int start, int end) {
if(end==start){
return nums[start];
}
int[] dp = new int[nums.length];
dp[start]=nums[start];
dp[start+1]=Math.max(nums[start],nums[start+1]);
for (int i = start+2; i <= end; i++) {
dp[i]=Math.max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[end];
}
}
这样我们的代码就好看很多了
打家劫舍 III
这题就比较有难度了,因为其涉及到了树,而对于树状的DP,我们之前是没有做过的,因此我们这里要细细讲一讲
首先对于树状的题目,首先我们应该要确定其遍历 方式,本题我们每次需要考虑的事情无非是偷还是不偷左右两个子节点,而每次判断我们都需要已经知道的结果才能作为依据,因此我们这里只能使用后序遍历,因为从后序遍历我们才能通过递归的返回值来做下一步计算,其他的遍历是无法满足的
更加确切地说,是我们只有后序遍历我才可以利用递归的特性去不断修正我们的结果,最终通过动态规划的递推公式得到答案,如果使用其他遍历,则会因为没有向下传递值的特性而无法得到我们想要的结果
这道题目算是树形dp的入门题目,因为是在树上进行状态转移,我们在讲解二叉树的时候说过递归三部曲,那么下面我以递归三部曲为框架,其中融合动规五部曲的内容来进行讲解
动态规划其实就是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱
那么我们来进行递归三部曲
- 确定递归函数的参数和返回值
这里我们要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组
这里的返回数组就是dp数组
所以dp数组(dp table)以及下标的含义:下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱,这里要时刻记住
所以本题dp数组就是一个长度为2的数组!
那么有同学可能疑惑,长度为2的数组怎么标记树中每个节点的状态呢?
在递归的过程中,系统栈会保存每一层递归的参数。
- 确定终止条件
在遍历的过程中,如果遇到空节点的话,很明显,无论偷还是不偷都是0,所以就返回,这也相当于dp数组的初始化
- 确定遍历顺序
首先明确的是使用后序遍历。 因为通过递归函数的返回值来做下一步计算。
通过递归左节点,得到左节点偷与不偷的金钱。
通过递归右节点,得到右节点偷与不偷的金钱。
- 确定单层递归的逻辑
如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0]; (如果对下标含义不理解就在回顾一下dp数组的含义)
如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);
最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}
- 举例推导dp数组
以示例1为例,dp数组状态如下:(注意用后序遍历的方式推导)
最后头结点就是 取下标0 和 下标1的最大值就是偷得的最大金钱
这个其实还好理解,那么最后我们可以写入我们的代码如下
class Solution {
public int rob(TreeNode root) {
int[] dp = dfs(root);
return Math.max(dp[0],dp[1]);
}
private int[] dfs(TreeNode root) {
if(root==null){
return new int[2];
}
int[] left = dfs(root.left);
int[] right = dfs(root.right);
int[] dp = new int[2];
dp[0] = Math.max(left[0],left[1])+Math.max(right[0],right[1]);
dp[1] = left[0]+right[0]+root.val;
return dp;
}
}
这是一道简单的树状DP的题目,作为入门的题目,还是比较经典的,学会这里的思想,以后解题时会简单很多
实在不行我先记住遇到树状DP,返回值往数组想,然后遍历方式往后序遍历上想,这样起码会有思路不是
买卖股票的最佳时机
这题当然用贪心来做就很不错,但是我们这里是动规专题,所以我们要用动规的方法来做
但是这题没做过的话还是比较有难度的,因为乍一看好像根本没什么办法将本题抽象出DP数组来做,也想不出什么递推公式
但是我们可以先往贪心上想,因为贪心无非是特殊的动规的情况,或许知道了贪心我们就知道了动规思路。我们知道本题贪心的思路是取最左最小值,取最右最大值,那么得到的差值就是最大利润
我们常规解题DP自然是想着用一个数组并用一个下标推导出最终的结果,或者是用二维数组,这个一般和背包问题有关
但是对于本题来说,我们可以根据贪心的思路,将本题分解为多个DP数组,同时将多个DP数组分为两个下标,一个存储买入时花费的最小值,另外一个存储卖出时的最大值,我们的递推公式就根据这两个来推导
那么接着我们就来进行我们的经典动规五部曲
分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i][0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?
其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。
dp[i][1] 表示第i天不持有股票所得最多现金
注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态
而i的下标则是代表每一天
- 确定递推公式
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
- 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
- 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1][0]
同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
- dp数组如何初始化
由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);可以看出
其基础都是要从dp[0][0]和dp[0][1]推导出来。
那么dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];
dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0,第0天肯定不持有股票,所以必然是0
- 确定遍历顺序
从递推公式可以看出dp[i]都是有dp[i - 1]推导出来的,那么一定是从前向后遍历。
- 举例推导dp数组
以示例1,输入:[7,1,5,3,6,4]为例,dp数组状态如下
那么最终我们可以写入其代码如下
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
for (int i = 1; i < dp.length; i++) {
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);
dp[i][1] = Math.max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[dp.length-1][1];
}
}
当然,由于我们这里其实只用了某一行两列的数据,没必要创建一个二维数组,所以我们可以将我们的代码修改如下
class Solution {
public int maxProfit(int[] prices) {
int[] dpBefore = new int[prices.length];
int[] dpAfter = new int[prices.length];
dpBefore[0]=-prices[0];
dpAfter[0]=0;
for (int i = 1; i < prices.length; i++) {
dpBefore[i]=Math.max(dpBefore[i-1],-prices[i]);
dpAfter[i]=Math.max(dpAfter[i-1],dpBefore[i-1]+prices[i]);
}
return dpAfter[prices.length-1];
}
}
再具体一点,我们会发现我们只用了两个数据,所以其实我们可以只保留两个数据就可以解答本题
class Solution {
public int maxProfit(int[] prices) {
int[] dp = new int[2];
dp[0] = -prices[0];
dp[1] = 0;
for (int i = 1; i <= prices.length; i++) {
dp[0] = Math.max(dp[0], -prices[i - 1]);
dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
}
return dp[1];
}
}
解这种类型的题目我们都推荐使用第三种方式,简单快捷,代码量也少
买卖股票的最佳时机 II
本题和上一题十分之像,唯一有区别的是上一题只允许买卖一次,而我们这里是允许无限次的买卖的,那么显而易见的,我们的解题思路也是差不多的,这里我们就不讲什么动规五部曲了,整体思路和上一个差不多,直接来看代码吧
class Solution {
public int maxProfit(int[] prices) {
int[] dp = new int[2];
// 0表示持有,1表示卖出
dp[0] = -prices[0];
dp[1] = 0;
for(int i = 1; i <= prices.length; i++){
// 前一天持有; 既然不限制交易次数,那么再次买股票时,要加上之前的收益
dp[0] = Math.max(dp[0], dp[1] - prices[i-1]);
// 前一天卖出; 或当天卖出,当天卖出,得先持有
dp[1] = Math.max(dp[1], dp[0] + prices[i-1]);
}
return dp[1];
}
}
可以看到我们的代码和上一题的几乎一模一样,唯一有不同的是这行代码dp[0] = Math.max(dp[0], dp[1] - prices[i-1]),在上一题里,这行代码是这样的dp[0] = Math.max(dp[0], -prices[i - 1]),可以看到具体有所不同的在于买股票是的代码,这里我们具体来解释下
首先,由于我们的题目本质是没有变的,因此我们这里仍然也是用一个两位数组来进行解题,核心思路也是分为买股票后和卖股票后来推导出最后的答案
但是我们的上一题只允许卖一次,因此我们的推导公式是在继续持有上一个股票和买下当前股票的花费之间取最小值,但是我们本题是允许买卖多次的,所以我们这里的推导公式就是继续持有上一个股票和卖出股票之后再买下当前股票的花费之间取最小值,因此我们这里的推导代码是dp[0] = Math.max(dp[0], dp[1] - prices[i-1]),这很好理解
买卖股票的最佳时机 III
这题和之前题目的区别是,这里指定了只能买卖两次。我们之前解决题目的时候都是将股票分为购买前和购买后,这里既然只能买卖两次,那么我们可以继续分状态,将股票分为五种状态,我们就用动规五部曲来一起讲述
- 确定dp数组以及下标的含义
一天一共就有五个状态,
- 没有操作
- 第一次买入
- 第一次卖出
- 第二次买入
- 第二次卖出
dp[i][j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金。
- 确定递推公式
需要注意:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区。
达到dpi状态,有两个具体操作:
- 操作一:第i天买入股票了,那么dp[i][1] = dp[i-1][0] - prices[i]
- 操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][1] = dp[i - 1][1]
那么dp[i][1]究竟选 dp[i-1][0] - prices[i],还是dp[i - 1][1]呢?
一定是选最大的,所以 dp[i][1] = max(dp[i-1][0] - prices[i], dp[i - 1][1]);
同理dp[i][2]也有两个操作:
- 操作一:第i天卖出股票了,那么dp[i][2] = dp[i - 1][1] + prices[i]
- 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][2] = dp[i - 1][2]
所以dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])
同理可推出剩下状态部分:
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
- dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;
第0天做第一次买入的操作,dp[0][1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0,
从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了。
所以dp[0][2] = 0;
第0天第二次买入操作,初始值应该是多少呢?应该不少同学疑惑,第一次还没买入呢,怎么初始化第二次买入呢?
第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后在买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0][3] = -prices[0];
同理第二次卖出初始化dp[0][4] = 0;
- 确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。
- 举例推导dp数组
以输入[1,2,3,4,5]为例
红色框为最后两次卖出的状态。
现在最大的时候一定是卖出的状态,而两次卖出的状态现金最大一定是最后一次卖出。
所以最终最大利润是dp[4][4]
那么最终我们可以写入我们的代码如下
class Solution {
public int maxProfit(int[] prices) {
int len = prices.length;
// 边界判断, 题目中 length >= 1, 所以可省去
if (prices.length == 0) {
return 0;
}
/*
* 定义 5 种状态:
* 0: 没有操作, 1: 第一次买入, 2: 第一次卖出, 3: 第二次买入, 4: 第二次卖出
*/
int[][] dp = new int[len][5];
dp[0][1] = -prices[0];
// 初始化第二次买入的状态是确保 最后结果是最多两次买卖的最大利润
dp[0][3] = -prices[0];
for (int i = 1; i < len; i++) {
dp[i][1] = Math.max(dp[i - 1][1], -prices[i]);
dp[i][2] = Math.max(dp[i - 1][2], dp[i][1] + prices[i]);
dp[i][3] = Math.max(dp[i - 1][3], dp[i][2] - prices[i]);
dp[i][4] = Math.max(dp[i - 1][4], dp[i][3] + prices[i]);
}
return dp[len - 1][4];
}
}
由于没有操作时的状态永远都是0,因此我们可以省略其对应的推导公式,实际上,我们即使省略这个状态都是可以的,完全不影响我们的解题,但是这样的话我们的逻辑就破坏了,因此我们还是要把第一个状态给加上
同样的,对于这样题目的解题,我们也是可以进行空间的优化的,显然每次推导是我们只需要利用上一个状态,因此我们可以直接创建一个五行数组即可解题
那么我们可以写入我们的代码如下
class Solution {
public int maxProfit(int[] prices) {
if(prices.length==1){
return 0;
}
int[] dp = new int[5];
dp[0] = 0;
dp[1] = -prices[0];
dp[2] = 0;
dp[3] = -prices[0];
dp[4] = 0;
for (int i = 1; i < prices.length; i++) {
dp[1] = Math.max(dp[1],dp[0]-prices[i]);
dp[2] = Math.max(dp[2],dp[1]+prices[i]);
dp[3] = Math.max(dp[3],dp[2]-prices[i]);
dp[4] = Math.max(dp[4],dp[3]+prices[i]);
}
return dp[4];
}
}
买卖股票的最佳时机 IV
这题跟上一题唯一的区别就是上一次只能买卖两次,而这里能买卖K次。我们分析上一题我们容易知道,上一题里但凡是奇数都代表持有股票,而偶数则是不持有,以此来表示买卖,那么我们这里同样依葫芦画瓢,无非是两次变成K次而已,那么我们可以写入我们的代码如下
class Solution {
public int maxProfit(int k, int[] prices) {
if(prices.length<=1 || k==0){
return 0;
}
int[] dp = new int[k*2+1];
for (int i = 0; i < dp.length; i++) {
if(i%2==1){
dp[i]=-prices[0];
}
}
for (int i = 1; i < prices.length; i++) {
for (int j = 1; j < dp.length; j++) {
if(j%2==1){
dp[j] = Math.max(dp[j],dp[j-1]-prices[i]);
}else {
dp[j] = Math.max(dp[j],dp[j-1]+prices[i]);
}
}
}
return dp[dp.length-1];
}
}
最后提一嘴,我们这里也是可以将第一个没有操作的状态省略掉的,但这样的话就需要在第二个for的逻辑里做j的下标处理,这样太麻烦了,不如直接保留第一个状态,这样能简化我们的代码
最佳买卖股票时期含冷冻期
这一题跟前面的题目不同的是,本题买卖股票时含有冷冻期,这个冷冻期就会让我们之前的解题逻辑失效,我们需要新的解题逻辑
实际上,经过这么多题,其实大家也都该有点想法,冷冻期就是一个新的状态,既然有新的状态,那么我们就多区分一组出来给这个状态来进行推导,当然,实际上我们这题不止需要多一个状态,具体请看下面的分析
本题具体可以区分出如下四个状态:
-
状态一:买入股票状态(今天买入股票,或者是之前就买入了股票然后没有操作)
-
卖出股票状态,这里就有两种卖出股票状态
- 状态二:两天前就卖出了股票,度过了冷冻期,一直没操作,今天保持卖出股票状态
- 状态三:今天卖出了股票
-
状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!
j的状态为:
- 0:状态一
- 1:状态二
- 2:状态三
- 3:状态四
接着我们进行动规五部曲分析
- 确定dp数组以及下标的含义
dp[i][j],第i天状态为j,所剩的最多现金为dp[i][j]。
- 确定递推公式
达到买入股票状态(状态一)即:dp[i][0],有两个具体操作:
-
操作一:前一天就是持有股票状态(状态一),dp[i][0] = dp[i - 1][0]
-
操作二:今天买入了,有两种情况
- 前一天是冷冻期(状态四),dp[i - 1][3] - prices[i]
- 前一天是保持卖出股票状态(状态二),dp[i - 1][1] - prices[i]
所以操作二取最大值,即:max(dp[i - 1][3], dp[i - 1][1]) - prices[i]
那么dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]);
达到保持卖出股票状态(状态二)即:dp[i][1],有两个具体操作:
- 操作一:前一天就是状态二
- 操作二:前一天是冷冻期(状态四)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
达到今天就卖出股票状态(状态三),即:dp[i][2] ,只有一个操作:
- 操作一:昨天一定是买入股票状态(状态一),今天卖出
即:dp[i][2] = dp[i - 1][0] + prices[i];
达到冷冻期状态(状态四),即:dp[i][3],只有一个操作:
- 操作一:昨天卖出了股票(状态三)
dp[i][3] = dp[i - 1][2];
综上分析,递推代码如下:
dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
dp[i][2] = dp[i - 1][0] + prices[i];
dp[i][3] = dp[i - 1][2];
- dp数组如何初始化
这里主要讨论一下第0天如何初始化。
如果是持有股票状态(状态一)那么:dp[0][0] = -prices[0],买入股票所剩现金为负数。
保持卖出股票状态(状态二),第0天没有卖出dp[0][1]初始化为0就行,
今天卖出了股票(状态三),同样dp[0][2]初始化为0,因为最少收益就是0,绝不会是负数。
同理dp[0][3]也初始为0。
- 确定遍历顺序
从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历。
- 举例推导dp数组
以 [1,2,3,0,2] 为例,dp数组如下:
最后结果是取 状态二,状态三,和状态四的最大值,状态四是冷冻期,最后一天如果是冷冻期也可能是最大值,状态一没买股票,默认为0,不用比较
那么我们可以写入我们的代码如下
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
if(n == 0){
return 0;
}
int[][] dp = new int[n][4];
//持有股票
dp[0][0] = -prices[0];
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i-1][0],Math.max(dp[i-1][3],dp[i-1][1])-prices[i]);
dp[i][1] = Math.max(dp[i-1][1],dp[i-1][3]);
dp[i][2] = dp[i-1][0]+prices[i];
dp[i][3] = dp[i-1][2];
}
return Math.max(dp[n-1][3],Math.max(dp[n-1][1],dp[n-1][2]));
}
}
当然,我们也还有优化空间之后的版本
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
if(n == 0){
return 0;
}
int[] dp = new int[4];
//持有股票
dp[0] = -prices[0];
for (int i = 1; i < n; i++) {
int a = dp[0];
int b = dp[2];
dp[0] = Math.max(dp[0], Math.max(dp[3],dp[1])-prices[i]);
dp[1] = Math.max(dp[1],dp[3]);
dp[2] = a+prices[i];
dp[3] = b;
}
return Math.max(dp[3],Math.max(dp[1],dp[2]));
}
}
我们这里优化空间之后的版本使用了一维数组,但是在我们的递推公式中需要使用到dp[0]和dp[2],然后他们在被使用之前就被改变了值,因此我们需要先保存其值,然后才能给后面的递推公式使用
买卖股票的最佳时机含手续费
本题非常简单啊,和不限制买卖股票的题目大差不差,无非是多了手续费,那就让每次交易成功之后都扣去手续费即可,那么我们可以写入我们的代码如下
class Solution {
public int maxProfit(int[] prices, int fee) {
if(prices.length==1){
return 0;
}
int[] dp = new int[2];
dp[0] = -prices[0];
for (int i = 1; i < prices.length; i++) {
dp[0] = Math.max(dp[0],dp[1]-prices[i]);
dp[1] = Math.max(dp[1],dp[0]+prices[i]-fee);
}
return dp[1];
}
}
最长递增子序列
题目求什么,我们就可以结合动规特点,让下标对应的数值来表示题目的所需要答案。
根据这个思路,我们容易得到dp[i]的定义
- dp[i]的定义
dp[i]表示i之前包括i的以nums[i]结尾最长上升子序列的长度
- 状态转移方程
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。
- dp[i]的初始化
每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是1.
- 确定遍历顺序
dp[i] 是有0到i-1各个位置的最长升序子序列 推导而来,那么遍历i一定是从前向后遍历。
j其实就是0到i-1,遍历i的循环在外层,遍历j则在内层
- 举例推导dp数组
输入:[0,1,0,3,2],dp数组的变化如下:
那么最终我们可以写入我们的代码如下:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums.length==1){
return 1;
}
int[] dp = new int[nums.length];
Arrays.fill(dp,1);
for (int i = 1; i < dp.length; i++) {
for (int j = 0; j < i; j++) {
if(nums[i]>nums[j]){
dp[i] = Math.max(dp[i],dp[j]+1);
}
}
}
int res = 0;
for (int i : dp) {
res = Math.max(res,i);
}
return res;
}
}
注意第一位的递推是没有意义的,因此我们这里将i设置为1,同时由于子序列的特点,只要是更大就可以计入因此我们这里只要确定最大之后就从当前最长和对应位置的长度再+1两者中取最大即可,至于为什么要取最大 ,这就是因为我们的数组下标定义就是要取最大的
然后j之所以小小于i,是因为i代表该下标往前的最长递增序列数,更新是由j的for循环负责的,因此我们只需要更新到i位置为止
最长连续递增序列
这题跟上一题差不多,不同的是这一题要求的是连续递增的子序列。
其实这一题就很忌讳想得太复杂,下标直接设定为指定该下标往前的连续递增序列的最大值,而既然要求连续递增的序列,那如果小于我们就将其值置为1即可,若大于就根据上一个动规值+1
那么最终我们可以写入其代码如下
class Solution {
public int findLengthOfLCIS(int[] nums) {
if(nums.length==1){
return 1;
}
int[] dp = new int[nums.length];
Arrays.fill(dp,1);
for (int i = 1; i < nums.length; i++) {
if(nums[i]>nums[i-1]){
dp[i] = dp[i-1]+1;
}
}
int ans = 0;
for (int i : dp) {
ans = Math.max(ans,i);
}
return ans;
}
}
最长重复子数组
本题的难度在于是有两个数组,我们要怎么从两个数组中找出连续长度最长的子数组,我们的思路可以是分横纵两个维度去遍历两个数组,遇到相同时,我们就令其+1,这样我们就能找到最长的数组,由于我们这里要求的必须是连续的数组,因此不连续时,我们就不做其他动作,相同时,我们从上一次+1的结果中取来继续+1
确定了我们的动规思路之后,接着我们来进行我们的五部曲
- 确定dp数组(dp table)以及下标的含义
dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。 (特别注意: “以下标i - 1为结尾的A” 标明一定是 以A[i-1]为结尾的字符串 )
此时细心的同学应该发现,那dp[0][0]是什么含义呢?总不能是以下标-1为结尾的A数组吧。
其实dp[i][j]的定义也就决定着,我们在遍历dp[i][j]的时候i 和 j都要从1开始。
那有同学问了,我就定义dp[i][j]为 以下标i为结尾的A,和以下标j 为结尾的B,最长重复子数组长度。不行么?
行倒是行! 但实现起来就麻烦一点,大家看下面的dp数组状态图就明白了。
- 确定递推公式
根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。
即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1;
根据递推公式可以看出,遍历i 和 j 要从1开始!
- dp数组如何初始化
根据dp[i][j]的定义,dp[i][0] 和dp[0][j]其实都是没有意义的!
但dp[i][0] 和dp[0][j]要初始值,因为 为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1;
所以dp[i][0] 和dp[0][j]初始化为0。
举个例子A[0]如果和B[0]相同的话,dp[1][1] = dp[0][0] + 1,只有dp[0][0]初始为0,正好符合递推公式逐步累加起来。
- 确定遍历顺序
外层for循环遍历A,内层for循环遍历B。
那又有同学问了,外层for循环遍历B,内层for循环遍历A。不行么?
也行,一样的,我这里就用外层for循环遍历A,内层for循环遍历B了。
同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i][j]的最大值记录下来。
- 举例推导dp数组
拿示例1中,A: [1,2,3,2,1],B: [3,2,1,4,7]为例,画一个dp数组的状态变化,如下:
那么我们可以写入我们的代码如下
class Solution {
public int findLength(int[] nums1, int[] nums2) {
int result = 0;
int[][] dp = new int[nums1.length + 1][nums2.length + 1];
for (int i = 1; i < nums1.length + 1; i++) {
for (int j = 1; j < nums2.length + 1; j++) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
result = Math.max(result, dp[i][j]);
}
}
}
return result;
}
}
当然,我们可以使用滚动数组来实现,但是要注意的是,使用滚动数组时,jfor的遍历需要从后往前同时条件不符合时需要赋0,这样才能保证每次取到前面的背包的正确性
class Solution {
public int findLength(int[] nums1, int[] nums2) {
int[] dp = new int[nums2.length + 1];
int result = 0;
for (int i = 1; i <= nums1.length; i++) {
for (int j = nums2.length; j > 0; j--) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[j] = dp[j - 1] + 1;
} else {
dp[j] = 0;
}
result = Math.max(result, dp[j]);
}
}
return result;
}
}