最长回文子串实验报告
- 1. 最长回文子串
【问题描述】
给定一个字符串,求该字符串中包含的最长回文子串。
输出起始位置最小的位置编号(从0开始)以及回文子串的长度。
【输入形式】
输入的第一行为一个整数T,表示有T组测试数据,接下来的T行,每行一个字符串,对应每组测试数据
【输出形式】
输出有T行,每行两个整数,分别表示对应测试用例中包含的最长回文子串的起始位置(从0开始)和子串长度,如果有多个,则输出起始位置最小的。
【样例输入】
2 12abcbats qwerabccbaw
【样例输出】
2 5 4 6
一、问题分析
分析并确定要处理的对象(数据)是什么
字符串的回文子串,这里我们可以用数组存下来以每个元素为中心的回文子串的回文半径,用于之后加速计算。
分析并确定要实现的功能是什么
找到回文字符串。
分析并确定处理后的结果如何显示
通过回文半径得出结果,输出至控制台。
请用题目中样例,详细给出样例求解过程。
二、数据结构和算法设计
抽象数据类型设计
在DP中,DP数组用于存储状态;
在manacher中,d数组用于存储回文半径。
物理数据对象设计
数组,本题目无特殊物理数据对象
算法思想的设计
在动态规划法中
我们要确定转移的状态,如表1我们确立一个二维数组dp[m][n],其中dp[i][j]表示的是从下标i到下标j的字段是否是回文子串,i是开始的索引,j是字符串末尾的索引,这样存储的好处显而易见,就是我们不用再去算一遍之前的子串是不是回文子串了,减少了操作的时间,查表就可以了,经过思考,根据i和j的抽象意义,我们的状态有三种情况,即i=j时,此时只有一个元素,dp[i][j]为1,i=1+j时,如果s[i]=s[j]那么他就是回文子串,这是偶数个数的回文子串,还有一种最普遍的情况,我们要想想dp[i][j]要由什么得到,那就是比它稍小一个的回文子串,dp[i+1][j-1],现在我们得到了状态转移方程,
dp[i][j]=dp[i+1][j-1]&&(s[i]==s[j]);我们要如何转移状态呢, i要由比它大的那个元素得到,j要由比它小的那个元素得到,那么i要从大到小遍历,j要从小到大遍历
根据用例 12abcbats
我们建立表1:
dp i j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
0 | 1 | ||||||||
1 | 0 | 1 | |||||||
2 | 0 | 0 | 1 | ||||||
3 | 0 | 0 | 0 | 1 | |||||
4 | 0 | 0 | 0 | 0 | 1 | ||||
5 | 0 | 0 | 0 | 1 | 0 | 1 | |||
6 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | ||
7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | |
8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1(开始) |
表1:箭头代表状态转移的方向。
在中心扩展法中
这是一个朴素算法,我们遍历字符串的每一个元素,这每一个元素就是一个种子,两边扩展就是相当于发芽,看看这个种子可以长到什么程度。但是这个要分奇偶,所以要两次二重循环。
在Manacher算法中
我们针对回文字符串发现了如下规律。
首先介绍几个概念:
回文半径与字符串处理:
类似于级数的收敛半径,回文半径是以回文子串以中心元素出发到达边界的最长距离,例如:aba中, 1号索引(字符串下标)对应的元素b的回文半径是2,至于如abba的情况可以通过加#号的方式来转换成奇数字符串,#a#b#b#a#,4号索引对应的元素#的回文半径是5,如果将aba加工成#a#b#a#,3号索引对应的元素b的回文半径是4,可以看出,这两个例子在加工后的回文半径的长度是回文子串的长度-1,这不是一个巧合!事实上,如表2,能够将#还原成普通字串的形式,使其原始子串刚好可以与回文半径一一对应(除了那个圆心对应的点)。至此我们发现这样处理,可以把所有偶数回文子串的情况转化为奇数回文子串的情况,如图1设原始字符串元素个数为n,则设n个隔板,在隔板两边分别插入元素,则要插入n+1个元素。因此现在的字符串共有2n+1个元素,一定为奇数。
这样的话,我们就可以根据回文半径求出回文子串起始位置和回文子串的长度,并且他们应该是正相关关系,所以找到最大的回文半径就可以解决此题。为了方便之后的计算,我们将每个回文中心对应的回文半径存入一个数组d[n]中,(这里都是在修改后的回文子串上进行操作)设回文半径为d[i],那么回文中心为i,回文子串的长度是d[i]-1,该回文子串在原来字符串的起始位置如图2所示,可以看到,原数组一个位置对应改造数组的两个位置,事实上,根据数学归纳法,i/2,就是原回文中心的位置,一对二也映照了对偶数回文子串的改造,在这里取哪一个元素取决于哪个元素对应回文半径的更大,#号对应偶数回文串情况和原有元素对应奇数回文串情况,这个取大的就好。并且,可以发现,回文半径-1也反映了原始回文中心扩展出来的回文子串的个数,事实上,回文半径已经包含回文子串的绝大部分信息。
对#所对应的原始回文串的所对应原始字符的下标,这个题目说是起始位置最小的位置编号(从0开始),那么也就是说,#对应的索引要尽可能小,这是针对取中心位置的情况的,可是如果求起始位置呢?我们可以发现,对于一个回文子串,它的开头和结尾必然是#,所以算出它的结尾以后,我们要+1(偏移量)才能得到正确的起始位置,这与之前中心位置的取法并不矛盾!
图1: 5个隔板,6个空位
# | a | # | b | # | b | # | a | # |
b | # | a | # |
表2:可以建立一一对应的关系。
# | a | # | b | # | b | # | a | # |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
a | b | b | a | |||||
0 | 1 | 2 | 3 |
图2:修改后的子串和原始子串间一一对应的关系,字符下面是其索引
加速盒子
对于如表3的回文字符串,回文中心a的回文半径为4,以[a-4,a+4]为界,构造一个盒子,盒子里面的回文子串有如下性质:右边位置的回文半径等于其对称左边位置的回文半径。这是由对称性决定的性质。
字符串元素 | # | b | # | a | # | b | # | a |
回文半径 | 1 | 3 | 1 | 4 | 1 | 3 | 1 | 1 |
表3:加速盒子内部的情况
根据这个性质,假设我们从左往右计算每个元素对应的回文半径,我们可以省下计算这个盒子内回文中心右边的回文半径的时间。
对于这个加速盒子,我们可以以贪心的方式尽量取远的(因为我们是从左往右计算,我们都是拿左边的半径来更新右边的半径,当然也可以反序进行,只不过没有必要),并且这个新的盒子的回文半径要大于原有的回文半径,这样子更新盒子可以让更多字符吃到这个加速盒子的buff,这里考虑的极端一点,就假设一个收益最小的情况(享受到加速盒子的元素个数仅仅多了1个),那就是回文中心右边一个元素刚刚好求得了一个比原来加速盒子对应的回文半径大1的回文半径,但凡回文半径更新的值少于这种情况的,都有可能更新出一个更差的值,所以说我们要选取比原半径大的半径,和远的点(这个无所谓,前文也说了,因为我们要更新半径,那指针肯定走到右边了,走到右边了就可以了)。
但是这里有一点要明确,如果左边的回文半径对应到右边的时候,以右边元素为回文中心的加速盒子已经超出了原来的加速盒子,这个时候,我们仅仅可以确定原来加速盒子内的部分的回文半径,至于外面的部分则需要调用朴素算法来暴力枚举。
不过,如果暴力枚举后得出的回文半径已经大于了原有盒子的回文半径,那我们就可以更新我们的加速盒子了,理由前面两段已经给出解释,这里不过多赘述。
现在,我们终于可以给出我们的算法步骤:
- 修改字符串,这里有一个小技巧,就是可以在字符串开始和末尾多加两个生僻的不同的元素,这样子朴素算法遇到了不相同的元素自动停止,不需要判断边界条件了。
- 初始化最开始的回文半径,建立第一个加速盒子
- 从2号索引开始循环,如果该元素在加速盒子内,直接用对称位置的回文半径更新该元素的回文半径,这里回文半径最多只到加速盒子边界,多的半径要通过朴素算法暴力枚举
- 调用中心扩展法暴力枚举,更新回文半径。
- 如果新的回文半径大于原有加速盒子回文半径,建立新的加速盒子(也可以认为是滑动窗口)。
关键功能的算法步骤
DP: dp[i][j]=dp[i+1][j-1];状态转移方程
Manacher:
- 从2号索引开始循环,如果该元素在加速盒子内,直接用对称位置的回文半径更新该元素的回文半径,这里回文半径最多只到加速盒子边界,多的半径要通过朴素算法暴力枚举
- 调用中心扩展法暴力枚举,更新回文半径。
- 如果新的回文半径大于原有加速盒子回文半径,建立新的加速盒子(也可以认为是滑动窗口)。
三、算法性能分析
对于朴素算法和动态规划,很容易看出复杂度是O(n2)。
对于manacher算法,我们可以证明,调用朴素算法的次数不会超过n次,因为超过n次以后,加速盒子就覆盖了整个数组了。所以manacher算法的时间复杂度是O(n)。
AC代码
#include<iostream>
#include<iomanip>
#include<cmath>
#include<cstring>
#include<queue>
#include<algorithm>
using namespace std;
string edit(string s){
char temp[100];
int len = s.size();
int k=0;
temp[0]='$';
temp[++k]='#';//要形成形如"$#a#b#c#d#\0"这种字符才行,保证必然是奇数个字符,必然对称
for(int i=0;i<len;i++){
temp[++k]=s[i];temp[++k]='#';
}
temp[++k]='\0';
string t(temp);
return t;
}
inline bool bound(string s,int i){
int len=s.size();
return (0<=i&&i<len);
}
void solve(string s){
//int len = s.size();
int spos=0;
string exs=edit(s);
int nlen=exs.size();
//cout<<exs<<' '<<nlen<<endl;
int l=0,r=0,symi=0;
int *d=new int[nlen]();
fill(d, d + nlen, 0);
d[0]=0;
d[1]=1;
//d[i]是回文半径
//cout<<nlen<<endl;
//memset(d,0,sizeof(d));
int maxv=d[1];
for(int i=2;i<nlen;i++){
//这个对称原理,是一个规律嘛?
//cout<<d[i]<<' ';
if(l<=i&&i<=r) symi=l+r-i,d[i]=min(d[symi],r-i+1);//因为d[i]的回文半径也是加了1的,这里也要补齐,从它本身开始计算回文半径ji
while(exs[i+d[i]]==exs[i-d[i]]&&bound(exs,i+d[i])&&bound(exs,i-d[i])) d[i]++;//暴力枚举检查一下啊,其实这里几乎可以看作O(1),为什么呢?因为它和路径压缩一样,前面的优化,导致它几乎不用更新多少,除非是这个元素超出盒子,并且由特别长的回文半径,但是这样子都不能超过n,这个算法最厉害的地方就在于,他这个while()循环执行的总次数基本上不会超出n次,因为但凡它走了n次,那代表那整个盒子都可以用对称的方法求得回文半径,对于循环到后面的元素,几乎这就是个判断语句罢了
if(l>i-d[i]+1) r=i+d[i]-1, l=i-d[i]+1; //更新盒子的长度,在盒子里面的都可以直接用对称原理求得
if(d[i]>maxv){
maxv=d[i];
spos=(i-d[i])/2;
}
//cout<<d[i]<<endl;
}
cout<<spos<<' '<<maxv-1<<endl;
}
int main() {
int t;
cin>>t;
while(t--){
string s;
cin>>s;
solve(s);
}
return 0;
}