相关算法---动态规划

动态规划

1. 理论基础

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。所以动态规划中每一个状态一定是由上一个状态推导出来的这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。

动态规划解题步骤:

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

动态规划如何找出bug:

  • 最好的方式就是把dp数组打印出来,看看是不是自己所想的。

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

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

2. 斐波那契数

题目:斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你n ,请计算 F(n) 。

题目分析:

动态规划步骤:

  • 确定dp数组以及下标的含义:dp[i]的定义为:第i个数的斐波那契数值是dp[i]。
  • 确定递推公式:题目已经给了: dp[i] = dp[i - 1] + dp[i - 2]
  • dp数组如何初始化:题目也直接给出来了:dp[0] = 0;dp[1] = 1;
  • 确定遍历顺序:从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的。
  • 举例推导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;
    }
}

//非压缩状态的版本
class Solution {
    public int fib(int n) {
        if(n <= 1){
            return n;
        }
        int[] dp = new int[n + 1];
        dp[0] = 0;
        dp[1] = 1;
        for(int i = 2; i <= n; i++){
            dp[i] = dp[i - 2] + dp[i - 1];
        }
        return dp[n];
    }
}

3. 爬楼梯

题目:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?注意:给定 n 是一个正整数。

题目分析:爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。

动规步骤:

  • 确定dp数组以及下标的含义:dp[i]: 爬到第i层楼梯,有dp[i]种方法。
  • 确定递推公式:dp[i] = dp[i - 1] + dp[i - 2] 。(i - 1层再跳一步就是dp[i], i - 2层再跳两步就是dp[i]。推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。
  • dp数组初始化:不考虑dp[0]如果初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。
  • 确定遍历顺序:递推公式看,是从前向后遍历。
  • 举例推导dp数组。
class Solution {
    public int climbStairs(int n) {
        int[] dp = new int[n + 1];
        if(n <= 2){
            return n;
        }
        dp[0] = 0;
        dp[1] = 1;
        dp[2] = 2;
        
        for(int i = 3; i <= n; i++){
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

4. 使用最小花费爬楼梯

题目:数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。

动规步骤:

  • dp数组以及下标的含义:dp[i]:到达第i个台阶所花费的最小体力为dp[i]。(第一步一定是要花费的
  • 递推公式:可以从dp[i - 1]和dp[i - 2]得到dp[i],但要取二者最小的一个,还要加上本身爬楼梯要消耗的体力值,所以公式为:dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
  • dp初始化:只需要初始化dp[0]=cost[0]和dp[1]=cost[1]
  • 遍历顺序:从前向后
  • 举例推导dp
class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int length = cost.length;
        if(length == 0 || cost == null){
            return 0;
        }
        if(length == 1 ){
            return cost[0];
        }
       
        int[] dp = new  int[length];
        dp[0] = cost[0];
        dp[1] = cost[1];
        for(int i = 2; i <= length - 1; i++){
            dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
        }
        //最后一步不用花费体力
        return Math.min(dp[length - 1], dp[length - 2]);
    }
}

5. 不同路径

题目:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?

动规步骤:

  • dp数组及其下标的含义:dp[i] [j]:表示从(0, 0)出发,到(i, j)有dp[i] [j]条不同的路径。
  • 递推公式:dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1],因为dp[i][j]只有这两个方向过来。(每次只能向下或者向右移动
  • dp初始化:首先dp[i] [0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条(在边边上,只能从上面向下才能移动到这。 dp[0] [j] 也是同理。
  • 遍历顺序:dp[i] [j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。这样就可以保证推导dp[i][j]的时候,dp[i - 1] [j] 和 dp[i] [j - 1]一定是有数值的。
  • 举例dp数组
  class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int [m][n];
        //初始化
        for(int i = 0; i < m; i ++){
            dp[i][0] = 1;
        }
        for(int j = 0; j < n; j++){
            dp[0][j] = 1;
        }

        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
            }
        }
        return dp[m - 1][ n - 1];
    }
}

6. 不同路径II

题目:一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。现在考虑网格中有障碍物,网格中的障碍物和空位置分别用 1 和 0 来表示。那么从左上角到右下角将会有多少条不同的路径?

动规步骤:

  • dp数组及其下标的含义:dp[i] [j] :表示从(0 ,0)出发,到(i, j) 有dp[i] [j]条不同的路径。
  • 递推公式:dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1]。但如果有障碍,就应该保持初始状态。
  • dp初始化:dp[i] [0]一定都是1, dp[0] [j] 也是同理。但是如果这条边上有了障碍之后,dp[i] [0]初始值还是为0.for循环的终止条件,一旦遇到obstacleGrid[i] [0] == 1的情况就停止dp[i] [0]的赋值1的操作,dp[0] [j]同理
  • 遍历顺序:从左到右一层一层遍历
  • 举例推导dp
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int n = obstacleGrid.length; //行数
        int m = obstacleGrid[0].length; //列数
        int[][] dp = new int[n][m];

        //初始化
        for(int i = 0; i < m; i++){
            //一旦遇到障碍,后续都到不了
            if(obstacleGrid[0][i] == 1){
                break;
            }
            dp[0][i] = 1;
        }
        for(int i = 0; i < n; i++){
            if(obstacleGrid[i][0] == 1){
                break;
            }
            dp[i][0] = 1;
        }

        for(int i = 1; i < n; i++){
            for(int j = 1; j < m; j++){
                //有障碍物就退出 走不到
                if(obstacleGrid[i][j] == 1){
                    continue;
                }
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[n - 1][m - 1];

    }
}

7. 整数拆分

题目:给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。

动规步骤:

  • dp数组及其下标的含义:dp[i]:分拆数字i,可以得到最大乘积为dp[i]

  • 递推公式:可以从1遍历到j,有两种情况可以获得dp[i]: j * (i - j) 直接相乘; j * dp[i - j].(j不需要拆分,是因为在之前遍历到j时,已经计算过了。)递推公式是:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

    • j * (i - j) 是单纯的把整数拆分为两个数相乘。
    • j * dp[i - j]是拆分成两个以及两个以上的个数相乘。
    • 比较dp[i]是因为要在每次计算dp[i]取最大值。
  • dp初始化:严格定义来讲,dp[0], dp[1]不应该初始化。只初始化dp[2] = 1,拆分就是1 * 1 = 1。

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

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

  • 举例推导dp

class Solution {
    public int integerBreak(int n) {
        //之所以是int[n + 1] 是害怕下面dp[2]越界
        int[] dp = new int[n + 1];
        dp[2] = 1;
        for(int i = 3; i <= n; i++){
            //j最大值就是i - j,再大也只是重复罢了
            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];
    }
}

8. 不同的二叉搜索树

题目:给定一个整数 n,求以 1 … n 为节点组成的二叉搜索树有多少种?

动规步骤:

  • dp数组及其下标的含义:dp[i]:1到i为节点组成的二叉搜索树的个数。(也可以理解成i的不同元素节点组成的二叉搜索树的个数

  • 递推公式: dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量],j相当于是头结点的元素,从1遍历到i为止。所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量。

  • dp初始化:从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。

    从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。

    所以初始化dp[0] = 1

  • 遍历顺序:首先是遍历节点数,遍历i里面每一个数作为头结点的状态,用j来遍历。

  • 举例推导dp

class Solution {
    public int numTrees(int n) {
        int[] dp = new int[n + 1];

        //初始化dp数组
        dp[0] = 1;
        dp[1] = 1;

        for(int i = 2; i <= n; i++){
            //对于第i个节点,需要考虑1作为根节点直到i作为根节点的情况,所以需要累加
            for(int j = 1; j <= i; j++){

                //一共i个节点,对于根节点j时,左子树的节点个数为j-1,右子树的节点个数为i-j
                dp[i] += dp[j - 1] * dp[i - j];
                
            }
        }
        return dp[n];
    }
}

9. 背包问题

在这里插入图片描述

只需要会01背包和完全背包即可。而完全背包又是01背包稍微变化而来的,即:完全背包的物品数量是无限的。所以背包问题的理论基础是01背包,要理解透彻。

01背包(二维数组

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

动规步骤:

  • dp数组及其下标的含义:用二维数组,dp[i] [j] 表示从下标[0 - i]的物品里任意取,放在容量为j的背包,价值总和最大是多少。
  • 递推公式: 可以从两个方向推出来:
    • 不放物品i:由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初始化:
    • 如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为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
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[j]:容量为j的背包,所背的物品价值最大为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]);(比较于二维数组,就是把i的维度给去掉了

  • dp初始化:dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

  • 遍历顺序:

    • 背包是从大到小遍历,为了保证物品i只会被放入一次(与二维数组不同,二维数组是从小到大

      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]);
      
          }
      }
      
    • 代码中是先遍历物品嵌套遍历背包容量。不可以先遍历背包容量嵌套遍历物品,因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

  • 举例推导dp

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

10. 分割等和子集

题目:给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200。

题目分析:只要在集合中能够找到sum / 2的子集总和,就表明可以分割成两个相同元素和子集。

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

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

动规步骤:

  • dp数组及其下标定义:dp[j]表示背包总容量是j,最大可以凑成j的子集总和为dp[j]。

  • 递推公式:背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

  • dp初始化:dp[0] = 0。如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

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

    // 开始 01背包
    for(int i = 0; i < nums.size(); i++) {
        for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
            dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
        }
    }
    
  • 举例推导dp:dp[i]的数值一定是小于等于i的。如果dp[i] == i 说明,集合中的子集总和正好可以凑成总和i。

class Solution {
    public boolean canPartition(int[] nums) {
        if(nums == null || nums.length == 0) return false;
        int n = nums.length;
        int sum = 0;
        for(int num : nums){
            sum += num;
        }
        //总和为奇数,不能平分
        if(sum % 2 != 0) return false;
        int target = sum / 2;
        int[] dp = new int[target + 1];
        for(int i = 0; i < n; i++){
            for(int j = target; j >= nums[i]; j--){
                //物品 i 的重量是 nums[i],其价值也是 nums[i]
                dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            }
        }
        return dp[target] == target;
    }
}

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

题目:有一堆石头,每块石头的重量都是正整数。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:如果 x == y,那么两块石头都会被完全粉碎; 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。 最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回 0。

题目分析:尽量让石头分为重量相同的两堆,相撞之后剩下的石头最小,这样就化解成了01背包问题了。本题物品的重量为store[i],物品的价值也为store[i]。对应着01背包里的物品重量weight[i]和 物品价值value[i]。

动规步骤:

  • 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初始化:因为提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000 。而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了。因为重量都不会是负数,所以dp[j]都初始化为0就可以了。

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

    for (int i = 0; i < stones.size(); i++) { // 遍历物品
        for (int j = target; j >= stones[i]; j--) { // 遍历背包
            dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
        }
    }
    
    
  • 举例推导dp:最后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;
        for (int i : stones) {
            sum += i;
        }
        int target = sum >> 1;
        //初始化dp数组
        int[] dp = new int[target + 1];
        for (int i = 0; i < stones.length; i++) {
            //采用倒序
            for (int j = target; j >= stones[i]; j--) {
                //两种情况,要么放,要么不放
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return sum - 2 * dp[target];
    }
}

12. 目标和

题目:给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

提示:

  • 数组非空,且长度不会超过 20 。
  • 初始的数组的和不会超过 1000 。
  • 保证返回的最终结果能被 32 位整数存下。

如何转化成01背包问题:假设加法的总和为x,那么减法对应的总和就是sum - x。所以我们要求的是 x - (sum - x) = S,x = (S + sum) / 2。此时问题就转化为,装满容量为x背包,有几种方法

看到(S + sum) / 2 应该担心计算的过程中向下取整有没有影响。

if ((S + sum) % 2 == 1) return 0; // 此时没有方案

如果 S的绝对值已经大于sum,那么也是没有方案的。

if (abs(S) > sum) return 0; // 此时没有方案

本题每个物品只能使用一次,装满有几种方法,就是一个组合问题。

动规步骤:

  • dp数组及其定义:dp[j]表示填满j这么大容积的包,有dp[j]种方法。
  • 递推公式:填满容量为j - nums[i]的背包,有dp[j - nums[i]]种方法,那么只要搞到nums[i]的话,凑成dp[j]就有dp[j - nums[i]] 种方法。所以求组合类问题的公式都是类似这种的:dp[j] += dp[j - nums[i]]。
  • 初始化:从公式可以看成dp[0]一定要初始化为1,要不然后面所有结果都是0。dp[0]:装满容量为0的背包,有1种方法,就是装0件物品。dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。
  • 遍历顺序:nums放在外循环,target在内循环,且内循环倒序。
  • 举例dp数组:
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for(int num : nums){
            sum += num;
        }
        //向下取整是1则表示没有方案
        if((target + sum) % 2 != 0){
            return 0;
        }
        //target的绝对值大于sum也表示没有方案
        if(Math.abs(target) > sum){
            return 0;
        }

        int size = (target + sum) / 2;
        size = Math.abs(size);
        int[] dp = new int[size + 1];
        //初始化一定要是1
        dp[0] = 1;
        for(int i = 0; i < nums.length; i++){
            for(int j = size; j >= nums[i]; j--){
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[size];
    }
}

13. 一和零

题目:给你一个二进制字符串数组 strs 和两个整数 m 和 n 。请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1 。如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

strs数组里的元素就是物品,每个物品只有一个。而m和n相当于一个背包,两个维度的背包。也是一个01背包问题:就是这个背包有两个维度,一个是m,一个是n,而不同长度的字符串就是不同大小的待装物品。

动规步骤:

  • dp定义: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] = max(dp[i] [j], dp[i - zeroNum] [j - oneNum] + 1); (对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

  • 初始化:初始化为0即可。

  • 遍历顺序:for循环遍历物品,内层for循环遍历背包容量且从后向前遍历:物品就是strs里的字符串,背包容量就是题目描述中的m和n。

  • 举例dp数组。

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        int[][] dp = new int[m + 1][n + 1];
        int oneNum, zeroNum;
        for(String s : strs){
            oneNum = 0;
            zeroNum = 0;
            for(char c : s.toCharArray()){
                if('0' == c){
                    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];
    }
}

14. 完全背包理论基础

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

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

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

因为完全背包的物品是可以添加多次的,就内循环从小到大去遍历。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!(但是具体问题具体分析

测试代码

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

//先遍历背包,再遍历物品
private static void testCompletePackAnotherWay(){
    int[] weight = {1, 3, 4};
    int[] value = {15, 20, 30};
    int bagWeight = 4;
    int[] dp = new int[bagWeight + 1];
    for (int i = 1; i <= bagWeight; i++){ // 遍历背包容量
        for (int j = 0; j < weight.length; j++){ // 遍历物品
            if (i - weight[j] >= 0){
                dp[i] = Math.max(dp[i], dp[i - weight[j]] + value[j]);
            }
        }
    }
    for (int maxValue : dp){
        System.out.println(maxValue + "   ");
    }
}

15. 零钱兑换II

题目:给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

典型的完全背包问题。

动规步骤:

  • dp定义:dp[j]:凑成总金额j的货币组合数为dp[j]。

  • 递推公式:dp[j] += dp[j - coins[i]];(组合问题

  • 初始化:dp[0] = 1是递归公式的基础。非0下标的dp[j]也初始化为0。

  • 遍历顺序:纯完全背包求是否能凑成总和,和凑成总和的元素顺序没有关系。而本题是求凑出来的组合数

    • 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况,这种遍历顺序中dp[j]里计算的是组合数!

      for (int i = 0; i < coins.size(); i++) { // 遍历物品
          for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
              dp[j] += dp[j - coins[i]];
          }
      }
      
    • 两层for交换顺序,此时dp[j]里算出来的就是排列数!

      for (int j = 0; j <= amount; j++) { // 遍历背包容量
          for (int i = 0; i < coins.size(); i++) { // 遍历物品
              if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
          }
      }
      
    • 如果求组合数就是外层for循环遍历物品,内层for遍历背包如果求排列数就是外层for遍历背包,内层for循环遍历物品。(手动模拟,对比一下不同

  • 举例dp数组

class Solution {
    public int change(int amount, int[] coins) {
        //递推表达式
        int[] dp = new int[amount + 1];
        //初始化dp数组,表示金额为0时只有一种情况,也就是什么都不装
        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];
    }
}

16. 组合总和IV

题目:给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

请注意,顺序不同的序列被视作不同的组合。

动规步骤:

  • dp定义:dp[i]: 凑成目标正整数为i的排列个数为dp[i]
  • 递推公式:dp[i] += dp[i - nums[j]];
  • 初始化:dp[0] = 1,非0下标初始化为0。
  • 遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历
  • 举例dp数组
class Solution {
    public int combinationSum4(int[] nums, int target) {
        int[] dp = new int[target + 1];
        dp[0] = 1;
        for (int i = 0; i <= target; i++) {
            for (int j = 0; j < nums.length; j++) {
                if (i >= nums[j]) {
                    dp[i] += dp[i - nums[j]];
                }
            }
        }
        return dp[target];
    }
}

17. 爬楼梯(进阶版

题目:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?注意:给定 n 是一个正整数。

题目分析:1阶,2阶,… m阶就是物品,楼顶就是背包。每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。问跳到楼顶有几种方法其实就是问装满背包有几种方法。就是一个完全背包问题。

动规步骤:

  • dp定义:dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法
  • 递推公式:dp[i] += dp[i - j]
  • 初始化:dp[0] = 1, 非0下标初始化为0。
  • 遍历顺序:需将target放在外循环,将nums放在内循环,内循环从前向后遍历。
  • 举例dp数组。
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];
    }
}

18. 零钱兑换

题目:给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。

动规步骤:

  • dp定义:dp[j]:凑足总额为j所需钱币的最少个数为dp[j]。
  • 递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
  • 初始化:凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0; dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
  • 遍历顺序:求钱币最小个数,因此内外循环的顺序没影响。因为钱币数量是无限的,所以内循环正序。
  • 举例dp数组。
class Solution {
    public int coinChange(int[] coins, int amount) {
        int max = Integer.MAX_VALUE;
        int[] dp = new int[amount + 1];
        //初始化dp数组为最大值
        for (int j = 0; j < dp.length; j++) {
            dp[j] = max;
        }
        //当金额为0时需要的硬币数目为0
        dp[0] = 0;
        for (int i = 0; i < coins.length; i++) {
            //正序遍历:完全背包每个硬币可以选择多次
            for (int j = coins[i]; j <= amount; j++) {
                //只有dp[j-coins[i]]不是初始最大值时,该位才有选择的必要
                if (dp[j - coins[i]] != max) {
                    //选择硬币数目最小的情况
                    dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }
        return dp[amount] == max ? -1 : dp[amount];
    }
}

19. 完全平方数

题目:给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。

题目分析:**完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?**转换成完全背包问题。

动规步骤:

  • dp定义:**dp[j]:**和为j的完全平方数的最少数量为dp[j]
  • 递推公式:dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);
  • 初始化:dp[0]=0完全是为了递推公式。从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖
  • 遍历顺序:求最小个数,因此内外循环的顺序没影响。因为平方数使用数量是无限的,所以内循环正序。
  • 举例dp数组。
class Solution {
    // 版本一,先遍历物品, 再遍历背包
    public int numSquares(int n) {
        int max = Integer.MAX_VALUE;
        int[] dp = new int[n + 1];
        //初始化
        for (int j = 0; j <= n; j++) {
            dp[j] = max;
        }
        //当和为0时,组合的个数为0
        dp[0] = 0;
        // 遍历物品
        for (int i = 1; i * i <= n; i++) {
            // 遍历背包
            for (int j = i * i; j <= n; j++) {
                if (dp[j - i * i] != max) {
                    dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
                }
            }
        }
        return dp[n];
    }
}

class Solution {
    // 版本二, 先遍历背包, 再遍历物品
    public int numSquares(int n) {
        int max = Integer.MAX_VALUE;
        int[] dp = new int[n + 1];
        // 初始化
        for (int j = 0; j <= n; j++) {
            dp[j] = max;
        }
        // 当和为0时,组合的个数为0
        dp[0] = 0;
        // 遍历背包
        for (int j = 1; j <= n; j++) {
            // 遍历物品
            for (int i = 1; i * i <= j; i++) {
                dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }
}

20. 单词拆分

题目:给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。说明:拆分时可以重复使用字典中的单词。你可以假设字典中没有重复的单词。

回溯算法也可以实现,但是背包问题才是重要。

单词就是物品,字符串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[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0]一定要为true,否则递归下去后面都都是false了。但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。
  • 遍历顺序:本题最终要求的是是否都出现过,所以对出现单词集合里的元素是组合还是排列,并不在意!但本题还有特殊性,因为是要求子串,最好是遍历背包放在外循环,将遍历物品放在内循环。最终选择的遍历顺序为:遍历背包放在外循环,将遍历物品放在内循环。内循环从前到后
  • 举例dp数组。
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] valid = new boolean[s.length() + 1];
        valid[0] = true;
        for (int i = 1; i <= s.length(); i++) {
            for (int j = 0; j < i; j++) {
                if (wordDict.contains(s.substring(j,i)) && valid[j]) {
                    valid[i] = true;
                }
            }
        }

        return valid[s.length()];
    }
}

// 回溯法+记忆化
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> wordDictSet = new HashSet(wordDict);
        int[] memory = new int[s.length()];
        return backTrack(s, wordDictSet, 0, memory);
    }
    
    public boolean backTrack(String s, Set<String> wordDictSet, int startIndex, int[] memory) {
        // 结束条件
        if (startIndex >= s.length()) {
            return true;
        }
        if (memory[startIndex] != 0) {
            // 此处认为:memory[i] = 1 表示可以拼出i 及以后的字符子串, memory[i] = -1 表示不能
            return memory[startIndex] == 1 ? true : false;
        }
        for (int i = startIndex; i < s.length(); ++i) {
            // 处理 递归 回溯       循环不变量:[startIndex, i + 1)
            String word = s.substring(startIndex, i + 1);
            if (wordDictSet.contains(word) && backTrack(s, wordDictSet, i + 1, memory)) {
                memory[startIndex] = 1;
                return true;
            }
        }
        memory[startIndex] = -1;
        return false;
    }
}

21. 打家劫舍系列

打家劫舍

题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

动规步骤:

  • dp定义: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房。(只是考虑,不一定非要偷
    • dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
  • 初始化:从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);
  • 遍历顺序:从前向后遍历
  • 举例推导dp
// 动态规划
class Solution {
	public int rob(int[] nums) {
		if (nums == null || 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(dp[0], nums[1]);
		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];
	}
}

打家劫舍II

题目:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。

题目分析:和上一道题的区别是成环了。对于一个数组,成环主要有三种情况:

  • 1.考虑不包含首尾元素
  • 2.考虑包含首元素,不包含尾元素
  • 3.考虑包含尾元素,不包含首元素

强调了情况一二三都是考虑范围,而具体房间偷与不偷是由递推公式去选择。情况二和三都包含了情况一,所以就只考虑情况二和三就行。

class Solution {
    public int rob(int[] nums) {
        if (nums == null || nums.length == 0)
            return 0;
        int len = nums.length;
        if (len == 1)
            return nums[0];
        return Math.max(robAction(nums, 0, len - 1), robAction(nums, 1, len));
    }

    int robAction(int[] nums, int start, int end) {
        int x = 0, y = 0, z = 0;
        for (int i = start; i < end; i++) {
            y = z;
            z = Math.max(y, x + nums[i]);
            x = y;
        }
        return z;
    }
}

打家劫舍III

题目:在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

题目分析:本题一定是后序遍历,因为通过递归函数的返回值来做下一步计算。如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”

而动态规划其实就是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱。这道题目算是树形dp的入门题目,因为是在树上进行状态转移,我们在讲解二叉树的时候说过递归三部曲,那么下面我以递归三部曲为框架,其中融合动规五部曲的内容来进行讲解

1.递归函数的参数和返回值

要求一个节点 偷与不偷的两个状态所得到的金钱,返回值是一个长度为2的数组。

dp定义:下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱。(本题dp数组就是一个长度为2的数组

2.终止条件

在遍历过程中,如果遇到空节点就返回。

这相当于dp数组的初始化

3.遍历顺序

后序遍历,通过递归函数的返回值做下一步计算。递归左节点,得到左节点偷与不偷的金钱。递归右节点,得到右节点偷与不偷的金钱。

4.单层递归的逻辑

如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0];

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

class Solution {
    // 1.递归去偷,超时
    public int rob(TreeNode root) {
        if (root == null)
            return 0;
        int money = root.val;
        if (root.left != null) {
            money += rob(root.left.left) + rob(root.left.right);
        }
        if (root.right != null) {
            money += rob(root.right.left) + rob(root.right.right);
        }
        return Math.max(money, rob(root.left) + rob(root.right));
    }

    // 2.递归去偷,记录状态
    // 执行用时:3 ms , 在所有 Java 提交中击败了 56.24% 的用户
    public int rob1(TreeNode root) {
        Map<TreeNode, Integer> memo = new HashMap<>();
        return robAction(root, memo);
    }

    int robAction(TreeNode root, Map<TreeNode, Integer> memo) {
        if (root == null)
            return 0;
        if (memo.containsKey(root))
            return memo.get(root);
        int money = root.val;
        if (root.left != null) {
            money += robAction(root.left.left, memo) + robAction(root.left.right, memo);
        }
        if (root.right != null) {
            money += robAction(root.right.left, memo) + robAction(root.right.right, memo);
        }
        int res = Math.max(money, robAction(root.left, memo) + robAction(root.right, memo));
        memo.put(root, res);
        return res;
    }

    // 3.状态标记递归
    // 执行用时:0 ms , 在所有 Java 提交中击败了 100% 的用户
    // 不偷:Max(左孩子不偷,左孩子偷) + Max(又孩子不偷,右孩子偷)
    // root[0] = Math.max(rob(root.left)[0], rob(root.left)[1]) +
    // Math.max(rob(root.right)[0], rob(root.right)[1])
    // 偷:左孩子不偷+ 右孩子不偷 + 当前节点偷
    // root[1] = rob(root.left)[0] + rob(root.right)[0] + root.val;
    public int rob3(TreeNode root) {
        int[] res = robAction1(root);
        return Math.max(res[0], res[1]);
    }

    int[] robAction1(TreeNode root) {
        int res[] = new int[2];
        if (root == null)
            return res;

        int[] left = robAction1(root.left);
        int[] right = robAction1(root.right);

        res[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
        res[1] = root.val + left[0] + right[0];
        return res;
    }
}

22.买卖股票最佳时机系列

买卖股票最佳时机

题目:给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

贪心也可以解决,但这下用动态规划。

class Solution {
    public int maxProfit(int[] prices) {
        // 找到一个最小的购入点
        int low = Integer.MAX_VALUE;
        // res不断更新,直到数组循环完毕
        int res = 0;
        for(int i = 0; i < prices.length; i++){
            low = Math.min(prices[i], low);
            res = Math.max(prices[i] - low, res);
        }
        return res;
    }
}

动规步骤:

  • dp定义:dp[i] [0] 表示第i天持有股票所得最多现金 ,其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。dp[i] [1] 表示第i天不持有股票所得最多现金。(持有不代表就是当天买入
  • 递推公式:
    • 第i天持有股票即dp[i] [0] = max(dp[i - 1] [0], -prices[i]);
      • 第i-1天就持有股票:dp[i - 1] [0]
      • 第i天买入股票:-prices[i]
    • 第i天不持有股票即dp[i] [1] = max(dp[i - 1] [1], prices[i] + dp[i - 1] [0]);
      • 第i-1天就不持有股票:dp[i - 1] [1]
      • 第i天卖出股票: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;
  • 遍历顺序:从前向后
  • 举例推导dp
// 解法1
class Solution {
    public int maxProfit(int[] prices) {
        if (prices == null || prices.length == 0) return 0;
        int length = prices.length;
        // dp[i][0]代表第i天持有股票的最大收益
        // dp[i][1]代表第i天不持有股票的最大收益
        int[][] dp = new int[length][2];
        int result = 0;
        dp[0][0] = -prices[0];
        dp[0][1] = 0;
        for (int i = 1; i < 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[length - 1][1];
    }
}

从递推公式可以看出,dp[i]只是依赖于dp[i - 1]的状态。那么我们只需要记录 当前天的dp状态和前一天的dp状态就可以了,可以使用滚动数组来节省空间。

class Solution {
  public int maxProfit(int[] prices) {
    int[] dp = new int[2];
    // 记录一次交易,一次交易有买入卖出两种状态
    // 0代表持有,1代表卖出
    dp[0] = -prices[0];
    dp[1] = 0;
    // 可以参考斐波那契问题的优化方式
    // 我们从 i=1 开始遍历数组,一共有 prices.length 天,
    // 所以是 i<=prices.length
    for (int i = 1; i <= prices.length; i++) {
      // 前一天持有;或当天买入
      dp[0] = Math.max(dp[0], -prices[i - 1]);
      // 如果 dp[0] 被更新,那么 dp[1] 肯定会被更新为正数的 dp[1]
      // 而不是 dp[0]+prices[i-1]==0 的0,
      // 所以这里使用会改变的dp[0]也是可以的
      // 当然 dp[1] 初始值为 0 ,被更新成 0 也没影响
      // 前一天卖出;或当天卖出, 当天要卖出,得前一天持有才行
      dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
    }
    return dp[1];
  }
}

买卖股票最佳时机II

题目:给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

在动规步骤中,区别主要是在递推公式上,其他和上一题是一样的。

递推公式:

  • 第i天持有股票即dp[i] [0]
    • 第i-1天就持有股票:dp[i - 1] [0]
    • 第i天买入股票:dp[i - 1] [1] - prices[i](区别所在:可以买卖股票多次
  • 第i天不持有股票即dp[i] [1]
    • 第i-1天就不持有股票:dp[i - 1] [1]
    • 第i天卖出股票:prices[i] + dp[i - 1] [0]
// 动态规划
class Solution 
    // 实现1:二维数组存储
    // 可以将每天持有与否的情况分别用 dp[i][0] 和 dp[i][1] 来进行存储
    // 时间复杂度:O(n),空间复杂度:O(n)
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];     // 创建二维数组存储状态
        dp[0][0] = 0;                   // 初始状态
        dp[0][1] = -prices[0];
        for (int i = 1; i < n; ++i) {
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);    // 第 i 天,没有股票
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);    // 第 i 天,持有股票
        }
        return dp[n - 1][0];    // 卖出股票收益高于持有股票收益,因此取[0]
    }
}

// 优化空间
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];
    }
}

买卖股票最佳时机III

题目:给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

题目分析:相比于前两道题难了不少。关键在于至多买卖两次。

动规步骤:

  • dp定义:dp[i] [j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i] [j]表示第i天状态j所剩最大现金。(0:没有操作,1:第一次买入,2:第一次卖出,3:第二次买入,4:第二次卖出
  • 递推公式:
    • dp[i] [1]表示的是第i天,买入股票的状态。 dp[i] [1] = max(dp[i-1] [0] - prices[i], dp[i - 1] [1]);
      • 第i天买入股票:dp[i] [1] = dp[i-1] [0] - prices[i]
      • 第i天没有操作:dp[i] [1] = dp[i - 1] [1]
    • dp[i] [2] = max(dp[i - 1] [1] + prices[i], dp[i - 1] [2])
      • 第i天卖出股票:dp[i] [2] = dp[i - 1] [1] + prices[i]
      • 第i天没有操作:dp[i][2] = dp[i - 1] [2]
    • 同理可以推出剩下部分
  • 初始化:
    • dp[0] [0] = 0
    • 第0天做第一次买入的操作,dp[0] [1] = -prices[0];
    • dp[0] [2] = 0;(买卖最差情况就是没有盈利,比0还小就没有必要收获这个利润了
    • dp[0] [3] = -prices[0];(第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后在买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
    • dp[0] [4] = 0
  • 遍历顺序:从前向后
  • 举例推导dp
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];
    }
}

买卖股票最佳时机IV

题目:给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

动规步骤:

  • dp定义:dp[i] [j] 表示第i天的状态为j,所剩下的最大现金是dp[i] [j]。(j的状态:除了0以外,偶数就是卖出,奇数就是买入。要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以了。
  • 递推公式:跟上道题一样,最大的区别就是要类比j为奇数就是买,偶数就是卖的状态。
  • 初始化:同理推出dp[0] [j]当j为奇数的时候都初始化为-price[0]。
  • 遍历顺序:从前往后。
  • 举例推导dp。
// 版本一: 三维 dp数组
class Solution {
    public int maxProfit(int k, int[] prices) {
        if (prices.length == 0) return 0;

        // [天数][交易次数][是否持有股票]
        int len = prices.length;
        int[][][] dp = new int[len][k + 1][2];
        
        // dp数组初始化
        // 初始化所有的交易次数是为确保 最后结果是最多 k 次买卖的最大利润
        for (int i = 0; i <= k; i++) {
            dp[0][i][1] = -prices[0];
        }

        for (int i = 1; i < len; i++) {
            for (int j = 1; j <= k; j++) {
                // dp方程, 0表示不持有/卖出, 1表示持有/买入
                dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i]);
                dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i]);
            }
        }
        return dp[len - 1][k][0];
    }
}

// 版本二: 二维 dp数组
class Solution {
    public int maxProfit(int k, int[] prices) {
        if (prices.length == 0) return 0;

        // [天数][股票状态]
        // 股票状态: 奇数表示第 k 次交易持有/买入, 偶数表示第 k 次交易不持有/卖出, 0 表示没有操作
        int len = prices.length;
        int[][] dp = new int[len][k*2 + 1];
        
        // dp数组的初始化, 与版本一同理
        for (int i = 1; i < k*2; i += 2) {
            dp[0][i] = -prices[0];
        }

        for (int i = 1; i < len; i++) {
            for (int j = 0; j < k*2 - 1; j += 2) {
                dp[i][j + 1] = Math.max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);
                dp[i][j + 2] = Math.max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);
            }
        }
        return dp[len - 1][k*2];
    }
}

//版本三:一维 dp数组
class Solution {
    public int maxProfit(int k, int[] prices) {
        if(prices.length == 0){
            return 0;
        }
        if(k == 0){
            return 0;
        }
        // 其实就是123题的扩展,123题只用记录2次交易的状态
        // 这里记录k次交易的状态就行了
        // 每次交易都有买入,卖出两个状态,所以要乘 2
        int[] dp = new int[2 * k];
        // 按123题解题格式那样,做一个初始化
        for(int i = 0; i < dp.length / 2; i++){
            dp[i * 2] = -prices[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]);
            // 还是与123题一样,与123题对照来看
            // 就很容易啦
            for(int j = 2; j < dp.length; j += 2){
                dp[j] = Math.max(dp[j], dp[j - 1] - prices[i-1]);
                dp[j + 1] = Math.max(dp[j + 1], dp[j] + prices[i - 1]);
            }
        }
        // 返回最后一次交易卖出状态的结果就行了
        return dp[dp.length - 1];
    }
}

23. 子序列问题系列

最长递增子序列

题目:给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

最长上升子序列是动规的经典题目,这里dp[i]是可以根据dp[j] (j < i)推导出来的。

动规步骤:

  • dp定义:dp[i]表示i之前包括i的最长子序列的长度。
  • 状态转移方程:位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
  • 初始化:对每个i,对应的dp[i]起始大小至少都是1.
  • 遍历顺序:从前向后。
  • 举例推导dp
class Solution {
    public int lengthOfLIS(int[] nums) {
        int[] dp = new int[nums.length];
        Arrays.fill(dp, 1);
        for (int i = 0; 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 = 0; i < dp.length; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

最长连续递增序列

题目:给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], …, nums[r - 1], nums[r]] 就是连续递增子序列。

动规步骤:

  • dp定义:dp[i]:以下标i为结尾的数组的连续递增的子序列长度为dp[i]。(一定是以下标i为结尾,并不是说一定以下标0为起始位置。
  • 递推公式:如果 nums[i + 1] > nums[i],那么以 i+1 为结尾的数组的连续递增的子序列长度 一定等于 以i为结尾的数组的连续递增的子序列长度 + 1 。即:dp[i + 1] = dp[i] + 1;
  • 初始化:长度至少是1,所以初始化:dp[i] = 1。
  • 遍历顺序:从前向后
  • 举例推导dp
 /**
     * 1.dp[i] 代表当前下标最大连续值
     * 2.递推公式 if(nums[i+1]>nums[i]) dp[i+1] = dp[i]+1
     * 3.初始化 都为1
     * 4.遍历方向,从其那往后
     * 5.结果推导 。。。。
     * @param nums
     * @return
     */
    public static int findLengthOfLCIS(int[] nums) {
        int[] dp = new int[nums.length];
        for (int i = 0; i < dp.length; i++) {
            dp[i] = 1;
        }
        int res = 1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i + 1] > nums[i]) {
                dp[i + 1] = dp[i] + 1;
            }
            res = res > dp[i + 1] ? res : dp[i + 1];
        }
        return res;
    }

贪心算法也可以:也就是遇到nums[i + 1] > nums[i]的情况,count就++,否则count为1,记录count的最大值就可以了。

public static int findLengthOfLCIS(int[] nums) {
    if (nums.length == 0) return 0;
    int res = 1; // 连续子序列最少也是1
    int count = 1;
    for (int i = 0; i < nums.length - 1; i++) {
        if (nums[i + 1] > nums[i]) { // 连续记录
            count++;
        } else { // 不连续,count从头开始
            count = 1;
        }
        if (count > res) res = count;
    }
    return res;
}

最长重复子数组

题目:给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

题目说的子数组,其实就是连续子序列。

动规步骤:

  • dp定义:dp[i] [j] :以下标i - 1为结尾的A和以下标j - 1为结尾的B,最长重复子数组长度为dp[i] [j]。定义也就决定着,我们在遍历dp[i][j]的时候i 和 j都要从1开始。
  • 递推公式:dp[i][j]的状态只能由dp[i - 1] [j - 1]推导出来。即当A[i - 1] 和B[j - 1]相等的时候,dp[i] [j] = dp[i - 1] [j - 1] + 1;
  • 初始化:为了方便递归公式dp[i] [j] = dp[i - 1] [j - 1] + 1;所以dp[i] [0] 和dp[0] [j]初始化为0。
  • 遍历顺序:外层for循环遍历A,内层for循环遍历B。(另一种也可以,都是一样的
  • 举例推导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;
    }
}

我们可以看出dp[i] [j]都是由dp[i - 1] [j - 1]推出。那么压缩为一维数组,也就是dp[j]都是由dp[j - 1]推出。也就是相当于可以把上一层dp[i - 1] [j]拷贝到下一层dp[i] [j]来继续用。此时遍历B数组的时候,就要从后向前遍历,这样避免重复覆盖

// 版本二: 滚动数组
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;
    }
}

最长公共子序列

题目:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。若这两个字符串没有公共子序列,则返回 0。

与上道题不同是不要求连续的,但要有相对顺序

动规步骤:

  • dp定义:dp[i] [j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i] [j](这样为了后面代码方便

  • 递推公式:

    • text1[i - 1] 与 text2[j - 1]相同:dp[i] [j] = dp[i - 1] [j - 1] + 1;

    • text1[i - 1] 与 text2[j - 1]不相同:dp[i] [j] = max(dp[i - 1] [j], dp[i] [j - 1]);

      if (text1[i - 1] == text2[j - 1]) {
          dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
          dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
      }
      
  • 初始化:test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i] [0] = 0;同理dp[0] [j]也是0。其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。

  • 遍历顺序:从前向后,从上到下。

  • 举例推导dp

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int[][] dp = new int[text1.length() + 1][text2.length() + 1]; // 先对dp数组做初始化操作
        for (int i = 1 ; i <= text1.length() ; i++) {
            char char1 = text1.charAt(i - 1);
            for (int j = 1; j <= text2.length(); j++) {
                char char2 = text2.charAt(j - 1);
                if (char1 == char2) { // 开始列出状态转移方程
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[text1.length()][text2.length()];
    }
}

不相交的线

题目:我们在两条独立的水平线上按给定的顺序写下 A 和 B 中的整数。现在,我们可以绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且我们绘制的直线不与任何其他连线(非水平线)相交。以这种方法绘制线条,并返回我们可以绘制的最大连线数。

直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。

本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!

代码相同,难在分析出题目的本质。

最大子序和

题目:给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

动规步骤:

  • dp定义:dp[i]:包括下标i之前的最大连续子序列和为dp[i]
  • 递推公式:dp[i] = max(dp[i - 1] + nums[i], nums[i]);
    • dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
    • nums[i],即:从头开始计算当前连续子序列和
  • 初始化:dp[0] = nums[0]。
  • 遍历顺序:从前向后。
  • 举例推导dp
  /**
     * 1.dp[i]代表当前下标对应的最大值
     * 2.递推公式 dp[i] = max (dp[i-1]+nums[i],nums[i]) res = max(res,dp[i])
     * 3.初始化 都为 0
     * 4.遍历方向,从前往后
     * 5.举例推导结果
     *
     * @param nums
     * @return
     */
    public static int maxSubArray(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }

        int res = nums[0];
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
            res = res > dp[i] ? res : dp[i];
        }
        return res;
    }

24.编辑距离问题系列

判断子序列

题目:给定字符串 s 和 t ,判断 s 是否为 t 的子序列。字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。

这道题应该算是编辑距离的入门题目,因为从题意中我们也可以发现,只需要计算删除的情况,不用考虑增加和替换的情况。

动规步骤:

  • dp定义:dp[i] [j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i] [j]。(判断s是否为t的子序列,即t的长度是大于等于s的。
  • 递推公式:
    • if (s[i - 1] == t[j - 1]),那么dp[i] [j] = dp[i - 1] [j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1] [j-1]的基础上加1。
    • if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i] [j] = dp[i] [j - 1];
  • 初始化:从递推公式可以看出dp[i][j]都是依赖于dp[i - 1] [j - 1] 和 dp[i] [j - 1],所以dp[0] [0]和dp[i] [0]是一定要初始化的。dp[i] [0] 表示以下标i-1为结尾的字符串,与空字符串的相同子序列长度,所以为0。dp[0] [j]同理。
  • 遍历顺序:从上到下,从左到右
  • 举例推导dp
class Solution {
    public boolean isSubsequence(String s, String t) {
        int length1 = s.length(); int length2 = t.length();
        int[][] dp = new int[length1+1][length2+1];
        for(int i = 1; i <= length1; i++){
            for(int j = 1; j <= length2; j++){
                if(s.charAt(i-1) == t.charAt(j-1)){
                    dp[i][j] = dp[i-1][j-1] + 1;
                }else{
                    dp[i][j] = dp[i][j-1];
                }
            }
        }
        if(dp[length1][length2] == length1){
            return true;
        }else{
            return false;
        }
    }
}

不同的子序列

题目:给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,“ACE” 是 “ABCDE” 的一个子序列,而 “AEC” 不是)题目数据保证答案符合 32 位带符号整数范围。

动规步骤:

  • dp定义:dp[i] [j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i] [j]。
  • 递推公式:
    • s[i - 1] 与 t[j - 1]相等时:dp[i] [j] = dp[i - 1] [j - 1] + dp[i - 1] [j];
      • 用s[i - 1]来匹配,那么个数为dp[i - 1] [j - 1]
      • 不用s[i - 1]来匹配,个数为dp[i - 1] [j]
    • s[i - 1] 与 t[j - 1]不相等时,dp[i] [j] = dp[i - 1] [j];
  • 初始化:
    • dp[i] [0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。
    • dp[0] [j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数,那么dp[0] [j]一定都是0。
    • dp[0] [0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。
  • 遍历顺序:从上到下,从左到右
  • 举例推导dp
class Solution {
    public int numDistinct(String s, String t) {
        int[][] dp = new int[s.length() + 1][t.length() + 1];
        for (int i = 0; i < s.length() + 1; i++) {
            dp[i][0] = 1;
        }
        
        for (int i = 1; i < s.length() + 1; i++) {
            for (int j = 1; j < t.length() + 1; j++) {
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                }else{
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        
        return dp[s.length()][t.length()];
    }
}

两个字符串的删除操作

题目:给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。

动规步骤:

  • dp定义:dp[i] [j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。
  • 递推公式:
    • word1[i - 1] 与 word2[j - 1]相同:dp[i] [j] = dp[i - 1] [j - 1];
    • word1[i - 1] 与 word2[j - 1]不相同:dp[i] [j] = min({dp[i - 1] [j - 1] + 2, dp[i - 1] [j] + 1, dp[i] [j - 1] + 1});
      • 情况一:删word1[i - 1],最少操作次数为dp[i - 1] [j] + 1
      • 情况二:删word2[j - 1],最少操作次数为dp[i] [j - 1] + 1
      • 情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1] [j - 1] + 2
  • 初始化:dp[i] [0]:word2为空字符串,以i-1为结尾的字符串word1要删除多少个元素,才能和word2相同呢,很明显dp[i] [0] = i。dp[0] [j]的话同理。
  • 遍历顺序:从上到下,从左到右(dp[i] [j]都是根据左上方、正上方、正左方推出来的
  • 举例推导dp
class Solution {
    public int minDistance(String word1, String word2) {
        int[][] dp = new int[word1.length() + 1][word2.length() + 1];
        for (int i = 0; i < word1.length() + 1; i++) dp[i][0] = i;
        for (int j = 0; j < word2.length() + 1; j++) dp[0][j] = j;
        
        for (int i = 1; i < word1.length() + 1; i++) {
            for (int j = 1; j < word2.length() + 1; j++) {
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1];
                }else{
                    dp[i][j] = Math.min(dp[i - 1][j - 1] + 2,
                                        Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
                }
            }
        }
        
        return dp[word1.length()][word2.length()];
    }
}

还有个想法:本题和“最长公共子序列”那题基本相同,只要求出两个字符串的最长公共子序列长度即可,那么除了最长公共子序列之外的字符都是必须删除的,最后用两个字符串的总长度减去两个最长公共子序列的长度就是删除的最少步数。

编辑距离

题目:给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

动规步骤:

  • dp定义:dp[i] [j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i] [j]。
  • 递推公式:
    • if (word1[i - 1] == word2[j - 1]):dp[i] [j] = dp[i - 1] [j - 1];(不用任何编辑
    • if (word1[i - 1] != word2[j - 1]):dp[i] [j] = min({dp[i - 1] [j - 1], dp[i - 1] [j], dp[i] [j - 1]}) + 1;
      • 操作一:word1删除一个元素,那么就是以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上一个操作。即 dp[i][j] = dp[i - 1][j] + 1;
      • 操作二:word2删除一个元素,那么就是以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上一个操作。即 dp[i][j] = dp[i][j - 1] + 1;
      • **word2添加一个元素,相当于word1删除一个元素。**操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作。即 dp[i][j] = dp[i - 1][j - 1] + 1;
  • 初始化:dp[i] [0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i] [0]。那么dp[i] [0]就应该是i,对word1里的元素全部做删除操作,即:dp[i] [0] = i;同理dp[0] [j] = j;
  • 遍历顺序:从左到右,从上到下去遍历
  • 举例推导dp
public int minDistance(String word1, String word2) {
    int m = word1.length();
    int n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    // 初始化
    for (int i = 1; i <= m; i++) {
        dp[i][0] =  i;
    }
    for (int j = 1; j <= n; j++) {
        dp[0][j] = j;
    }
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            // 因为dp数组有效位从1开始
            // 所以当前遍历到的字符串的位置为i-1 | j-1
            if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = Math.min(Math.min(dp[i - 1][j - 1], dp[i][j - 1]), dp[i - 1][j]) + 1;
            }
        }
    }
    return dp[m][n];
}

25.回文子串

题目:给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

暴力解法

两层for循环,遍历区间起始位置和终止位置,然后判断这个区间是不是回文。

动态规划

步骤:

  • dp定义:布尔类型的dp[i] [j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
  • 递推公式:
    • s[i]与s[j]不相等:dp[i] [j] = false;
    • s[i]与s[j]相等:
      • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
      • 情况二:下标i 与 j相差为1,例如aa,也是文子串
      • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1] [j - 1]是否为true。
  • 初始化:dp[i] [j]初始化为false。
  • 遍历顺序:一定要从下到上,从左到右遍历,这样保证dp[i + 1] [j - 1]都是经过计算的。
  • 举例推导dp

注意因为dp[i] [j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分

class Solution {
    public int countSubstrings(String s) {
        int len, ans = 0;
        if (s == null || (len = s.length()) < 1) return 0;
        //dp[i][j]:s字符串下标i到下标j的字串是否是一个回文串,即s[i, j]
        boolean[][] dp = new boolean[len][len];
        for (int j = 0; j < len; j++) {
            for (int i = 0; i <= j; i++) {
                //当两端字母一样时,才可以两端收缩进一步判断
                if (s.charAt(i) == s.charAt(j)) {
                    //i++,j--,即两端收缩之后i,j指针指向同一个字符或者i超过j了,必然是一个回文串
                    if (j - i < 3) {
                        dp[i][j] = true;
                    } else {
                        //否则通过收缩之后的字串判断
                        dp[i][j] = dp[i + 1][j - 1];
                    }
                } else {//两端字符不一样,不是回文串
                    dp[i][j] = false;
                }
            }
        }
        //遍历每一个字串,统计回文串个数
        for (int i = 0; i < len; i++) {
            for (int j = 0; j < len; j++) {
                if (dp[i][j]) ans++;
            }
        }
        return ans;
    }
}

双指针法

首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。在遍历中心点的时候,要注意中心点有两种情况:一个元素可以作为中心点,两个元素也可以作为中心点。

class Solution {
    public int countSubstrings(String s) {
        int len, ans = 0;
        if (s == null || (len = s.length()) < 1) return 0;
        //总共有2 * len - 1个中心点
        for (int i = 0; i < 2 * len - 1; i++) {
            //通过遍历每个回文中心,向两边扩散,并判断是否回文字串
            //有两种情况,left == right,right = left + 1,这两种回文中心是不一样的
            int left = i / 2, right = left + i % 2;
            while (left >= 0 && right < len && s.charAt(left) == s.charAt(right)) {
                //如果当前是一个回文串,则记录数量
                ans++;
                left--;
                right++;
            }
        }
        return ans;
    }
}

26.最长回文子序列

题目:给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。

回文子串是要连续的,回文子序列可不是连续的!

动规步骤:

  • dp定义:dp[i] [j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i] [j]。
  • 递推公式:
    • s[i]与s[j]相同:dp[i] [j] = dp[i + 1] [j - 1] + 2;
    • s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。加入s[j]的回文子序列长度为dp[i + 1] [j],加入s[i]的回文子序列长度为dp[i] [j - 1]。dp[i] [ j]一定是取最大的,即:dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]);
  • 初始化:当i与j相同,那么dp[i] [j]一定是等于1的,即:一个字符的回文子序列长度就是1。其他情况dp[i] [j]初始为0就行。
  • 遍历顺序:从递推公式dp[i] [j] = dp[i + 1] [j - 1] + 2 和 dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]) 可以看出,dp[i] [j]是依赖于dp[i + 1] [j - 1] 和 dp[i + 1] [j]。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的
  • 举例推导dp
public class Solution {
    public int longestPalindromeSubseq(String s) {
        int len = s.length();
        int[][] dp = new int[len + 1][len + 1];
        for (int i = len - 1; i >= 0; i--) { // 从后往前遍历 保证情况不漏
            dp[i][i] = 1; // 初始化
            for (int j = i + 1; j < len; j++) {
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j], Math.max(dp[i][j], dp[i][j - 1]));
                }
            }
        }
        return dp[0][len - 1];
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值