动态规划 - 两个数组的 dp 问题

两个数组的 dp 问题

动态规划的核心思想 是将复杂问题分解为相互重叠的子问题,通过记忆化存储子问题的解(通常用数组 dp 表示)来避免重复计算,最终高效地得到原问题的最优解。关键在于找到"状态"(即 dp 数组每个位置所代表的含义)和"状态转移方程"(即子问题解之间的递推关系)。


两个数组的 DP 问题 ,特指那些状态由两个输入数组(或字符串)的索引共同决定 的题目。这类问题的 dp[i][j] 通常定义为:考虑第一个数组的前 i 个元素和第二个数组的前 j 个元素时,所能得到的某种最优解或可行解的数量。

其题目类型和适用场景主要有以下几种:

  1. 最长公共子序列 :求两个字符串/数组的最长公共子序列长度。dp[i][j] 表示 s1[0..i-1]s2[0..j-1] 的 LCS 长度。

  2. 编辑距离 :求将一个字符串转换成另一个字符串所需的最少操作次数(增、删、改)。dp[i][j] 表示将 s1[0..i-1] 转换为 s2[0..j-1] 的最小编辑距离。

  3. 字符串交织 :判断一个字符串是否由另外两个字符串交错组成。dp[i][j] 表示 s1 的前 i 个字符和 s2 的前 j 个字符是否能交织形成 s3 的前 i+j 个字符。

  4. 子序列匹配 :判断一个字符串是否是另一个字符串的子序列(进阶版可能涉及多个匹配)。dp[i][j] 可以表示匹配状态。

  5. 最大公共子数组 :求两个数组中最长的连续公共子数组的长度。dp[i][j] 通常表示以 nums1[i-1]nums2[j-1] 结尾的最长公共子数组的长度。

总结来说,一个题目适用于"两个数组的DP"当且仅当:

  • 问题涉及两个序列(数组或字符串)的相互关系,比如比较、匹配、转换。

  • 问题的解可以自然地通过逐步考虑两个序列的前缀来构建,即存在"子结构"。

  • 在逐步构建解的过程中,存在需要重复计算的子问题,从而使得动态规划能发挥优势。

题目练习

1143. 最长公共子序列 - 力扣(LeetCode)

解法(动态规划):

算法思路:

  1. 状态表示:

对于两个数组的动态规划,我们的定义状态表示的经验就是:

  • ⅰ. 选取第一个数组 [0, i] 区间以及第二个数组 [0, j] 区间作为研究对象;

  • ⅱ. 结合题目要求,定义状态表示。

在这道题中,我们根据定义状态表示为:

dp[i][j] 表示:s1[0, i] 区间以及 s2[0, j] 区间内的所有的子序列中,最长公共子序列的长度。

  1. 状态转移方程:

分析状态转移方程的经验就是根据 **「最后一个位置」** 的状况,分情况讨论。

对于 dp[i][j],我们可以根据 s1[i]s2[j] 的字符分情况讨论:

  • ⅰ. 两个字符相同,s1[i] = s2[j]:那么最长公共子序列就在 s1[0, i - 1] 以及 s2[0, j - 1] 区间上找到一个最长的,然后再加上 s1[i] 即可。因此 dp[i][j] = dp[i - 1][j - 1] + 1

  • ⅱ. 两个字符不相同,s1[i] != s2[j]:那么最长公共子序列一定不会同时以 s1[i]s2[j] 结尾。那么我们找最长公共子序列时,有下面三种策略:

    • s1[0, i - 1] 以及 s2[0, j] 区间内找:此时最大长度为 dp[i - 1][j]

    • s1[0, i] 以及 s2[0, j - 1] 区间内找:此时最大长度为 dp[i][j - 1]

    • s1[0, i - 1] 以及 s2[0, j - 1] 区间内找:此时最大长度为 dp[i - 1][j - 1]

我们要三者的最大值即可。但是我们细细观察会发现,第三种包含在第一种和第二种情况里面,但是我们求的是最大值,并不影响最终结果。因此只需求前两种情况下的最大值即可。

综上,状态转移方程为:

cpp 复制代码
if (s1[i] == s2[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
if (s1[i] != s2[j]) dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
  1. 初始化:
  • a. **「空串」** 是有研究意义的,因此我们将原始 dp 表的规模多加上一行和一列,表示空串。

  • b. 引入空串后,大大的方便我们的初始化。

  • c. 但也要注意 **「下标的映射关系」,以及里面的值要「保证后续填表是正确的」**。

s1 为空时,没有长度,同理 s2 也是。因此第一行和第一列里面的值初始化为 0 即可保证后续填表是正确的。

  1. 填表顺序:

根据 **「状态转移方程」** 得:从上往下填写每一行,每一行从左往右。

  1. 返回值:

根据 **「状态表示」** 得:返回 dp[m][n]

cpp 复制代码
class Solution {
public:
    int longestCommonSubsequence(string s1, string s2) {
        int m = s1.size(), n = s2.size();
        s1 = "_" + s1; s2 = "_" + s2;
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                if(s1[i] == s2[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
                else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[m][n];
    }
};

1035. 不相交的线 - 力扣(LeetCode)

解法(动态规划):

算法思路:

如果要保证两条直线不相交,那么我们 **「下一个连线」必须在 「上一个连线」对应的两个元素的 「后面」寻找相同的元素。这不就转化成 「最长公共子序列」的模型了嘛。那就是在这两个数组中寻找「最长的公共子序列」**。

只不过是在整数数组中做一次 **「最长的公共子序列」**,代码几乎一模一样,这里就不再赘述算法原理啦~

cpp 复制代码
class Solution {
public:
    int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size(), n = nums2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                if(nums1[i - 1] == nums2[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                else
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[m][n];
    }
};

115. 不同的子序列 - 力扣(LeetCode)

解法(动态规划):

算法思路:

  1. 状态表示:

对于两个字符串之间的 dp 问题,我们一般的思考方式如下:

  • ⅰ. 选取第一个字符串的 [0, i] 区间以及第二个字符串的 [0, j] 区间当成研究对象,结合题目的要求来定义 **「状态表示」**;

  • ⅱ. 然后根据两个区间上 **「最后一个位置的字符」,来进行 「分类讨论」,从而确定「状态转移方程」**。

我们可以根据上面的策略,解决大部分关于两个字符串之间的 dp 问题。

dp[i][j] 表示:在字符串 s 的 [0, j] 区间内的所有子序列中,有多少个 t 字符串 [0, i] 区间内的子串。

  1. 状态转移方程:

老规矩,根据 **「最后一个位置」** 的元素,结合题目要求,分情况讨论:

  • ⅰ. 当 t[i] == s[j] 的时候,此时的子序列有两种选择:

    • 一种选择是:子序列选择 s[j] 作为结尾,此时相当于在状态 dp[i - 1][j - 1] 中的所有符合要求的子序列的后面,再加上一个字符 s[j](请大家结合状态表示,好好理解这句话),此时 dp[i][j] = dp[i - 1][j - 1]

    • 另一种选择是:我就是任性,我就不选择 s[j] 作为结尾。此时相当于选择了状态 dp[i][j - 1] 中所有符合要求的子序列。我们也可以理解为继承了上个状态里面的求得的子序列。此时 dp[i][j] = dp[i][j - 1]

    两种情况加起来,就是 t[i] == s[j] 时的结果。

  • ⅱ. 当 t[i] != s[j] 的时候,此时的子序列只能从 dp[i][j - 1] 中选择所有符合要求的子序列。只能继承上个状态里面求得的子序列,dp[i][j] = dp[i][j - 1]

综上所述,状态转移方程为:

  • 所有情况下都可以继承上一次的结果:dp[i][j] = dp[i][j - 1]

  • t[i] == s[j] 时,可以多选择一种情况:dp[i][j] += dp[i - 1][j - 1]

  1. 初始化:
  • a. **「空串」** 是有研究意义的,因此我们将原始 dp 表的规模多加上一行和一列,表示空串。

  • b. 引入空串后,大大的方便我们的初始化。

  • c. 但也要注意 **「下标的映射关系」,以及里面的值要「保证后续填表是正确的」**。

s 为空时,t 的子串中有一个空串和它一样,因此初始化第一行全部为 1

  1. 填表顺序:

「从上往下」填每一行,每一行「从左往右」

  1. 返回值:

根据 **「状态表示」**,返回 dp[m][n] 的值。

本题有一个巨恶心的地方,题目上说结果不会超过 int 的最大值,但是实际在计算过程会超。为了避免报错,我们选择 double 存储结果。

cpp 复制代码
class Solution {
public:
    int numDistinct(string s, string t) {
        int m = t.size(), n = s.size();
        vector<vector<double>> dp(m + 1, vector<double>(n + 1));
        s = "_" + s, t = "_" + t;
        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];
                if(s[j] == t[i]) dp[i][j] += dp[i - 1][j -1];
            }
        }
        return dp[m][n];
    }
};

44. 通配符匹配 - 力扣(LeetCode)

解法(动态规划):

算法思路:

  1. 状态表示:

对于两个字符串之间的 dp 问题,我们一般的思考方式如下:

  • ⅰ. 选取第一个字符串的 [0, i] 区间以及第二个字符串的 [0, j] 区间当成研究对象,结合题目的要求来定义 **「状态表示」**;

  • ⅱ. 然后根据两个区间上 **「最后一个位置的字符」,来进行 「分类讨论」,从而确定「状态转移方程」**。

我们可以根据上面的策略,解决大部分关于两个字符串之间的 dp 问题。

因此,我们定义状态表示为:

dp[i][j] 表示:p 字符串 [0, j] 区间内的子串能否匹配字符串 s 的 [0, i] 区间内的子串。

  1. 状态转移方程:

老规矩,根据最后一个位置的元素,结合题目要求,分情况讨论:

  • ⅰ. 当 s[i] == p[j]p[j] == '?' 的时候,此时两个字符串匹配上了当前的一个字符,只能从 dp[i - 1][j - 1] 中看当前字符前面的两个子串是否匹配。只能继承上个状态中的匹配结果,dp[i][j] = dp[i][j - 1]

  • ⅱ. 当 p[j] == '*' 的时候,此时匹配策略有两种选择:

    • 一种选择是:* 匹配空字符串,此时相当于它匹配了一个寂寞,直接继承状态 dp[i][j - 1],此时 dp[i][j] = dp[i][j - 1]

    • 另一种选择是:* 向前匹配 1 ~ n 个字符,直至匹配上整个 s1 串。此时相当于从 dp[k][j - 1]0 <= k <= i)中所有匹配情况中,选择性继承可以成功的情况。此时 dp[i][j] = dp[k][j - 1]0 <= k <= i);

  • ⅲ. 当 p[j] 不是特殊字符,且不与 s[i] 相等时,无法匹配。

三种情况加起来,就是所有可能的匹配结果。

综上所述,状态转移方程为:

  • s[i] == p[j]p[j] == '?' 时:dp[i][j] = dp[i][j - 1]

  • p[j] == '*' 时,有多种情况需要讨论:dp[i][j] = dp[k][j - 1]0 <= k <= i)。

优化:当我们发现,计算一个状态的时候,需要一个循环才能搞定的时候,我们要想到去优化。优化的方向就是用一个或者两个状态来表示这一堆的状态。通常就是把它写下来,然后用数学的方式做一下等价替换:

当 p[j] == '*' 时,状态转移方程为:dp[i][j] = dp[i][j - 1] || dp[i - 1][j - 1] || dp[i - 2][j - 1]......

我们发现 i 是有规律的减小的,因此我们去看看 dp[i - 1][j]:dp[i - 1][j] = dp[i - 1][j - 1] || dp[i - 2][j - 1] || dp[i - 3][j - 1]......

我们惊奇的发现,dp[i][j] 的状态转移方程里面除了第一项以外,其余的都可以用 dp[i - 1][j] 替代。因此,我们优化我们的状态转移方程为:dp[i][j] = dp[i - 1][j] || dp[i][j - 1]。

  1. 初始化:

由于 dp 数组的值设置为是否匹配,为了不与答案值混淆,我们需要将整个数组初始化为 false

由于需要用到前一行和前一列的状态,我们初始化第一行、第一列即可。

  • dp[0][0] 表示两个空串能否匹配,答案是显然的,初始化为 true

  • 第一行表示 s 是一个空串,p 串和空串只有一种匹配可能,即 p 串表示为 "***",此时也相当于空串匹配上空串。所以,我们可以遍历 p 串,把所有前导为 "*"p 子串和空串的 dp 值设为 true

  • 第一列表示 p 是一个空串,不可能匹配上 s 串,跟随数组初始化即可。

  1. 填表顺序:

从上往下填每一行,每一行从左往右。

  1. 返回值:

根据状态表示,返回 dp[m][n] 的值。

cpp 复制代码
class Solution {
public:
    bool isMatch(string s, string p) {
        int m = s.size(), n = p.size();
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        s = "_" + s; p = "_" + p;
        dp[0][0] = true;
        for(int j = 1; j <= n; ++j)
        {
            if(p[j] == '*') dp[0][j] = true;
            else break;
        }
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                if(p[j] == '*')
                    dp[i][j] = dp[i -1][j] || dp[i][j - 1];
                else
                    dp[i][j] = (p[j] == '?' || s[i] == p[j]) && dp[i - 1][j - 1];
            }
        }
        return dp[m][n];
    }
};

10. 正则表达式匹配 - 力扣(LeetCode)

解法(动态规划):

算法思路:

  1. 状态表示:

对于两个字符串之间的 dp 问题,我们一般的思考方式如下:

  • ⅰ. 选取第一个字符串的 [0, i] 区间以及第二个字符串的 [0, j] 区间当成研究对象,结合题目的要求来定义 **「状态表示」**;

  • ⅱ. 然后根据两个区间上 **「最后一个位置的字符」,来进行 「分类讨论」,从而确定「状态转移方程」**。

我们可以根据上面的策略,解决大部分关于两个字符串之间的 dp 问题。

因此我们定义状态表示:

dp[i][j] 表示:字符串 p[0, j] 区间和字符串 s[0, i] 区间是否可以匹配。

  1. 状态转移方程:

老规矩,根据最后一个位置的元素,结合题目要求,分情况讨论:

  • a. 当 s[i] == p[j]p[j] == '.' 的时候,此时两个字符串匹配上了当前的一个字符,只能从 dp[i - 1][j - 1] 中看当前字符前面的两个子串是否匹配。只能继承上个状态中的匹配结果,dp[i][j] = dp[i - 1][j - 1]

  • b. 当 p[j] == '*' 的时候,和上道题稍有不同的是,上道题 "*" 本身便可匹配 0 ~ n 个字符,但此题是要带着 p[j - 1] 的字符一起,匹配 0 ~ n 个和 p[j - 1] 相同的字符。此时,匹配策略有两种选择:

    • 一种选择是:p[j - 1]* 匹配空字符串,此时相当于这两个字符都匹配了一个寂寞,直接继承状态 dp[i][j - 2],此时 dp[i][j] = dp[i][j - 2]

    • 另一种选择是:p[j - 1]* 向前匹配 1 ~ n 个字符,直至匹配上整个 s1 串。此时相当于从 dp[k][j - 2]0 < k <= i)中所有匹配情况中,选择性继承可以成功的情况。此时 dp[i][j] = dp[k][j - 2]0 < k <= is[k]~s[i] = p[j - 1]);

  • c. 当 p[j] 不是特殊字符,且不与 s[i] 相等时,无法匹配。

三种情况加起来,就是所有可能的匹配结果。

综上所述,状态转移方程为:

  • s[i] == p[j]p[j] == '.' 时:dp[i][j] = dp[i - 1][j - 1]

  • p[j] == '*' 时,有多种情况需要讨论:dp[i][j] = dp[i][j - 2]dp[i][j] = dp[k][j - 1]0 <= k <= i)。

优化:当我们发现,计算一个状态的时候,需要一个循环才能搞定的时候,我们要想到去优化。优化的方向就是用一个或者两个状态来表示这一堆的状态。通常就是把它写下来,然后用数学的方式做一下等价替换:

p[j] == '*' 时,状态转移方程为:dp[i][j] = dp[i][j - 2] || dp[i - 1][j - 2] || dp[i - 2][j - 2]......

我们发现 i 是有规律的减小的,因此我们去看看 dp[i - 1][j]dp[i - 1][j] = dp[i - 1][j - 2] || dp[i - 2][j - 2] || dp[i - 3][j - 2]......

我们惊奇的发现,dp[i][j] 的状态转移方程里面除了第一项以外,其余的都可以用 dp[i - 1][j] 替代。因此,我们优化我们的状态转移方程为:dp[i][j] = dp[i][j - 2] || dp[i - 1][j]

  1. 初始化:

由于 dp 数组的值设置为是否匹配,为了不与答案值混淆,我们需要将整个数组初始化为 false

由于需要用到前一行和前一列的状态,我们初始化第一行、第一列即可。

  • dp[0][0] 表示两个空串能否匹配,答案是显然的,初始化为 true

  • 第一行表示 s 是一个空串,p 串和空串只有一种匹配可能,即 p 串全部字符表示为 **「任一字符 + *」,此时也相当于空串匹配上空串。所以,我们可以遍历 p 串,把所有前导为 「任一字符 + *」** 的 p 子串和空串的 dp 值设为 true

  • 第一列表示 p 是一个空串,不可能匹配上 s 串,跟随数组初始化即可。

  1. 填表顺序:

从上往下填每一行,每一行从左往右。

  1. 返回值:

根据状态表示,返回 dp[m][n] 的值。

cpp 复制代码
class Solution {
public:
    bool isMatch(string s, string p) {
        int m = s.size(), n = p.size();
        s = "_" + s; p = "_" + p;
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        dp[0][0] = true;
        for(int j = 2; j <= n; j += 2) 
        {
            if(p[j] == '*') dp[0][j] = true;
            else break;
        }
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                if(p[j] == '*') dp[i][j] = dp[i][j - 2] || (p[j - 1] == '.' || p[j - 1] == s[i]) && dp[i - 1][j];
                else dp[i][j] = (p[j] == s[i] || p[j] == '.') && dp[i -1][j - 1];
            }
        }
        return dp[m][n];
    }
};

97. 交错字符串 - 力扣(LeetCode)

解法(动态规划):

算法思路:

对于两个字符串之间的 dp 问题,我们一般的思考方式如下:

  • ⅰ. 选取第一个字符串的 [0, i] 区间以及第二个字符串的 [0, j] 区间当成研究对象,结合题目的要求来定义 **「状态表示」**;

  • ⅱ. 然后根据两个区间上 **「最后一个位置的字符」,来进行 「分类讨论」,从而确定「状态转移方程」**。

我们可以根据上面的策略,解决大部分关于两个字符串之间的 dp 问题。

这道题里面空串是有研究意义的,因此我们先预处理一下原始字符串,前面统一加上一个占位符:s1 = " " + s1s2 = " " + s2s3 = " " + s3

  1. 状态表示:

dp[i][j] 表示字符串 s1[1, i] 区间内的字符串以及 s2[1, j] 区间内的字符串,能否拼接成 s3[1, i + j] 区间内的字符串。

  1. 状态转移方程:

先分析一下题目,题目中交错后的字符串为 s1 + t1 + s2 + t2 + s3 + t3......,看似一个 s 一个 t。实际上 s1 能够拆分成更小的一个字符,进而可以细化成 s1 + s2 + s3 + t1 + t2 + s4......

也就是说,并不是前一个用了 s 的子串,后一个必须要用 t 的子串。这一点理解,对我们的状态转移很重要。

继续根据两个区间上 **「最后一个位置的字符」,结合题目的要求,来进行「分类讨论」**:

  • ⅰ. 当 s3[i + j] = s1[i] 的时候,说明交错后的字符串的最后一个字符和 s1 的最后一个字符匹配了。那么整个字符串能否交错组成,变成:s1[1, i - 1] 区间上的字符串以及 s2[1, j] 区间上的字符串,能够交错形成 s3[1, i + j - 1] 区间上的字符串,也就是 dp[i - 1][j];此时 dp[i][j] = dp[i - 1][j]

  • ⅱ. 当 s3[i + j] = s2[j] 的时候,说明交错后的字符串的最后一个字符和 s2 的最后一个字符匹配了。那么整个字符串能否交错组成,变成:s1[1, i] 区间上的字符串以及 s2[1, j - 1] 区间上的字符串,能够交错形成 s3[1, i + j - 1] 区间上的字符串,也就是 dp[i][j - 1]

  • ⅲ. 当两者的末尾都不等于 s3 最后一个位置的字符时,说明不可能是两者的交错字符串。

上述三种情况下,只要有一个情况下能够交错组成目标串,就可以返回 true。因此,我们可以定义状态转移为:

cpp 复制代码
dp[i][j] = (s1[i - 1] == s3[i + j - 1] && dp[i - 1][j])
          || (s2[j - 1] == s3[i + j - 1] && dp[i][j - 1])

只要有一个成立,结果就是 true

  1. 初始化:

由于用到 i - 1j - 1 位置的值,因此需要初始化 **「第一个位置」以及 「第一行」「第一列」**。

  • 第一个位置:dp[0][0] = true,因为空串 + 空串能够构成一个空串。

  • 第一行:第一行表示 s1 是一个空串,我们只用考虑 s2 即可。因此状态转移之和 s2 有关:dp[0][j] = s2[j - 1] == s3[j - 1] && dp[0][j - 1]j1nns2 的长度);

  • 第一列:第一列表示 s2 是一个空串,我们只用考虑 s1 即可。因此状态转移之和 s1 有关:dp[i][0] = s1[i - 1] == s3[i - 1] && dp[i - 1][0]i1mms1 的长度)。

  1. 填表顺序:

根据 **「状态转移」,我们需要 「从上往下」填每一行,每一行「从左往右」**。

  1. 返回值:

根据 **「状态表示」**,我们需要返回 dp[m][n] 的值。

cpp 复制代码
class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        int m = s1.size(), n = s2.size();
        if(m + n != s3.size()) return false;
        s1 = "_" + s1; s2 = "_" + s2; s3 = "_" + s3; 
        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
        dp[0][0] = true;
        for(int j = 1; j <= n; ++j)
            if(s2[j] == s3[j]) dp[0][j] = true;
            else break;
        for(int i = 1; i <= m; ++i)
            if(s1[i] == s3[i]) dp[i][0] = true;
            else break;
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                dp[i][j] = (s1[i] == s3[i + j] && dp[i - 1][j])
                        || (s2[j] == s3[i + j] && dp[i][j - 1]);
            }
        }
        return dp[m][n];
    }
};

712. 两个字符串的最小ASCII删除和 - 力扣(LeetCode)

解法(动态规划):

算法思路:

正难则反 :求两个字符串的最小 ASCII 删除和,其实就是找到两个字符串中所有的公共子序列里面,ASCII 最大和。

因此,我们的思路就是按照 **「最长公共子序列」** 的分析方式来分析。

  1. 状态表示:

dp[i][j] 表示:s1[0, i] 区间以及 s2[0, j] 区间内的所有的子序列中,公共子序列的 ASCII 最大和。

  1. 状态转移方程:

对于 dp[i][j] 根据 **「最后一个位置」** 的元素,结合题目要求,分情况讨论:

  • ⅰ. 当 s1[i] == s2[j] 时:应该先在 s1[0, i - 1] 区间以及 s2[0, j - 1] 区间内找一个公共子序列的最大和,然后在它们后面加上一个 s1[i] 字符即可。此时 dp[i][j] = dp[i - 1][j - 1] + s1[i]

  • ⅱ. 当 s1[i] != s2[j] 时:公共子序列的最大和会有三种可能:

    • s1[0, i - 1] 区间以及 s2[0, j] 区间内:此时 dp[i][j] = dp[i - 1][j]

    • s1[0, i] 区间以及 s2[0, j - 1] 区间内:此时 dp[i][j] = dp[i][j - 1]

    • s1[0, i - 1] 区间以及 s2[0, j - 1] 区间内:此时 dp[i][j] = dp[i - 1][j - 1]

    但是前两种情况里面包含了第三种情况,因此仅需考虑前两种情况下的最大值即可。

综上所述,状态转移方程为:

  • s1[i - 1] == s2[j - 1] 时,dp[i][j] = dp[i - 1][j - 1] + s1[i]

  • s1[i - 1] != s2[j - 1] 时,dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

  1. 初始化:
  • a. **「空串」** 是有研究意义的,因此我们将原始 dp 表的规模多加上一行和一列,表示空串。

  • b. 引入空串后,大大的 **「方便我们的初始化」**。

  • c. 但也要注意 **「下标的映射」关系,以及里面的值要保证「后续填表是正确的」**。

s1 为空时,没有长度,同理 s2 也是。因此第一行和第一列里面的值初始化为 0 即可保证后续填表是正确的。

  1. 填表顺序:

「从上往下」填每一行,每一行「从左往右」

  1. 返回值:

根据 **「状态表示」**,我们不能直接返回 dp 表里面的某个值:

  • ⅰ. 先找到 dp[m][n],也是最大公共 ASCII 和;

  • ⅱ. 统计两个字符串的 ASCII 码和 sum

  • ⅲ. 返回 sum - 2 * dp[m][n]

cpp 复制代码
class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
        int m = s1.size(), n = s2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]);
                if(s1[i - 1] == s2[j - 1])
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + s1[i - 1]);
            }
        }
        int sum = 0;
        for(auto s : s1) sum += s;
        for(auto s : s2) sum += s;
        return sum - 2 * dp[m][n];
    }
};

718. 最长重复子数组 - 力扣(LeetCode)

解法(动态规划):

算法思路:

子数组是数组中 **「连续」的一段,我们习惯上 「以某一个位置为结尾」** 来研究。由于是两个数组,因此我们可以尝试:以第一个数组的 i 位置为结尾以及第二个数组的 j 位置为结尾来解决问题。

  1. 状态表示:

dp[i][j] 表示 **「以第一个数组的 i 位置为结尾」,以及 「第二个数组的 j 位置为结尾」公共的、长度最长的「子数组」** 的长度。

  1. 状态转移方程:

对于 dp[i][j],当 nums1[i] == nums2[j] 的时候,才有意义,此时最长重复子数组的长度应该等于 1 加上除去最后一个位置时,以 i - 1, j - 1 为结尾的最长重复子数组的长度。

因此,状态转移方程为:dp[i][j] = 1 + dp[i - 1][j - 1]

  1. 初始化:

为了处理 **「越界」** 的情况,我们可以添加一行和一列,dp 数组的下标从 1 开始,这样就无需初始化。

第一行表示第一个数组为空,此时没有重复子数组,因此里面的值设置成 0 即可;

第一列也是同理。

  1. 填表顺序:

根据 **「状态转移」,我们需要 「从上往下」填每一行,每一行「从左往右」**。

  1. 返回值:

根据 **「状态表示」,我们需要返回 dp 表里面的「最大值」**。

cpp 复制代码
class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size(), n = nums2.size(), ret = 0;
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= n; ++j)
            {
                if(nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
                else dp[i][j] = 0;
                ret = max(ret, dp[i][j]);
            }
        }
        return ret;
    }
};
相关推荐
微笑尅乐3 小时前
从暴力到滑动窗口全解析——力扣8. 字符串转换整数 (atoi)
算法·leetcode·职场和发展
火花怪怪3 小时前
LaMer结晶动力学模型
算法
legendary_bruce3 小时前
【22.2 增强决策树】
算法·决策树·机器学习
老马啸西风4 小时前
力扣 LC27. 移除元素 remove-element
算法·面试·github
数智顾问4 小时前
中秋特别篇:使用QtOpenGL和着色器绘制星空与满月——从基础框架到光影渲染
算法
txwtech4 小时前
第5篇 如何计算两个坐标点距离--opencv图像中的两个点
人工智能·算法·机器学习
CoovallyAIHub4 小时前
YOLO26学界首评:四大革新点究竟有多强?
深度学习·算法·计算机视觉
用户916357440954 小时前
LeetCode热题100——11.盛最多水的容器
javascript·算法
Gorgous—l4 小时前
数据结构算法学习:LeetCode热题100-矩阵篇(矩阵置零、螺旋矩阵、旋转图像、搜索二维矩阵 II)
数据结构·学习·算法