预备知识
回文串就是正反读着都一样,就比如:“习习羊习习”(我的一个算法高手朋友)
- tips:本文部分概述并不精确。例如,s[0, j] 意思为 s 的 (0,j - 1)子串。//不包含s[j]
5. 最长回文子串
我们观察回文子串,假设(i,j)子串为回文,那么(i + 1,j - 1)子串回文(或者 j - i <= 2,ep:“a”, “aa”,“bab”)。
这也表明,是否是回文串也可以从:他的缩略是否回文 且 左右边界相等 来推导是否回文,即:s[i] == s[j] && dp[i + 1][j - 1]
-
边界情况:每单个字符均为长度为1 的子串
-
注意,我们是由短子串推向长子串,这样才符合推导逻辑,所以遍历顺序应该为顺序
public String longestPalindrome(String s) {
if (s.length() < 2){
return s;
}
int size = s.length();
int maxlen = 1;//最长长度暂定为1(单字符)
int begin = 0;//记录最长会问子串的起始下标
boolean[][] dp = new boolean[size][size];
//每一个只含单字符的子串均为回文子串
for (int i = 0; i < size; ++i) {
dp[i][i] = true;
}
//先枚举子串长度,从2开始
for (int L = 2; L <= size; ++L) {
//定义左边界
for (int i = 0; i < size; ++i) {
//表示右边界
int j = L + i - 1;
//一旦发生数组越界,break;
if(j >= size){
break;
}
//状态转移过程
if (s.charAt(i) != s.charAt(j)){
dp[i][j] = false;
}else{
if(j - i < 3){ //加快判断,如果 j - i < 3,则一定回文
dp[i][j] = true;
}else{
dp[i][j] = dp[i + 1][j - 1];//和dp[i + 1][j - 1] 保持一致
}
}
if (dp[i][j] && j - i + 1 > maxlen){ //如果是回文串且大于记录的最大长度,则进行更新
maxlen = j - i + 1;
begin = i;
}
}
}
return s.substring(begin, begin + maxlen);
}
647. 回文子串
又是回文子串问题,有了刚刚的推导,相信你能够很自信的做出的这道题目,题目中只有一个字符串,我们可定义dp[i][j]表示,s中(i, j)的答案,即回文子串的数目。 吗?
如果这样推导,状态的转移并不好想,所以我们可以dp[i][j]表示为(i, j)是否为回文子串,如果是,我们让计数变量++。
是否为回文串的状态转移可就好想多了,思考也会更加简便。
public int countSubstrings(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
//用于判断(i, j)是否为回文字符串
int res = 0;
for (int i = n - 1; i >= 0; --i) {
for (int j = i; j < n; ++j) {
if (s.charAt(i) == s.charAt(j) && (i == j || i == j - 1 || dp[i + 1][j - 1])){
dp[i][j] = true;
++res;
}
}
}
return res;
}
139. 单词拆分
该问题明显为完全背包问题,我们可选择的物品就是字符串列表wordDict,要去填充填满这个字符串 s
我们定义dp[i] 为字符串(0, i )能否被字典所拼接,也可以被分解成,
是否存在某个j, 使得(0,j)子串可被拼接,且(j, i)子串也可以被拼接或者存在于字典中,0 < j < i
- 边界问题,dp[0],没有东西,当然能被拼接,所以
dp[0] = true;
public boolean wordBreak(String s, List<String> wordDict) {
HashSet<String> set = new HashSet<>(wordDict);
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;
for (int i = 0; i <= s.length(); ++i) {
for (int j = 0; j < i; ++j) {
if (dp[j] && set.contains(s.substring(j, i))){
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
516. 最长回文子序列
- 首先,让我们区分子串和子序列,子串是连续的,子序列不是,但都满足从前往后的顺序。
定义:
dp[i][j]
表示s[i…j]代表的字符串的最长回文子序列
为什么这样定义,因为回文子序列不可能说光从0开始,并且我们需要确定左右边界来确定,而且这样定义,我们也可以通过已知数据去推导下一个dp数组值。
s[i, j]的最长回文子序列,很明显和两个东西有关:
①s【i,j】的子串的最长回文子序列,②s[i] == s[j] ?
- 如果
s[i] == s[j]
,那么很明显s【i,j】的最长回文子序列一定是s【i + 1, j - 1】子串最长回文子序列 在加上这两个字符:dp[i][j] = dp[i + 1][j - 1] + 2;
- 那
s[i] != s[j]
呢?此时最两边的字符已经抱不上回文的希望了,只能找子串的最大的回文子序列了,哪个子串的最长回文子序列最长呢?那肯定是最长子串的要有更长的可能,所以此时dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
- 边界问题的处理:由于每个单字符都是回文子序列,所以
dp[i][i] = 1
其实也有另一种巧妙地思想,但是会结合公共子序列的问题。就是:
最长回文子序列即: 字符串 str 与其逆序字符串所构成的最长公共子序列
public int longestPalindromeSubseq(String s) {
if (s.length() <= 1){
return s.length();
}
int[][] dp = new int[s.length()][s.length()];
//dp[i][j] 表示从i到j的最长回文子序列长度
//dp[i][i] = 1 ,每个单个字符都是回文子序列
for (int i = 0; i < dp.length; ++i) {
dp[i][i] = 1;
}
//如果边界相等,则等于dp[i + 1][j - 1] + 2
//否则 找两边的最大值
for (int i = dp.length - 1; i >= 0; --i) {
//需要逆序,因为要从短的子序列到长的子序列
for (int j = i + 1; j < dp.length; ++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], dp[i][j - 1]);
}
}
}
return dp[0][dp.length - 1];
}
72.编辑距离
有了之前的经验,我相信你能轻松想到:
定义 dp[i][j] 表示 word[0,i] 转换成 word[0, j]的最少操作数
-
我们在递推的过程中,要有一种已经推出其子串的思想,我们循序渐进的进一步转换罢了。
-
如果
word1[i] == word2[j]
说明这两个字符根本不需要任何转换,在dp[i - 1][j - 1]中,我们已经得出用word1[0, i - 1],word2[0, j - 1]的所需操作数,word1[i] == word2[j]说明这一步我们不需要任何操作,所以dp[i][j] = dp[i - 1][j - 1];
-
那么如果
word1[i] != word2[j]
那么说明一定需要 增删改 的任一操作,我们选其中的最小操作数即可.那么很好想:1. 增:`dp[i][j] = dp[i][j - 1] + 1` (多一个 插入word2[j] 的步骤) 2. 删:`dp[i][j] = dp[i - 1][j] + 1` (多一个删除word1[i]的操作) 3. 改:`dp[i][j] = dp[i - 1][j - 1] + 1` (此处难理解,其实就是已知从word1[i - 1] 到word2[j - 1]的操作数,再加上一个操作:把word1[i]改成word2[j] )
-
综合上述,即略写为:
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1]));
-
边界问题处理:一个子串为0,要转换成另一个子串 or 该零子串被转换的结果,均为该子串的长度(插入 length 个字符转换而成 or 删除 length 个字符转换而成)
class Solution {
public int minDistance(String s1, String s2) {
int len1 = s1.length(), len2 = s2.length();
int[][] dp = new int[len1 + 1][len2 + 1];
//dp[i][j]表示s1[0,i] 和 s2[0,j] 最少操作数
//初始化,边界情况为另一个子串的长度
for (int i = 0; i <= len1; i++) {
dp[i][0] = i;
}
for (int i = 0; i <= len2; i++) {
dp[0][i] = i;
}
for (int i = 1; i <= len1; ++i) {
for (int j = 1; j <= len2; ++j) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)){
dp[i][j] = dp[i - 1][j - 1];
}else {
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1]));
}
}
}
return dp[len1][len2];
}
}
712. 两个字符串的最小ASCII删除和
先说一个知识点 ,
s.codePointAt(int idx)
返回 s 的第 idx - 1 位字符的Unicode码。
- 乍一看,跟上个题一毛一样,没啥说的,边界处理也一模一样,只不过步骤改成了Ascii码而已。直接摆上代码:
public int minimumDeleteSum(String s1, String s2) {
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
//特殊情况处理,如果i = 0 或者 j = 0时,那么这个值就是另一个子串的所有ASCII码值
for (int i = 1; i <= s1.length(); ++i) {
dp[i][0] = dp[i - 1][0] + s1.codePointAt(i - 1);
}
for (int i = 1; i <= s2.length(); ++i) {
dp[0][i] = dp[0][i - 1] + s2.codePointAt(i - 1);
}
for (int i = 1; i <= s1.length(); ++i) {
for (int j = 1; j <= s2.length(); ++j) {
if(s1.charAt(i - 1) == s2.charAt(j - 1)){ //相等,不加东西,继承上一个的和
dp[i][j] = dp[i - 1][j - 1];
}else{
//此时三种情况,要i不要j,要j不要i,或者都不要,看哪个小
dp[i][j] = Math.min(dp[i - 1][j] + s1.codePointAt(i - 1), dp[i][j - 1] + s2.codePointAt(j - 1));
}
}
}
return dp[s1.length()][s2.length()];
}
115. 不同的子序列
定义
dp[i][j]
表示s的前i个子序列中,t的(0,j)子串出现的个数
-
如果s[i] == t[j] ,那么我们可以将求dp[i][j] 分解成两个集合,①让s[i]跟它匹配,dp[i - 1][j - 1] ②不让s[i]跟他匹配,即 dp[i - 1][j],且②的子序列中,均不包含 s[i]…可得
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
-
如果 s[i] != t[j] , 那么只能让 s 前边的子序列去匹配,即:
dp[i][j] = dp[i - 1][j]
-
边界问题:s 为空时,任意 t 均无法寻找到子序列; t 为空时,任意 s 均可找到一条 t 的子序列。
public int numDistinct(String s, String t) {
if (s.length() == 0 || t.length() ==0){
return 0;
}
int[][] dp = new int[s.length() + 1][t.length() + 1];
//dp[i][j] 表示s的前i个子序列中,t的(0,j)子串出现的个数
//边界问题:s为0,t长度任意,也找不到子序列。所以dp[0][i]均为0
//s任意,t为0,则可在s中找到t的子序列,为1, 故dp[i][0] = 1
for (int i = 0; i <= s.length(); ++i) {
dp[i][0] = 1;
}
for (int i = 1; i <= s.length(); ++i) {
for (int j = 1; j <= t.length(); ++j) {
if(j > i){
//如果j>i, 子序列无意义,直接跳过
continue;
}
if (s.charAt(i - 1) == t.charAt(j - 1)){
//如果相等,那么有两个情况,以i位结尾匹配,不以i位结尾匹配,
//①以i位结尾匹配,因为相等,所以肯定=dp[i - 1][j - 1[
//②不以i位结尾匹配,那肯定和上一个的状态有关,因为都是推来的,
// 那么我们就要在i - 1中,找j的子序列的个数,即dp[i - 1][j]
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
}else {
//此时最后一位不相等,所以答案肯定是s(0,i - 1)中能匹配t(0,j)的个数,因为s的i位不是t的最后一位,指望不上。
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[s.length()][t.length()];
}
总结:
-
其实我感觉有些题目需要先思考边界问题,会更有利于问题思路的思考,但本文每道题目都把边界问题的思考都放到了最后,个人认为边界问题是一个问题很关键的部分,尤其在动态规划,边界问题的初始化,对整个dp数组的赋值很有必要
-
在构造dp数组这方面, 通常如果题目中只出现一个字符串,我们通常定义dp[i][j]表示 s 中(i, j) 子串的答案 如果题目出现了两个字符串,我们通常定义dp[i][j], 表示s1的(0, i)和 s2的(0, j) 两个子串所对应的答案。 这样定义也有助于我们思考并从前往后推导,这也点明了我们的解题入手点,毕竟正常思路就是如此。