0 前言:
实际上动态规划问题是有几个子模块的,之前我们讲的状态压缩以及背包类问题就是属于两个模块,今天我们进入下一个模块的学习:字符串模块。
首先我们来看一个高频考题,回文串:
1 回文子串
1.1题目描述
给你一个字符串 s ,请你统计并返回这个字符串中回文子串的数目。
回文字符串是正着读和倒过来读一样的字符串。例如:“aba”,“aa”,“a”。
子字符串是字符串中的由连续字符组成的一个序列。
注意: 这里要着重注意一下子字符串这个概念,他是指从原字符串上截取的一段连续的字符串。后面还会有一个子序列的概念,子序列的概念可以参考我之前的回溯算法文章。如果不想看,这里我就简单介绍一下,子序列就是可以删除掉中间某些元素,不改变剩余元素相对位置的子集合。
1.2 题目分析
结合回文串的概念,我们可以知道,对于一个长度为n的字符串,内部回文子串的个数一定大于等于n,因为每个字符就可以独自构成一个字符串。关键是那些长度大于1的子串如何判断。
回文串的判定方法:
对于一个给定的字符串,判定他是不是回文串只需要使用双针法,同时指向首尾字符即可,这个没有难度。假设我们将这个接口封装为bool fun(string&);
bool fun(string& str) {
int l = 0;
int r = str.size()-1;
while(r>=l) {
if(str[l] != str[i]) { retrun false; }
l++;
r--;
}
return true;
}
基于这个我们可以提出一个基础的暴力解法,首先用两层for循环取出所有可能的子串,对于每个子串调用fun()
接口,就可以轻松解决。当然,超时是肯定的。分析上面方法的时间复杂度为O(n^3)。
主要的问题就是,上面的方法经过了太多重复的计算。实际上我们可以简单思考一下,假设我们已经知道的一个字符串是不是回文串了,这个时候如果我们给这个字符串的首位各加一个字符构成一个新的字符串,我们改如何判断新字符串是不是回文串呢?
情况一:原字符串不是回文串,那么新字符串一定也不是回文串。(这里注意,新字符串是在原字符串首位各加一个新字符)
情况二:原字符串是回文串,那么如果新加的两个字符相同,那新字符一定是回文串。反之则不是回文串。
可以看到我们通过结合之前的计算结果减少了计算量,这里的核心就是我们知道原字符串的性质。否这我们就要对新字符串再次调用fun()
接口来判断了。
所以说如果我们设计一个容器来存放曾经计算过的字符串的性质,就可以简化计算量,实际上这就是动态规划的思想,这个容器就是dp
数组。这里我直接引导大家去完成dp
数组的创建。如果想自己思考的话,这里给大家提供一下思路,这里需要使用两个下标,标注子串的首位索引,也就是dp
是二维的矩阵(一般涉及到两个字符串或者回文字符串的动规问题都需要二维dp
数组,至于为什么需要二维,我在后面的文章里会给大家总结)。
1.2.1 dp数组定义
这里定义dp[i][j]
:以下标i
为始,下标j
为结尾的子串是否为回文串,如果是回文串,dp[i][j] = true
,反之dp[i][j] = false
。
1.2.2 dp的递推公式
这里直接一个语句就可以表示:
dp[i][j] = dp[i+1][j-1] && (str[i] == str[j]);
这个语句直接涵盖了我们上面将的两个情况,首先判断以下标i+1
为始,下标j-1
为结尾的原子串是否为回文串,因为我们对dp
的定义,保证了这一步。之后判断新加入的两个字符是否相等。
1.2.3 dp数组的初始化
首先看一下递推的方向:
可以看到我们要把左下方向完成初始化。但是注意我们dp
的定义,j
是末尾下标,一定到大于或者等于i
才有意义。因此矩阵主对角线下的数据都是无意义的。我们先初始化主对角线,这里的i=j
,暨单个字符,应该为true
,我们用绿色表示。
我们发现了一个不幸的问题,即使左下角是无意义的,但是我们的递推还是需要左下角的部分信息。这时我们结合实际情况观察,当i=2, j=3
时,子串有两个元素,决定他是否为回文串的因素就是两个元素是否相等,因此dp[3][2]
一定要为true
。另一种解释就是当i>j
时,子串为空,空串也可以理解为回文串。因此我们最终初始化的效果为:
讲到这里就没有什么其他的要点的,我们直接上代码吧:
1.3 示例代码
class Solution {
public:
int countSubstrings(string s) {
vector<vector<int>> dp(s.size(), vector<int>(s.size(), true));
int ret = s.size();
for(int i = s.size()-1; i>=0; i--) {
for(int j = i+1; j<s.size(); j++) {
if( dp[i][j] = dp[i+1][j-1] && (s[i] == s[j]) )
ret++;
}
}
return ret;
}
};
关键点说明:
1、这里内外循环条件比较抽象,最好看着图来写。
2、需要定义一个计数器,因为左下角也为true
,但是这些数据不计入结果。
3、ret
直接初始化为数组长度的原因上面讲过,单个字符也是回文串。
4、这个dp
数组内部的数字并不是最终结果,和传统的动态规划有所区别。
2 回文子序列
2.1 题目描述
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
注意:这里和刚刚不同,刚刚要的是子串,现在是子序列,关于子序列的定义在上文所过。
2.2 题目分析
和字符串动态规划相关的处理方法只有那几个,对于只有一个字符串的题目,就是索引选取子串。
首先看过我回溯篇章的同学都应该能想出一个暴力解法,直接生成所有的子序列,并使用双指针判断是否为回文,挑选最长的。没错,如果这道题让我们输出最长回文子序列,那只能这么做,但是这道题要求的仅仅是最长回文子序列的长度,因此只用动归就可以解决。
2.2.1 dp数组的定义
碎碎念:
首先我要说明一点,刚刚上面那个题目中dp
数组内部元素不等于最终结果,需要进行计数的情况是极度少见的,一般的动态规划问题中,dp
数组内部元素直接与结果强相关,只要逻辑自洽,能找到正确的递推公式,那么dp
数组定义的越靠近结果越好。
另一个重点就是初始化,仅定义dp
数组是不够的,还要考虑初始化的难度。
这里我们定义dp[i][j]
为在下标i
到j
范围内最长回文子序列的长度。那我们的最终的结果就是dp[0][n-1]
。这里n
为字符串长度。这就是我说的dp
与结果强相关。
接下来看dp的递推公式:
情况1:str[i] == str[j]
,那么dp[i][j] = dp[i+1][j-1]
。这个没什么说的,dp
的定义已经是最优定义(直接是区间内最大值)了,所以我们不需要考虑其他情况。
情况2:str[i] != str[j]
,在这种情况下,我们无法将str[i]
与str[j]
同时加入,因此我们只能选择性的抛弃其中一个字符或者两个同时都不要,结合我们对dp
的定义,这种情况下dp[i][j] = max({dp[i+1][j], dp[i][j-1], dp[i+1][j-1]})
。
如果对dp定义了解的更加深入的同学可以知道,dp[i+1][j-1]
一定小于dp[i+1][j]
和dp[i][j-1]
,因此dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
递推公式详解:
dp
定义后的逻辑自洽是关键,一定要充分利用dp
的性质。
已知str[i] != str[j]
,因此[i, j]
范围内最长子序列的长度一定在[i+1, j]
与[i,j-1]
里诞生,与我们dp
的定义吻合。所以直接大胆使用就行。当我们选择合适的遍历方向后,dp[i+1][j]
和dp[i][j-1]
一定会比dp[i][j]
先计算,我们可以直接拿到对应的值。由下图可知,我们应该选择从左下角遍历到右上角。
2.2.2 dp数组的初始化
这里没什么可说的了:
当i == j
时,对于单个字符,dp[i][j] = 1;
当i > j
时,无意义,dp[i][j] = 0;
2.3 示例代码
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for(int i = 0; i<s.size(); i++) { dp[i][i] = 1; }
for(int i = s.size()-1; i>=0; i--) {
for(int j = i+1; j<s.size(); j++) {
if(s[i] == s[j]) dp[i][j] = dp[i+1][j-1] + 2;
else dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
return dp[0].back();
}
};
关键点说明:
1、注意遍历顺序要和递推方向一致。
2、这个dp
数组内部的数字和结果强相关,不需要其他处理。
3 小结
对于二维动态规划,如果看了我背包问题的同学应该很熟悉了。
这里对于子串与子序列的区别,大家一定要牢记,后续会反复出现。
当题目要求子串时与题目要求子序列时的处理方法其实是通用的,后续我会带领大家深入理解。
我在下一篇文章中继续为大家介绍使用动态规划解决字符串类的问题,还会给大家提供一个回文串的变体解决方案:动态规划算法:字符串类问题(2)公共串