专题七:两个数组的 dp (含字符串数组)

79 阅读26分钟

1 最长公共子序列

1.1 题目链接

1143. 最长公共子序列

1.2 题目描述

给定两个字符串 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 。

 

提示:

  • 1 <= text1.length, text2.length <= 1000
  • text1 和 text2 仅由小写英文字符组成。

1.3 解法(动态规划)

算法思路

  1. 状态表⽰:

    • 对于两个数组的动态规划,我们的定义状态表⽰的经验就是:
      • i. 选取第⼀个数组 [0, i] 区间以及第⼆个数组 [0, j] 区间作为研究对象;
      • ii. 结合题⽬要求,定义状态表⽰。
    • 在这道题中,我们根据定义状态表⽰为:
      • dp[i][j] 表⽰: s1 的 [0, i] 区间以及 s2 的 [0, j] 区间内的所有的⼦序列中,最⻓公共⼦序列的⻓度。
  2. 状态转移⽅程:

    • 分析状态转移⽅程的经验就是根据「最后⼀个位置」的状况,分情况讨论。
    • 对于 dp[i][j] ,我们可以根据 s1[i] 与 s2[j] 的字符分情况讨论:
      • i. 两个字符相同, s1[i] = s2[j] :那么最⻓公共⼦序列就在 s1 的 [0, i - 1] 以及 s2 的 [0, j - 1] 区间上找到⼀个最⻓的,然后再加上 s1[i] 即可。因此dp[i][j] = dp[i - 1][j - 1] + 1 ;
      • ii. 两个字符不相同, 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] 。
    • 我们要三者的最⼤值即可。但是我们细细观察会发现,第三种包含在第⼀种和第⼆种情况⾥⾯,但是我们求的是最⼤值,并不影响最终结果。因此只需求前两种情况下的最⼤值即可。
    • 综上,状态转移⽅程为:
      • 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]) 。
  3. 初始化:

    • a. 「空串」是有研究意义的,因此我们将原始 dp 表的规模多加上⼀⾏和⼀列,表⽰空串。
    • b. 引⼊空串后,⼤⼤的⽅便我们的初始化。
    • c. 但也要注意「下标的映射关系」,以及⾥⾯的值要「保证后续填表是正确的」。

    当 s1 为空时,没有⻓度,同理 s2 也是。因此第⼀⾏和第⼀列⾥⾯的值初始化为 0 即可保证后续填表是正确的。

  4. 填表顺序:

    根据「状态转移⽅程」得:从上往下填写每⼀⾏,每⼀⾏从左往右。

  5. 返回值:

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

1.4 C++算法代码:

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size(), n = text2.size();

        text1 = " " + text1, text2 = " " + text2;
        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(text1[i] == text2[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
                else dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]);
        return dp[m][n];
    }
};

2 不相交的线

2.1 题目链接

1035. 不相交的线

2.2 题目描述

在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。

现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足:

  •  nums1[i] == nums2[j]
  • 且绘制的直线不与任何其他连线(非水平线)相交。

请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。

以这种方法绘制线条,并返回可以绘制的最大连线数。

 

示例 1:

输入: nums1 = [1,4,2], nums2 = [1,2,4]
输出: 2
解释: 可以画出两条不交叉的线,如上图所示。 
但无法画出第三条不相交的直线,因为从 nums1[1]=4 到 nums2[2]=4 的直线将与从 nums1[2]=2 到 nums2[1]=2 的直线相交。

示例 2:

输入: nums1 = [2,5,1,2,5], nums2 = [10,5,2,1,5,2]
输出: 3

示例 3:

输入: nums1 = [1,3,7,1,7,5], nums2 = [1,9,2,5,1]
输出: 2

 

提示:

  • 1 <= nums1.length, nums2.length <= 500
  • 1 <= nums1[i], nums2[j] <= 2000

2.3 解法(动态规划)

算法思路

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

  • 只不过是在整数数组中做⼀次「最⻓的公共⼦序列」,代码⼏乎⼀模⼀样,这⾥就不再赘述算法原理啦~

2.4 C++算法代码:

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][j - 1], dp[i - 1][j]);
        return dp[m][n];
    }
};

3 不同的子序列

3.1 题目链接

115. 不同的子序列

3.2 题目描述

给你两个字符串 s ****和 t ,统计并返回在 s 的 子序列 中 t 出现的个数,结果需要对 109 + 7 取模。

 

示例 1:

输入: s = "rabbbit", t = "rabbit"
输出 : 3
解释:
如下所示, 有 3 种可以从 s 中得到 "rabbit" 的方案。
rabbbit
rabbbit
rabbbit

示例 2:

输入: s = "babgbag", t = "bag"
输出 : 5
解释:
如下所示, 有 5 种可以从 s 中得到 "bag" 的方案。 
babgbag
babgbag
babgbag
babgbag
babgbag

 

提示:

  • 1 <= s.length, t.length <= 1000
  • s 和 t 由英文字母组成

3.3 解法(动态规划)

算法思路

  1. 状态表⽰:
  • 对于两个字符串之间的 dp 问题,我们⼀般的思考⽅式如下:
    • i. 选取第⼀个字符串的 [0, i] 区间以及第⼆个字符串的 [0, j] 区间当成研究对象,结合题⽬的要求来定义「状态表⽰」;
    • ii. 然后根据两个区间上「最后⼀个位置的字符」,来进⾏「分类讨论」,从⽽确定「状态转移⽅程」。
  • 我们可以根据上⾯的策略,解决⼤部分关于两个字符串之间的 dp 问题。
    • dp[i][j] 表⽰:在字符串 s 的 [0, j] 区间内的所有⼦序列中,有多少个 t 字符串 [0, i] 区间内的⼦串。
  1. 状态转移⽅程:

    • ⽼规矩,根据「最后⼀个位置」的元素,结合题⽬要求,分情况讨论:
      • i. 当 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] 时的结果。
      • ii. 当 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]
  2. 初始化:

    • a. 「空串」是有研究意义的,因此我们将原始 dp 表的规模多加上⼀⾏和⼀列,表⽰空串。
    • b. 引⼊空串后,⼤⼤的⽅便我们的初始化。
    • c. 但也要注意「下标的映射关系」,以及⾥⾯的值要「保证后续填表是正确的」。

    当 s 为空时, t 的⼦串中有⼀个空串和它⼀样,因此初始化第⼀⾏全部为 1 。

  3. 填表顺序:

    「从上往下」填每⼀⾏,每⼀⾏「从左往右」。

  4. 返回值:

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

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

3.4 C++算法代码:

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));
        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(t[i - 1] == s[j - 1]) dp[i][j] += dp[i - 1][j - 1];
            }
        }
        return dp[m][n];
    }
};

4 通配符匹配

4.1 题目链接

44. 通配符匹配

4.2 题目描述

给你一个输入字符串 (s) 和一个字符模式 (p) ,请你实现一个支持 '?' 和 '*' 匹配规则的通配符匹配:

  • '?' 可以匹配任何单个字符。
  • '*' 可以匹配任意字符序列(包括空字符序列)。

判定匹配成功的充要条件是:字符模式必须能够 完全匹配 输入字符串(而不是部分匹配)。

 

示例 1:

输入: s = "aa", p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。

示例 2:

输入: s = "aa", p = "*"
输出: true
解释: '*' 可以匹配任意字符串。

示例 3:

输入: s = "cb", p = "?a"
输出: false
解释: '?' 可以匹配 'c', 但第二个 'a' 无法匹配 'b'

 

提示:

  • 0 <= s.length, p.length <= 2000
  • s 仅由小写英文字母组成
  • p 仅由小写英文字母、'?' 或 '*' 组成

4.3 解法(动态规划)

算法思路

  1. 状态表⽰:

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

i. 选取第⼀个字符串的 [0, i] 区间以及第⼆个字符串的 [0, j] 区间当成研究对象,结

合题⽬的要求来定义「状态表⽰」;

ii. 然后根据两个区间上「最后⼀个位置的字符」,来进⾏「分类讨论」,从⽽确定「状态转移

⽅程」。

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

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

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

⼦串。

  1. 状态转移⽅程:

    • ⽼规矩,根据最后⼀个位置的元素,结合题⽬要求,分情况讨论:
      • i. 当 s[i] == p[j] 或 p[j] == '?' 的时候,此时两个字符串匹配上了当前的⼀个字符,只能从 dp[i - 1][j - 1] 中看当前字符前⾯的两个⼦串是否匹配。只能继承上个状态中的匹配结果, dp[i][j] = dp[i][j - 1] ;
      • ii. 当 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) ;
      • iii. 当 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] 。
  2. 初始化:

    • 由于 dp 数组的值设置为是否匹配,为了不与答案值混淆,我们需要将整个数组初始化为false 。
    • 由于需要⽤到前⼀⾏和前⼀列的状态,我们初始化第⼀⾏、第⼀列即可。
      • dp[0][0] 表⽰两个空串能否匹配,答案是显然的, 初始化为 true 。
      • 第⼀⾏表⽰ s 是⼀个空串, p 串和空串只有⼀种匹配可能,即 p 串表⽰为 " *** " ,此时也相当于空串匹配上空串。所以,我们可以遍历 p 串,把所有前导为 "*" 的 p ⼦串和空串的 dp 值设为 true 。
      • 第⼀列表⽰ p 是⼀个空串,不可能匹配上 s 串,跟随数组初始化即可。
  3. 填表顺序:

    从上往下填每⼀⾏,每⼀⾏从左往右。

  4. 返回值:

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

4.4 C++算法代码:

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 = 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];
    }
};

5 正则表达式匹配

5.1 题目链接

10. 正则表达式匹配

5.2 题目描述

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。

  • '.' 匹配任意单个字符
  • '*' 匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

 

示例 1:

输入: s = "aa", p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。

示例 2:

输入: s = "aa", p = "a*"
输出: true
解释: 因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。

示例 3:

输入: s = "ab", p = ".*"
输出: true
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。

 

提示:

  • 1 <= s.length <= 20
  • 1 <= p.length <= 20
  • s 只包含从 a-z 的小写字母。
  • p 只包含从 a-z 的小写字母,以及字符 . 和 *
  • 保证每次出现字符 * 时,前面都匹配到有效的字符

5.3 解法(动态规划)

算法思路

  1. 状态表⽰:

    • 对于两个字符串之间的 dp 问题,我们⼀般的思考⽅式如下:
      • i. 选取第⼀个字符串的 [0, i] 区间以及第⼆个字符串的 [0, j] 区间当成研究对象,结合题⽬的要求来定义「状态表⽰」;
      • ii. 然后根据两个区间上「最后⼀个位置的字符」,来进⾏「分类讨论」,从⽽确定「状态转移⽅程」。
    • 我们可以根据上⾯的策略,解决⼤部分关于两个字符串之间的 dp 问题。
    • 因此我们定义状态表⽰:
      • dp[i][j] 表⽰:字符串 p 的 [0, j] 区间和字符串 s 的 [0, i] 区间是否可以匹配。
  2. 状态转移⽅程:

    • ⽼规矩,根据最后⼀个位置的元素,结合题⽬要求,分情况讨论:
      • 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 <= i 且 s[k]~s[i] = p[j - 1]) ;
      • c. 当 p[j] 不是特殊字符,且不与 s[i] 相等时,⽆法匹配。
    • 三种情况加起来,就是所有可能的匹配结果。
    • 综上所述,状态转移⽅程为:
      • 当 s[i] == p[j] 或 p[j] == '.' 时: dp[i][j] = dp[i][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] 。
  3. 初始化:

    • 由于 dp 数组的值设置为是否匹配,为了不与答案值混淆,我们需要将整个数组初始化为false 。
    • 由于需要⽤到前⼀⾏和前⼀列的状态,我们初始化第⼀⾏、第⼀列即可。
      • dp[0][0] 表⽰两个空串能否匹配,答案是显然的, 初始化为 true 。
    • 第⼀⾏表⽰ s 是⼀个空串, p 串和空串只有⼀种匹配可能,即 p 串全部字符表⽰为 "任⼀字符 + *",此时也相当于空串匹配上空串。所以,我们可以遍历 p 串,把所有前导为 "任⼀字符 + *"的 p ⼦串和空串的 dp 值设为 true 。
    • 第⼀列表⽰ p 是⼀个空串,不可能匹配上 s 串,跟随数组初始化即可。
  4. 填表顺序:

    从上往下填每⼀⾏,每⼀⾏从左往右。

  5. 返回值:

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

5.4 C++算法代码:

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];
    }
};

6 交错字符串

6.1 题目链接

97. 交错字符串

6.2 题目描述

给定三个字符串 s1s2s3,请你帮忙验证 s3 是否是由 s1 和 s2 **交错 组成的。

两个字符串 s 和 t 交错 的定义与过程如下,其中每个字符串都会被分割成若干 非空 

子字符串

  • s = s1 + s2 + ... + sn
  • t = t1 + t2 + ... + tm
  • |n - m| <= 1
  • 交错 是 s1 + t1 + s2 + t2 + s3 + t3 + ... 或者 t1 + s1 + t2 + s2 + t3 + s3 + ...

注意: a + b 意味着字符串 a 和 b 连接。

 

示例 1:

输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac"
输出: true

示例 2:

输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc"
输出: false

示例 3:

输入: s1 = "", s2 = "", s3 = ""
输出: true

 

提示:

  • 0 <= s1.length, s2.length <= 100
  • 0 <= s3.length <= 200
  • s1s2、和 s3 都由小写英文字母组成

6.3 解法(动态规划)

算法思路

  • 对于两个字符串之间的 dp 问题,我们⼀般的思考⽅式如下:
    • i. 选取第⼀个字符串的 [0, i] 区间以及第⼆个字符串的 [0, j] 区间当成研究对象,结合题⽬的要求来定义「状态表⽰」;
    • ii. 然后根据两个区间上「最后⼀个位置的字符」,来进⾏「分类讨论」,从⽽确定「状态转移⽅程」。
  • 我们可以根据上⾯的策略,解决⼤部分关于两个字符串之间的 dp 问题。
  • 这道题⾥⾯空串是有研究意义的,因此我们先预处理⼀下原始字符串,前⾯统⼀加上⼀个占位符:
    • s1 = " " + s1, s2 = " " + s2, s3 = " " + s3 。
  1. 状态表⽰:

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

  2. 状态转移⽅程:

    • 先分析⼀下题⽬,题⽬中交错后的字符串为 s1 + t1 + s2 + t2 + s3 + t3...... ,看似⼀个 s ⼀个 t 。实际上 s1 能够拆分成更⼩的⼀个字符,进⽽可以细化成 s1 + s2 + s3 + t1 + t2 + s4...... 。
    • 也就是说,并不是前⼀个⽤了 s 的⼦串,后⼀个必须要⽤ t 的⼦串。这⼀点理解,对我们的状态转移很重要。
    • 继续根据两个区间上「最后⼀个位置的字符」,结合题⽬的要求,来进⾏「分类讨论」:
      • i. 当 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]
      • ii. 当 s3[i + j] = s2[j] 的时候,说明交错后的字符串的最后⼀个字符和 s2 的最后⼀个字符匹配了。那么整个字符串能否交错组成,变成:
        • s1 中 [1, i] 区间上的字符串以及 s2 中 [1, j - 1] 区间上的字符串,能够交错形成 s3 中 [1, i + j - 1] 区间上的字符串,也就是 dp[i][j - 1] ;
      • iii. 当两者的末尾都不等于 s3 最后⼀个位置的字符时,说明不可能是两者的交错字符串。
    • 上述三种情况下,只要有⼀个情况下能够交错组成⽬标串,就可以返回 true 。因此,我们可以定义状态转移为:
      • 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 。
  3. 初始化:

    • 由于⽤到 i - 1 , j - 1 位置的值,因此需要初始化「第⼀个位置」以及「第⼀⾏」和「第⼀列」。
    • 第⼀个位置:
      • dp[0][0] = true ,因为空串 + 空串能够构成⼀个空串。
    • 第⼀⾏:
      • 第⼀⾏表⽰ s1 是⼀个空串,我们只⽤考虑 s2 即可。因此状态转移之和 s2 有关:
      • dp[0][j] = s2[j - 1] == s3[j - 1] && dp[0][j - 1] , j 从 1 到 n( n 为 s2 的⻓度)
    • 第⼀列:
      • 第⼀列表⽰ s2 是⼀个空串,我们只⽤考虑 s1 即可。因此状态转移之和 s1 有关:
      • dp[i][0] = s1[i - 1] == s3[i - 1] && dp[i - 1][0] , i 从 1 到 m( m 为 s1 的⻓度)
  4. 填表顺序:

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

  5. 返回值:

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

6.4 C++算法代码:

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 i = 1; i <= m; i++) // 初始化第⼀列
            if(s1[i] == s3[i]) dp[i][0] = true;
            else break;
        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++)
            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];
    }
};

7 两个字符串的最小ASCII删除和

7.1 题目链接

712. 两个字符串的最小ASCII删除和

7.2 题目描述

给定两个字符串s1 和 s2,返回 使两个字符串相等所需删除字符的 ASCII 值的最小和

 

示例 1:

输入: s1 = "sea", s2 = "eat"
输出: 231
解释: 在 "sea" 中删除 "s" 并将 "s" 的值(115)加入总和。
在 "eat" 中删除 "t" 并将 116 加入总和。
结束时,两个字符串相等,115 + 116 = 231 就是符合条件的最小和。

示例 2:

输入: s1 = "delete", s2 = "leet"
输出: 403
解释: 在 "delete" 中删除 "dee" 字符串变成 "let",
将 100[d]+101[e]+101[e] 加入总和。在 "leet" 中删除 "e"101[e] 加入总和。
结束时,两个字符串都等于 "let",结果即为 100+101+101+101 = 403 。
如果改为将两个字符串转换为 "lee""eet",我们会得到 433417 的结果,比答案更大。

 

提示:

  • 0 <= s1.length, s2.length <= 1000
  • s1 和 s2 由小写英文字母组成

7.3 解法(动态规划)

算法思路

  • 正难则反:求两个字符串的最⼩ ASCII 删除和,其实就是找到两个字符串中所有的公共⼦序列⾥⾯, ASCII 最⼤和。
  • 因此,我们的思路就是按照「最⻓公共⼦序列」的分析⽅式来分析。
  1. 状态表⽰:

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

  2. 状态转移⽅程:

    • 对于 dp[i][j] 根据「最后⼀个位置」的元素,结合题⽬要求,分情况讨论:
      • i. 当 s1[i] == s2[j] 时:应该先在 s1 的 [0, i - 1] 区间以及 s2 的 [0, j - 1] 区间内找⼀个公共⼦序列的最⼤和,然后在它们后⾯加上⼀个 s1[i] 字符即可。
        • 此时 dp[i][j] = dp[i - 1][j - 1] + s1[i] ;
      • ii. 当 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])
  3. 初始化:

    • a. 「空串」是有研究意义的,因此我们将原始 dp 表的规模多加上⼀⾏和⼀列,表⽰空串。
    • b. 引⼊空串后,⼤⼤的「⽅便我们的初始化」。
    • c. 但也要注意「下标的映射」关系,以及⾥⾯的值要保证「后续填表是正确的」。
    • 当 s1 为空时,没有⻓度,同理 s2 也是。因此第⼀⾏和第⼀列⾥⾯的值初始化为 0 即可保证后续填表是正确的。
  4. 填表顺序:

    「从上往下」填每⼀⾏,每⼀⾏「从左往右」。

  5. 返回值:

    • 根据「状态表⽰」,我们不能直接返回 dp 表⾥⾯的某个值:
      • i. 先找到 dp[m][n] ,也是最⼤公共 ASCII 和;
      • ii. 统计两个字符串的 ASCII 码和 s u m;
      • iii. 返回 sum - 2 * dp[m][n] 。

7.4 C++算法代码:

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 - 1][j], dp[i][j - 1]);
                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 e : s1) sum += e;
        for(auto e : s2) sum += e;
        return sum - 2 * dp[m][n];
    }
};

8 最长重复子数组

8.1 题目链接

718. 最长重复子数组

8.2 题目描述

给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度

 

示例 1:

输入: nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出: 3
解释: 长度最长的公共子数组是 [3,2,1]

示例 2:

输入: nums1 = [0,0,0,0,0], nums2 = [0,0,0,0,0]
输出: 5

 

提示:

  • 1 <= nums1.length, nums2.length <= 1000
  • 0 <= nums1[i], nums2[i] <= 100

8.3 解法(动态规划)

算法思路

  • ⼦数组是数组中「连续」的⼀段,我们习惯上「以某⼀个位置为结尾」来研究。由于是两个数组,因此我们可以尝试:以第⼀个数组的 i 位置为结尾以及第⼆个数组的 j 位置为结尾来解决问题。
  1. 状态表⽰:

    dp[i][j] 表⽰「以第⼀个数组的 i 位置为结尾」,以及「第⼆个数组的 j 位置为结尾」公共的 、⻓度最⻓的「⼦数组」的⻓度。

  2. 状态转移⽅程:

    对于 dp[i][j] ,当 nums1[i] == nums2[j] 的时候,才有意义,此时最⻓重复⼦数组的⻓度应该等于 1 加上除去最后⼀个位置时,以 i - 1, j - 1 为结尾的最⻓重复⼦数组的⻓度。 因此,状态转移⽅程为: dp[i][j] = 1 + dp[i - 1][j - 1]

  3. 初始化:

    • 为了处理「越界」的情况,我们可以添加⼀⾏和⼀列, dp 数组的下标从 1 开始,这样就⽆需初始化。
    • 第⼀⾏表⽰第⼀个数组为空,此时没有重复⼦数组,因此⾥⾯的值设置成 0 即可;
    • 第⼀列也是同理。
  4. 填表顺序:

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

  5. 返回值:

    根据「状态表⽰」,我们需要返回 dp 表⾥⾯的「最⼤值」。

8.4 C++算法代码:

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size(), n = nums2.size();

        int 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, ret = max(ret, dp[i][j]);
        return ret;
    }
};