动态规划 - 两个数组的 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;
    }
};
相关推荐
Jerry2 小时前
LeetCode 28. 找出字符串中第一个匹配项的下标
算法
Jerry4 小时前
LeetCode 459. 重复的子字符串
算法
海石7 小时前
1500分的题目,确实有实力,不过还是我略胜一筹
算法·leetcode
海石7 小时前
【记忆化搜索】条条大路通AC,走好适合你的那一条,走到后再考虑走得快
算法·leetcode
Jerry9 小时前
LeetCode 151. 反转字符串中的单词
算法
a11177612 小时前
LM 算法迭代过程动画演示(SLAM)
算法
头茬韭菜12 小时前
Context 的生死抉择:四层压缩、截断算法与 Session Memory
算法·ai
Jerry12 小时前
LeetCode 541. 反转字符串 II
算法
Jerry12 小时前
LeetCode 344. 反转字符串
算法
搞科研的小刘选手12 小时前
【香港大学主办&IEEE出版】第六届计算机视觉、应用与算法国际学术会议(CVAA 2026)
算法·计算机视觉·应用·学术会议