【算法笔记】Manacher 算法

  • 本文总计约 7000 字,阅读大约需要 25 分钟

前言

Manacher 算法是字符串里面比较冷门的算法吧。但是它的思想非常的有意义,利用回文的对称性,进行记忆化优化。

虽然 Manacher 算法在洛谷上是蓝题的难度,但是理解它的难度实在是不如作为黄题 KMP 字符串匹配 QwQ,至少要比 KMP 直观得很多。然而,网上对 Manacher 算法的介绍并不多,而且大部分都非常的模糊,所以笔者希望自己写一篇博客,来详细介绍一下 Manacher 算法。希望能给读者的学习之路上,带来或多或少的帮助吧。

题目引入

如果一个字符串正着读和反着读完全一样,我们就称之为回文串,例如:ACABCCBAACA 就是一个回文串。

现在给定一个字符串 strstr 的长度为 n,且 n107,请求出它的最长回文子串 palin 的长度。

样例输入:str=ABCABCBACABCCBACA
样例输出:10
样例解释:palin=ACABCCBACA,它在字符串中出现在如下位置:
image

定义

  • 回文中心:给定一个回文串,若它以某个字符为中心翻转之后,依旧能与本身重合,那么我们称之为回文串的回文中心
    例如:ABCDEDCBA 是一个回文串,它的回文中心是 E。因为它关于 E 为中心反转之后依旧为 ABCDEDCBA
    于是,我们就有了一个显而易见的结论:一个回文串,有且仅有一个回文中心。但是要注意,回文中心的位置不一定在字符上。考虑回文串 ABCCBA,它的回文中心就在两个 C 中间。

  • 回文半径:对于一个回文串,它的两端到回文中心的长度,我们称之为回文半径
    例如:ABCDEDCBA 的回文中心为 E,从左端的 AE,长度为 5,故其回文半径是 5
    当然,可以计算出来,如果一个回文串长度为 n,回文半径为 R,那么一定有 R=n2

暴力怎么做

暴力枚举

最简单,最粗暴的办法,就是 Θ(n2) 暴力枚举每个子串可能的左右端点,然后 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);  //更新答案
		}
	}
}

它的时间复杂度为 O(n3),复杂度直接升天了有木有 QwQ!

暴力优化

然鹅,我们发现,不一定非得枚举所有的端点,只要枚举所有可能成为回文子串的中心对称点的位置,然后向两端拓展回文串就可以了:

image

代码甚至比 O(n3) 的还好写:

/*** 求 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);
}

时间复杂度比起上面的暴力枚举,有了很大程度的优化,但是依旧达到了 O(n2),这就意味着,像上面那样,n107 的数据是依旧不能通过的 QwQ。

字符串预处理

而且,上面的 O(n2) 的做法不止是低效,而且还有一个特别致命的弱点:没法判断长度为偶数的回文串,因为它们的回文中心并不在字符上

考虑字符串 str=ABCCBABC,其中最长的回文子串应该是 palin=ABCCBA,但是我们会发现,它的回文中心在两个 C 之间,就没法被枚举到了。

解决的办法也非常简单:我们在每两个相邻的字符中间都插入一个分隔符 |,比如原来是 ABCCBA,就变成了 |A|B|C|C|B|A|。显然,它依旧是一个回文串,但回文中心变成了两个 C 中间的那个分隔符,就可以被枚举到了。一般化地,我们就把所有的回文串,都变成了长度为奇数的回文串

而如何求原回文串的长度呢?其实也很简单,因为对于一个长度为 n 的回文串,在它两边插入了 (n+1) 个分隔符,处理后的回文串的回文半径为 (n+n+1)2=n+1。更一般化地,当我们知道了处理后的回文串的回文半径为 R,那么原回文串的长度就为 R1

Manacher 算法

Manacher 算法引入

尽管 O(n2) 的暴力算法并不能通过 n107 的数据,我们还是用了很多的时间去研究它的做法。

因为暴力算法的思路其实是没有问题的,只是因为同一个重叠的字符串大量重复搜索,才导致暴力的算法非常低效。

假如我们对于每个字符,都可以在均摊 O(1) 的时间复杂度内完成求出以它为回文中心的最长回文子串的半径,那么整个算法的时间复杂度就达到了 Θ(n),就可以线性求出它的最长回文子串的长度了。

有没有这种算法呢?还真有,这就是我们所说的 Manacher 算法。在国内,又戏称为“马拉车”算法(其实还挺形象的 QwQ)。它是由计算机科学家 Manacher1975 年发明的。它就可以通过记忆化,实现 Θ(n) 的算法。

Manacher 算法的概述

以下的算法介绍中的字符串,默认为通过添加分隔符预处理后的字符串。

假如我们定义 R[i] 为以第 i 个字符为中心,可以拓展出的最长回文子串的回文半径。接下来,Manacher 算法的步骤为:

  1. 我们定义 maxright 为当前遍历的所有字符中,拓展处最长回文子串的最右位置,mid 为这个 maxright 是由哪个字符为中心,拓展出的回文子串的右边界;
  2. 在字符串中,从第一个字符开始遍历,假如遍历第 i 个字符时,有 imaxright,那么我们就暴力拓展 R[i],并且更新 maxright 的值;
  3. 假如遍历第 i 个字符时,有 i<maxright,那么就意味着 i 应该在 maxright 所在的回文子串中,既然如此,与 i 关于 mid 对称的点为 2midi,它一定在 i 的左侧,我们就通过 i 关于 mid 的对称点,通过 O(1) 转移得到 R[i] 的值。我们定义 left=2mid1,那么:
    1. 假如 leftR[left]midR[mid],那么这就意味着 left 扩展的回文串完全在 mid 拓展的回文串之内,根据回文串的对称性i 拓展出的回文串应该与 left 的对称,那么就有 R[i]=R[left]
    2. 假如 leftR[left]<midR[mid],就是说 left 扩展的回文串超出了左边界,那么我们无法保证超出边界的部分可以用对称性转移,转移的时候,就有 R[i]=2mid1i

当然,光是讲述算法步骤还是不够直观,让我们用下图的字符串 str=ABCBADABCBD,为例来详细解释 Manacher 的过程(因为加分隔符太麻烦了,我就先不加了 QwQ,反正加不加在这个字符串里不影响结果):
image

  1. 我们从 1 号字符开始遍历,发现以这个字符为中心,只能拓展出它自己,所以以 1 号字符为中心的最长回文半径就是 1R[1]=1。拓展出的 maxright=1,mid=1,我们记录这些数据;
    image

  2. 遍历到 2 号字符,发现它也只能拓展出自己,所以 maxright=2,mid=2,R[2]=2,记录之;
    image

  3. 当我们遇到 3 号字符时,情况发生了一些变化:它可以拓展处的最长回文子串为 ABCBA,回文半径为 R[3]=3,它拓展到最右侧的字符为 5 号字符,那么 maxright=5,mid=3,记录之(这里的图中 mid 打错了,应该为 3,但是又懒得改了 QwQ,就这么看吧);
    image

  4. 当我们遇到 4 号字符时,发现了 maxright4,也就是说 4 在一个回文串的内部,这就意味着 4 号字符应该有一个关于 mid 字符对称的字符 jj 满足 j+42=mid,也就是说 j=2mid4=2,那么就说明 R[4]=R[j]=1
    image

  5. 同样地,更新 5 号字符,R[5]=1。此处不加以赘述;

  6. 遇到 6 号字符,我们发现了它也可以拓展出一个回文串 BCBACABCB,那么,我们更新 mid=6,maxright=10,R[6]=5
    image

  7. 类似步骤 4,我们可以转移 R[7]=R[2×67]=1,R[8]=R[2×68]=1,R[9]=R[2×69]=3。此处不加以赘述;

  8. 11 号字符,可以更新 R[11]=1
    image

于是,最长的回文子串就是 BCBACABCB

在这个过程中,我们同时假设遍历到 i 号字符,且 i<maxright,这个字符关于 mid 的对称的字符为 left。那么就会有以下的两种情况。

  1. 假如 left 拓展的回文串在 mid 的之内,如下图:
    image
    那么这就意味着以 i 为中心的回文串应该与 left 的完全一样,那么 R[i] 就可以由 R[left] 转移过来。
  2. 假如 left 拓展的回文串触碰到了 mid 的边界,甚至超出了 mid 拓展的回文串,如下图:
    image
    那么我们就不能保证,图中标绿圈的部分也在回文串中,我们就只能用 leftmaxleft+1 来转移 R[i]

综上所述,R[i]=min(R[2midi],leftmaxleft+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

时间复杂度分析

对于遍历的过程,mxright 不回退,最多前进 n 次,拓展的总次数显然不超过 mxright 前进的次数,时间复杂度为 Θ(n)。也就相当于扫一遍字符串就能得到答案,比暴力有了很大的优化。

和 KMP 一样,Manacher 巧妙地借用了回文串的对称性,进行记忆化搜索。在我们解决问题的时候,利用问题的特殊性质来寻找特殊的解法的思想,也是特别重要的。

例题

本题目列表会持续更新。

posted @   CaO氧化钙  阅读(63)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示