二、理论基础

三、拆解问题

3.1 基础问题

3.2 背包问题

关于背包,最好的学习资料是背包九讲。但如果应对面试,掌握 01背包和完全背包就够用了。关于几种背包区别如下:
动态规划(题解) - 图1
没有单纯背包的题目,都是需要通过理解题意并转换为与之对应的背包问题进行求解。

01 背包

问题描述:有 N 件物品和一个最多能装载重量为 W 的背包,第 i 件物品重量为 weight[i],价值为 value[i],每件商品只能选中一次。问哪些物品装入背包使得背包内物品价值总和最大 ?

二维数组模板

  • 规定:以后关于 01背包问题就将横坐标 i 设定为物品,纵坐标 j 设定为背包容量dp[i][j] 的含义就是前 i 件物品体积不超过 j 的情况下能获取的最大价值。
  • 递推式:关键在于是否将物品 i 放入背包
    • 不放物品 idp[i][j] = dp[i - 1][j]。即前 i 个物品的最大价值等于只取前 i-1 个物品时的最大价值。
    • 放物品 idp[i][j] = dp[i - 1][j - weight[i]] + value[i]
  • 初始化: dp[i][0] 这一列为 0 即可。因为背包为 0 无法容纳任何物品。而对于 dp[0][i] 表示存放编号为 0 的物品时获取的最大价值,换句话说,物品 0 能否装进背包,如果能装进,那么 dp[0][i] = weight[i],否则为 0。
  • 遍历顺序(其实都可以):
    • 二维数组:先遍历物品,后遍历背包容量。
  • 注意细节:当在二次 for 循环中,遇到 j < nums[i] 的值时,需要继承 dp[i - 1][j] 的值。
  • 遍历顺序:先物品、然后再是背包容量 ```java // 二维数组 int len = nums.length(), weight; int[][] dp = new int[len][weight + 1];

// base case:初始化第 0 行:将物品 0 和 weight 比较

// 首先遍历物品 for (int i = 0; i < len; i++) { // 接着遍历 weight for (int j = 0; j <= weight; j++) { if (j < w[i]) { // ★ 别忘记这一步,需要继承前面的值 // 当前物品过重,无法放入背包 dp[i][j] = dp[i - 1][j]; } else { // #3 这里不能写成 Math.max(a, b) + w[i],会死人的 dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + w[i]); } } }

  1. ![01 背包 DP 数组求解过程.png](https://cdn.nlark.com/yuque/0/2021/png/105848/1632892730287-4a17b07d-30a1-4631-8db2-18c063a8e6cf.png#clientId=u5ff629b2-3526-4&from=drop&height=539&id=u5f60c5ca&margin=%5Bobject%20Object%5D&name=01%20%E8%83%8C%E5%8C%85%20DP%20%E6%95%B0%E7%BB%84%E6%B1%82%E8%A7%A3%E8%BF%87%E7%A8%8B.png&originHeight=1078&originWidth=1512&originalType=binary&ratio=1&size=100294&status=done&style=none&taskId=ue395ebf0-ee90-4a54-9a7b-cf8e0ef0250&width=756)
  2. <a name="pTm4t"></a>
  3. #### 一组数组模板
  4. 一维数组是对二维数组的空间压缩,从递推公式 `dp[i][j] = Max(dp[i - 1][j], dp[i - 1][j - w]) + v` 看出,`dp[i][j]` 的值由 `dp[i - 1][j - w]` 推导出来,简单地说就是由当前位置 `(i, j)` 左上部分推导出来的。由于一维数组需要存储历史数据,那么遍历时就需要从后往前遍历才不会覆盖历史数据。
  5. ![01 背包 一维 DP 数组遍历顺序问题.png](https://cdn.nlark.com/yuque/0/2021/png/105848/1632893718170-c10685fd-6646-4f00-bae1-5f415253fbbc.png#clientId=u5ff629b2-3526-4&from=drop&id=u20b0bd0b&margin=%5Bobject%20Object%5D&name=01%20%E8%83%8C%E5%8C%85%20%E4%B8%80%E7%BB%B4%20DP%20%E6%95%B0%E7%BB%84%E9%81%8D%E5%8E%86%E9%A1%BA%E5%BA%8F%E9%97%AE%E9%A2%98.png&originHeight=1193&originWidth=2052&originalType=binary&ratio=1&size=100448&status=done&style=none&taskId=u7bf1bd0a-72bc-43ef-a5f8-b47f6d51431)
  6. <a name="uyU7P"></a>
  7. ### 完全背包
  8. > 问题描述:有 N 件物品和一个最多能装载重量为 W 的背包,第 i 件物品重量为 weight[i],价值为 value[i],每件商品有无限个。问哪些物品装入背包使得背包内物品价值总和最大 ?
  9. 完全背包和 01背包不同的地方仅限于物品数量的不同。在代码中的就是遍历顺序不一样。
  10. <a name="mcLP3"></a>
  11. #### 二维数组模板
  12. 1. 递推式是 `dp[i][j] = Math.max(dp[i][j], dp[i][j - weight[i - 1]] + val[i - 1]);`
  13. 1. 我们只需要记得在第 `i` 行比较大小即可,因为第 `i` 行包含了第 `i - 1` 行的信息。
  14. 1. `dp` 数组长度分别都需要 `+1`,这就可以避免对第 0 行进行初始化操作。
  15. ```java
  16. // 二维数组
  17. int len = nums.length(), wei;
  18. int[][] dp = new int[len + 1][wei + 1];
  19. for (int i = 1; i <= len; i++) {
  20. for (int j = 0; j <= bag; j++) {
  21. if (weight[i - 1] > j) {
  22. dp[i][j] = dp[i - 1][j];
  23. } else {
  24. dp[i][j] = Math.max(dp[i][j], dp[i][j - weight[i - 1]] + val[i - 1]);
  25. }
  26. }
  27. }

完全背包二维数组示意图.png完全背包推导式简单解释.png

一维数组模板

在 01 背包的遍历中是逆序遍历,而完全背包是正序遍历。

  • 注意内循环变量 j 是从 weight[i - 1] 开始遍历。
  • 一维数组根据实际情况初始化。 ```java int[] dp = new int[wei + 1];

for (int i = 1; i <= len; i++) { // j 从 weight[i - 1] 开始遍历 for (int j = weight[i - 1]; j <= bag; j++) { dp[j] = Math.max(dp[j], dp[j - weight[i - 1]] + val[i - 1]); } }

<a name="R7mqG"></a>
### 大神总结背包问题
常见的背包问题有:

1. 组合问题:`dp[i] += dp[i - num]`
   1. [377. 组合总和 Ⅳ](https://leetcode-cn.com/problems/combination-sum-iv/description/)
   1. [494. 目标和](https://leetcode-cn.com/problems/target-sum/description/)
   1. [518. 零钱兑换 II](https://leetcode-cn.com/problems/coin-change-2/description/)
2. True、False问题:`dp[i] = dp[i] || dp[i - num]`
   1. [139. 单词拆分](https://leetcode-cn.com/problems/word-break/)
   1. [416. 分割等和子集](https://leetcode-cn.com/problems/partition-equal-subset-sum/description/)
3. 最大最小值问题:`dp[i] = max/min(dp[i], dp[i - num] + 1) `
   1. [474. 一和零](https://leetcode-cn.com/problems/ones-and-zeroes/description/)
   1. [322. 零钱兑换](https://leetcode-cn.com/problems/coin-change/description/)
   1. [279. 完全平方数](https://leetcode-cn.com/problems/perfect-squares/)

拿到问题,做以下分析步骤:

1. 分析是否为背包问题,如果是,属于哪一种。01 背包是物品只限使用一次,而完全背包是物品无限次数。
1. 再分析是否为组合问题。排列是需要考虑元素位置。而组合不用。

再说说遍历技巧(只针对一维数组)

1. 01 背包:先遍历物品,再遍历 `target`,内层循环为逆序。
1. 完全背包:先遍历物品,再遍历 `target`,内层循环为正序。
1. 考虑组合:先遍历 `target`,再遍历物品,内层循环为正序。
<a name="fNx56"></a>
### 题目解析
<a name="Uk7bm"></a>
#### 1. 组合问题
<a name="BrY8r"></a>
##### [377. 组合总和 Ⅳ](https://leetcode-cn.com/problems/combination-sum-iv/)
这里的细节太多了。

1. `base case` `dp[0] = 1`,这样才能正常打表。这个初始值是无意义的。
1. 由于是**排列**,需要考虑元素位置。先遍历 `target`,再遍历物品。
```java
class Solution {
    public int combinationSum4(int[] nums, int target) {
        if (nums == null || nums.length == 0) return 0;
        int[] dp = new int[target + 1];
        // 初始化,
        dp[0] = 1;

        for (int i = 1; i <= target; i++) {
            for (int num : nums) {
                if (i >= num) {
                    // 求组合,递推公式就长这样
                    dp[i] += dp[i - num];
                }
            }
        }
        return dp[target];
    }
}

494. 目标和

#1 回溯法
  1. 回溯法中使用 sum + / sum - 等操作来模拟 + -

    class Solution {
     int res;
     public int findTargetSumWays(int[] nums, int target) {
         if (nums == null) return 0;
         res = 0;
         // + - 如何表示
         backTracking(nums, 0, 0, target);
         return res;
     }
    
     private void backTracking(int[] nums, int pos, int sum, int target) {
         if (pos == nums.length) {
             if (sum == target) {
                 res ++;
             }
             return ;
         }
    
         backTracking(nums, pos + 1, sum + nums[pos], target);
         backTracking(nums, pos + 1, sum - nums[pos], target);
     }
    }
    

    #2 记忆化递归(消除重叠子问题)

    对于递归函数来说,函数参数中会变的参数就是状态,而目标和中会变的就是 sumpos。这里有一个一眼看出重叠的方法:就是令 nums[pos] == 0,那么式子就变成

    backTracking(nums, pos + 1, sum, target);
    backTracking(nums, pos + 1, sum, target);
    

    这样就出现了两个状态完全相同的递归函数。只要我们找到一个,一般就能说明还存在很多重叠子问题。 ```java

class Solution { public int findTargetSumWays(int[] nums, int t) { return dfs(nums, t, 0, 0); } int dfs(int[] nums, int t, int u, int cur) { if (u == nums.length) { return cur == t ? 1 : 0; } int left = dfs(nums, t, u + 1, cur + nums[u]); int right = dfs(nums, t, u + 1, cur - nums[u]); return left + right; } }

<a name="RK850"></a>
#### 2. True、False
<a name="EJU7j"></a>
##### [416. 分割等和子集](https://leetcode-cn.com/problems/partition-equal-subset-sum/)
这题如果将 dp 数组定义为 `boolean[][]`,考虑的细节较多,如果面试的时候出现,考虑使用 `int[][]` 或者一组数据解决。
<a name="rGuUI"></a>
##### 二维 `boolean[][]` 数组

1. `boolean[i][j]` 数组表示前 `i` 个数能否恰好构成 `j`。
1. `#1` 需要初始化为 `true`。然后对第 `0` 行进行初始化:遇到等于 `target` 时为 `true`。
1. `#2` 需要继承 `dp[i - 1][j]` 的值
```java
class Solution {
    public boolean canPartition(int[] nums) {
        if (nums == null || nums.length == 0) return true;
        int len = nums.length, target = 0;
        for (int num : nums) { target += num; }
        if ((target & 1) == 1) return false;
        target /= 2;

        boolean[][] dp = new boolean[len][target + 1];

        // base case:初始化第一行
        // #1 
        dp[0][0] = true;
        if (nums[0] == target) { dp[0][nums[0]] = true; }

        // caculate dp table
        for (int i = 1; i < len; i++) {
            for (int j = 0; j <= target; j++) {
                if (j < nums[i]) {
                    // #2
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i][j] || dp[i - 1][j] || dp[i - 1][j - nums[i]];
                }
            } 
        }
        return dp[len - 1][target];
    }
}

一维 boolean[] 数组
  1. 注意 #2 内部写法

    class Solution {
     public boolean canPartition(int[] nums) {
         if (nums == null || nums.length == 0) return true;
         int len = nums.length, target = 0;
         for (int num : nums) { target += num; }
         if ((target & 1) == 1) return false;
         target /= 2;
    
         boolean[] dp = new boolean[target + 1];
    
         // base case:初始化第一行
         dp[0] = true;
         if (nums[0] == target) { dp[nums[0]] = true; }
    
         // caculate dp table
         for (int i = 1; i < len; i++) {
             for (int j = target; j >= 0; j--) {
                 if (j >= nums[i]) {
                     dp[j] = dp[j] ||  dp[j - nums[i]];
                 }
             } 
         }
    
         // 还可以写成这种形式,效率会高一点
         for (int i = 1; i < len; i++) {
             // #2 注意这里的写法
             for (int j = target; j >= nums[i]; j--) {
                 dp[j] = dp[j] || dp[j - nums[i]];
             } 
         }
    
         return dp[target];
     }
    }
    

    3. 最大值最小值问题

    322. 零钱兑换
  • 数组含义:dp[i][j] 表示前 i 个硬币可以凑成 j 所需最小硬币数量。
  • dp[] 一维数组初始化为 amount 可以避免整数类型溢出问题。
  • dp[0] = 0 要记住。

    279. 完全平方数

    279.完全平方数.png
    转化为完全背包问题:

  • 物品:完全平方数,比如 动态规划(题解) - 图5动态规划(题解) - 图6 等等。

  • target:就是 n
  • 求:取任意物品(可重复)得到 n 时最小数。

    HJ16 购物单

    这是一道基于 01背包问题的改编题,十分有意思。
    购物单.png
    属于 01背包框架,但加上了一些额外的条件:主件和附件归属问题,如果选取附件,则必拿对应的主件。这里的思路是,我们只将主件考虑为物品,而附件只是附属品。因为最多只能有 2 个附件,因此我们可以通过枚举所有可能的情况并取最大值。

    3.3 打家劫舍

    198. 打家劫舍

  • 主要是讨论第 i 取还是不取,得出 dp[i] =

    class Solution {
      public int rob(int[] nums) {
          // dp[i] = Max(dp[i - 1], dp[i - 2] + nusm[i]);
          if (nums == null || nums.length == 0) return 0;
          int n = nums.length;
          int[] dp = new int[n + 1];
    
          // base case
          dp[1] = nums[0];
    
          for (int i = 2; i <= n; i++) {
              dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
          }
    
          return dp[n];
      }
    }
    

    213. 打家劫舍 II

    class Solution {
      public int rob(int[] nums) {
          if (nums == null || nums.length == 0) return 0;
          int n = nums.length;
          if (n == 1) return nums[0];
          int[] includeFirseHouse = new int[n - 1];
          int[] includeLastHouse = new int[n - 1];
          System.arraycopy(nums, 0, includeFirseHouse, 0, n - 1);
          System.arraycopy(nums, 1, includeLastHouse, 0, n - 1);
          return Math.max(robHouse(includeFirseHouse), robHouse(includeLastHouse));
      }
    
      private int robHouse(int[] nums) {
          int n = nums.length;
          int[] dp = new int[n + 1];
          dp[1] = nums[0];
          for (int i = 2; i <= n; i++) {
              dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
          }
    
          return dp[n];
      }
    }
    

    256. 粉刷房子

    房子不能刷成相同的颜色,所以我们要打表的过程中需要针对不同的颜色进行区分比较。 ```java class Solution { public int minCost(int[][] costs) {

      // 可被粉刷成三种中的一种,
      if (costs == null) return 0;
      // costs[0][0]:红色,1:绿色,2:蓝色
      // 求刷完房子最小花费成本
      int n = costs.length; // 房间数
      // dp[i][0] 表示房间 i 粉刷成红色所花费的最小的总成本
      // n 还是 n+1
      int[][] dp = new int[n][3];
      dp[0][0] = costs[0][0];
      dp[0][1] = costs[0][1];
      dp[0][2] = costs[0][2];
    
    for (int i = 1; i < n; i++) {
        dp[i][0] = Math.min(dp[i - 1][1], dp[i - 1][2]) + costs[i][0];
        dp[i][1] = Math.min(dp[i - 1][0], dp[i - 1][2]) + costs[i][1];
        dp[i][2] = Math.min(dp[i - 1][0], dp[i - 1][1]) + costs[i][2];
    }
    return Math.min(dp[n - 1][0], Math.min(dp[n - 1][1], dp[n - 1][2]));
}

}


<a name="KqSf4"></a>
## 3.4 股票问题
<a name="izlN3"></a>
## 3.5 子序列问题

354. 俄罗斯套娃信封问题    LeetCode 题解链接    困难    🤩🤩🤩🤩🤩
354. 最大整除子集    LeetCode 题解链接    中等    🤩🤩🤩🤩
354. 等差数列划分 II - 子序列    LeetCode 题解链接    困难    🤩🤩🤩🤩🤩
354. 删除并获得点数    LeetCode 题解链接    中等    🤩🤩🤩🤩🤩
354. 最长湍流子数组    LeetCode 题解链接    中等    🤩🤩🤩
354. 不相交的线    LeetCode 题解链接    中等    🤩🤩🤩🤩
354. 最长公共子序列    LeetCode 题解链接    中等    🤩🤩🤩🤩
354. 粉刷房子 III    LeetCode 题解链接    困难    🤩🤩🤩🤩
354. 得到子序列的最少操作次数    LeetCode 题解链接    困难    🤩🤩🤩🤩🤩
<a name="WyL5W"></a>
### ★ [300. 最长递增子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/)
![300.最长递增子序列.png](https://cdn.nlark.com/yuque/0/2021/png/105848/1633746603495-953a851d-7c33-4502-9e87-3c0694dcbb7a.png#clientId=ud720e5e8-b156-4&from=drop&id=u70d516e3&margin=%5Bobject%20Object%5D&name=300.%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97.png&originHeight=398&originWidth=1403&originalType=binary&ratio=1&size=68221&status=done&style=none&taskId=u156f3ee2-f984-4e6c-888f-a9bf715dedc)<br />本题求子序列的**长度**,我们需要在第二层 `for` 循环在 `[0, i)` 区间通过枚举得到最长长度。<br />时间复杂度 ![](https://cdn.nlark.com/yuque/__latex/8e9c5fee65a4f32abccd0e83ff203e39.svg#card=math&code=O%28N%5E2%29&id=uYLjc)。
```java
class Solution {
    public int lengthOfLIS(int[] nums) {
        if (nums.length == 0) return 0;

        // dp[i] 表示必须包含位置 i 所能构成的最大子序列长度
        int[] dp = new int[nums.length];
        Arrays.fill(dp, 1);
        int res = 1;
        for (int i = 1; i < nums.length; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            if (dp[i] > res) res = dp[i];
        }
        return res;
    }
}

还有一种二分查找解法(切牌法),思路如下:
300. 最长递增子序列之二分查找法.png

  1. 遍历 [0, nums.length),每一个元素 k 和当前牌的顶部比较。如果 k 大于任意牌堆顶,则创建一个新的堆。
  2. 确定元素 k 位于哪个堆顶的过程就是通过二分查找来解决,使用的是寻找左边界模板。

    class Solution {
     public int lengthOfLIS(int[] nums) {
         if (nums.length == 0) return 0;
         int[] top = new int[nums.length];
         // 当前有多少堆
         int piles = 0;
    
         // 遍历元素 nums
         for (int i = 0; i < nums.length; i++) {
             // 左边界二分查找,
             int left = 0, right = piles;
             int target = nums[i];
             while (left < right) {
                 int mid = left + (right - left) / 2;
                 if (top[mid] == target) {
                     right = mid;
                 } else if (top[mid] > target) {
                     right = mid;
                 } else {
                     left = mid + 1;
                 }
             }
    
             // 当前元素 i 比堆顶都大,另建一个堆
             if (left == piles) piles++;
    
             // 更新堆顶元素
             top[left] = target;
         }
         return piles;
     }
    }
    

    写法二:

    class Solution {
     public int lengthOfLIS(int[] nums) {
         if (nums == null || nums.length == 0) return 0;
         int n = nums.length;
         // 创建「n+1」长度的堆
         int[] heap = new int[n + 1];
    
         // base case:首堆为 nums[0]
         heap[1] = nums[0];
         int piles = 1;
    
         for (int i : nums) {
             // 如果大于piles指向的元素,则新建一个堆
             if (i > heap[piles]) {
                 heap[ ++piles ] = i;
             } else {
                 // 否则,使用二分查找搜索左边界,注意,这里 left 指向 1,否则会出错
                 int left = 1, right = piles;
                 while (left < right) {
                     int mid = left + (right - left) / 2;
                     if (i == heap[mid]) {
                         // 相等,收缩右边界
                         right = mid;
                     } else if (i > heap[mid]) {
                         left = mid + 1;
                     } else {
                         right = mid;
                     }
                 }
                 // 找到合适的堆,更新堆的值
                 heap[left] = i;
             }
         }
         return piles;
     }
    }
    

    变种:NC91 最长递增子序列

    NC91 最长递增子序列.png
    解题细节:

  3. 使用两个 dp 数组。dp[] 存储以 arr[i] 结尾的最长长度。end[i] 存储长度为 i 时末尾最小的数。

    public int[] LIS (int[] arr) {
     if (arr.length == 0) return new int[0];
     int len = arr.length;
    
     // dp[i]:以 arr[i] 结尾时最长长度
     int[] dp  = new int[len];
    
     // end[i]:长度为 i 时末尾最小数值
     int[] end = new int[len + 1];
    
     // 初始化
     dp[0] = 1;
     end[1] = arr[0];
     int maxLength = 1;
     for (int i = 0; i < arr.length; i++) {
         int target = arr[i];
         if (target > end[maxLength]) {
             // 新建一个堆,更新 end[]数组,表示长度为 maxLen 的末尾最小数为 target
             end[ ++maxLength ] = target;
             dp[i] = maxLength;
         } else {
             // 使用左边界二分查找模板
             int left = 1, right = maxLength;
             while (left < right) {
                 int mid = left + (right - left) / 2;
                 if (end[mid] == target) {
                     right = mid;
                 } else if (end[mid] > target) {
                     right = mid;
                 } else {
                     left = mid + 1;
                 }
             }
             // 找到合适的 left,更新
             end[left] = target;
    
             // 对于 end 的 left 来说,这个就是 i 对应的长度呀
             dp[i] = left;
         }
     }
    
     // 构造结果,从后往前遍历,数组长度为 maxLength
     int[] result = new int[maxLength];
     for (int i = arr.length - 1; i >= 0; i--) {
         // 判断 dp[i] 是否为最长长度的一部分
         if (dp[i] == maxLength) {
             result[--maxLength] = arr[i];
         }
     }
     return result;
    }
    

    674. 最长连续递增序列(求子序列长度)

    674. 最长连续递增序列.png
    使用贪心算法

    class Solution {
     public int findLengthOfLCIS(int[] nums) {
         if (nums == null || nums.length == 0) return 0;
         int res = 1;
         int count = 1;
         for (int i = 1; i < nums.length; i++) {
             if (nums[i] > nums[i - 1]) {
                 count++;
             } else {
                 count = 1;
             }
             res = Math.max(count, res);
         }
    
         return res;
     }
    }
    

    动态规划求解明确 dp[i] 数组定义:dp[i] 表示以 i 结尾的最长连续递增序列的长度。

    class Solution {
     public int findLengthOfLCIS(int[] nums) {
         if (nums == null || nums.length == 0) return 0;
         int len = nums.length;
         // dp[i] 表示以 i 结尾的最长递增子序列
         int[] dp = new int[len];
         dp[0] = 1;
         int maxVal = 1;
         for (int i = 1; i < len; i++) {
             if (nums[i] > nums[i - 1]) {
                 dp[i] = dp[i - 1] + 1;
             } else {
                 dp[i] = 1;
             }
             maxVal = Math.max(maxVal, dp[i]);
         }
         return maxVal;
     }
    }
    

    673. 最长递增子序列的个数(求子序列个数)

    与求子序列长度不同,这里是求序列个数。因此,我们只需要在子序列长度的基础上增加额外的辅助信息即可。我们需要额外定义一个 dp 数组,叫做 g[i]表示以 nums[i] 结尾的最长上升子序列的个数(最长、个数)。那么对于 g[i] 的状态转换解析如下:

  4. 每个元素能单独成序,因此初始值 g[i] = 1

  5. 枚举 [0, i] 之间的元素,判断是否能组成最长子序列,并记录最长长度出现的次数即可。

解析需注意以下细节:

  • 定义两个数组,数组 g[i] 表示以 nums[i] 结尾的最长递增子序列的个数。
  • 更新 dp[i]g[i] 的时机。在进行枚举过程中,当 nums[i] > nums[j] 时,则需要根据长度分别更新 dp[i]g[i]
  • 注意,g[i] 的值是需要加上 g[j] 的值。
  • 在计算最长子序列个数的个数时,需要得到最长长度 maxLen,然后遍历 dp[i],累加 g[i] 就能得到最终值。
  • 第二层循环变量 j 的范围是 [0, i)

    class Solution {
      public int findNumberOfLIS(int[] nums) {
          // 求最长递增子序列的个数
          if (nums == null || nums.length == 0) return 0;
          int len = nums.length;
          // dp[i] 表示以 i 结尾的最长递增子序列长度
          int[] dp = new int[len];
          Arrays.fill(dp, 1);
    
          // g[i] 表示以 i 结尾的最长长度个数
          int[] g = new int[len];
          Arrays.fill(g, 1);
    
          // 最长递增序列长度
          int maxLen = 1;
    
          for (int i = 0; i < len; i++) {
              // j 不能越过 i
              for (int j = 0; j < i; j++) {
                  // 构成递增序列
                  if (nums[i] > nums[j]) {
                      // 当前长度
                      int curLen = dp[j] + 1;
                      if (curLen > dp[i]) {
                          // 最大长度,两个dp数组都需要更新
                          dp[i] = curLen;
                          g[i] = g[j];
                      } else if (curLen == dp[i]) {
                          g[i] += g[j];
                      }
                  }
                  maxLen = Math.max(maxLen, dp[i]);
              }
          }
          int res = 0;
          for (int i = 0; i < len; i++) {
              // 收集所有长度为maxLen对应的次数
              if (dp[i] == maxLen) res += g[i];
          }
          return res;
      }
    }
    

    1143. 最长公共子序列

    1143. 最长公共子序列.png
    注意以下区别:

  1. 子序列和子串的区别。子序列是相对位置不可变,中间可以任意删除字符。而子串不允许删除字符。求子序列一般使用动态规划,而求子串一般使用滑动窗口。详见 3. 无重复字符的最长子串

    class Solution {
     public int longestCommonSubsequence(String text1, String text2) {
         if (text1 == null || text2 == null) return 0;
         // dp[i][j] 表示 (0, i) 和 (0, j) 两个字符串所拥有的LCS
         int len1 = text1.length(), len2 = text2.length();
    
         int[][] dp = new int[len1 + 1][len2 + 1];
    
         for (int i = 1; i <= len1; i++) {
             for (int j = 1; j <= len2; j++) {
                 if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                     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[len1][len2];
     }
    }
    

    NC127 最长公共子串

    NC127 最长公共子串.png
    本题带给我的提升是保留其它信息,因为使用动态规划,一般结果都在数组里面,但是在某些题中 dp[] 数组只是辅助信息。比如在本题中,dp[][] 数组只记录以 i,j 结尾的最长公共子串长度,而题目要求返回子串。所以我们需要两个变量 endIndexmaxLen 分别表示 maxLen 长度的末尾索引位置。这样我们就可以截取子串并返回。
    本题的状态转移方程也比较简单,就是比较末尾字符串是否相同,如果相同则在 dp[i - 1][j - 1] 的基础上 +1,否则不修改任何值,即 0

    public class Solution {
     /**
      * longest common substring
      * @param str1 string字符串 the string
      * @param str2 string字符串 the string
      * @return string字符串
      */
     public String LCS (String s1, String s2) {
         if (s1 == null || s2 == null) return "";
         int n1 = s1.length();
         int n2 = s2.length();
         int[][] dp = new int[n1 + 1][n2 + 1];
         int maxLen = 0, endIndex = 0;
         for (int i = 1; i <= n1; i++) {
             for (int j = 1; j <= n2; j++) {
                 if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
                     dp[i][j] = dp[i - 1][j - 1] + 1;
                 }
                 if (dp[i][j] > maxLen) {
                     maxLen = dp[i][j];
                     endIndex = i;
                 }
             }
         }
         return s1.substring(endIndex - maxLen, endIndex);
     }
    }
    

    72. 编辑距离

    ```java class Solution { public int minDistance(String word1, String word2) {

     // 定义dp数组及下标含义
     // i、j为字符串的长度,
     // dp[i][j]表示[0, i-1], [0, j-1]两个字符串转换成一样的需要的最少操作数
     // 可以通过插入一个字符、删除一个字符、替换一个字符
     // 1.当字符串相等,则不需要进行任何操作,有dp[i][j] = dp[i-1][j-1]
     // 2.当字符串不相等,则需要进行调整
     //    2.1 替换:把字符串 word1[i]替换成word[j]相等,则有 dp[i][j] = dp[i-1][j-1]+1
     //    2.2 插入:在word1末尾插入一个与word2[j]相等的字符,则有dp[i][j] = dp[i][j-1] + 1
     //    2.3 删除:将字符word1[i]删除,则有dp[i][j] = dp[i-1][j] + 1
    
    // 初始化:第一行和第一列。当有一个字符串长度为0时,转化为另外一个字符串只有能进行插入或删除操作

    if (word1 == null && word2 == null) return 0;
    if (word1 == null || word2 == null) return word1 == null ?  word2.length() : word1.length();
    int m = word1.length();
    int n = word2.length();
    int[][] dp = new int[m + 1][n + 1];

    // 初始化
    for (int i = 0; i <= n; i++) { dp[0][i] = i; }
    for (int i = 0; i <= m; i++) { dp[i][0] = i; }

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            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][j-1], dp[i-1][j]), dp[i-1][j-1]) + 1;
            }
        }
    }

    return dp[m][n];
}

}

<a name="LLQrE"></a>
### [44. 通配符匹配](https://leetcode-cn.com/problems/wildcard-matching/)
![通配符匹配.png](https://cdn.nlark.com/yuque/0/2021/png/105848/1633944110792-926937fe-c213-4f02-b3ce-b4a542791d57.png#clientId=u54e2cb42-8076-4&from=drop&id=u6fd9813f&margin=%5Bobject%20Object%5D&name=%E9%80%9A%E9%85%8D%E7%AC%A6%E5%8C%B9%E9%85%8D.png&originHeight=384&originWidth=974&originalType=binary&ratio=1&size=70648&status=done&style=none&taskId=u4e9beada-a9b3-41dd-934c-b53badb3433)

1. base case 是对模式串那一行进行初始化。
1. 理解 `dp[i][j] = dp[i - 1][j] || dp[i][j - 1];` 这一行。`dp[i][j - 1]` 表示代表的是空字符,例如 `ab, ab`。`dp[i - 1][j]` 表示代表的是非空字符,例如 `abcd, ab`。
```java
class Solution {
    public boolean isMatch(String s, String p) {
        if ((s == null && p == null) || p == null) return false;
        int n = s.length(), m = p.length();
        // 橫坐标是模式串,纵坐标是字符串
        boolean[][] dp = new boolean[m + 1][n + 1];

        // base case
        dp[0][0] = true;
        for (int i = 1; i <= m && p.charAt(i - 1) == '*'; i++) {
            dp[i][0] = true;
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (p.charAt(i - 1) == '?' || s.charAt(j - 1) == p.charAt(i - 1)) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else if (p.charAt(i - 1) == '*') {
                    dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
                }
            }
        }
        return dp[m][n];
    }
}

926. 将字符串翻转到单调递增

思路一:使用 300. 最长递增子序列 算法,找到最长子序列,剩下的就是需要翻转次数。可以使用二分算法:

class Solution {
    public int minFlipsMonoIncr(String s) {
        // LIS
        int[] d = new int[s.length()];
        Arrays.fill(d, -1);

        int len = 0;// 队伍的长度
        int i = 0;// 0放入队伍中的位置

        for(char c : s.toCharArray()) {
            if(c == '0') {
                d[i] = 0;
                if(i == len) len++;// 此时队伍中没有1
                i++;
            } else {
                d[len] = 1;// 1放入队伍的末尾
                len++;
            }
        }

        return s.length() - len;
    }
}

可以看出,维护的 d[] 本身实际上并未使用,上面的代码只是为了便于理解的中间过程,最终可以简化为
class Solution {
    public int minFlipsMonoIncr(String s) {
        // LIS
        int len = 0;// 队伍的长度
        int i = 0;// 0放入队伍中的位置

        for(char c : s.toCharArray()) {
            if(c == '0') {
                if(i == len) len++;// 此时队伍中没有1
                i++;
            } else {
                len++;
            }
        }

        return s.length() - len;
    }
}

思路二:动态规划。
dp[i][0] 表示以 0 结尾的最小翻转次数,dp[i][1] 表示以 1 结尾的最小翻转次数。状态转移过程是判断末尾字符是否为 0

  1. s[i] == '0'dp[i][0] = dp[i - 1][0]dp[1][1] = Min(dp[i - 1][0], dp[i - 1][1]) + 1
  2. s[i] == '1'dp[i][0] = dp[i - 1][0] + 1dp[1][1] = Min(dp[i - 1][0], dp[i - 1][1]) + 1

    class Solution {
     public int minFlipsMonoIncr(String s) {
         if (s == null || s.length() == 0) return 0;
         // ① 字符可任意翻转,求最小翻转次数
         int n = s.length();
         int[][] dp = new int[n][2];
         dp[0][0] = s.charAt(0) == '0' ? 0 : 1;
         dp[0][1] = s.charAt(0) == '1' ? 0 : 1;
    
         for (int i = 1; i < n; i++) {
             dp[i][0] = dp[i - 1][0] + (s.charAt(i) == '0' ? 0 : 1);
             dp[i][1] = Math.min(dp[i - 1][0], dp[i - 1][1]) + (s.charAt(i) == '0' ? 1 : 0);
         }
         return Math.min(dp[n - 1][0], dp[n - 1][1]);
     }
    }
    

四、总结

[

](https://leetcode-cn.com/problems/2AoeFn/)