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