CHAPTER_11 提高篇(5)——动态规划
11.5最长回文子串
回文串是一个正读和反读都一样的字符串,即是一个左右对称的串。
下面来看最长回文子串的问题。
题目:
给出一个字符串S,字母区分大小写,求S的最大回文子串的长度。
输入样例:
PATZJUJZTACCBCC
输出样例:
9
//最长回文子串为ATZJUJZTA,长度为9
思路:
如果采用暴力的解法,枚举左右端点,然后判断区间[ i , j ]内的子串是否回文。这么做的时间复杂度为O(n^3),显然不是很优。
下面介绍动态规划的一种做法,时间复杂度为O(n^2)。
令dp[i][j]表示S[i]至S[j](0<=i<=j<S.size())所表示的子串是否是回文子串,是则为1,不是为0。这样根据S[i]是否等于S[j],可以把情况分为两类:
(1)若S[i]==S[j]。那么只要S[i+1]至S[j-1]是回文串,S[i]至S[j]就是回文串;如果S[i+1]至S[j-1]不是回文串,则S[i]至S[j]不是回文串。
(2)若S[i]!=S[j]。那么S[i]至S[j]一定不是回文串。
由此写出状态转移方程如下:
边界: dp[i][i]=1,dp[i][i+1]=(S[i]==S[i+1])?1:0。
这里还有一个问题没有解决,那就是如何枚举递推?如果按照之前的dp做法从 i 和 j 由小到大开始枚举,就无法保证计算dp[i][j]时dp[i+1][j-1]已经计算完毕。因此如果递推得到整个dp数组将是一个难点。
首先需要明确的是,因为 i < = j ,整个二维数组dp其实只使用了右半部分,即只有右半部分元素的下标才能满足i < = j 。因此递推是为了将dp数组的右半部分全部按转移方程赋值,我们的遍历需要在数组斜对角线的右半部分进行。如下图:
通过边界的确定,我们先将所有的dp[i][i]和dp[i][i+1]计算出来了(正如上图两条斜实线所表示的)。我们需要从边界开始递推其他的数,在计算dp[i][j]时应该保证dp[i+1][j]和dp[i][j-1]已经计算完毕,也就是dp[i][j]的下面一个数和左边一个数已经计算完毕。为了达到这个目的,我们的 i 需要从大到小枚举,j 从小到大枚举。因为两条斜实线已经计算完毕,我们的枚举从图中虚线开始,按照 i 从len-3到0、j 从 i+2到 len-1的方式遍历完dp的右半部分。
上面说的有些抽象,其实有种更容易理解的方式,在前面的动态规划小节中,有的题目dp数组只有一维,要计算dp[i]只需知道dp[i-1],这样我们从边界由小到大枚举 i 一定能保证dp[i-1]已经计算完成。但如果要计算dp[i]需要知道的是dp[i+1],我们则需要从边界由大到小枚举 i 才能保证递推 。这种思路在二维数组也是一样,本题中计算dp[i][j]需要知道dp[i+1][j-1],我们需要在 i 方向上由大到小枚举,j 方向上由小到大枚举。本题还有一点特殊在于,边界是斜对角线并且我们只需要用到一半的dp空间,因此在枚举第二维度 j 时会受到第一维度 i 的影响。
参考代码:
#include<iostream>
#include<string>
using namespace std;
const int maxn=101;
int dp[maxn][maxn];
int main() {
string s;
int ans=-1;
cin>>s;
int len=s.size();
//边界
for(int i=0;i<len;i++) {
dp[i][i]=1;
if(i<len-1) {
dp[i][i+1]=(s[i]==s[i+1])?1:0;
}
}
//状态转移方程
for(int i=len-3;i>=0;i--) {
for(int j=i+2;j<=len-1;j++) {
if(s[i]!=s[j]) {
dp[i][j]=0;
}
else {
dp[i][j]=dp[i+1][j-1];
}
}
}
//找到最大长度回文串
for(int i=0;i<=len-1;i++) {
for(int j=i;j<=len-1;j++) {
if(dp[i][j]==1&&ans<(j-i))
ans=j-i;
}
}
cout<<ans+1<<endl;
return 0;
}
除此之外,最长回文串问题还有其他解法。例如二分法+字符串hash的做法时间复杂度为O(nlogn),将在后续的12.1节中介绍;还有时间复杂度仅仅为O(n)的Manacher算法,这里不再阐述,有兴趣可以去搜索该算法。