文章目录
1. 判断子序列
题目链接:392. 判断子序列 - 力扣(LeetCode)
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
进阶:
如果有大量输入的 S,称作 S1, S2, … , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
示例 1:
输入:s = “abc”, t = “ahbgdc”
输出:true
示例 2:
输入:s = “axc”, t = “ahbgdc”
输出:false
提示:
- 0 <= s.length <= 100
- 0 <= t.length <= 10^4
- 两个字符串都只由小写字符组成。
思路:
本道题是编辑距离的入门题目,题目只需要计算删除的情况,不用考虑增加和替换的情况。
下面用动归五部曲分析一下
-
状态定义:dp[i] [j] 表示:以下标 i-1 为结尾的字符串 S,和以下标 j-1 为 结尾的字符串 t,相同子序列的长度为 dp[i] [j]。
这样定义的目的是想 dp 推导的最后,只要和 s 长度相等,那么 s 肯定就是 t 的子序列,返回 true 就可以了。
定义下标为 i-1 和 j-1 的目的是为了方便初始化。如果直接定义 i 和 j,比如 dp[i] [0] 的初始化,当 i = 0 时,和 s 第一个元素相等时就要初始化,每次都要比较来进行初始化。而定义下标 i-1 和 j-1 的好处就是,初始化是在后面递推公式中初始化的,不用额外判断,遍历时只要 s 和 t 的从第二元素开始遍历即可。
-
状态转移:
if (s[i-1] == t[j-1]) // t 中的一个元素在 s 中也存在 dp[i][j] = dp[i-1][j-1] + 1 // 因为找到一个相同的字符,所以子序列长度就要在 dp[i-1][j-1] 的基础上加 1 if (s[i-1] != t[j-1]) // 此时 t 中的这个元素在 s 中不存在,那么删除 t 中的这个元素,也就是要删除元素 t[j-1],那么 dp[i][j] 的值就是要看 s[i-1] 与 t[j-2] 的比较结果(是否一样), dp[i][j] = dp[i][j-1]
需要注意本道题是只有 t 可以删除
-
初始化:分析递推公式,dp[i] [j] 依赖 dp[i-1] [j-1] 和 dp[i] [j-1],所以 dp[0] [0] 和 dp[i] [0] 一定要要初始化。
- 遍历顺序:从上到下,从左到右
- 返回值:dp[s.length()] [t.length()]
代码:
/**
1. 状态定义:dp[i] [j] 表示:以下标 i-1 为结尾的字符串 S,和以下标 j-1 为 结尾的字符串 t,相同子序列的长度为 dp[i] [j]
2. 状态转移:if (s[i-1] == t[j-1])
dp[i][j] = dp[i-1][j-1] + 1
if (s[i-1] != t[j-1])
dp[i][j] = dp[i][j-1]
3. 初始化:0
4. 遍历顺序:从上到下,从左到右
5. 返回值:dp[s.length()] [t.length()]
*/
public boolean isSubsequence(String s, String t) {
int s1 = s.length();
int t1 = t.length();
int[][] dp = new int[s1+1][t1+1];
for(int i = 1; i <= s1; i++) {
for(int j = 1; j <= t1; j++) {
if(s.charAt(i-1) == t.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1]+1;
} else {
dp[i][j] = dp[i][j-1];
}
}
}
boolean ret = dp[s1][t1] == s1 ? true : false;
return ret;
}
2. 不同的子序列
题目链接:115. 不同的子序列 - 力扣(LeetCode)
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,“ACE” 是 “ABCDE” 的一个子序列,而 “AEC” 不是)
题目数据保证答案符合 32 位带符号整数范围。
示例 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
提示:
- 0 <= s.length, t.length <= 1000
- s 和 t 由英文字母组成
思路:
上一题是判断 s 是否是 t 的子序列,而本道题是计算在 s 的子序列中 t 出现的个数(有子序列了,然后要统计个数)
继续用动归五部曲分析
-
状态定义:dp[i] [j] 表示:以 i-1 为结尾 s 子序列中有以 j-1 为结尾的 t 的个数为 dp[i] [j]
这里定义 i-1 和 j-1 是为了方便初始化。
-
状态转移:
有两种情况:
-
s[i-1] 和 t[j-1] 相等 dp[i] [j] = dp[i-1] [j-1] + dp[i-1] [j]
相等时 dp[i] [j] 由两个部分组成
一部分是 s[i-1] 参与匹配,那么就是用 s 中 0 到 i-2 个字符去匹配 t 中的 0 到 j-2 字符,此时匹配的值就是 dp[i-1] [j-1]
一部分是 s[i-1] 不参与匹配,那么就是用 s 中 0 到 i-2 个字符去匹配 t 中的 0 到 j-1 字符,此时匹配的值就是 dp[i-1] [j]
- s[i-1] 和 t[j-1] 不相等 dp[i] [j] = dp[i-1] [j]
那么就不用 s[i-1] 来匹配了,相当于模拟在 s 中删除这个元素
-
初始化:可以从上面的图中看出来 dp 是由左上方和上方推导出来的,所以dp[i] [0] 和 dp[0] [j] 要初始化
- dp[i] [0] 表示以 i-1 为结尾的 s 可以随便删除元素,出现空字符串的个数
所以 dp[i] [0] = 1,可以把 s 中所有元素删除,这样空字符串个数就是 1
- dp[0] [j] 表示 空字符串 s 可以随便删除元素,出现以 j-1 为结尾的字符串 t 的个数,所以 dp[0] [j] = 0
- dp[0] [0] = 1 因为空字符串 s ,可以删除 0 个元素就变成空字符串 t
-
遍历顺序:从上到下,从左到右
-
返回值:dp[s.length()] [t.length()]
代码:
/**
1. 状态定义:dp[i][j] 表示:以 i-1 为结尾 s 子序列中有以 j-1 为结尾的 t 的个数为 dp[i][j]
2. 状态转移: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] = dp[i-1][j]
3. 初始化: dp[i][0] = 1 dp[0][0] = 1
4. 遍历顺序:从上到下,从左到右
5. 返回值:dp[s.length()][t.length()]
*/
public int numDistinct(String s, String t) {
int s1 = s.length();
int t1 = t.length();
int[][] dp = new int[s1+1][t1+1];
// 初始化
for(int i = 0; i < s1; i++) {
dp[i][0] = 1;
}
// 遍历
for(int i = 1; i <= s1; i++) {
for(int j = 1; j <= t1; j++) {
if(s.charAt(i-1) == t.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
} else {
dp[i][j] = dp[i-1][j];
}
}
}
return dp[s1][t1];
}
3. 两个字符串的删除操作
题目链接:583. 两个字符串的删除操作 - 力扣(LeetCode)
给定两个单词 word1 和 word2 ,返回使得 word1 和 word2 相同所需的最小步数。
每步 可以删除任意一个字符串中的一个字符
示例 1:
输入: word1 = “sea”, word2 = “eat”
输出: 2
解释: 第一步将 “sea” 变为 “ea” ,第二步将 "eat "变为 “ea”
示例 2:
输入:word1 = “leetcode”, word2 = “etco”
输出:4
提示:
- 1 <= word1.length, word2.length <= 500
- word1 和 word2 只包含小写英文字母
思路:
在上一题 不同的子序列中 是只能删除一个字符串,然后取匹配;而本道题是两个字符串都可以删除,大体思路和上一题是一样的,就是多考虑一种情况。
两个字符串互相可以删除,还是使用动态规划来解决。
下面进行动归五部曲分析
-
状态定义:以 i-1 结尾的 word1 和 j-1 为结尾的 word2 转换为相同的序列最少的操作次数为 dp[i] [j]
-
状态转移:
-
当 word1[i-1] == word2[j-1] 时, 当前元素相同那么最少操作次数,和上一次是一样的,所以 dp[i] [j] = dp[i-1] [j-1]
-
当 word1[i-1] != word2[j-1] 时,因为两个字符串都可以删除了,所以就有三种情况了
1)删除 word1[i-1] 元素,那么要取 dp[i-1] [] 对应过来就是 word1[i-2] 就是不考虑 word1[i-1] 元素,然后操作次数 +1. 也就是 dp[i-1] [j] + 1
2)删除 word2[j-1] 元素,同理, dp[i] [j-1] + 1
3)删除 word1[i-1] 和 word2[j-1] 元素,操作次数为 dp[i-1] [j-1] + 2
然后取这三种情况中最小值 dp[i] [j] = Math.min(dp[i-1] [j]+1,dp[i] [j-1]+1,dp[i-1] [j-1]+2)
-
-
初始化:从这个图中可以看出来,dp[i] [j] 是由这三个方向推导出来的。
所以 dp[i] [0] 和 dp[0] [j] 是一定要初始化的
dp[i] [0] 表示 word2 为空字符串,以 i-1 为结尾的字符串 word1 要删除多少个元素,才能和 word2 相同,所以 dp[i] [0] = i (注意前面的 i-1 为下标,这里的 i 为操作次数)
同理 dp[0] [j] = j
-
遍历顺序:从上到下,从左到右
-
返回值:dp[word1.length()] [word2.length()]
代码:
/**
1. 状态定义:以 i-1 结尾的 word1 和 j-1 为结尾的 word2 转换为相同的序列最少的操作次数为 dp[i][j]
2. 状态转移:if(word1.charAt(i-1) == word2.charAt(j-1))
dp[i][j] = dp[i-1][j-1];
else dp[i][j] = Math.min(dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+2) // 删除当前 word1 元素,dp取[i-1]相当于 word 取 i-2,然后删除这个元素就+1
3. 初始化:dp[i][0]=i;dp[0][j]=j
4. 遍历顺序:从小到大
5. 返回值:dp[word1.length()][word2.length()]
*/
public int minDistance(String word1, String word2) {
int w1 = word1.length();
int w2 = word2.length();
int[][] dp = new int[w1+1][w2+1];
// 初始化
for(int i = 0; i <= w1; i++) {
dp[i][0] = i;
}
for(int j = 0; j <= w2; j++) {
dp[0][j] = j;
}
// 遍历
for(int i = 1; i <= w1; i++) {
for(int j = 1; j <= w2; j++) {
if(word1.charAt(i-1) == word2.charAt(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][j-1]+1,dp[i-1][j]+1));
}
}
}
return dp[w1][w2];
}
这里还有一个思路就是,这个虽然说是求两个字符串的删除操作的最少次数,本质上还是找这个最长公共子串,所以本道题可以换一种思路就是,这个最小次数就等于,求这两个字符串长度之和 然后 减去这两个字符串的最大公共子串的2倍,这个步骤之后不就得到了那个最少操作的次数,就将两个字符串转化为一样的了
// 1. 先通过 dp 找最长公共子串
// 2. 要将两个字符串变为相同的最少操作次数 = w1 + w2 - dp*2;
public int minDistance(String word1, String word2) {
int w1 = word1.length();
int w2 = word2.length();
int[][] dp = new int[w1+1][w2+1];
for(int i = 1; i <= w1; i++) {
for(int j = 1; j <= w2; j++) {
if(word1.charAt(i-1) == word2.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
}
}
}
return w1+w2-dp[w1][w2]*2;
}
4. 编辑距离
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
示例 2:
输入:word1 = “intention”, word2 = “execution”
输出:5
解释:
intention -> inention (删除 ‘t’)
inention -> enention (将 ‘i’ 替换为 ‘e’)
enention -> exention (将 ‘n’ 替换为 ‘x’)
exention -> exection (将 ‘n’ 替换为 ‘c’)
exection -> execution (插入 ‘u’)
提示:
- 0 <= word1.length, word2.length <= 500
- word1 和 word2 由小写英文字母组成
思路:
前面的几道题都是为这道 编辑距离 做铺垫,还是用动态规划来做
下面进行动归五部曲分析
-
状态定义:dp[i] [j] 表示以下标 i-1 为结尾的字符串 word1,和以下标 j-1 为结尾的字符串 word2,要转化为相同的字符串最少的操作次数是 dp[i] [j]。
-
状态转移:
当 word1[i-1] == word2[i-2] 时,不用进行任何操作,当前的最少操作次数就是上一次的 dp,也就是 dp[i-1] [j-1],所以 dp[i] [j] = dp[i-1] [j-1]
当 word[i-1] != word2[j-1] 时,此时就需要操作了,通过 增、删、改让元素相等。
先看删除操作
两个元素不相等,如果删除 word1 元素,那么就是以下标 i-2 为结尾的 word1 与 j-1 为结尾的 word2 的最少操作次数+1,即 dp[i-1] [j] + 1
如果删除 word2 元素,那么就是以下标 i-1 为结尾 word1 与 j-2 为结尾的 word2 的最少操作次数+1,即 dp[i] [j-1] + 1
下面看添加操作
比如 word1 = “ad”, word2 = “a”,给 word2 添加元素 d,和 给 word1 删除 元素 d,让两个字符串变为一样的操作次数是相同的,所以 添加操作和删除操作是一样
最后看替换操作
当前 word1 和 word2 的两个元素不相同,只需要修改这两个元素中任意一个元素和另一个元素相同就可以了,这个只需要操作一次,但这个是在 dp[i-1] [j-1] 的基础上操作一次,即 dp[i-1] [j-1] +1
取这三种情况中最小的 dp[i] [j] = Math.min(dp[i- 1] [j]+1,Math.min(dp[i] [j-1]+1,dp[i-1] [j-1]+1))
-
初始化:
可以看到 dp 是由这三个方向推导出来的,所以应该初始化 dp[i] [0] 和 dp[0] [j]。
dp[i] [0] 应该初始化为 i,因为此时表示以下标 i-1 为结尾的 word1,和 空字符串 word2,转化为相同的最少操作次数,比如 i 为 1 时,此时就是在初始化 dp[1] [0] ,也就是 word1 以下标为 0 的字符结尾,转化为 word2 空字符串的最少操作次数为 1,所以 dp[i] [0] = i,同理 dp[0] [j] = j
-
遍历顺序:从上到下,从左到右(从小到大)
-
返回值: dp[word1.length()] [word2.length()]
代码:
public int minDistance(String word1, String word2) {
int w1 = word1.length();
int w2 = word2.length();
int[][] dp = new int[w1+1][w2+1];
// 初始化
for(int i = 0; i <= w1; i++) {
dp[i][0] = i;
}
for(int j = 0; j <= w2; j++) {
dp[0][j] = j;
}
// 遍历
for(int i = 1; i <= w1; i++) {
for(int j = 1; j <= w2; j++) {
if(word1.charAt(i-1) == word2.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1];
} else {
dp[i][j] = Math.min(dp[i-1][j]+1,Math.min(dp[i][j-1]+1,dp[i-1][j-1]+1));
}
}
}
return dp[w1][w2];
}
5. 回文子串
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
示例 1:
输入:s = “abc”
输出:3
解释:三个回文子串: “a”, “b”, “c”
示例 2:
输入:s = “aaa”
输出:6
解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”
提示:
- 1 <= s.length <= 1000
- s 由小写英文字母组成
思路:
所以 dp 数组要定义为一个二维 dp 数组
-
状态定义:布尔类型的 dp[i] [j]表示:在区间 [i, j] 的 子串是否是回文子串,如果是 dp[i] [j] = true,如果不是 dp[i] [j] = false
-
状态转移:有两种情况
s[i] != s[j] ,那么 dp[i] [j] 肯定不是回文子串,即 dp[i] [j] = false;
s[i] == s[j],又分为三种情况
- 下标 i == j,那么就是 i 和 j 指向的是同一个字符,所以肯定是回文子串
- 下标 i 和 j 相差1,也就是 i 和 j 是相邻的,两个相邻的元素相同,那肯定是回文子串
- 下标 i 和 j 相差大于1,就是上面图中的情况,区间 [i, j] 是不是回文子串要看区间 [i+1, j-1] 是不是回文子串
if(s[i] == s[j]) { if(j-i <= 1) { // 指向同一个元素 或 指向的两个元素相邻 dp[i][j] = true; result++; // 统计回文子串的数量 } else if(dp[i+1][j-1]) { // 要看 dp[i][j] 是否是回文,就要判断 dp[i+1][j-1] 的情况 dp[i][j] = true; result++; } } // 这里 不用再判断 s[i] != s[j] 是因为这个肯定不是回文子串,没必要在判断了,初始化时直接 false
-
初始化:dp[i] [j] 都要初始化为 false,这样状态转移方程经过判断后才修改为 true。
-
遍历顺序:在不清楚遍历顺序情况下,最好的方式就是根据状态转移方程画一个简单的递推图,就明白应该怎么初始化
dp[i] [j] 的推导是 从下到上,从左到右的
-
返回值:result
代码:
/**
1. 状态定义;布尔类型的 dp[i] [j]表示:在区间 [i, j] 的 子串是否是回文子串,如果是 dp[i] [j] = true,如果不是 dp[i] [j] = false
2. 状态转移:
if(s[i] == s[j]) {
if(j-i <= 1) { // 指向同一个元素 或 指向的两个元素相邻
dp[i][j] = true;
result++; // 统计回文子串的数量
} else if(dp[i+1][j-1]) { // 要看 dp[i][j] 是否是回文,就要判断 dp[i+1][j-1] 的情况
dp[i][j] = true;
result++;
}
}
3. 初始化:dp[i] [j] 都要初始化为 false
4. 遍历顺序: 从下到上,从左到右的
5. 返回值:result
*/
public int countSubstrings(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
int result = 0;
for(int i = n-1; i >= 0; i--) {
for(int j = i; j < n; j++) {
if(s.charAt(i) == s.charAt(j)) {
if(j - i <= 1) {
dp[i][j] = true;
result++;
} else if(dp[i+1][j-1]) {
dp[i][j] = true;
result++;
}
}
}
}
return result;
}
6. 最长回文子序列
题目链接:516. 最长回文子序列 - 力扣(LeetCode)
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
示例 1:
输入:s = “bbbab”
输出:4
解释:一个可能的最长回文子序列为 “bbbb” 。
示例 2:
输入:s = “cbbd”
输出:2
解释:一个可能的最长回文子序列为 “bb” 。
提示:
- 1 <= s.length <= 1000
- s 仅由小写英文字母组成
思路:
在上一题中求的是回文子串的数量,而本题要求的这个回文子序列最长是多少。
并且上一题回文子串要连续,而本道题回文子序列不要去连续。
下面进行动归五部曲分析
-
状态定义:dp[i] [j] 表示:字符串 s 在 [i, j] 范围内最长的回文子序列的长度为 dp[i] [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[i] 的回文子序列长度 dp[i] [j-1]
加入 s[j] 的回文子序列长度 dp[i+1] [j]
取这两种情况中最大的 dp[i] [j] = Math.max(dp[i] [j-1],dp[i+1] [j])
-
初始化:
当 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] = Math.max(dp[i] [j-1],dp[i+1] [j]) 才不会被初始值覆盖
-
遍历顺序:从下到上,从左到右
-
返回值: dp[0] [s.length()] // 注意这里要返回最长的区间 [0,s.length()]
代码:
/**
1. 状态定义;dp[i] [j] 表示:字符串 s 在 [i, j] 范围内最长的回文子序列的长度为 dp[i] [j]
2. 状态转移:
if(s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i+1][j-1] + 2;
} else {
dp[i][j] = Math.max(dp[i][j-1],dp[i+1][j]);
}
3. 初始化:dp[i][i] = 1
4. 遍历顺序:从下到上,从左到右
5. 返回值:dp[0][n-1]
*/
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// 初始化
for(int i = 0; i < n; i++) {
dp[i][i] = 1;
}
// 遍历
for(int i = n-1; i >= 0; i--) {
for(int j = i+1; j < n; 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][j-1],dp[i+1][j]);
}
}
}
return dp[0][n-1];
}