算法训练--动态规划(一)

277 阅读16分钟

算法训练--动态规划(一)

[TOC]

动态规划基础理论

什么是动态规划

  • 动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

    所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

    动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。

    但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。

    所以贪心解决不了动态规划的问题。

  • 大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。

动态规划的解题步骤

  • 状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式
  • 对于动态规划问题,拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
    1. 确定dp数组(dp table)以及下标的含义
    2. 确定递推公式
    3. dp数组如何初始化
    4. 确定遍历顺序
    5. 举例推导dp数组

动归应该如何debug

  • 找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!

  • 做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

    然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

    如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

    如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

    这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了

    这也是我为什么在动规五步曲里强调推导dp数组的重要性

基础题目

509. 斐波那契数

  • 题目描述

    image.png

  • 题解1

    class Solution {
        public int fib(int n) {
            if(n<2) return n;
            int f1=0,f2=1,f3=0;
            for(int i=2;i<n+1;i++){
                f3=f1+f2;
                f1=f2;
                f2=f3;
            }
            return f3;
        }
    }
    
  • 题解2:动规五部曲

    • 1、确定dp数组以及下标的含义,dp[i]的定义为:第i个数的斐波那契数值是dp[i]

    • 2、确定递推公式

      题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];

    • 3、dp数组如何初始化:

      dp[0] = 0;
      dp[1] = 1;
      
    • 4、确定遍历顺序

      从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

    • 5、举例推导dp数组

      按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:0 1 1 2 3 5 8 13 21 34 55

      如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的

    class Solution {
        public int fib(int n) {
            if(n<2) return n;
            int[] dp=new int[n+1];
            dp[0]=0;dp[1]=1;
            for(int i=2;i<n+1;i++){
                dp[i]=dp[i-1]+dp[i-2];
            }
            return dp[n];
        }
    }
    

70. 爬楼梯

  • 题目描述

    image.png

  • 题解

    class Solution {
        public int climbStairs(int n) {
            if(n<2) return n;
            int[] dp=new int[n+1];
            dp[1]=1;dp[2]=2;
            for(int i=3;i<n+1;i++){
                dp[i]=dp[i-1]+dp[i-2];
            }
            return dp[n];
        }
    }
    

746. 使用最小花费爬楼梯

  • 题目描述

    image.png

  • 题解

    注意题目描述:每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯

    动规五部曲:

    1、确认dp数组以及下标的意义

    使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。

    dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。(注意这里认为是第一步一定是要花费)

    2、确定递推公式

    可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]

    那么究竟是选dp[i-1]还是dp[i-2]呢?

    一定是选最小的,所以dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];

    注意这里为什么是加cost[i],而不是cost[i-1],cost[i-2]之类的,因为题目中说了:每当你爬上一个阶梯你都要花费对应的体力值

    3、dp数组如何初始化

    只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出

    dp[0] = cost[0];
    dp[1] = cost[1];
    

    4、确定遍历顺序

    因为是模拟台阶,而且dp[i]又dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了

    5、举例推导dp数组

    拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:

    746.使用最小花费爬楼梯

    class Solution {
        public int minCostClimbingStairs(int[] cost) {
            int[] dp=new int[cost.length+1];
            dp[0]=cost[0];
            dp[1]=cost[1];
            for(int i=2;i<cost.length;i++){
                dp[i]=Math.min(dp[i-1],dp[i-2])+cost[i];
            }
            //最后一步,如果是由倒数第二步爬,则最后一步的体力花费可以不用算
            return Math.min(dp[cost.length-1],dp[cost.length-2]);
        }
    }
    

62. 不同路径

  • 题目描述

    image.png

    image.png

  • 题解

    动规五部曲:

    1、确定dp数组及下标意义

    dp[i][j] :表示从(00)出发,到(i, j) 有dp[i][j]条不同的路径
    

    2、确定递推公式

    想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。
    
    此时在回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理。
    
    那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来
    

    3、dp数组的初始化

    如何初始化呢,首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。
    所以初始化代码为:
    for (int i = 0; i < m; i++) dp[i][0] = 1;
    for (int j = 0; j < n; j++) dp[0][j] = 1;
    

    4、确定遍历顺序

    这里要看一下递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了
    

    5、举例推导dp数组

    62.不同路径1

    class Solution {
        public int uniquePaths(int m, int n) {
          	//dp数组含义:从(0,0)到(m,n)有多少条路径
            int[][] dp=new int[m][n];
          	//dp[0][i]一定都是1,因为从(0, 0)的位置到(0, i)的路径只有一条
            for(int i=0;i<n;i++) dp[0][i]=1;
          	//dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条
            for(int i=0;i<m;i++) dp[i][0]=1;
          	//从左到右,一层层的遍历
            for(int i=1;i<m;i++){
                for(int j=1;j<n;j++){
                    dp[i][j]=dp[i-1][j]+dp[i][j-1];
                }
            }
            return dp[m-1][n-1];
        }
    }
    

63. 不同路径 II

  • 题目描述

    image.png

    image.png

  • 题解

    62.不同路径 (opens new window)中我们已经详细分析了没有障碍的情况,有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了

    动规五部曲:

    1、确定dp数组(dp table)以及下标的含义

    dp[i][j] :表示从(00)出发,到(i, j) 有dp[i][j]条不同的路径。
    

    2、确定递推公式

    递推公式和62.不同路径一样,dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。
    但这里需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)
    if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
    

    3、dp数组的初始化

    如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0

    63.不同路径II

    for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
    for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
    

    注意代码里for循环的终止条件,一旦遇到obstacleGrid[i][0] == 1的情况就停止dp[i][0]的赋值1的操作,dp[0][j]同理

    4、确定遍历顺序

    从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中可以看出,一定是从左到右一层一层遍历,这样保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值
    

    5、举例推导dp数组

    63.不同路径II2

    class Solution {
        public int uniquePathsWithObstacles(int[][] obstacleGrid) {
            int m=obstacleGrid.length;
            int n=obstacleGrid[0].length;
            int[][] dp=new int[m][n];
            for(int i=0;i<n && obstacleGrid[0][i]==0;i++) dp[0][i]=1;
            for(int i=0;i<m && obstacleGrid[i][0]==0;i++) dp[i][0]=1;
            for(int i=1;i<m;i++){
                for(int j=1;j<n;j++){
                    if(obstacleGrid[i][j]==1) continue;
                    dp[i][j]=dp[i-1][j]+dp[i][j-1];
                }
            }
            return dp[m-1][n-1];
        }
    }
    

343. 整数拆分

  • 题目描述

    image.png

  • 题解

    动规五部曲:

    1、确定dp数组(dp table)以及下标的含义

    dp[i]:分拆数字i,可以得到的最大乘积为dp[i]

    2、确定递推公式

    可以想 dp[i]最大乘积是怎么得到的呢?其实可以从1遍历j,然后有两种渠道得到dp[i].

    ​ 一个是j * (i - j) 直接相乘。

    ​ 一个是j * dp[i - j],相当于是拆分(i - j)

    也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘

    所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

    那么在取最大值的时候,为什么还要比较dp[i]呢?因为在递推公式推导的过程中,每次计算dp[i],取最大的而已

    3、dp数组初始化

    严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。

    这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议!

    4、确定遍历顺序

    确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

    dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。

    枚举j的时候,是从1开始的。i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。

    5、举例推导dp数组

    343.整数拆分

    class Solution {
        public int integerBreak(int n) {
          	//dp[i] 为正整数 i 拆分后的结果的最大乘积
            int[] dp=new int[n+1];
            dp[2]=1;
            for(int i=3;i<n+1;i++){
              	//j最大到i-j
                for(int j=1;j<=i-j;j++){
                  	//j * (i - j) 是单纯的把整数 i 拆分为两个数 也就是 i,i-j ,再相乘
                    //而j * dp[i - j]是将 i 拆分成两个以及两个以上的个数,再相乘。
                    dp[i]=Math.max(dp[i],Math.max(((i-j)*j),j*dp[i-j]));
                }
            }
            return dp[n];
        }
    }
    

96. 不同的二叉搜索树

  • 题目描述

    image.png

  • 题解

    动规五部曲:

    1、确认dp数组及下标的含义

    dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]

    2、确认递推公式

    96.不同的二叉搜索树

    96.不同的二叉搜索树1

    来看看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]

    96.不同的二叉搜索树2

    在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量,j相当于是头结点的元素,从1遍历到i为止。

    所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量

    3、dp数组的初始化

    dp[0] = 1

    4、确认遍历顺序

    首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。

    那么遍历i里面每一个数作为头结点的状态,用j来遍历

    5、举例推导dp数组

    96.不同的二叉搜索树3

    class Solution {
        public int numTrees(int n) {
            int[] dp=new int[n+1];
            dp[0]=1;
            for(int i=1;i<n+1;i++){
                for(int j=1;j<=i;j++){
                  	//对于第i个节点,需要考虑1作为根节点直到i作为根节点的情况,所以需要累加
                    //j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
                    dp[i]+=dp[j-1]*dp[i-j];
                }
            }
            return dp[n];
        }
    }
    

01背包

01背包理论基础一

01背包

  • 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

    这是标准的背包问题,暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!

    在下面的讲解中,我举一个例子:

    背包最大重量为4。问背包能背的物品最大价值是多少?

    物品为:

    image.png

01背包的dp数组

  • 动规五部曲:

    • 1、确定dp数组及下标含义

      dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

      动态规划-背包问题1

    • 2、确定递推公式

      再回顾一下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]);
      
    • 3、dp数组的初始化

      关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

      首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:

      动态规划-背包问题2

      状态转移方程 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物品。

      动态规划-背包问题7

      其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。

      初始-1,初始-2,初始100,都可以!

      但只不过一开始就统一把dp数组统一初始为0,更方便一些。

      如图:

      动态规划-背包问题10

    • 4、确定遍历顺序

      在如下图中,可以看出,有两个遍历的维度:物品与背包重量

      要理解递归的本质和递推的方向。
      
      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]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:
      

      先遍历物品,再遍历背包动态规划-背包问题6

    • 5、举例推导dp数组

      最终结果就是dp[2][4]
      

      建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。

      做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

      动态规划-背包问题4

    测试代码

      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];
            //初始化:背包容量为0时,能获得的价值都为0
            for (int i = 0; i <= wlen; i++){
                dp[i][0] = value0;
            }
            //遍历顺序:先遍历物品,再遍历背包容量
            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");
            }
        }
    

01背包理论基础二

滚动数组

  • 就是把二维dp降为一维dp

image.png

一维dp数组(滚动数组)

  • 对于背包问题其实状态都是可以压缩的。
    在使用二维数组的时候,递推公式: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的背包,价值总和最大是多少。
    
  • 动规五部曲

    • 1、确定dp数组及下标含义

      在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。

    • 2、确定递归公式

      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]);
      
    • 3、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就可以了。
      这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
      那么假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
      
    • 4、确定遍历顺序

      二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

      两个嵌套for循环的顺序,是先遍历物品嵌套遍历背包容量

      倒序遍历是为了保证物品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]并不会被覆盖!
      

      所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!

    • 5、举例推导dp数组

      一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:

      动态规划-背包问题9

    测试代码

        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] + " ");
            }
        }
    

416. 分割等和子集

  • 题目描述

    image.png

  • 题解

    要注意题目描述中商品是不是可以重复放入。

    即一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的

    回归主题:首先,本题要求集合里能否出现总和为 sum / 2 的子集。

    那么来一一对应一下本题,看看背包问题如果来解决。

    只有确定了如下四点,才能把01背包问题套到本题上来。

    • 背包的体积为sum / 2
    • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
    • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
    • 背包中每一个元素是不可重复放入。
  • 动规五部曲

    • 1、确定dp数组及下标的含义

      01背包中,dp[j] 表示: 容量为j的背包,所背的物品价值可以最大为dp[j]。

      套到本题,dp[j]表示 背包总容量是j,最大可以凑成j的子集总和为dp[j]

    • 2、确定递推公式

      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]);

    • 3、dp数组的初始化

      在01背包,一维dp如何初始化,已经讲过,

      从dp[j]的定义来看,首先dp[0]一定是0。

      如果如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

      这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

      本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

    • 4、确定遍历顺序

      如果使用一维dp数组,物品遍历的for循环放在外层遍历背包的for循环放在内层,且内层for循环倒序遍历!

    • 5、举例推导dp数组

      dp[j]的数值一定是小于等于j的。

      如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。

      用例1,输入[1,5,11,5] 为例,如图:

      416.分割等和子集2

    /**
    	基本就是按照01背包的写法来的
    	主要要理解,题目中物品是nums[i],重量是nums[i],价值也是nums[i],背包容量是sum/2。
    */
    class Solution {
        public boolean canPartition(int[] nums) {
            int sum=0;
          	//dp[i]中的i表示背包内总和
            //题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
            //总和不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
            int[] dp=new int[10001];
            for(int i=0;i<nums.length;i++){
                sum+=nums[i];
            }
            if(sum%2==1) return false;
            int target=sum/2;
            for(int i=0;i<nums.length;i++){
              	//每一个元素一定是不可重复放入,所以从大到小遍历
                for(int j=target;j>=nums[i];j--){
                  	//dp[j]表示 背包总容量是j,最大可以凑成j的子集总和为dp[j]
                  	//物品 i 的重量是 nums[i],其价值也是 nums[i]
                    dp[j]=Math.max(dp[j],dp[j-nums[i]]+nums[i]);
                }
            }
          	//集合中的元素正好可以凑成总和target
            if(dp[target]==target) return true;
            return false;
        }
    }
    

1049. 最后一块石头的重量 II

  • 题目描述

    image.png

  • 题解

    本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了

    本题物品的重量为store[i],物品的价值也为store[i]。对应着01背包里的物品重量weight[i]和 物品价值value[i]。

    dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背dp[j]这么重的石头

    最后dp[target]里是容量为target的背包所能背的最大重量。

    那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。

    在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的

    那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]

    class Solution {
        public int lastStoneWeightII(int[] stones) {
            int sum=0;
            int[] dp=new int[15001];
            for(int i=0;i<stones.length;i++){
                sum+=stones[i];
            }
            int target=sum/2;
            for(int i=0;i<stones.length;i++){
                for(int j=target;j>=stones[i];j--){
                  	//dp[j]表示容量为j的背包,最多可以背dp[j]这么重的石头
                    dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);
                }
            }
            return sum-dp[target]-dp[target];
        }
    }
    

494. 目标和

  • 题目描述

    image.png

  • 题解

    本题要如何使表达式结果为target,既然为target,那么就一定有 left组合 - right组合 = target。

    left + right等于sum,而sum是固定的。公式来了, left - (sum - left) = target -> left = (target + sum)/2 。

    target是固定的,sum是固定的,left就可以求出来。

    此时问题就是在集合nums中找出和为left的组合

    确定递推公式

    不考虑nums[i]的情况下,填满容量为j - nums[i]的背包,有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[j] += dp[j - nums[i]]
    这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
    

    dp数组的初始化

    从递归公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递归结果将都是0。

    dp[0] = 1,理论上也很好解释,装满容量为0的背包,有1种方法,就是装0件物品。

    /**
    	在求装满背包有几种方法的情况下,递推公式一般为:
    	dp[j] += dp[j - nums[i]];
    	求组合(01背包)
    */
    class Solution {
        public int findTargetSumWays(int[] nums, int target) {
            int sum=0;
            for(int num:nums){
                sum+=num;
            }
            if((sum+target)%2==1) return 0;
            if(Math.abs(target)>sum) return 0;
            int left=(sum+target)/2;
            int[] dp=new int[left+1];
            dp[0]=1;
            for(int i=0;i<nums.length;i++){
                for(int j=left;j>=nums[i];j--){
                  	//组合类:累加结果
                    dp[j]+=dp[j-nums[i]];
                }
            }
            return dp[left];
        }
    }
    

474. 一和零

  • 题目描述

    image.png

  • 题解

    多重背包是每个物品,数量不同的情况。

    本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包,两个维度的背包

    这不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品

    dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
    
    dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。
    dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 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背包的dp数组初始化为0就可以。因为不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖
    
    在动态规划:关于01背包问题,你该了解这些!(滚动数组中,我们讲到了01背包为什么一定是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!
    那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n
    
    class Solution {
        public int findMaxForm(String[] strs, int m, int n) {
            int[][] dp=new int[m+1][n+1];
          	//遍历物品
            for(String str:strs){
                int zeroNum=0,oneNum=0;
                char[] strArr=str.toCharArray();
                for(char ch:strArr){
                    if(ch=='0')zeroNum++;
                    else oneNum++;
                }
              	//遍历背包 倒序
                for(int i=m;i>=zeroNum;i--){
                    for(int j=n;j>=oneNum;j--){
                        dp[i][j]=Math.max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
                    }
                }
            }
            return dp[m][n];
        }
    }
    

完全背包

完全背包基础理论

  • 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

    完全背包和01背包问题唯一不同的地方就是,每种物品有无限件

    同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。

    在下面的讲解中,我依然举这个例子:

    背包最大重量为4。

    物品为:

    重量价值
    物品0115
    物品1320
    物品2430

    **每件商品都有无限个!**问背包能背的物品最大价值是多少?

  • 01背包和完全背包唯一不同就是体现在遍历顺序上

    首先在回顾一下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]);
        }
    }
    

    完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

    // 先遍历物品,再遍历背包
    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]);
    }
    
  • dp状态图

    动态规划-完全背包

  • 测试代码

    //先遍历物品,再遍历背包
    private 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 + "   ");
        }
    }
    

518. 零钱兑换 II

  • 题目描述

    image.png

  • 题解

    例如:dp[j],j 为5, 已经有一个1(coins[i]) 的话,有 dp[4]种方法 凑成 dp[5] 已经有一个2(coins[i]) 的话,有 dp[3]种方法 凑成 dp[5] 已经有一个3(coins[i]) 的话,有 dp[2]种方法 凑成 dp[5] 已经有一个4(coins[i]) 的话,有 dp[1]种方法 凑成 dp[5] 已经有一个5 (coins[i])的话,有 dp[0]种方法 凑成 dp[5]

    那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。

    /**
    	求组合(完全背包:硬币数量不限)
    	求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];
    */
    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=0;j<=amount;j++){
                    if(j>=coins[i]){
                        dp[j]+=dp[j-coins[i]];
                    }
                }
            }
            return dp[amount];
        }
    }
    

377. 组合总和 Ⅳ

  • 题目描述

    image.png

  • 题解

    本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列!

    dp[i]: 凑成目标正整数为i的排列个数为dp[i]

    image.png

    确定遍历顺序:

    • 个数可以不限使用,说明这是一个完全背包。得到的集合是排列,说明需要考虑元素之间的顺序。

      本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了

    • 如果求组合数就是外层for循环遍历物品,内层for遍历背包

      如果求排列数就是外层for遍历背包,内层for循环遍历物品

    • 如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!

      所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历

      请注意,顺序不同的序列被视作不同的组合 这句话表明 本题其实是求排列

    /**
    	注意排列问题:先遍历背包
    	dp[j]+=dp[j-nums[i]];
    */
    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];
        }
    }
    

70. 爬楼梯-进阶版

  • 题目描述

    image.png

  • 题解

    /**
    	改为:一步一个台阶,两个台阶,三个台阶,.......,直到 m个台阶
    	问有多少种不同的方法可以爬到楼顶呢?
    	背包里求排列问题,即:1、2 步 和 2、1 步都是上三个台阶,但是这两种方法不一样!
    */
    class Solution {
        public int climbStairs(int n,int m) {
            int[] dp = new int[n + 1];
            dp[0] = 1;
          	//排列:先遍历背包
            for (int j = 0; j <= n; j++) {
                for (int i = 1; i < m; i++) {
                  	//判断 + 组合公式 dp[j]+=dp[j-nums[i]]
                    if (j >= i) dp[j] += dp[j-i];
                }
            }
            return dp[n];
        }
    }
    

322. 零钱兑换

  • 题目描述

    image.png

  • 题解

    1、确定dp数组及下标的意义

    dp[j]:凑足总额为j所需钱币的最少个数为dp[j]

    2、确定递推公式

    ​ 得到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]);

    3、dp数组初始化

    ​ 首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;

    ​ 其他下标对应的数值呢?考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。

    ​ 所以下标非0的元素都是应该是最大值。

    4、确定遍历顺序、

    ​ 本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数

    ​ 所以本题并不强调集合是组合还是排列。

    如果求组合数就是外层for循环遍历物品,内层for遍历背包

    如果求排列数就是外层for遍历背包,内层for循环遍历物品

    所以本题的两个for循环的关系是:外层for循环遍历物品,内层for遍历背包或者外层for遍历背包,内层for 循环遍历物品都是可以的!

    /**
    	先遍历物品、再遍历背包
    */
    class Solution {
        public int coinChange(int[] coins, int amount) {
            int max=Integer.MAX_VALUE;
            int[] dp=new int[amount+1];
            Arrays.fill(dp,max);
            dp[0]=0;
            for(int i=0;i<coins.length;i++){
                for(int j=0;j<=amount;j++){
                  	//注意 如果 dp[j-coins[i]]==max 就没有交换的必要了
                    if(j>=coins[i] && dp[j-coins[i]]!=max){
                        dp[j]=Math.min(dp[j],dp[j-coins[i]]+1);
                    }
                }
            }
            return dp[amount]==max?-1:dp[amount];
        }
    }
    



### [279. 完全平方数](https://leetcode-cn.com/problems/perfect-squares/)

* 题目描述

  ![image.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/271186a58c33406a85b5b579ca11ae0e~tplv-k3u1fbpfcp-watermark.image?)

* 题解

  **把题目翻译一下:完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?**

  **dp[j]:和为j的完全平方数的最少数量为dp[j]**

  **递推公式**:dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]。此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);

  **dp数组初始化**:dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0。从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,**所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖**。

  ```java
  class Solution {
      public int numSquares(int n) {
          int max=Integer.MAX_VALUE;
          int[] dp=new int[n+1];
          Arrays.fill(dp,max);
          dp[0]=0;
          for(int i=1;i*i<=n;i++){
              for(int j=i;j<=n;j++){
                  if(j>=i*i){
                      dp[j]=Math.min(dp[j],dp[j-i*i]+1);
                  }
              }
          }
          return dp[n];
      }
  }

139. 单词拆分

  • 题目描述

    image.png

  • 题解

    单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满

    动规五部曲分析如下:
    
    确定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数组如何初始化
    从递归公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0]一定要为true,否则递归下去后面都都是false了。
    下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。
    
    确定遍历顺序
    题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。
    还要讨论两层for循环的前后循序。
    如果求组合数就是外层for循环遍历物品,内层for遍历背包。
    如果求排列数就是外层for遍历背包,内层for循环遍历物品。
    
    /**
    	考虑到截取子串,因此外层遍历背包、内层遍历物品
    */
    class Solution {
        public boolean wordBreak(String s, List<String> 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;j++){
                    if(wordDict.contains(s.substring(j,i)) && dp[j]==true){
                        dp[i]=true;
                    }
                }
            }
            return dp[s.length()];
        }
    }
    

背包问题总结

  • 几种常见的背包问题

    416.分割等和子集1

  • 在讲解背包问题的时候,我们都是按照如下五部来逐步分析,相信大家也体会到,把这五部都搞透了,算是对动规来理解深入了。

    1. 确定dp数组(dp table)以及下标的含义
    2. 确定递推公式
    3. dp数组如何初始化
    4. 确定遍历顺序
    5. 举例推导dp数组

    其实这五部里哪一步都很关键,但确定递推公式和确定遍历顺序都具有规律性和代表性,所以下面我从这两点来对背包问题做一做总结

  • 背包递推公式

    image.png

  • 遍历顺序

    • 01背包

      • 二维dp:先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历
      • 滚动数组、一维dp:只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历

      一维dp数组的背包在遍历顺序上和二维dp数组实现的01背包其实是有很大差异的,大家需要注意!

    • 完全背包

      • 纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历

      • 如果求组合数就是外层for循环遍历物品,内层for遍历背包

        如果求排列数就是外层for遍历背包,内层for循环遍历物品

    • 对于背包问题,其实递推公式算是容易的,难是难在遍历顺序上,如果把遍历顺序搞透,才算是真正理解

打家劫舍

198. 打家劫舍

  • 题目描述

    image.png

  • 题解

    打家劫舍是dp解决的经典问题,动规五部曲分析如下:

    1. 确定dp数组(dp table)以及下标的含义:dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]

    2. 确定递推公式:决定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]);

    3. 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]);

    4. 确定遍历顺序:dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!

    5. 举例推导dp数组:以示例二,输入[2,7,9,3,1]为例

      198.打家劫舍

      红框dp[nums.size() - 1]为结果

    class Solution {
        public int rob(int[] nums) {
            if(nums.length==0) return 0;
            if(nums.length==1) return nums[0];
            int[] dp=new int[nums.length];
            dp[0]=nums[0];
            dp[1]=Math.max(nums[1],dp[0]);
            for(int i=2;i<nums.length;i++){
                dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);
            }
            return dp[nums.length-1];
        }
    }
    

213. 打家劫舍 II

  • 题目描述

    image.png

  • 题解

    跟上一题差不多,唯一区别就是成环了

    对于一个数组,成环的话主要有如下情况:

    • 考虑包含首元素,不包含尾元素

      213.打家劫舍II1

    • 考虑包含尾元素,不包含首元素

      213.打家劫舍II2

    class Solution {
        public int rob(int[] nums) {
            if(nums.length==1) return nums[0];
          	//不偷第一间房 包含尾元素,不包含首元素
            int res1=robTraversal(nums,1,nums.length);
          	//不偷最后一间房  包含首元素不包含尾元素
            int res2=robTraversal(nums,0,nums.length-1);
            return Math.max(res1,res2);
        }
      	//198.打家劫舍的逻辑 
        public int robTraversal(int[] nums,int start,int end){
            if(end-start==1) 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-1],dp[i-2]+nums[i]);
            }
            return dp[end-1];
        }
    }
    

337. 打家劫舍 III

  • 题目描述

    image.png

    image.png

  • 题解

    本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算。与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢。如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”

    dp数组(dp table)以及下标的含义:下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱

    确定遍历顺序:

    ​ 首先明确的是使用后序遍历。 因为通过递归函数的返回值来做下一步计算。

    ​ 通过递归左节点,得到左节点偷与不偷的金钱。

    ​ 通过递归右节点,得到右节点偷与不偷的金钱。

    确定单层递归的逻辑

    ​ 如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0]; (如果对下标含义不理解就在回顾一下dp数组的含义

    ​ 如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);

    ​ 最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}

    337.打家劫舍III

    /**
    	树形dp
    */
    class Solution {
        public int rob(TreeNode root) {
            int[] res=robTraversal(root);
            return Math.max(res[0],res[1]);
        }
        public int[] robTraversal(TreeNode root){
          	//长度为2的数组,0:不偷,1:偷
            int[] res=new int[2];
            if(root==null) return res;
            int[] left=robTraversal(root.left);
            int[] right=robTraversal(root.right);
          	//不偷:Max(左孩子不偷,左孩子偷) + Max(又孩子不偷,右孩子偷)
            res[0]=Math.max(left[0],left[1])+Math.max(right[0],right[1]);
          	//偷:左孩子不偷+ 右孩子不偷 + 当前节点偷
            res[1]=root.val+left[0]+right[0];
            return res;
        }
    }