09 子序列问题(编辑距离和回文)

283 阅读14分钟

09 子序列问题(编辑距离和回文)

编辑距离类型问题

1、判断子序列

题目简介:

给定字符串 s 和 t ,判断 s 是否为 t 的子序列。

字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。

示例:
输入: s = "abc", t = "ahbgdc"
输出: true

题解:

1、dp[i]的定义:dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j] 。注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。

2、状态转移方程:首先要考虑如下两种操作:

  • if (s[i - 1] == t[j - 1]) ——> t中找到了一个字符在s中也出现了;

  • if (s[i - 1] != t[j - 1]) ——> 相当于t要删除元素,继续匹配;

if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1。

if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1]。

3、dp[i]的初始化:从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。dp[i][0] 表示以下标i-1为结尾的字符串,与空字符串的相同子序列长度,所以为初始化0,dp[0][j]同理。

4、确定遍历顺序:同理从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右。

5、举例推导dp数组:dp[s.size()][t.size()] 与 字符串s的长度相同说明:s与t的最长相同子序列就是s,那么s 就是 t 的子序列。

    public boolean isSubsequence(String s, String t) {
        int length1 = s.length(); int length2 = t.length();    //获取字符串s、t的长度length1、length2
        int[][] dp = new int[length1+1][length2+1];    //创建dp数组,大小为length1 * length2
        for(int i = 1; i <= length1; i++){    //外层循环字符串s
            for(int j = 1; j <= length2; j++){    //内层循环字符串t
                if(s.charAt(i-1) == t.charAt(j-1)){    //判断此时i、j位置对应的字符是否相等
                    dp[i][j] = dp[i-1][j-1] + 1;    //如果相等则dp[i][j]等于:dp[i-1][j-1] + 1
                }else{    
                    dp[i][j] = dp[i][j-1];    // 如果不相等则dp[i][j]等于:dp[i][j-1]
                }
            }
        }
        if(dp[length1][length2] == length1){    //如果dp[length1][length2]等于length1
            return true;    //说明s是t的子序列
        }else{    //否则就不是
            return false;
        }
    }

2、不同的子序列

题目简介:

给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。

字符串的一个子序列是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,"ACE" 是 "ABCDE" 的一个子序列,而 "AEC" 不是)

题目数据保证答案符合 32 位带符号整数范围。

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

题解:

1、dp[i]的定义:dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。

2、状态转移方程:分析两种情况:

  • s[i - 1] 与 t[j - 1]相等;
  • s[i - 1] 与 t[j - 1] 不相等;

当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成:

一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。即不需要考虑当前s子串和t子串的最后一位字母,所以只需要 dp[i-1][j-1];

一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j];

所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];

当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配(就是模拟在s中删除这个元素),即:dp[i - 1][j];

所以递推公式为:dp[i][j] = dp[i - 1][j];

3、dp[i]的初始化:从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j] 是从上方和左上方推导而来,如图:,那么 dp[i][0] 和dp[0][j]是一定要初始化的。

dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。

再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。那么dp[0][j]一定都是0,s如论如何也变成不了t。

最后就要看一个特殊位置了,dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

4、确定遍历顺序:从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j]都是根据左上方和正上方推出来的。所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

5、举例推导dp数组。

    public int numDistinct(String s, String t) {
        int[][] dp = new int[s.length() + 1][t.length() + 1];    //创建dp数组,大小为:字符串1长度 * 字符串2长度
        for (int i = 0; i < s.length() + 1; i++) {
            dp[i][0] = 1;    //初始书dp[i][0]全部初始化为1
        }
        for (int i = 1; i < s.length() + 1; i++) {    //外循环遍历字符串1
            for (int j = 1; j < t.length() + 1; j++) {    //内循环遍历字符串2
                if (s.charAt(i - 1) == t.charAt(j - 1)) {    //判断i、j位置对应的字符是否相等
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];    //如果相等,则....
                }else{
                    dp[i][j] = dp[i - 1][j];    //如果不相等,则....
                }
            }
        }
        return dp[s.length()][t.length()];     //返回dp[字符串1长度][字符串2长度]即为结果
    }

3、两个字符串的删除操作

题目简介:

给定两个单词 word1 和 word2 ,返回使得 word1 和  word2 **相同所需的最小步数

每步 可以删除任意一个字符串中的一个字符。

示例:
输入: word1 = "sea", word2 = "eat"
输出: 2
解释: 第一步将 "sea" 变为 "ea" ,第二步将 "eat "变为 "ea"

题解:

其实就是两个字符串都可以删除了,情况虽说复杂一些,但整体思路是不变的。

1、dp[i]的定义:dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。

2、状态转移方程:

  • 当word1[i - 1] 与 word2[j - 1]相同的时候 : dp[i][j] = dp[i - 1][j - 1];

  • 当word1[i - 1] 与 word2[j - 1]不相同的时候 : 有三种情:

情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1;

情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1;

情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2;

那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1})。

因为 dp[i][j - 1] + 1 = dp[i - 1][j - 1] + 2,所以递推公式可简化为:dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1)。

3、dp[i]的初始化:dp[i][0] 和 dp[0][j]是一定要初始化的。dp[i][0]:word2为空字符串,以i-1为结尾的字符串word1要删除多少个元素,才能和word2相同呢,很明显dp[i][0] = i。dp[0][j]的话同理。

4、确定遍历顺序:从递推公式 dp[i][j] = min(dp[i - 1][j - 1] + 2, min(dp[i - 1][j], dp[i][j - 1]) + 1); 和dp[i][j] = dp[i - 1][j - 1]可以看出dp[i][j]都是根据左上方、正上方、正左方推出来的。所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

5、举例推导dp数组。

    public int minDistance(String word1, String word2) {
        int[][] dp = new int[word1.length() + 1][word2.length() + 1];    //创建dp数组,大小为:字符串1长度 + 1 * 字符串2长度 + 1
        for (int i = 0; i < word1.length() + 1; i++) dp[i][0] = i;    //初始化dp[i][0]为i               for (int j = 0; j < word2.length() + 1; j++) dp[0][j] = j;    //初始化dp[0][j]为j
        for (int i = 1; i < word1.length() + 1; i++) {    //外层循环字符串1
            for (int j = 1; j < word2.length() + 1; j++) {    //内层循环字符串2
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {    //若i、j位置对应的字符相等
                    dp[i][j] = dp[i - 1][j - 1];    //则dp[i][j] = dp[i-1][j-1]
                }else{
                    dp[i][j] = Math.min(dp[i - 1][j - 1] + 2,Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));    //如果不相等,则....
                }
            }
        }
        return dp[word1.length()][word2.length()];    //最后返回dp[字符串1长度][字符串长度2]
    }

4、编辑距离

题目简介:

给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数。

你可以对一个单词进行如下三种操作:

插入一个字符、删除一个字符、替换一个字符

示例:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

题解:

1、dp[i]的定义:dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]

2、状态转移方程:四种情况:

if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];

if (word1[i - 1] != word2[j - 1]),此时就需要编辑了:

  • 操作一:word1删除一个元素,那么就是以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上一个操作。即 dp[i][j] = dp[i - 1][j] + 1;

  • 操作二:word2删除一个元素,那么就是以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上一个操作。即 dp[i][j] = dp[i][j - 1] + 1;

  • 操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增删加元素。可以回顾一下,if (word1[i - 1] == word2[j - 1])的时候我们的操作 是 dp[i][j] = dp[i - 1][j - 1] 对吧。

那么只需要一次替换的操作,就可以让 word1[i - 1] 和 word2[j - 1] 相同。所以 dp[i][j] = dp[i - 1][j - 1] + 1;

综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;

3、dp[i]的初始化:dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;同理dp[0][j] = j。

4、确定遍历顺序:dp[i][j]是依赖左方,上方和左上方元素的,所以在dp矩阵中一定是从左到右从上到下去遍历。

5、举例推导dp数组。

    public int minDistance(String word1, String word2) {
        int m = word1.length();    //m为字符串1长度
        int n = word2.length();    //n为字符串2长度
        int[][] dp = new int[m + 1][n + 1];    //创建dp数组,大小为 : m+1 * n + 1
        for (int i = 1; i <= m; i++) {    //初始化dp[i][0]为i
            dp[i][0] =  i;
        }
        for (int j = 1; j <= n; j++) {    //初始化dp[0][j]为j
            dp[0][j] = j;
        }
        for (int i = 1; i <= m; i++) {    //外层遍历字符串1
            for (int j = 1; j <= n; j++) {    //内层遍历字符串2
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1];    //如果i、j位置对应的字符相等,dp[i][j]为dp[i-1][j-1]
                } else {
                    dp[i][j] = Math.min(Math.min(dp[i - 1][j - 1], dp[i][j - 1]), dp[i - 1][j]) + 1;    //如果不相等,取三种操作的最小值
                }
            }
        }
        return dp[m][n];    //返回dp[m][n]即为结果
    }

回文类型问题

1、回文子串

题目简介:

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。

回文字符串 是正着读和倒过来读一样的字符串。

子字符串 是字符串中的由连续字符组成的一个序列。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

示例:
输入: s = "abc"
输出: 3
解释: 三个回文子串: "a", "b", "c"

题解:

1、dp[i]的定义:dp数组是要定义成一位二维dp数组。因为判断一个子字符串(字符串的下表范围[i,j])是否回文,依赖于,子字符串(下表范围[i + 1, j - 1])) 是否是回文。

布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

2、状态转移方程:分析如下几种情况:就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。

当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false;

当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况:

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串;

  • 情况二:下标i 与 j相差为1,例如aa,也是回文子串;

  • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。;

3、dp[i]的初始化:dp[i][j]可以初始化为true么? 当然不行,怎能刚开始就全都匹配上了。所以dp[i][j]初始化为false。

4、确定遍历顺序:首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。dp[i + 1][j - 1] 在 dp[i][j]的左下角,如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。

所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的

5、举例推导dp数组:dp数组中有几个true就是几个回文子串。注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分

    public int countSubstrings(String s) {
        int len, ans = 0;
        if (s == null || (len = s.length()) < 1) return 0;
        boolean[][] dp = new boolean[len][len];    //dp[i][j]:s字符串下标i到下标j的字串是否是一个回文串,即s[i, j]
        for (int j = 0; j < len; j++) {
            for (int i = 0; i <= j; i++) {
                if (s.charAt(i) == s.charAt(j)) {    //当两端字母一样时,才可以两端收缩进一步判断
                    if (j - i < 3) {
                        dp[i][j] = true;    //i++,j--,即两端收缩之后i,j指针指向同一个字符或者i超过j了,必然是一个回文串
                    } else {
                        dp[i][j] = dp[i + 1][j - 1];    //否则通过收缩之后的字串判断
                    }
                } else {
                    dp[i][j] = false;    //两端字符不一样,不是回文串
                }
            }
        }
        for (int i = 0; i < len; i++) {    //遍历每一个字串,统计回文串个数
            for (int j = 0; j < len; j++) {
                if (dp[i][j]) ans++;
            }
        }
        return ans;
    }

2、最长的回文子序列

题目简介:

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例:
输入: s = "bbbab"
输出: 4
解释: 一个可能的最长回文子序列为 "bbbb" 。

题解:

回文子串是要连续的,回文子序列可不是连续的!

1、dp[i]的定义:dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]

2、状态转移方程:在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;

如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

加入s[j]的回文子序列长度为dp[i + 1][j];

加入s[i]的回文子序列长度为dp[i][j - 1];

那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])。

3、dp[i]的初始化:首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。

所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。

其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。

4、确定遍历顺序:从递归公式中,可以看出,dp[i][j] 依赖于 dp[i + 1][j - 1] ,dp[i + 1][j] 和 dp[i][j - 1],所以遍历i的时候一定要从下到上遍历,这样才能保证下一行的数据是经过计算的。j的话,可以正常从左向右遍历。

5、举例推导dp数组:dp[0][s.size() - 1] 为最终结果。

    public int longestPalindromeSubseq(String s) {
        int len = s.length();    //len为字符串长度
        int[][] dp = new int[len + 1][len + 1];    //创建dp数组,大小为:len + 1 * len + 1
        for (int i = len - 1; i >= 0; i--) {    // 后往前遍历 保证情况不漏
            dp[i][i] = 1;    // 初始化为1
            for (int j = i + 1; j < len; j++) {    
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j], Math.max(dp[i][j], dp[i][j - 1]));
                }
            }
        }
        return dp[0][len - 1];
    }