字符串专题-学习笔记:Manacher 算法
1. 前言
Manacher 算法,俗称“马拉车”算法,是一种字符串算法,该算法可以在线性时间内求出一个串中最长回文串的长度,以及以每一个点为回文中心的奇长度回文串的长度。
实际上偶长度回文串的长度也能够求,后面会讲。
前置知识:无。
2. 详解
2.1 奇偶化归
上面这个词是我自己编的,百度搜不到qwq
首先看这两个回文串:ABABABA
和 ABAABA
。
前面的回文串长度为 7,是个奇数,这样的回文串称之为奇长度回文串,简称奇回文串。
后面的回文串长度为 6,是个偶数,这样的回文串称之为偶长度回文串,简称偶回文串。
可以发现,奇回文串的回文中心是最中间的字符,偶回文串的回文中心是中间两个字符的中间(也就是 AA
中间的空隙)。
因为奇回文串与偶回文串回文中心性质不相同(一个字符,一个空隙),因此我们需要对字符串做一点手脚:在每两个字符中间以及左右两端插入一个未曾出现过的字符。
比如说还是上面两个字符串 ABABABA
和 ABAABA
,我们按照上述描述插入 #
这个字符,于是这两个字符串就变成了 #A#B#A#B#A#B#A#
和 #A#B#A#A#B#A#
。
此时你会发现这两个字符串统一变成了奇回文串。
因此 Manacher 算法的第一步就是插入字符,使所有可能回文串变成奇回文串。
我将其称之为『奇偶化归』,因为这个方法统一了奇回文串和偶回文串。
下面若无特殊说明,默认字符串为奇偶化归之后的字符串。
2.2 翻转推移
还是我自己起的qwq
实际上翻转推移分为 2 块:翻转、推移。
接下来是 Manacher 算法的核心步骤。
设 \(f_i\) 表示以 \(i\) 为回文中心的字符串的最长半径(不是长度)。
又设两个值 \(id,Maxn\),其中 \(Maxn\) 是所有回文中心在 \([1,i-1]\) 中的回文串所能到达的最右端距离的最大值,而 \(id\) 是这个回文中心。我称这个字符串为最右字符串。
显然,以 \(i\) 为回文中心,这个回文串能够到达的最右端距离是 \(f_i+i\)。
那么假设我们已经处理完了 \([1,i-1]\) 内的所有 \(f_i\),如何处理 \(f_i\) 呢?
看图。
- 如果 \(i<Maxn\)。
设在以 \(id\) 为中心的回文串中 \(i\) 的对称位置为 \(j\),那么由中点公式有 \(j=2 \times id-i\)。
此时考虑两种情况:
- 若以 \(j\) 为回文中心的回文串在最右字符串中间,显然有 \(f_i=f_j\)。
- 若不是,继续看下图:
上图的红色部分代表以 \(j\) 为中心的回文串。
显然,上图的两个紫色部分代表的字符串相同,而且互相全等,此时的回文半径为 \(Maxn-i\)。
那么因此有 \(f_i=Maxn-i\)。
两者取 \(\min\) 即可得到 \(f_i\) 的初值。
- 若 \(i \geq Maxn\)
这个时候我们什么也不知道,只能令 \(f_i=1\)。
综上,当 \(i <Maxn\) 有 \(f_i=\min(f_j,Maxn-i)\)。
当 \(i \geq Maxn\) 有 \(f_i=1\)。
上述操作就是『翻转』操作,因为其充分利用了回文串的性质,对称翻转得到了尽可能大而又准确的 \(f_i\)。
但显然这个 \(f_i\) 肯定是有问题的,因为当 \(f_i=1\) 或 \(f_i=Maxn-i\) 时可能会有更长的回文半径。
此时我们就需要推移得到真正的 \(f_i\),这一块 暴力 做即可。
我没骗你,暴力,而且复杂度是正确的,就是 \(O(n)\) 做法。
暴力做法就是直接暴力匹配 \(i+f_i\) 与 \(i-f_i\),看看能不能继续向外扩展回文串即可。
最后不要忘记更新 \(id,Maxn\)。
上述做法我将其称之为『推移』操作,因为这个操作将 \(Maxn\) 往右边推移了。
那么最长回文串长度就是 \(\max\{f_i\}-1\),即最大半径 -1,减一是因为一个要去除我们奇偶化归时加入的字符 #
,另一个没有除以 2 是因为本身我们的字符串长度就是翻倍过的。
2.3 复杂度证明
上面提到了,有一个暴力推移操作,这个操作后面的部分很暴力,还能做到开头提出的 \(O(n)\) 算法吗?
复杂度仍然是 \(O(n)\) 的,证明如下:
考虑 \(f_i\) 初值的 3 种情况:
- \(f_i=f_j\)。
此时因为 \(f_j\) 已经达到最大,那么 \(f_i\) 也达到最大,因此无法往外推移,不计复杂度。
- \(f_i=Maxn-i/f_i=1\)
这个时候就需要推移了。
但是需要注意的是,对于前一种情况,以 \(i\) 为回文中心的回文串最右端就是 \(Maxn\),而对于后一种情况,\(i\) 本身大于 \(Maxn\)。
因此在这两种情况下,\(Maxn\) 一定会变大,而且也只能变大。
因为 \(Maxn\) 最大值为 \(n\)(整个串就是回文串),而 \(Maxn\) 只能往右边推移,因此更新 \(Maxn\) 也就是暴力的复杂度至多为 \(O(n)\)。
综上,暴力总复杂度为 \(O(n)\),而且推移部分不会与暴力部分干扰。
这就说明了其实推移部分的 \(O(n)\) 与暴力部分的 \(O(n)\) 复杂度是独立的,因此总复杂度是相加不是相乘,为 \(O(n)\)。
2.4 代码
请注意代码里面的枚举细节,\(i\) 是从字符串的第二个字符开始枚举的,如果从第一个字符开始枚举,会导致 i - f[i]
越界,造成代码错误。
代码:
/*
========= Plozia =========
Author:Plozia
Problem:P3805 【模板】manacher算法
Date:2021/5/12
========= Plozia =========
*/
#include <bits/stdc++.h>
#define Max(a, b) (((a) > (b)) ? (a) : (b))
typedef long long LL;
const int MAXN = 2.3e7 + 10;
int len1, len2, f[MAXN];
char str1[MAXN], str2[MAXN];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
// int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void init()
{
len1 = strlen(str1);
str2[0] = '^'; str2[1] = '$';
for (int i = 0; i < len1; ++i)
{
str2[(i << 1) + 2] = str1[i];
str2[(i << 1) + 3] = '$';
}
str2[(len1 << 1) + 2] = '@';
len2 = (len1 << 1) + 2;
}
void Manacher()
{
int id = 0, Maxn = 0;
for (int i = 1; i < len2; ++i)
{
if (Maxn > i) f[i] = Min(f[(id << 1) - i], Maxn - i);
else f[i] = 1;
for (; str2[i + f[i]] == str2[i - f[i]]; ++f[i]) ;
if (f[i] + i > Maxn) { Maxn = f[i] + i; id = i; }
}
}
int main()
{
scanf("%s", str1);
init(); Manacher(); int ans = 0;
for (int i = 0; i <= len2; ++i) ans = Max(ans, f[i]);
printf("%d\n", ans - 1);
}
3. 总结
Manacher 算法分为 2 步:奇偶化归,翻转推移。
- 奇偶化归:将奇回文串与偶回文串化归为奇回文串。
- 翻转推移:利用回文串性质翻转得到 \(f_i\) 初值,暴力推移得到正确的 \(f_i\)。