Manacher
Manacher算法是一个用来查找一个字符串中的最长回文子串(不是最长回文序列)的线性算法。它的优点就是把时间复杂度为 \(O(n^2)\) 的暴力算法优化到了 \(O(n)\)。首先先让我们来看看最原始的暴力扩展,分析其存在的弊端,以此来更好的理解Manacher算法。
暴力匹配
暴力匹配算法的原理很简单,就是 从原字符串的首部开始,依次向尾部进行遍历,每访问一个字符,就以此字符为中心向两边扩展,记录该点的最长回文长度。 那么我们可以想想,这样做存在什么弊端,是不是可以求出真正的最长回文子串?
答案是显然不行的,我们从两个角度来分析这个算法
1.不适用于偶数回文串
我们举两个字符串做例子,它们分别是 "aba","abba",我们通过肉眼可以观察出,它们对应的最长回文子串长度分别是3和4,然而我们要是用暴力匹配的方法去对这两个字符串进行操作就会发现,"aba" 对应的最长回文长是 "131","abba" 对应的最长回文长度是 "1111",我们对奇数回文串求出了正确答案,但是在偶数回文串上并没有得到我们想要的结果,通过多次测试我们发现,这种暴力匹配的方法不适用于偶数回文串
2.时间复杂度 \(O(n^2)\)
这里的时间复杂度是一个平均时间复杂度,并不代表每一个字符串都是这个复杂度,但因为每到一个新位置就需要向两边扩展比对,所以平均下来时间复杂度达到了 \(O(n \cdot n)\)。
Manacher算法本质上也是基于暴力匹配的方法,只不过做了一点简单的预处理,且在扩展时提供了加速
Manacher对字符串的预处理
我们知道暴力匹配是无法解决偶数回文串的,可Manacher算法也是一种基于暴力匹配的算法,那它是怎么来实现暴力匹配且又不出错的呢?它用来应对偶数字符串的方法就是——做出预处理,这个预处理可以巧妙的让所有字符串都变为奇数回文串,不论它原本是什么。操作实现也很简单,就是将原字符串的首部和尾部以及每两个字符之间插入一个特殊字符,这个字符是什么不重要,不会影响最终的结果(具体原因会在后面说),这一步预处理操作后的效果就是原字符串的长度从 \(n\) 改变成了 \(2 \cdot n+1\),也就得到了我们需要的可以去做暴力扩展的字符串,并且从预处理后的字符串得到的最长回文字符串的长度除以2就是原字符串的最长回文子串长度,也就是我们想要得到的结果。
这里解释一下为什么预处理后不会影响对字符串的扩展匹配
比如我们的原字符串是 "aa" ,假设预处理后的字符串是 "#a#a#" ,我们在任意一个点,比如字符 '#' ,向两端匹配只会出现 'a' 匹配 'a' , '#' 匹配 '#' 的情况,不会出现原字符串字符与特殊字符匹配的情况,这样就能保证我们不会改变原字符串的匹配规则。通过这个例子,你也可以发现实际得到的结果与上述符合。
Manacher算法核心
Manacher算法的核心部分在于它巧妙的令人惊叹的加速,这个加速一下把时间复杂度提升到了线性,让我们从暴力的算法中解脱出来,我们先引入概念,再说流程,最后提供实现代码。
概念:
ManacherString:经过Manacher预处理的字符串,以下的概念都是
基于Manacher产生的。
回文半径和回文直径:因为处理后回文字符串的长度一定是奇数,所以回文半径是包括回文中心在内的回文子串的一半的长度,回文直径则是回文半径的2倍减1。比如对于字符串 "aba",在字符 'b' 处的回文半径就是2,回文直径就是3。
最右回文边界 \(R\):在遍历字符串时,每个字符遍历出的最长回文子串都会有一个右边界,而 \(R\) 则是所有已知右边界中最靠右的位置,也就是说 \(R\) 的值是只增不减的。
回文中心 \(C\):取得当前 \(R\) 的第一次更新时的回文中心。由此可见 \(R\) 和 \(C\) 时伴生的。
半径数组:这个数组记录了原字符串中每一个字符对应的最长回文半径。
流程:
-
步骤1:预处理原字符串
先对原字符串 \(S\) 进行预处理,预处理后得到一个新的字符串 \(A\)(下标从1开始),为了更直观明了的让大家理解Manacher的流程操作,我们在下文的 \(A\) 中不显示特殊字符(这样并不影响结果)。
-
步骤2:\(R\) 和 \(C\) 的初始值为 0,创建半径数组 b
-
步骤3:开始从下标 \(i = 1\) 去遍历字符串 \(A\)
-
分支1:\(i > R\) ,也就是 \(i\) 在 \(R\) 外,此时没有什么花里胡哨的方法,直接暴力匹配,此时记得看看 \(C\) 和 \(R\) 要不要更新。
-
分支2:\(i \le R\),也就是 \(i\) 在 \(R\) 内,此时分三种情况,在讨论这三个情况前,我们先构建一个模型
\(L\) 是当前 \(R\) 关于 \(C\) 的对称点,\(i'\) 是 \(i\) 关于 \(C\) 的对称点,可知 \(i' = 2 \cdot C - i\),并且我们会发现,\(i'\) 的回文区域是我们已经求过的,从这里我们就可以开始判断是不是可以进行加速处理了
-
情况1:\(i'\) 的回文区域在 \([L,R]\) 的内部,此时 \(i\) 的回文直径与 \(i'\) 相同,我们可以直接得到 \(i\) 的回文半径,下面给出证明
红线部分是 \(i'\) 的回文区域,因为整个 \(A_{[L,R]}\) 就是一个回文串,回文中心是 \(C\),所以i形成的回文区域和 \(i'\) 形成的回文区域是关于 \(C\) 对称的。
-
情况2:\(i'\) 的回文区域左边界超过了 \(L\),此时 \(i\) 的回文半径则是 \([i,R]\),下面给出证明
首先我们设 \(L\) 点关于 \(i'\) 对称的点为 \(L'\),\(R\) 点关于 \(i\) 点对称的点为 \(R'\),\(L\) 的前一个字符为 \(x\),\(L'\) 的后一个字符为 \(y\),\(k\) 和 \(z\) 同理,此时我们知道 \(A_{[L,L']}\) 是 \(i'\) 回文区域内的一段回文串,故可知 \(A_{[R',R]}\) 也是回文串,因为 \(A_{[L,R]}\) 是一个大回文串。所以我们得到了一系列关系,\(x = y,y = k,x \not = z\),所以 \(k \not = z\)。这样就可以验证出 \(i\) 点的回文半径是 \([i,R]\)。
-
情况3:\(i'\) 的回文区域左边界恰好和 \(L\) 重合,此时 \(i\) 的回文半径最少是 \([i,R]\),回文区域从 \(R\) 继续向外部匹配,下面给出证明
因为 \(i'\) 的回文左边界和 \(L\) 重合,所以已知的 \(i\) 的回文半径就和 \(i'\) 的一样了,我们设 \(i\) 的回文区域右边界的下一个字符是 \(y\),\(i\) 的回文区域左边界的上一个字符是 \(x\),现在我们只需要从 \(x\) 和 \(y\) 的位置开始暴力匹配,看是否能把 \(i\) 的回文区域扩大即可。
-
-
总结一下,Manacher算法的具体流程就是先匹配 -> 通过判断 \(i\) 与 \(R\) 的关系进行不同的分支操作 -> 继续遍历直到遍历完整个字符串。
时间复杂度:
我们可以计算出时间复杂度为何是线性的,分支一的情况下时间时间复杂度是 \(O(n)\),分支二的前两种情况都是 \(O(1)\),分支二的第三种情况,我们可能会出现 \(O(1)\)——无法从 \(R\) 继续向后匹配,也可能出现 \(O(n)\)——可以从 \(R\) 继续匹配,即使可以继续匹配,\(R\) 的值也会增大,这样会影响到后续的遍历匹配复杂度,所以综合起来整个算法的时间复杂度就是线性的,也就是 \(O(n)\)。
代码实现
整个代码并不是对上述流程的生搬硬套(那样会显得代码冗长),代码进行了精简优化
#include <bits/stdc++.h>
using namespace std;
const int N=2.2e7+10;
int n,R,C;
int b[N],ans;//b:半径数组
char a[N],s[N];
void init(){//预处理出新的字符串
a[0]='~',a[n=1]='#';//新字符串下标从 1 开始,a[0]设为其他字符防止越界
for(int i=0;i<strlen(s);i++)
a[++n]=s[i],a[++n]='#';
}
int main() {
cin>>s;//输入原字符串
init();
for(int i=1;i<=n;i++){
if(i<=R) //对于分支2的三种情况,i的回文半径至少是 i'的回文半径 和 i到R的距离 二者中较小的一个
b[i]=min(b[(C<<1)-i],R-i+1);//中点公式:i'=2*C-i
while(a[i-b[i]]==a[i+b[i]]) ++b[i];//如果是分支2,就是在b[i]基础上暴力向外扩展;如果是分支1,那么就相当于直接暴力
if(b[i]+i>R) R=b[i]+i-1,C=i;//更新R、C
if(b[i]>ans) ans=b[i];//更新最长回文子串的长度
}
printf("%d",ans-1);//ans直接记录的是新字符串中最长回文的半径,对应到原字符串整个字串的长度只需要-1即可。
return 0;
}