【算法笔记】Manacher 算法
- 本文总计约 7000 字,阅读大约需要 25 分钟。
前言
Manacher 算法是字符串里面比较冷门的算法吧。但是它的思想非常的有意义,利用回文的对称性,进行记忆化优化。
虽然 Manacher 算法在洛谷上是蓝题的难度,但是理解它的难度实在是不如作为黄题 KMP 字符串匹配 QwQ,至少要比 KMP 直观得很多。然而,网上对 Manacher 算法的介绍并不多,而且大部分都非常的模糊,所以笔者希望自己写一篇博客,来详细介绍一下 Manacher 算法。希望能给读者的学习之路上,带来或多或少的帮助吧。
题目引入
如果一个字符串正着读和反着读完全一样,我们就称之为回文串,例如: 就是一个回文串。
现在给定一个字符串 , 的长度为 ,且 ,请求出它的最长回文子串 的长度。
样例输入:。
样例输出:。
样例解释:,它在字符串中出现在如下位置:
定义
-
回文中心:给定一个回文串,若它以某个字符为中心翻转之后,依旧能与本身重合,那么我们称之为回文串的回文中心。
例如: 是一个回文串,它的回文中心是 。因为它关于 为中心反转之后依旧为 。
于是,我们就有了一个显而易见的结论:一个回文串,有且仅有一个回文中心。但是要注意,回文中心的位置不一定在字符上。考虑回文串 ,它的回文中心就在两个 中间。 -
回文半径:对于一个回文串,它的两端到回文中心的长度,我们称之为回文半径。
例如: 的回文中心为 ,从左端的 到 ,长度为 ,故其回文半径是 。
当然,可以计算出来,如果一个回文串长度为 ,回文半径为 ,那么一定有 。
暴力怎么做
暴力枚举
最简单,最粗暴的办法,就是 暴力枚举每个子串可能的左右端点,然后 判断这个字串是不是回文的。就像下面这样:
/*** 求 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); //更新答案
}
}
}
它的时间复杂度为 ,复杂度直接升天了有木有 QwQ!
暴力优化
然鹅,我们发现,不一定非得枚举所有的端点,只要枚举所有可能成为回文子串的中心对称点的位置,然后向两端拓展回文串就可以了:
代码甚至比 的还好写:
/*** 求 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);
}
时间复杂度比起上面的暴力枚举,有了很大程度的优化,但是依旧达到了 ,这就意味着,像上面那样, 的数据是依旧不能通过的 QwQ。
字符串预处理
而且,上面的 的做法不止是低效,而且还有一个特别致命的弱点:没法判断长度为偶数的回文串,因为它们的回文中心并不在字符上!
考虑字符串 ,其中最长的回文子串应该是 ,但是我们会发现,它的回文中心在两个 之间,就没法被枚举到了。
解决的办法也非常简单:我们在每两个相邻的字符中间都插入一个分隔符 ,比如原来是 ,就变成了 。显然,它依旧是一个回文串,但回文中心变成了两个 中间的那个分隔符,就可以被枚举到了。一般化地,我们就把所有的回文串,都变成了长度为奇数的回文串。
而如何求原回文串的长度呢?其实也很简单,因为对于一个长度为 的回文串,在它两边插入了 个分隔符,处理后的回文串的回文半径为 。更一般化地,当我们知道了处理后的回文串的回文半径为 ,那么原回文串的长度就为 。
Manacher 算法
Manacher 算法引入
尽管 的暴力算法并不能通过 的数据,我们还是用了很多的时间去研究它的做法。
因为暴力算法的思路其实是没有问题的,只是因为同一个重叠的字符串大量重复搜索,才导致暴力的算法非常低效。
假如我们对于每个字符,都可以在均摊 的时间复杂度内完成求出以它为回文中心的最长回文子串的半径,那么整个算法的时间复杂度就达到了 ,就可以线性求出它的最长回文子串的长度了。
有没有这种算法呢?还真有,这就是我们所说的 Manacher 算法。在国内,又戏称为“马拉车”算法(其实还挺形象的 QwQ)。它是由计算机科学家 在 年发明的。它就可以通过记忆化,实现 的算法。
Manacher 算法的概述
以下的算法介绍中的字符串,默认为通过添加分隔符预处理后的字符串。
假如我们定义 为以第 个字符为中心,可以拓展出的最长回文子串的回文半径。接下来,Manacher 算法的步骤为:
- 我们定义 为当前遍历的所有字符中,拓展处最长回文子串的最右位置, 为这个 是由哪个字符为中心,拓展出的回文子串的右边界;
- 在字符串中,从第一个字符开始遍历,假如遍历第 个字符时,有 ,那么我们就暴力拓展 ,并且更新 的值;
- 假如遍历第 个字符时,有 ,那么就意味着 应该在 所在的回文子串中,既然如此,与 关于 对称的点为 ,它一定在 的左侧,我们就通过 关于 的对称点,通过 转移得到 的值。我们定义 ,那么:
- 假如 ,那么这就意味着 扩展的回文串完全在 拓展的回文串之内,根据回文串的对称性, 拓展出的回文串应该与 的对称,那么就有 ;
- 假如 ,就是说 扩展的回文串超出了左边界,那么我们无法保证超出边界的部分可以用对称性转移,转移的时候,就有 。
当然,光是讲述算法步骤还是不够直观,让我们用下图的字符串 ,为例来详细解释 Manacher 的过程(因为加分隔符太麻烦了,我就先不加了 QwQ,反正加不加在这个字符串里不影响结果):
-
我们从 号字符开始遍历,发现以这个字符为中心,只能拓展出它自己,所以以 号字符为中心的最长回文半径就是 ,。拓展出的 ,我们记录这些数据;
-
遍历到 号字符,发现它也只能拓展出自己,所以 ,记录之;
-
当我们遇到 号字符时,情况发生了一些变化:它可以拓展处的最长回文子串为 ,回文半径为 ,它拓展到最右侧的字符为 号字符,那么 ,记录之(这里的图中 打错了,应该为 ,但是又懒得改了 QwQ,就这么看吧);
-
当我们遇到 号字符时,发现了 ,也就是说 在一个回文串的内部,这就意味着 号字符应该有一个关于 字符对称的字符 , 满足 ,也就是说 ,那么就说明 ;
-
同样地,更新 号字符,。此处不加以赘述;
-
遇到 号字符,我们发现了它也可以拓展出一个回文串 ,那么,我们更新 。
-
类似步骤 ,我们可以转移 。此处不加以赘述;
-
号字符,可以更新 。
于是,最长的回文子串就是 。
在这个过程中,我们同时假设遍历到 号字符,且 ,这个字符关于 的对称的字符为 。那么就会有以下的两种情况。
- 假如 拓展的回文串在 的之内,如下图:
那么这就意味着以 为中心的回文串应该与 的完全一样,那么 就可以由 转移过来。 - 假如 拓展的回文串触碰到了 的边界,甚至超出了 拓展的回文串,如下图:
那么我们就不能保证,图中标绿圈的部分也在回文串中,我们就只能用 来转移 。
综上所述,。
代码
代码不是很好理解,我把所有可能有点模糊的步骤都用注释注出来了,需要读者自行体会。
#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
时间复杂度分析
对于遍历的过程, 不回退,最多前进 次,拓展的总次数显然不超过 前进的次数,时间复杂度为 。也就相当于扫一遍字符串就能得到答案,比暴力有了很大的优化。
和 KMP 一样,Manacher 巧妙地借用了回文串的对称性,进行记忆化搜索。在我们解决问题的时候,利用问题的特殊性质来寻找特殊的解法的思想,也是特别重要的。
例题
本题目列表会持续更新。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】