【算法笔记】Manacher 算法
- 本文总计约 7000 字,阅读大约需要 25 分钟。
前言
Manacher 算法是字符串里面比较冷门的算法吧。但是它的思想非常的有意义,利用回文的对称性,进行记忆化优化。
虽然 Manacher 算法在洛谷上是蓝题的难度,但是理解它的难度实在是不如作为黄题 KMP 字符串匹配 QwQ,至少要比 KMP 直观得很多。然而,网上对 Manacher 算法的介绍并不多,而且大部分都非常的模糊,所以笔者希望自己写一篇博客,来详细介绍一下 Manacher 算法。希望能给读者的学习之路上,带来或多或少的帮助吧。
题目引入
如果一个字符串正着读和反着读完全一样,我们就称之为回文串,例如:\(\tt{ACABCCBAACA}\) 就是一个回文串。
现在给定一个字符串 \(\tt{str}\),\(\tt{str}\) 的长度为 \(n\),且 \(n\le 10^7\),请求出它的最长回文子串 \(\tt{palin}\) 的长度。
样例输入:\(\tt{str=ABCABCBACABCCBACA}\)。
样例输出:\(10\)。
样例解释:\(\tt{palin=ACABCCBACA}\),它在字符串中出现在如下位置:
定义
-
回文中心:给定一个回文串,若它以某个字符为中心翻转之后,依旧能与本身重合,那么我们称之为回文串的回文中心。
例如:\(\tt{ABCDEDCBA}\) 是一个回文串,它的回文中心是 \(\tt{E}\)。因为它关于 \(\tt{E}\) 为中心反转之后依旧为 \(\tt{ABCDEDCBA}\)。
于是,我们就有了一个显而易见的结论:一个回文串,有且仅有一个回文中心。但是要注意,回文中心的位置不一定在字符上。考虑回文串 \(\tt{ABCCBA}\),它的回文中心就在两个 \(\tt{C}\) 中间。 -
回文半径:对于一个回文串,它的两端到回文中心的长度,我们称之为回文半径。
例如:\(\tt{ABCDEDCBA}\) 的回文中心为 \(\tt{E}\),从左端的 \(\tt{A}\) 到 \(\tt{E}\),长度为 \(5\),故其回文半径是 \(5\)。
当然,可以计算出来,如果一个回文串长度为 \(n\),回文半径为 \(R\),那么一定有 \(R=\left\lceil \dfrac{n}{2} \right\rceil\)。
暴力怎么做
暴力枚举
最简单,最粗暴的办法,就是 \(\Theta(n^2)\) 暴力枚举每个子串可能的左右端点,然后 \(\mathcal{O}(n)\) 判断这个字串是不是回文的。就像下面这样:
/*** 求 str 中的最长回文子串***/
int ans = 1;
for(int i = 0; str[i]; ++i) {
for(int j = i; str[j]; ++j) { //O(n^2) 枚举子串左右端点
bool is_palin = true;
for(int k = i; k <= i + j - k; ++k) { //暴力判断是否回文
if(str[k] != str[i + j - k]) {
is_palin = false;
break ;
}
}
if(is_palin) {
ans=max(ans, i + j); //更新答案
}
}
}
它的时间复杂度为 \(\mathcal{O}(n^3)\),复杂度直接升天了有木有 QwQ!
暴力优化
然鹅,我们发现,不一定非得枚举所有的端点,只要枚举所有可能成为回文子串的中心对称点的位置,然后向两端拓展回文串就可以了:
代码甚至比 \(\mathcal{O}(n^3)\) 的还好写:
/*** 求 str 中的最长回文子串***/
int ans = 0;
for(int i = 0; str[i]; ++i) {
int rad = 1;
while(i - rad + 1 >= 0 && str[i + rad - 1] == str[i - rad + 1]) { //在回文串不越界的情况下拓展回文串
++rad;
}
ans = max(ans, rad * 2 - 1);
}
时间复杂度比起上面的暴力枚举,有了很大程度的优化,但是依旧达到了 \(\mathcal{O}(n^2)\),这就意味着,像上面那样,\(n\le 10^7\) 的数据是依旧不能通过的 QwQ。
字符串预处理
而且,上面的 \(\mathcal{O}(n^2)\) 的做法不止是低效,而且还有一个特别致命的弱点:没法判断长度为偶数的回文串,因为它们的回文中心并不在字符上!
考虑字符串 \(\tt{str=ABCCBABC}\),其中最长的回文子串应该是 \(\tt{palin=ABCCBA}\),但是我们会发现,它的回文中心在两个 \(\tt{C}\) 之间,就没法被枚举到了。
解决的办法也非常简单:我们在每两个相邻的字符中间都插入一个分隔符 \(\tt{|}\),比如原来是 \(\tt{ABCCBA}\),就变成了 \(\tt{|A|B|C|C|B|A|}\)。显然,它依旧是一个回文串,但回文中心变成了两个 \(\tt{C}\) 中间的那个分隔符,就可以被枚举到了。一般化地,我们就把所有的回文串,都变成了长度为奇数的回文串。
而如何求原回文串的长度呢?其实也很简单,因为对于一个长度为 \(n\) 的回文串,在它两边插入了 \((n+1)\) 个分隔符,处理后的回文串的回文半径为 \(\left\lceil \dfrac{(n+n+1)}{2} \right\rceil=n+1\)。更一般化地,当我们知道了处理后的回文串的回文半径为 \(R\),那么原回文串的长度就为 \(R-1\)。
Manacher 算法
Manacher 算法引入
尽管 \(\mathcal{O}(n^2)\) 的暴力算法并不能通过 \(n\le 10^7\) 的数据,我们还是用了很多的时间去研究它的做法。
因为暴力算法的思路其实是没有问题的,只是因为同一个重叠的字符串大量重复搜索,才导致暴力的算法非常低效。
假如我们对于每个字符,都可以在均摊 \(\mathcal{O}(1)\) 的时间复杂度内完成求出以它为回文中心的最长回文子串的半径,那么整个算法的时间复杂度就达到了 \(\Theta(n)\),就可以线性求出它的最长回文子串的长度了。
有没有这种算法呢?还真有,这就是我们所说的 Manacher 算法。在国内,又戏称为“马拉车”算法(其实还挺形象的 QwQ)。它是由计算机科学家 \(\mathcal{Manacher}\) 在 \(1975\) 年发明的。它就可以通过记忆化,实现 \(\Theta(n)\) 的算法。
Manacher 算法的概述
以下的算法介绍中的字符串,默认为通过添加分隔符预处理后的字符串。
假如我们定义 \(R[i]\) 为以第 \(i\) 个字符为中心,可以拓展出的最长回文子串的回文半径。接下来,Manacher 算法的步骤为:
- 我们定义 \(\textit{maxright}\) 为当前遍历的所有字符中,拓展处最长回文子串的最右位置,\(\textit{mid}\) 为这个 \(\text{maxright}\) 是由哪个字符为中心,拓展出的回文子串的右边界;
- 在字符串中,从第一个字符开始遍历,假如遍历第 \(i\) 个字符时,有 \(i\ge \textit{maxright}\),那么我们就暴力拓展 \(R[i]\),并且更新 \(\textit{maxright}\) 的值;
- 假如遍历第 \(i\) 个字符时,有 \(i<\textit{maxright}\),那么就意味着 \(i\) 应该在 \(\textit{maxright}\) 所在的回文子串中,既然如此,与 \(i\) 关于 \(mid\) 对称的点为 \(2\textit{mid}-i\),它一定在 \(i\) 的左侧,我们就通过 \(i\) 关于 \(mid\) 的对称点,通过 \(\mathcal{O}(1)\) 转移得到 \(R[i]\) 的值。我们定义 \(\textit{left}=2\textit{mid}-1\),那么:
- 假如 \(\textit{left}-R[\textit{left}]\ge \textit{mid}-R[\textit{mid}]\),那么这就意味着 \(\textit{left}\) 扩展的回文串完全在 \(\textit{mid}\) 拓展的回文串之内,根据回文串的对称性,\(i\) 拓展出的回文串应该与 \(\textit{left}\) 的对称,那么就有 \(R[i]=R[\textit{left}]\);
- 假如 \(\textit{left}-R[\textit{left}] < \textit{mid}-R[\textit{mid}]\),就是说 \(\textit{left}\) 扩展的回文串超出了左边界,那么我们无法保证超出边界的部分可以用对称性转移,转移的时候,就有 \(R[i]=2\textit{mid}-1-i\)。
当然,光是讲述算法步骤还是不够直观,让我们用下图的字符串 \(\tt{str=ABCBADABCBD}\),为例来详细解释 Manacher 的过程(因为加分隔符太麻烦了,我就先不加了 QwQ,反正加不加在这个字符串里不影响结果):
-
我们从 \(1\) 号字符开始遍历,发现以这个字符为中心,只能拓展出它自己,所以以 \(1\) 号字符为中心的最长回文半径就是 \(1\),\(R[1]=1\)。拓展出的 \(\textit{maxright}=1,\textit{mid}=1\),我们记录这些数据;
-
遍历到 \(2\) 号字符,发现它也只能拓展出自己,所以 \(\textit{maxright}=2,\textit{mid}=2,R[2]=2\),记录之;
-
当我们遇到 \(3\) 号字符时,情况发生了一些变化:它可以拓展处的最长回文子串为 \(\tt{ABCBA}\),回文半径为 \(R[3]=3\),它拓展到最右侧的字符为 \(5\) 号字符,那么 \(\textit{maxright}=5,\textit{mid}=3\),记录之(这里的图中 \(\textit{mid}\) 打错了,应该为 \(3\),但是又懒得改了 QwQ,就这么看吧);
-
当我们遇到 \(4\) 号字符时,发现了 \(\textit{maxright}\ge 4\),也就是说 \(4\) 在一个回文串的内部,这就意味着 \(4\) 号字符应该有一个关于 \(\textit{mid}\) 字符对称的字符 \(j\),\(j\) 满足 \(\dfrac{j+4}{2}=\textit{mid}\),也就是说 \(j=2\textit{mid}-4=2\),那么就说明 \(R[4]=R[j]=1\);
-
同样地,更新 \(5\) 号字符,\(R[5]=1\)。此处不加以赘述;
-
遇到 \(6\) 号字符,我们发现了它也可以拓展出一个回文串 \(\tt{BCBACABCB}\),那么,我们更新 \(\textit{mid}=6,\textit{maxright}=10,R[6]=5\)。
-
类似步骤 \(4\),我们可以转移 \(R[7]=R[2\times 6-7]=1,R[8]=R[2\times 6-8]=1,R[9]=R[2\times 6-9]=3\)。此处不加以赘述;
-
\(11\) 号字符,可以更新 \(R[11]=1\)。
于是,最长的回文子串就是 \(\tt{BCBACABCB}\)。
在这个过程中,我们同时假设遍历到 \(i\) 号字符,且 \(i<\textit{maxright}\),这个字符关于 \(\textit{mid}\) 的对称的字符为 \(\textit{left}\)。那么就会有以下的两种情况。
- 假如 \(\textit{left}\) 拓展的回文串在 \(\textit{mid}\) 的之内,如下图:
那么这就意味着以 \(i\) 为中心的回文串应该与 \(\textit{left}\) 的完全一样,那么 \(R[i]\) 就可以由 \(R[\textit{left}]\) 转移过来。 - 假如 \(\textit{left}\) 拓展的回文串触碰到了 \(\textit{mid}\) 的边界,甚至超出了 \(\textit{mid}\) 拓展的回文串,如下图:
那么我们就不能保证,图中标绿圈的部分也在回文串中,我们就只能用 \(\textit{left}-\textit{maxleft}+1\) 来转移 \(R[i]\)。
综上所述,\(R[i]=\min(R[2\textit{mid}-i],\textit{left}-\textit{maxleft}+1)\)。
代码
代码不是很好理解,我把所有可能有点模糊的步骤都用注释注出来了,需要读者自行体会。
#include <cstdio>
using namespace std;
const int maxN = 50000001;
char str[maxN];
int arr[maxN];
void qread() { //字符串预处理
int cnt;
char ch = getchar();
str[0] = '?'; //字符串首插入特殊字符以防越界
str[cnt = 1]='$';
while(ch < 'a' || ch > 'z') { //过滤掉无用字符
ch=getchar();
}
while(ch >= 'a' && ch <= 'z') { //将每个字符间都插入分隔符 '$'
str[++cnt] = ch;
str[++cnt] = '$';
ch = getchar();
}
}
int manacher(char* str) {
int mid = 0, mxright = 0, ans = 0;
for(int i = 1; str[i]; ++i) {
if(i <= mxright) { //用回文串的信息转移 arr[i]
arr[i] = min(arr[(mid << 1) - i], mxright - i + 1);
}
while(str[i - arr[i]] == str[i + arr[i]]) {
++arr[i]; //暴力拓展 arr[i]
}
if(arr[i] + i > mxright) { //更新 mxright 和 mid
mxright=arr[i] + i - 1;
mid = i;
}
ans=max(ans, arr[i]); //更新最大的回文半径
}
return ans - 1; //预处理前的最长回文串长度等于预处理后的最长回文串半径减一
}
int main(void) {
qread();
printf("%d", manacher(str));
return 0;
}
//by CaO
时间复杂度分析
对于遍历的过程,\(\textit{mxright}\) 不回退,最多前进 \(n\) 次,拓展的总次数显然不超过 \(\textit{mxright}\) 前进的次数,时间复杂度为 \(\Theta(n)\)。也就相当于扫一遍字符串就能得到答案,比暴力有了很大的优化。
和 KMP 一样,Manacher 巧妙地借用了回文串的对称性,进行记忆化搜索。在我们解决问题的时候,利用问题的特殊性质来寻找特殊的解法的思想,也是特别重要的。
例题
本题目列表会持续更新。