其他分享
首页 > 其他分享> > 【字符串匹配】【回文】Manacher

【字符串匹配】【回文】Manacher

作者:互联网

【字符串匹配】【回文】Manacher

给定一个字符串\(S\),求\(S\)中最长的回文子串长度。时空复杂度要求为线性。

暴力扫描T走不谢。

下面介绍马拉车Manacher算法。

首先对于任意字符串\(S\),定义\(S_{i\dots j}\)表示下标从\(i\)到\(j\)的连续子串。

考虑一个回文串\(A\),如果他的长度为奇数\(2k+1(k\in\N)\),那么就会有\(A_{1\dots k}\) 和 \(A_{k+2\dots2k+1}\)是关于\(A_{k+1}\)这一个字母对称的,也就是说\(A_{1\dots k}\)在翻转之后和\(A_{k+2\dots2k+1}\)完全相等。

那么就称\(A\)是以\(A_{k+1}\)为中心的(下文简记为以\(k+1\)为中心),半径为\(k+1\)的回文串。(这里“半径”只是我口胡的一个词,实际上应该没有这个定义。)

可以发现,这个回文串一定是奇数回文,整个回文串的长度就是两倍的半径减一。

那么就先解决奇数回文的情况。

对于一个字符串\(S_{1\dots n}\)来说,他可以有许多子串都是奇数回文,也可以有一些奇数回文的不同子串拥有相同的中心。由于我们只关注最长回文子串的长度,因此对于相同中心的子串我们只需要知道其中半径最长的就可以了。

所以我们定义一个序列\(m\),其中\(m_i\)表示字符串\(S\)的子串中,以\(i\)为中心的奇数回文子串的最大半径。保存半径的原因是方便转移,如果不纠结常数问题也可以直接保存长度。

明显,\(m_i\geq 1(1\leq i\leq n)\),一个字母的时候是一个奇数回文,半径为\(1\)。

现在我们内心涌出了一个邪恶的想法,就是对于每一个位置,枚举以其为中心的最大半径,大概就是这样写(忽略边界问题):

m[i]=1;
while(s[i+m[i]]==s[i-m[i]]) ++m[i];

然后很快就会发现这样的转移其实也是二次方级别的,达不到线性。

考虑如何线性转移。在kmp的时候为了求出新前缀中最长公共前缀后缀的长度,我们运用了已知前缀的信息。这里我们也可以这样干。

假设当前转移到序列第\(i\)项,维护一个\(x(x<i)\),使得\(x+m_x\)最大,记最大值为\(y\)。

为什么要维护\(x+m_x\)最大?因为对于每一个\(x\),\(m_x\)表示以\(x\)为中心的最大半径,也就是说 \(S_{x-m_x+1\dots x+m_x-1}\) 一定是一个奇数回文子串,而且是以\(x\)为中心的最长的奇数回文子串。如果当前位置\(i\)在某个回文子串中,而且保证了该回文子串的中心\(x\)在\(i\)前面,我们就可以利用\(2x-i\)位置的信息来加快\(i\)位置的求值。

如果\(i\)被包含在这个回文子串中,也就是\(i\leq y\),那么\(2x-i\)也包含在其中(废话),而这个奇数回文子串是以\(x\)为中心的,如果记以\(2x-i\)为中心,半径为\((2x-i)-(x-m_x+1)+1\)的子串(不一定是回文串)为\(A\),记以\(i\)为中心,半径与\(A\)相同的子串为\(B\)的话,那么就可以说,\(A,B\)是关于\(S_x\)对称的(因为\(A,B\)都被包含在以\(x\)为中心的回文串中)。

那么,就有了一个很天真的想法,既然他们对称了,如果只关心回文串的长度可以认为他们俩包含的信息相同。因此就可以说\(m_i=m_{2x-i}\)。

为什么这样做是不对的呢?因为子串\(S_{2x-i-m_{2x-i}+1\dots2x-i+m_{2x-i}-1}\),也就是以\(2x-i\)为中心的最大半径子串有可能已经超出了子串\(S_{x-m_x+1\dots x+m_x-1}\)的范围。一旦超出这个范围,子串\(A,B\)就不再相等了(因为\(m_x\)是以\(x\)为中心的最大半径,因此\(S_{x-m_x}\neq S_{x+m_x}\))

所以我们在优化的时候要取一个最小值避免超出范围。

在优化之后就是上面枚举最大半径的方法啦。

while(s[i+m[i]]==s[i-m[i]]) ++m[i]

为什么这样时间复杂度是线性的呢?

对于当前要求的\(m_i\),如果求出来发现\(i+m_i\)仍然没有达到\(y\),即这个新的奇数回文子串仍然被包含在上面维护的最大的\(x+m_x\)的回文子串中,那么其实在求他的过程就是直接执行了\(m_i=m_{2x-i}\),根本没有执行过++m[i]。这是因为\(i+m_i\)没有达到\(y\),那么在对称的另一边也没有达到\(x-m_x\),一切还在掌握之中,因为\(m_{2x-i}\)是最大半径,因此就不可能执行++m[i]的拓展了。

如果初始时\(i\)就达到或者超过了\(y\),或者求出来\(i+m_i\)达到或者超过了\(y\),那么就说明他一定执行了++m[i],因为仅凭\(m_i=m_{2x-i}\)是不可能做到出来的结果能达到\(y\),我们在赋值的时候为了不让他越界而取了一个限制,因此超过就肯定是执行了++m[i]。又因为他能超过,因此在赋值的时候就应该是被上界限制住了,所以在执行++m[i]之前应该有\(i+m_i=y=x+m_x\)。执行++m[i]之后,执行了多少次\(y\)就会往后移动多少位,因为执行完之后要更新维护\(y\)的值。

综上所述,\(y\)的移动和++m[i]的操作是同步的,而\(y\)的最大值只能去到\(n\),所以++m[i]最多也就只能执行\(n\)次,因此时间复杂度就是线性的了。


上面说了那么多,都是针对奇数回文子串的,但是现实中是有偶数回文的啊?怎么搞?难道把他删掉一个字符或者增加一个字符变成奇数?那不就改变字符串本来结构了吗?

其实,就是把他变成奇数,但是为了不改变原来的结构,我们需要加一堆字符。

比如字符串abccbab,他的最长奇数回文子串为bab,最长偶数回文子串为abccba,而偶数回文子串按照上面的方法是不能正确识别的。

因此我们插入一些字符,把原字符串变为#a#b#c#c#b#a#b,其实就是在原来每个字符前插入一个未曾出现过的字符。char类型是八位有符号整数,因此实际操作中建议选择一个比较大的负数插入避免和原字符串重合。这里为了方便演示而用了可见字符。

那么现在新字符串的最长奇数回文子串就变成了#a#b#c#c#b#a#,对应原串的偶数回文子串,这样就把偶数子串转化为了奇数子串,而原来的奇数子串仍然可以成功识别,他就是b#a#b

模拟可得,新字符串中\(m_i\)(回文串的半径)的最大值减一就是原字符串中最长的回文串的长度。

那么现在得到转换的规则(初稿):在每一个字母前面加上一个未曾出现过的字符。

下面这个例子:aa,转换之后变成#a#a,最长奇数回文子串#a#,貌似和原串不太一样。

那么改一下转换规则(不改版):每个字母前面加上一个未曾出现过的字符,最后再添加一个这个字符。

然后现在想一下边界问题,在进行s[i-m[i]]==s[i+m[i]]拓展时有可能出现i-m[i]为负数的情况,这样会因为神秘的东方力量而导致RWA,因此我们就要保证到达s[0]的时候就不能让他继续拓展了,这个时候我们可以再次修改规则(打死不改版):

在原字符串的每个空隙中插入一个未出现过的字符,然后在新字符串的两端加入两个不同的未出现过的字符

其中的空隙表示两个字符之间或者首字母前或者尾字母后。

由于字符串结尾一般使用\0表示,他的ASCII为\(0\),因此我们只需要在开头插入另一个未出现过的字符就可以了。

Code

题目

#include <cstdio>
#include <cstring>

inline int min(const int a,const int b) { return a<b? a:b; }
inline int max(const int a,const int b) { return a>b? a:b; }
const int N=1.1e7+3;
char s[N],S[N<<1];
int m[N<<1],n,len;

int main() {
	scanf("%s",s);
	len=strlen(s);
	S[n++]=-17;
	for(int i=0;i<len;++i) {
		S[n++]=-18;
		S[n++]=s[i];
	}
	S[n++]=-18;
	S[n]=0;
	register int x=0,y=0;
	for(int i=1;i<=n;++i) {
		if(i<y) m[i]=min(m[(x<<1)-i],y-i);
		else m[i]=1;
		while(S[i-m[i]]==S[i+m[i]]) ++m[i];
		if(i+m[i]>y) y=i+m[x=i];
	}
	x=0;
	for(int i=1;i<=n;++i) x=max(x,m[i]);
	printf("%d\n",x-1);
	return 0;
}

标签:子串,奇数,Manacher,2x,半径,字符串,回文
来源: https://www.cnblogs.com/LASS/p/16599978.html