4.2 经典动态规划

348 阅读8分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


学习动态规划问题要格外注意这⼏个词:状态」,「选择」,「dp 数组的定义。你把这⼏个词理解到位了,就理解了动态规划的核⼼。

1.最长递增子序列问题M300

题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

🌸「示例:」

示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1

解答

最⻓递增⼦序列(Longest Increasing Subsequence,简写 LIS)是⾮常经典的⼀个算法问题,⽐较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅⼊深讲解如何找状态转移⽅程,如何写出动态规划解法。

解法一:动态规划解法

动态规划的核⼼设计思想是数学归纳法。

⽐如我们想证明⼀个数学结论,那么我们先假设这个结论在 k<n 时成⽴,然后根据这个假设,想办法推导证明出 k=n 的时候此结论也成⽴。如果能够证明出来,那么就说明这个结论对于 k 等于任何数都成⽴。

类似的,我们设计动态规划算法,不是需要⼀个 dp 数组吗?我们可以假设 dp[0...i-1] 都已经被算出来了,然后问⾃⼰:怎么通过这些结果算出 dp[i]?

我们定义:dp[i] 表示以 nums[i] 这个数结尾的最⻓递增⼦序列的⻓度。

dp 数组的定义⽅法也就那⼏种。

假设我们已经知道了 dp[0..4] 的所有结果,我们如何通过这些已知结果推出 dp[5] 呢?

image.png

nums[5] = 3,既然是递增⼦序列,我们只要找到前⾯那些结尾⽐ 3 ⼩的⼦序列,然后把 3 接到最后,就可以形成⼀个新的递增⼦序列。

class Solution300 {

    public int lengthOfLIS(int[] nums) {
        // (1)定义dp数组:dp[i] 表示以 nums[i] 这个数结尾的最⻓递增⼦序列的⻓度。
        int length = nums.length;
        int[] dp = new int[length];
        Arrays.fill(dp, 1);

        // (3)数学归纳法
        for (int i = 0; i < length; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j]){
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
        }



        // (2)最后返回的是
        //int count = 1;

        //for (int i : dp) {
        //    if (i > count){
        //        count = i;
        //    }
        //}
        Arrays.sort(dp);
        return dp[dp.length -1];
    }
}

时间复杂度 O(N^2)。

解法二:二分查找解法

image.png

⽐如说上述的扑克牌最终会被分成这样 5 堆(我们认为纸牌 A 的牌⾯是最⼤的,纸牌 2 的牌⾯是最⼩的)。

image.png

2.最大子数组和问题 E53

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。

🌸「示例:」

示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:
输入:nums = [1]
输出:1

示例 3:
输入:nums = [5,4,-1,7,8]
输出:23

解答

解法一:动态规划解法

  • dp 数组的定义

  • 返回最大子数组和

  • 使用数学归纳法来找状态转移关系

class Solution53 {
    public int maxSubArray(int[] nums) {
        //(1)定义dp数组:dp[i]表示以nums[i]结尾的最大和连续子数组
        int[] dp = new int[nums.length];
        Arrays.fill(dp, Integer.MIN_VALUE);

        // base case
        dp[0] = nums[0];
        //(3)状态转移
        for (int i = 1; i < nums.length; i++) {
            dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
        }

        //(2)返回
        System.out.println("dp[]: " + Arrays.toString(dp));
        Arrays.sort(dp);
        return dp[dp.length - 1];
    }
}

时间复杂度是 O(N),空间复杂度也是 O(N)。

较暴⼒解法已经很优秀了,不过注意到 dp[i] 仅仅和 dp[i-1] 的状态有关,那么我们可以进⾏「状态压缩」,将空间复杂度降低:

总结一下

「最⼤⼦数组和」就和「最⻓递增⼦序列」⾮常类似,dp 数组的定义是「以 nums[i] 为结尾的最⼤⼦数组和/最⻓递增⼦序列为 dp[i]」。因为只有这样定义才能将 dp[i+1]dp[i] 建⽴起联系,利⽤数学归纳法写出状态转移⽅程。

3.最长公共子序列问题M1143

题目描述

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。 两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

🌸「示例:」

示例 1:
输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace" ,它的长度为 3 。

示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。

示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0

解答

正确的思路是不要考虑整个字符串,⽽是细化到 s1s2 的每个字符。

对于两个字符串求子序列的问题,都是用两个指针ij分别在两个字符串上移动,大概率是动态规划思路

最长公共子序列的问题也可以遵循这个规律,我们可以先写一个dp函数:

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j)

这个dp函数的定义是:dp(s1, i, s2, j)计算s1[i..]s2[j..]的最长公共子序列长度

根据这个定义,那么我们想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)j == len(s2)时,因为这时候s1[i..]s2[j..]就相当于空串了,最长公共子序列的长度显然是 0:

int longestCommonSubsequence(String s1, String s2) {\
    return dp(s1, 0, s2, 0);\
}\
\
/* 主函数 */\
int dp(String s1, int i, String s2, int j) {\
    // base case\
    if (i == s1.length() || j == s2.length()) {\
        return 0;\
    }\
    // ...

接下来,咱不要看s1s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么

我们只看s1[i]s2[j]如果s1[i] == s2[j],说明这个字符一定在lcs

image.png

这样,就找到了一个lcs中的字符,根据dp函数的定义,我们可以完善一下代码:

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
    if (s1.charAt(i) == s2.charAt(j)) {
        // s1[i] 和 s2[j] 必然在 lcs 中,
        // 加上 s1[i+1..] 和 s2[j+1..] 中的 lcs 长度,就是答案
        return 1 + dp(s1, i + 1, s2, j + 1)
    } else {
        // ...
    }
}

刚才说的s1[i] == s2[j]的情况,但如果s1[i] != s2[j],应该怎么办呢?

s1[i] != s2[j]意味着,s1[i]s2[j]中至少有一个字符不在lcs

image.png

如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?

其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度

int dp(String s1, int i, String s2, int j) {
    if (s1.charAt(i) == s2.charAt(j)) {
        return 1 + dp(s1, i + 1, s2, j + 1)
    } else {
        // s1[i] 和 s2[j] 中至少有一个字符不在 lcs 中,
        // 穷举三种情况的结果,取其中的最大结果
        return max(
            // 情况一、s1[i] 不在 lcs 中
            dp(s1, i + 1, s2, j),
            // 情况二、s2[j] 不在 lcs 中
            dp(s1, i, s2, j + 1),
            // 情况三、都不在 lcs 中
            dp(s1, i + 1, s2, j + 1)
        );
    }
}

这里就已经非常接近我们的最终答案了,还有一个小的优化,情况三「s1[i]s2[j]都不在 lcs 中」其实可以直接忽略

因为我们在求最大值嘛,情况三在计算s1[i+1..]s2[j+1..]lcs长度,这个长度肯定是小于等于情况二s1[i..]s2[j+1..]中的lcs长度的,因为s1[i+1..]s1[i..]短嘛,那从这里面算出的lcs当然也不可能更长嘛。

同理,情况三的结果肯定也小于等于情况一。说白了,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三,完整代码如下:


// 备忘录,消除重叠子问题
int[][] memo;

/* 主函数 */
int longestCommonSubsequence(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // 备忘录值为 -1 代表未曾计算
    memo = new int[m][n];
    for (int[] row : memo) 
        Arrays.fill(row, -1);
    // 计算 s1[0..] 和 s2[0..] 的 lcs 长度
    return dp(s1, 0, s2, 0);
}

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
    // base case
    if (i == s1.length() || j == s2.length()) {
        return 0;
    }
    // 如果之前计算过,则直接返回备忘录中的答案
    if (memo[i][j] != -1) {
        return memo[i][j];
    }
    // 根据 s1[i] 和 s2[j] 的情况做选择
    if (s1.charAt(i) == s2.charAt(j)) {
        // s1[i] 和 s2[j] 必然在 lcs 中
        memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1);
    } else {
        // s1[i] 和 s2[j] 至少有一个不在 lcs 中
        memo[i][j] = Math.max(
            dp(s1, i + 1, s2, j),
            dp(s1, i, s2, j + 1)
        );
    }
    return memo[i][j];
}

解法一:动态规划解法



4.编辑距离问题

题目描述

🌸「示例:」

解答

解法一:动态规划解法

5.正则表达式问题

题目描述

🌸「示例:」

解答

解法一:动态规划解法