Manacher学习笔记


$\mathrm{Manacher}$ 算法用于解决求解字符串中的最长回文子串问题,时间复杂度为 $O(n$) 是目前求解该问题效率最高的算法。

求最长回文子串

之前学进制哈希的时候学到了一种名为中心扩展法的求解方法,时间复杂度 $O(n^2)$ ,搭配上 哈希+二分 可以优化到 $O(n\log n)$ 。

分析一下中心扩展法的效率,最好时,即每个字符都只需扩展一次,这样复杂度为 $O(n)$ ,最坏的话每个字符都要扩展 $n$ 次,复杂度 $O(n^2)$ 。究其低效的原因,在于有大量的重复检查。

如果能够改善这种重复,那么便可以设计出一个高效的算法。

$\mathrm{Manacher}$ 算法发巧妙地利用回文串本身的特征实现了极大的改善。

$\mathrm{Manacher}$ 算法

考虑回文串时有两种情况,一种是有一个中心字符的奇数串,另一种是有两个中心字符的偶数串。

那么求 $S$ 的最长回文子串时,两种情况编码较为麻烦。

考虑做一个变换,使这两种情况变为一种情况。

在字符串 $S$ 的每个字符左右插入一个不属于 $S$ 的字符,如 # ,同时为了编程方便,在 $S$ 的首尾再加上两个奇奇怪怪的字符防止越界。

例如字符串 $S$ 为 abba ,经过变换后变为 $#a#b#b#a#& ,经过变换后的字符串,不影响对其中回文串的判定。

定义数组 $P[]$ ,其中 $P[i]$ 表示以字符 $S[i]$ 为中心字符的最长回文串的半径。

如果已经计算出了 $P[]$ ,那么最大的 $P[i]-1$ 就是答案,这个最长回文串在原字符串的开头位置是 $(i-P[i])/2$ 。

那么现在的问题就是如何高效地求出 $P[]$ 。

考虑用动态规划的思路。

假设目前已经求出了 $P[0]\sim P[i-1]$ ,考虑如何求出 $P[i]$ 。

令 $R$ 为 $P[0]\sim P[i-1]$ 这些回文串中最大的右端点,即能覆盖到的最远点。

令 $C$ 为这个 $R$ 对应的回文串的中心点。

也就是说, $P[C]$ 是已经求得的一个回文串,它的右端点是 $R$ ,且 $R$ 是所有已经求得的回文串的右端点的最大值, $R=P[C]+C$ 。

在字符串 $S$ 上, $R$ 左边的字符已经被检查过, $R$ 右边的字符还未被检查。

下面来计算 $P[i]$ 。

设 $j$ 是 $i$ 关于 $C$ 的镜像点,$P[j]$ 已经被计算出来了。

若 $i\ge R$ ,由于 $R$ 右边的字符都没有检查过,所以只能初始化 $P[i]=1$ ,然后用暴力中心扩展法求出 $P[i]$ 。

若 $i<R$ ,细分为两种情况:

  1. $j$ 的回文串被 $C$ 的回文串包含。而 $i$ 为 $j$ 的景象,所以镜像 $i$ 的回文不会越过 $C$ 的右端点 $R$ ,所以 $P[i]$ 有初值 $P[i]=P[j]$ 。

    根据 $(i+j)/2=C$ ,所以 $j=2C-i$ ,即 $P[i]$ 有初值 $P[2C-i]$ 。这个初值并非最后的 $P[i]$ 的值,仍需继续用中心扩展法来扩展。

  2. $j$ 的回文串不被 $C$ 的回文串包含,即 $j$ 回文串的左端点比 $C$ 回文串的左端点小。根据镜像原理,所以 $i$ 回文串的右端点比 $R$ 大,但是由于 $R$ 右边的字符还没有被检查过,只能先让 $P[i]$ 被限制在 $R$ 之内,有 $P[i]=R-i$ 。然后继续用中心扩展法完成 $P[i]$ 的计算。

上面的两种情况可以一起处理, $P[i]$ 取两者的较小值,然后用中心扩展法来计算。


例题 洛谷3805 【模板】manacher 算法

给出一个只由小写英文字符 $\texttt a,\texttt b,\texttt c,\ldots\texttt y,\texttt z$ 组成的字符串 $S$ ,求 $S$ 中最长回文串的长度 。

字符串长度为 $n$。

代码

直接贴代码吧

#include<bits/stdc++.h>
#define N 11000010

using namespace std;

inline int read() {
    int w=1,x=0;
    char ch=0;
    while(ch<'0'||ch>'9') {if(ch=='-') w=-1;ch=getchar();}
    while(ch>='0'&&ch<='9') x=x*10+ch-'0',ch=getchar();
    return w*x;
}

char a[N],s[N<<1];
int n,P[N<<1],ans=0;

void init() {
    n=strlen(a);
    int m=0;
    s[m++]='$',s[m++]='#';
    for(int i=0;i<n;i++) s[m++]=a[i],s[m++]='#';
    s[m++]='&';
    n=m;
}

void Manacher() {
    int R=0,C;
    for(int i=1;i<n;i++) {
        if(i<R) P[i]=min(P[(C<<1)-i],R-i);
        else P[i]=1;
        while(s[i+P[i]]==s[i-P[i]]) P[i]++;
        if(P[i]+i>R) {
            R=P[i]+i;
            C=i;
        }
        ans=max(ans,P[i]-1);
    }
}

int main() {
    scanf("%s",a);
    init();
    Manacher();
    printf("%d\n",ans);
    system("pause");
    return 0;
}

时间复杂度

最后来分析分析 $\mathrm{Manacher}$ 算法的时间复杂度。

该算法表面上是逐步求解 $P[i]$ ,实际上是在扩展 $R$ 。

$R$ 的扩展是通过 while 循环实现的,算法的计算量取决于 $R$ 的计算。

而从 $S[1]$ 到 $S[n]$ 的整个扩展过程中, $R$ 对每个字符只计算了一次,所以总时间复杂度为 $O(n)$ 。


参考资料

《算法竞赛》罗勇军


文章作者: HoshiuZ
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 HoshiuZ !
  目录