经知乎用户@Yaxe 提醒,最大回文子串问题可能不能用动态规划解决,因为不满足最优子问题条件,如反例bananas, abbabbabba等。
鉴于本博文对于我自己仍有学习动态规划的意义,还是保留原文。
原文如下:
运用动态规划识别最大回文子串
本文记录了一种灵活利用动态规划(无须回溯)来识别最大回文子串的算法,比流行的马拉车算法性能更好,是我练习动态规划时想到的。
本文将先给出一个要求识别最大回文子串的简化例题,再给出流行的马拉车算法解法、简单的动态规划解法、优化后的动态规划解法,最后给出一个关于相比于简化例题更一般化场景的解答
一个简化例题
例题来自 1040 Longest Symmetric String (25分)
题目中文大意:给定一个非空字符串,仅要求输出其最大回文子串长度。
输入样例:
Is PAT&TAP symmetric?
输出样例:
11
马拉车解法(Manacher’s Algorithm)
马拉车算法需要扩充输入的字符串以适应初始串长奇偶不同的情况,其时间复杂度为O(n),空间复杂度为O(n),其中n为初始串长。
马拉车算法详细解释可见https://zhuanlan.zhihu.com/p/70532099
容易想到的动态规划解法
时间O(n) 空间O(n)
原始输入数据:
设置动态规划数组dp[]用于保存以line[i]为末尾的回文串的串首下标
观察上表会很容易联想到i与dp[i]的关系中包含了最大回文子串的信息,即最大回文子串的长度必为i-dp[i]+1的最大取值(11-1+1==11)。
那么要如何在不回溯的情况下一边扫描获得dp[]数组呢?dp[i]由远及近依次:
(0、dp[0]根据定义必然是0)
1、可能是dp[i-1]-1(对应于line下标从dp[i-1]到i-1是一个回文串,且line[dp[i-1]]==line[i],即dp[i-1]-1为最远可能的地方);
2、可能是dp[i-1](对应于line[k]==line[k+1]==line[k+2]==line[…]==line[i-2]==line[i-1]==line[i],其中0<=k<i,即一连串相同的字符);
3、可能是i本身(对应于line[i]与之前的字符不构成回文串,类似于dp[0])
其中第2条不能写成
if(情况0){...}
else if(情况1){...}
else if(line[i]==line[i-1]){
dp[i]=dp[i-1]; //错误
}
else{...}//情况3
比如反例:
此时dp[4]就不是dp[3]==1,而是3,这是因为虽然line[i]==line[i-1],但不能保证dp[i-1]也是由情况2产生的。
因此需要引入另一个数组seq[],用于在情况2时判断以line[i-1]为末尾的回文串是否由连续的同一字符组成(情况0、3除外)。
那么情况2应该写成
if(情况0){...}
else if(情况1){...}
else if(line[i]==line[i-1]){
if(seq[i-1])dp[i]=dp[i-1];
else dp[i]=i-1;
seq[i]=true;
}
else{...}//情况3
上述讨论落实到编程时还要注意i-1要有意义,dp[i-1]-1要有意义。
当获得了dp[]数组后,便可以一趟扫描确定表达式i-dp[i]+1最大的值,也就是最长回文子串的长度。
而上面所有的seq[]数组、dp[]数组、最大长度均能写入同一个一趟扫描循环之内。
// 时间O(n) 空间O(n)
int main(){
string line;
getline(cin,line);
vector<int>dp(line.size()); // dp[i]为以line[i]为末尾的回文串的串首下标
vector<bool>seq(line.size()); // seq[i]表示以line[i]为末尾的回文串是否由连续的同一字符组成
vector<bool>sep(line.size()); // sep[i]表示以line[i]为末尾的回文串是否由连续的同一字符组成
int maxlen=1;
for(int i=1;i<line.size();++i){
int counterind=dp[i-1]-1; // 最远可能的回文串串首下标
if(counterind>=0&&line[counterind]==line[i]) // 先检查最远处是否可能
dp[i]=counterind;
else{ // 此时最远下标已不可能
if(line[i-1]==line[i]){ // 检查是否可能组成更短的由连续相同字符组成的回文串
if(seq[i-1]) // 前面已经有好几个相同字符了
dp[i]=dp[i-1];
else // 当前字符才刚刚为第二个相同字符
dp[i]=i-1;
seq[i]=true;
}
else // 最远处不可能,也不可能是相同字符的回文串,则只能是最简单回文串
dp[i]=i; // 即本回文串就只有这一个字符
}
if(i-dp[i]+1>maxlen) // 更新最大回文串长度
maxlen=i-dp[i]+1;
}
cout<<maxlen;
return 0;
}
只需一趟扫描就能同步确定seq[]、dp[]和最大长度,且无回溯,故时间复杂度为O(n),n为输入的字符串长度。
使用的辅助数组seq[]和dp[]均和输入字符串一样长,故空间复杂度为O(n)
改进的动态规划解法
时间O(n) 空间O(1)
仔细观察上述算法,发现每次确定最大长度时之和当前的dp[i]有关,而确定dp[i]只与dp[i-1]、seq[i-1]有关,故可以压缩辅助数组seq[],只使用单个变量seq表示原来的seq[i-1],同理只用单个变量dp表示原来的dp[i]与dp[i-1]。
比如如果原来是dp[i]=dp[i-1],则dp不用改变,否则如果是dp[i]=某值,则现在为dp=某值)
// 动态规划,空间优化 时间O(n) 空间O(1)
int main(){
string line;
getline(cin,line);
int dp=0; // 即dp[i-1]或dp[i]
bool seq=false; // 即seq[i-1]或seq[i]
int maxlen=1;
for(int i=1;i<line.size();++i){
int counterind=dp-1; // dp[i-1]
if(counterind>=0&&line[counterind]==line[i]){
dp=counterind; // dp[i]
seq=false; // new
}
else{
if(line[i-1]==line[i]){
//if(seq) dp[i]=dp[i-1];
if(!seq)
dp=i-1; // dp[i]
seq=true; // changed
}
else{
dp=i; // dp[i]
seq=false; // new
}
}
if(i-dp+1>maxlen) // dp[i]
maxlen=i-dp+1; // dp[i]
}
cout<<maxlen;
return 0;
}
时间效率方面,本就是一趟遍历,无法更加优化,故还是O(n)
空间效率方面,将原来长度为n的两个辅助数组压缩成两个简单变量,优化成了O(1)
完整的例题
不仅仅要求出最大回文串长度,而且要给出最大回文串在初始串中的位置,换句话说就是要给出该最大回文串本身。这才是识别最大回文串的更一般化的场景。
不难发现,只需增设一个变量记录最大回文串出现的位置,在上面的一趟遍历更新最大回文串长的时候同步更新该变量,即可方便地满足上述需求。且时间复杂度、空间复杂度还是为O(n)、O(1)。
杂谈
这是我的第二篇博文,私以为还蛮有创造性和价值的,希望对你有所帮助。