【40讲系列13】动态规划

一、理论

递归问题 -> 重叠子问题、最优子结构 -> 记忆化搜索(自顶向下) -> 动态规划(记忆化 + 自底向上)

动态规划(Dynamic Programming),即动态递推(递归 + 记忆化 -> 递推)。动态规划问题的一般形式就是求最值。

1. 状态的定义:opt[n],dp[n],fib[n]

2. 状态转移方程:opt[n] = best_of(opt[n-1], opt[n-2], ...)

3. 存在重叠子问题,具备最优子结构. (最优子结构是指通过求子问题的最优解,可以获得原问题的最优解)

4. 自底向上。(这也是为什么动态规划不用递归,而是要循环迭代完成计算)

5. 状态压缩(滚动数组)。如果发现每次状态转移只需要DP数组的一部分,那么可以运用【滚动数组】的思想,进行状态压缩,只记录必要的数据,优化空间。

 

题目特点:动态规划的题目分为两大类,

  一种是求最优解类,典型问题是背包问题。 当前问题的最优解取决于子问题的最优解。

  另一种是计数类,如统计方案数等问题。 当前问题的方案数取决于子问题的方案数。

动态规划的解题步骤:

  • 定义子问题
  • 写出子问题的递推关系
  • 确定 DP 数组的计算顺序
  • 空间优化(可选)

DP vs 回溯 vs 贪心

回溯(递归)—重复计算

贪心—永远局部最优

DP—记录局部最优子结构 / 多种记录值

Note:何时用【回溯】,何时用【动态规划】?  —— 如果是求走迷宫的【路径】,必然是回溯; 如果是走迷宫的【路径的条数 / 方案数】,必然是dp。

二、从斐波那契数谈起

  经典的斐波那契数列的递归解法如下所示(时间复杂度为:O(2^n),为指数级复杂度),它的缺点在于有大量的子问题被重复计算。

class Solution {
    public int fib(int n) {
        // 执行耗时:10 ms,击败了17.41% 的Java用户
        if (n == 0) return 0;
        if (n == 1) return 1;
        return fib(n - 1) + fib(n - 2);
    }
}

  优化方案1:记忆化搜索,自顶向下。 (时间复杂度:O(n),仍然需要递归)

class Solution {
    // 假设 memo[]数组已赋好初值,初始化都为-1
    public int fib(int n) {
        if (n == 0) return 0;
        if (n == 1) return 1;
        if (memo[n] == -1) { // 如果没计算过
            memo[n] = fib(n-1) + fib(n-2);
        }
        return memo[n];
    }
}

  优化方案2:动态规划----记忆化自底向上。      (时间复杂度为O(n))

class Solution {
    public int fib(int n) {
        //        执行耗时:0 ms,击败了100.00% 的Java用户
        //        内存消耗:35.3 MB,击败了40.50% 的Java用户
        if (n == 0 || n == 1) return n;
        int[] arr = new int[n + 1];
        arr[0] = 0;
        arr[1] = 1;
        for (int i = 2; i <= n; i++) {
            arr[i] = arr[i-1] + arr[i-2];
        }
        return arr[n];
    }
}

  优化方案3:空间优化  (当前状态只和之前两个状态有关,所以只存储之前两个状态即可,空间复杂度可降为O(1))

class Solution {
    public int fib(int n) {
        //        执行耗时:0 ms,击败了100.00% 的Java用户
        //        内存消耗:35.1 MB,击败了79.25% 的Java用户
        /*
        if (n == 0 || n == 1) return n;
        int n1 = 1; // 第n-1项
        int n2 = 0; // 第n-2项
        int sum = 0;
        for (int i = 2; i <= n; i++) {
            sum = n1 + n2;
            n2 = n1;
            n1 = sum;
        }
        return sum;
        */
        // 进一步优化
        if (n == 0 || n == 1) return n;
        int sum = 1;  // 利用 sum 存储第 n-1 项
        int n2 = 0;
        for (int i = 2; i <= n; i++) {
            sum = sum + n2;
            n2 = sum - n2;
        }
        return sum;
    }
}

三、0-1背包问题(参考资料:bobo、背包九讲)

  问题描述:背包的容量为C,有n中不同的物品,编号为0...n-1,每一件物品的重量为w(i),价值为v(i)。问可以向背包中放哪些物品,使得在不超过背包容量的基础上,物品的总价值最大。

  问题初探:该问题不能使用贪心算法(优先放入平均价值最高的物品),举例可发现行不通。

  问题分析:设F(n,c)为考虑将n个物品放在容量为c的背包中,使得价值最大。每一个物品都有 放 与 不放,两种状态。

       F(i,c) = max( F(i-1,c) , v(i) + F( i-1, c - w(i)) )

       重叠子问题在:同样的 i 和 c数据对,可能计算了多次。

  0-1背包问题的复杂度分析: 时间复杂度O(n*C)  空间复杂度O(n*C)

0-1问题的优化1(滚动数组):由状态转移方程可以看出,第 i 行元素只依赖于第i-1行元素。理论上,只需要保持两行元素,空间复杂度可以优化为O(2*C) = O(C)。  具体实现上,可以根据 i 的奇偶性,轮流使用两行数组

0-1问题的优化2(只使用一维数组):由于第二行当前格子的计算依赖于前一行上边左边 某个格子的信息,而不会碰右边的格子。因此,只有一行时,可以从右向左进行计算。

0-1背包问题的变种

  (1)完全背包问题:每个物品可以无限使用。         

    分析:由于背包容量是有限的,那么每个物品可以取到的个数有最大值,因此,可以转化为普通背包问题,区别在于物品选取列表中,很多物品是重复的。更进一步,可以用二进制优化重复物品的个数,如1,2,4,8,16...。

  (2)多重背包问题:每个物品不止1个,有num(i)个。

  (3)多维费用背包问题:要考虑物品的体积和重量两个维度?

    分析:状态需要多一个参数,设置三维数组

  (4)物品间约束,如物品间相互依赖或相互排斥。

背包问题题型归纳与技巧:希望用一种规律搞定背包问题

四、典型例题

①:爬楼梯问题(LC70、剑指08.跳台阶

思路:本题是斐波那契数列的应用。

class Solution {
    public int climbStairs(int n) {
        /*if (n <= 2) return n;
        int n1 = 1;
        int n2 = 2;
        for (int i = 3; i <= n; i++) {
            n2 = n2 + n1;
            n1 = n2 - n1;
        }
        return n2;*/

        // 写成动态规划的递推形式
        if (n <= 2) return n;
        int[] dp = new int[n + 1];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

☆☆☆②:三角形的最小路径和(LC120数字三角形问题(动态规划)

思路:本题是DP的经典题目。需要自底向上开始递推,首先定义DP的状态,然后推出DP方程。

优化:使用一维数组而不是二维数组,这样只使用了O(n)的额外空间。

class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        // 方法1: 时间复杂度和空间复杂度 都是O(n^2)
        /*int n = triangle.size();
        // 1. 状态的定义:dp[i][j]表示从点(i,j)到底边的最小路径和
        int[][] dp = new int[n + 1][n + 1];
        // 从三角形的最后一行开始递推。
        for (int i = n - 1; i >= 0; i--) {
            for (int j = 0; j <= i; j++) {
                // 2. 状态转移方程
                dp[i][j] = Math.min(dp[i + 1][j],dp[i + 1][j + 1]) + triangle.get(i).get(j);
            }
        }
        return dp[0][0];*/

        // 由于递推中,计算dp[i][j]时,只用到了下一行的dp[i+1][j]和dp[i+1][j+1]。
        // 而不需要记录整个层的结果,因此dp数组不需要定义N行,只需要一行即可。
        // 方法2:进行空间优化,仅使用一维数组. 时间复杂度O(n^2),空间复杂度O(n)
        int n = triangle.size();
        int[] dp = new int[n + 1];
        for (int i = n - 1; i >= 0; i--) {
            for (int j = 0; j <= i; j++) {
                dp[j] = Math.min(dp[j],dp[j + 1]) + triangle.get(i).get(j);
            }
        }
        return dp[0];
    }
}

☆☆☆③:乘积最大子数组(LC152、剑指30.连续子数组的最大和

思路:DP。关键点在于:由于存在负数,那么会导致最大的变最小的,最小的变最大的。因此还需要维护当前最小值imin。

视频:覃超P47

class Solution {
    public int maxProduct(int[] nums) {
        int max = Integer.MIN_VALUE;
        int imax = 1, imin = 1;
        for (int i = 0; i < nums.length; i++) {
            // 由于存在负数,那么会导致最大的变最小的,最小的变最大的。
            // 当负数出现时则imax与imin进行交换再进行下一步计算
            if (nums[i] < 0){
                int temp = imax;
                imax = imin;
                imin = temp;
            }
            imax = Math.max(imax * nums[i], nums[i]);
            imin = Math.min(imin * nums[i], nums[i]);
            max = Math.max(max,imax);
        }
        return max;
    }
}

☆☆☆☆☆☆④:动态规划解决买卖股票问题(LC121、LC122【40讲系列7】贪心 、LC123、LC188、LC309、LC714

题目描述:

  • LC121:一次买卖一支股票                   (暴力解法、动态规划)
  • LC122:多次买卖一支股票                   (暴力解法、贪心算法、动态规划)
  • LC123:最多两次买卖一支股票                   (动态规划)
  • LC188:最多K次买卖一支股票                     (动态规划)
  • LC309:多次买卖一支股票,并且卖出股票后无法在第二天买入股票(冷冻期1天)                   (动态规划)
  • LC714:多次买卖但需要付手续费                   (动态规划)

分析:前四个属于一类问题,LC121相当于K=1的情况;LC122相当于K为无穷的情况;LC123相当于K=2的情况;LC188属于一般情况(三维动态规划)

参考:

股票问题系列通解(转载翻译)

股票问题题解

class Solution121 {
    public int maxProfit(int[] prices) {
        // DP写法1:前i天的最大收益 = max{前i-1天的最大收益,第i天的价格-前i-1天中的最小价格}
        if (prices == null || prices.length == 0) return 0;
        int min = prices[0];
        int profit = 0;
        for (int i = 1; i < prices.length; i++) {
            profit = Math.max(profit, prices[i] - min);
            min = Math.min(min, prices[i]);
        }
        return profit;
        // DP此类问题的通用写法:
        /*if (prices == null || prices.length == 0) return 0;
        int len = prices.length;
        int[][] dp = new int[len][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for (int i = 1; i < len; i++) {
            dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);
            dp[i][1] = Math.max(dp[i-1][1],-prices[i]);// 注意与122题对比,只允许交易一次,因此手上的现金数就是当天的股价的相反数
        }
        return dp[len-1][0];*/
        // 对上述代码进行空间优化
        /*if (prices == null || prices.length == 0) return 0;
        int dp0 = 0, dp1 = -prices[0];
        for (int i = 1; i < prices.length; i++) {
            dp0 = Math.max(dp0,dp1+prices[i]);
            dp1 = Math.max(dp1,-prices[i]);
        }
        return dp0;*/
    }
} 
class Solution122 {
    public int maxProfit(int[] prices) {
        /**
         * 方法1:贪心(针对此问题的特殊解法,因为一天可以买卖无数次)
         * 当天卖出后,当天还可以买入。只要今天比昨天大,就卖出
         */
        int res = 0;
        for (int i = 1; i < prices.length; i++) {
            if (prices[i] > prices[i - 1]){
                res += prices[i] - prices[i-1];
            }
        }
        return res;
        /**
         * 方法2.1 : 动态规划
         * 不能同时参与多笔交易,所以每天交易结束后只有持有一支股票和没有持有两种状态。
         * dp[i][0]表示第i天交易完成后没有持股的最大利润
         * dp[i][1] 表示第i天交易完成后持股的最大利润.
         * 第一种dp[i][0],说明第i天没有持股,此时有两种可能,前一天没有持股,今天也不买。或者前一天持股,在今天卖出。
         * 第二种dp[i][1],说明第i天持有股票,此时有两种可能,前一天持股,今天保持不动。或者前一天没有持股,今天买入。
         */
//        int len = prices.length;
//        int[][] dp = new int[len][2];
//        dp[0][0] = 0;
//        dp[0][1] = -prices[0];
//        for (int i = 1; i < len; i++) {
//            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
//            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]);
//        }
//        return dp[len - 1][0];
        /**
         * 方法2.2:动态规划
         * 由于今天的利润只和前一天相关,我们只需要记录前一天的利润。因此可以进行空间优化。
         */
//        int len = prices.length;
//        int dp0 = 0, dp1 = -prices[0];
//        for (int i = 1; i < len; i++) {
//            int newDp0 = Math.max(dp0,dp1 + prices[i]);
//            int newDp1 = Math.max(dp1,dp0 - prices[i]);
//            dp0 = newDp0;
//            dp1 = newDp1;
//        }
//        return dp0;
    }
}
class Solution123 {
    public int maxProfit(int[] prices) {
        /**
         * 针对此类问题的通用写法:
         * 注: 规定一次交易产生是在 【买入股票】 的时候,即买入股票时交易次数加1
         */
        /*if (prices == null || prices.length == 0) return 0;
        int len = prices.length;
        // 第 2 维的 0 没有意义,1 表示交易进行了 1 次,2 表示交易进行了 2 次
        int[][][] dp = new int[len][3][2];
        dp[0][1][1] = -prices[0];
        dp[0][2][1] = Integer.MIN_VALUE; //注意不能初始化为0
        for (int i = 1; i < len; i++) {
            // 转移顺序先持股,再卖出
            dp[i][1][1] = Math.max(dp[i-1][1][1], -prices[i]);
            dp[i][1][0] = Math.max(dp[i-1][1][0], dp[i-1][1][1] + prices[i]);
            dp[i][2][1] = Math.max(dp[i-1][2][1], dp[i-1][1][0] - prices[i]);
            dp[i][2][0] = Math.max(dp[i-1][2][0], dp[i-1][2][1] + prices[i]);
        }
        return Math.max(dp[len-1][1][0],dp[len-1][2][0]);*/
        /**
         * 对上述代码进行空间优化
         * 由于今天只参考了昨天的状态,所以直接去掉第一维不会影响状态转移的正确性
         */
        /*if (prices == null || prices.length == 0) return 0;
        int[][] dp = new int[3][2];
        dp[1][1] = -prices[0];
        dp[2][1] = Integer.MIN_VALUE;
        for (int i = 1; i < prices.length; i++) {
            dp[1][1] = Math.max(dp[1][1], -prices[i]);
            dp[1][0] = Math.max(dp[1][0], dp[1][1] + prices[i]);
            dp[2][1] = Math.max(dp[2][1], dp[1][0] - prices[i]);
            dp[2][0] = Math.max(dp[2][0], dp[2][1] + prices[i]);
        }
        return dp[2][0];*/ //  收益值(dp[2][0] >= dp[1][0])

        /**
         * 针对本题的变量写法:
         * buy1:第一次买入股票可获得的最大收益
         * sell1:第一次卖出股票可获得的最大收益
         */
        if (prices == null || prices.length == 0) return 0;
        int buy1 = -prices[0], buy2 = Integer.MIN_VALUE;
        int sell1 = 0, sell2 = 0;
        for (int i = 1; i < prices.length; i++) {
            buy1 = Math.max(buy1, -prices[i]);
            sell1 = Math.max(sell1, buy1 + prices[i]);
            buy2 = Math.max(buy2, sell1 - prices[i]);
            sell2 = Math.max(sell2, buy2 + prices[i]);
        }
        return sell2;
    }
}
class Solution188 {
    public int maxProfit(int k, int[] prices) {
        /**
         * 最一般的情况,相当于122题(K为无穷) 和 123题(K=2) 的融合。
         * 如果k超过一个临界值,最大收益就不再取决于最大交易次数,而是取决于数组的长度
         * 一个有收益的交易至少需要两天(在前一天买入,在后一天卖出,前提是买入价格低于卖出价格)。
         * 如果数组长度为 n,则有收益的交易的数量最多为 n/2 ,因此如果k>=n/2,等价于k为无穷的情况。
         */
        if (prices == null || prices.length == 0) return 0;
        int len = prices.length;
        if (k >= len/2){
            return greedy(prices);
        }
        int[][] dp = new int[k+1][2];
        for (int kk = 1; kk <= k; kk++) {
            dp[kk][1] = -prices[0];
        }
        for (int i = 1; i < len; i++) {
            for (int kk = 1; kk <= k; kk++) {
                dp[kk][1] = Math.max(dp[kk][1], dp[kk-1][0] - prices[i]);
                dp[kk][0] = Math.max(dp[kk][0], dp[kk][1] + prices[i]);
            }
        }
        return dp[k][0];
    }
    // 相当于k为无穷的情况,LeetCode 122
    public int greedy(int[] prices) {
        int res = 0;
        for (int i = 1; i < prices.length; i++) {
            if (prices[i] - prices[i-1] > 0){
                res += prices[i] - prices[i-1];
            }
        }
        return res;
        // 使用DP
        /*int[][] dp = new int[prices.length][2];
        dp[0][1] = -prices[0];
        for (int i = 1; i < prices.length; i++) {
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]);
        }
        return dp[prices.length - 1][0];*/
    }
}
class Solution309 {
    public int maxProfit(int[] prices) {
        if (prices == null || prices.length == 0) return 0;
        int len = prices.length;
        // 定义三个状态:    注意定义的状态是第i天结束后的状态
        //      1:持股;  0:不持股且不是冷冻期;  2:冷冻期(今天卖出,不持股)
        int[][] dp = new int[len][3];
        dp[0][1] = -prices[0];
        for (int i = 1; i < len; i++) {
            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]);
            dp[i][2] = dp[i-1][1] + prices[i];
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][2]);
        }
        return Math.max(dp[len-1][0], dp[len-1][2]);

        // 空间优化
        /*int dp1 = -prices[0];
        int dp0 = 0, dp2 = 0;
        for (int i = 1; i < len; i++) {
            int newDp1 = Math.max(dp1, dp0 - prices[i]);
            int newDp2 = dp1 + prices[i];
            int newDp0 = Math.max(dp0, dp2);
            dp0 = newDp0;
            dp1 = newDp1;
            dp2 = newDp2;
        }
        return Math.max(dp0,dp2);*/
    }
}
class Solution714 {
    public int maxProfit(int[] prices, int fee) {
        // 与122题类似,只是多了手续费
        if (prices == null || prices.length == 0) return 0;
        int len = prices.length;
        int[][] dp = new int[len][2];
        dp[0][1] = -prices[0] - fee;
        for (int i = 1; i < len; i++) {
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i] - fee);
        }
        return dp[len-1][0];
        /*int dp0 = 0, dp1 = -prices[0] - fee;
        for (int i = 1; i < len; i++) {
            int newDp0 = Math.max(dp0, dp1 + prices[i]);
            int newDp1 = Math.max(dp1, dp0 - prices[i] - fee);
            dp0 = newDp0;
            dp1 = newDp1;
        }
        return dp0;*/
    }
}

☆☆☆☆☆⑤:最长上升子序列(LC300)

Note:该题为不连续的情况,可以延伸到最长连续子序列长度该怎么求。  另外,除了要会求子序列的长度,还要会求出最长的子序列是什么。

方法1:DP----O(n^2)

方法2:二分-------O(nlogn)。  不是很理解二分的过程 T_T

class Solution {
    public int lengthOfLIS(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        /**
         * 方法1:动态规划,时间复杂度O(n^2)
         */
        // 1.状态定义: dp[i]状态表示从开始到第i个元素结尾(包含第i个元素),最长子序列的长度
        // 2.DP方程:   dp[i] = max{dp[j] + 1} ( j<i, nums[j]<nums[i] )
        /*int[] dp = new int[nums.length];
        int res = 1;
        for (int i = 0; i < nums.length; i++) {
            dp[i] = 1; // 初始化。如果0到i-1中所有的数都不比第i个数小,则单独一个数是子序列
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]){
                    // 以哪个数结尾的最大递增子序列更大,就选其作为倒数第二个数
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            res = Math.max(res, dp[i]); // 所有dp[i]中找最大的
        }
        return res;*/
        /**
         * 方法2:二分查找,时间复杂度O(nlogn)
         *  a.直接维护最长子序列数组res[],注意该数组是有序的
         *    1)如果当前元素比结果数组的值都大,就追加在结果数组的尾部(相当于递增序列长度加1)
         *    2)否则,用当前元素覆盖掉第一个比它大的元素(比它大的元素中最小的那个)。 这样就能给后面数机会
         *  b.返回数组的长度
         *  说明:由于每个数O(n)找到其要插入的位置用的是二分查找O(logn),所以总体是O(nlogn)的。
         *        2)中的覆盖操作可能会导致最终的结果数组并不是最终的递增序列,但对长度无影响。
         *  举个例子:[10,11,12,13,1,2,3,4,5]
         *          ->[10,11,12,13]->[1,11,12,13]->[1,2,12,13] ....-> [1,2,3,4,5]
         */
        int[] res = new int[nums.length];
        int len = 0; // res当前的长度
        for (int num : nums){
            int start = 0, end = len;
            // 利用二分查找res数组索引为[0, len)之间元素,找到第一个大于num的元素。
            while (start < end){
                int mid = start + ((end - start) >> 1);
                if (res[mid] < num){
                    start = mid + 1;
                }else {
                    end = mid;
                }
            }
            res[start] = num;
            if (start == len){ // 说明区间中不存在比num大的数+
                len++;
            }
            // 调二分API
            /*int idx = Arrays.binarySearch(res,0,len,num);
            idx = idx < 0 ? -idx - 1 : idx;
            res[idx] = num;
            if (idx == len) len++;*/
        }
        return len;
    }
}

【拓展】:如何根据dp数组得到最长递增子序列是什么?------>参考左神P203

    public int[] generateLIS(int[] arr, int[] dp) {
        int len = 0;
        int index = 0;
        for (int i = 0; i < dp.length; i++) {
            if (dp[i] > len){
                len = dp[i];
                index = i;
            }
        }
        int[] lis = new int[len];
        lis[--len] = arr[index];
        for (int i = index; i >= 0; i--) {
            if (arr[i] < arr[index] && dp[i] == dp[index] - 1) {
                lis[--len] = arr[i];
                index = i;
            }
        }
        return lis;
    }

☆☆☆⑥:零钱兑换(LC322)

思路:转换成 “跳台阶” 问题。

class Solution {
    /**
     * 思路; 转化为跳台阶问题
     *  1. 状态的定义: dp[i] 表示到i台阶时的最小步数, 也即凑齐i需要的最少硬币数
     *  2. 状态转移方程 dp[i] = min {dp[i-coins[j]} + 1
     *      举例: [1,2,5] 11
     *          dp[i] = min{i-1, i-2, i-5} + 1
     *          dp[11] = min{dp[10],dp[9],dp[6]} + 1
     */
    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        // 最多的硬币数就是全部使用面值1的硬币来换, amount+1是不可能达到的换取数量。
        // 因为硬币面额最小为整数1,所以只要有解,最小硬币数必然小于amount+1
        int max = amount + 1;
        Arrays.fill(dp, max);
        dp[0] = 0;
        for (int i = 1; i <= amount; i++) {
            for (int j = 0; j < coins.length; j++) {
                if (i >= coins[j]) {
                    dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
                }
            }
        }
        return dp[amount] == amount + 1 ? -1 : dp[amount];
    }
}

☆☆☆⑦:计算最短编辑距离问题(LC72)

class Solution {
    /**
     *  1. 状态的定义:dp[i][j]:word1前i个字符 变为 word2前j个字符 所需最小操作数
     *      最后返回dp[word1.length][word2.length]
     *  2. DP方程: 分两种情况,
     *      1)如果当前字母相同,则dp[i][j] = dp[i-1][j-1]
     *      2) 否则,取 删-增-替 的最小值 + 1,
     *          即 min{dp[i-1][j],dp[i][j-1],dp[i-1][j-1]} + 1
     */
    public int minDistance(String word1, String word2) {
        int len1 = word1.length(), len2 = word2.length();
        int[][] dp = new int[len1 + 1][len2 + 1];
        for (int i = 0; i <= len1; i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= len2; j++) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                // 注意dp数组有一个偏移,所以是 charAt(i-1)而不是i
                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][j-1],Math.min(dp[i-1][j],dp[i-1][j-1])) + 1;
                }
            }
        }
        return dp[len1][len2];
    }
}

五、扩展例题

第一组:LeetCode120. 三角形最小路径和LeetCode64. 最小路径和

第二组(发现重叠子问题):LeetCode343. 整数拆分LeetCode279. 完全平方数LeetCode91. 解码方法LeetCode62. 不同路径LeetCode63. 不同路径 II

第三组(状态定义和状态转移):LeetCode198. 打家劫舍LeetCode213. 打家劫舍 IILeetCode337. 打家劫舍 IIILeetCode309. 最佳买卖股票时机含冷冻期

第四组(0-1背包问题):LeetCode416. 分割等和子集LeetCode322. 零钱兑换LeetCode377. 组合总和 ⅣLeetCode474. 一和零LeetCode139. 单词拆分LeetCode494. 目标和

第五组(LIS问题):LeetCode300. 最长递增子序列LeetCode376. 摆动序列

第六组(各种子序列问题):LeetCode674. 最长连续递增序列LeetCode1143. 最长公共子序列LeetCode128. 最长连续序列LeetCode392. 判断子序列

原文地址:https://www.cnblogs.com/HuangYJ/p/14031237.html