【算法笔记】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}\),它在字符串中出现在如下位置:
image

定义

  • 回文中心:给定一个回文串,若它以某个字符为中心翻转之后,依旧能与本身重合,那么我们称之为回文串的回文中心
    例如:\(\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!

暴力优化

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

image

代码甚至比 \(\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 算法的步骤为:

  1. 我们定义 \(\textit{maxright}\) 为当前遍历的所有字符中,拓展处最长回文子串的最右位置,\(\textit{mid}\) 为这个 \(\text{maxright}\) 是由哪个字符为中心,拓展出的回文子串的右边界;
  2. 在字符串中,从第一个字符开始遍历,假如遍历第 \(i\) 个字符时,有 \(i\ge \textit{maxright}\),那么我们就暴力拓展 \(R[i]\),并且更新 \(\textit{maxright}\) 的值;
  3. 假如遍历第 \(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\),那么:
    1. 假如 \(\textit{left}-R[\textit{left}]\ge \textit{mid}-R[\textit{mid}]\),那么这就意味着 \(\textit{left}\) 扩展的回文串完全在 \(\textit{mid}\) 拓展的回文串之内,根据回文串的对称性\(i\) 拓展出的回文串应该与 \(\textit{left}\) 的对称,那么就有 \(R[i]=R[\textit{left}]\)
    2. 假如 \(\textit{left}-R[\textit{left}] < \textit{mid}-R[\textit{mid}]\),就是说 \(\textit{left}\) 扩展的回文串超出了左边界,那么我们无法保证超出边界的部分可以用对称性转移,转移的时候,就有 \(R[i]=2\textit{mid}-1-i\)

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

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

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

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

  4. 当我们遇到 \(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\)
    image

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

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

  7. 类似步骤 \(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\)。此处不加以赘述;

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

于是,最长的回文子串就是 \(\tt{BCBACABCB}\)

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

  1. 假如 \(\textit{left}\) 拓展的回文串在 \(\textit{mid}\) 的之内,如下图:
    image
    那么这就意味着以 \(i\) 为中心的回文串应该与 \(\textit{left}\) 的完全一样,那么 \(R[i]\) 就可以由 \(R[\textit{left}]\) 转移过来。
  2. 假如 \(\textit{left}\) 拓展的回文串触碰到了 \(\textit{mid}\) 的边界,甚至超出了 \(\textit{mid}\) 拓展的回文串,如下图:
    image
    那么我们就不能保证,图中标绿圈的部分也在回文串中,我们就只能用 \(\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 巧妙地借用了回文串的对称性,进行记忆化搜索。在我们解决问题的时候,利用问题的特殊性质来寻找特殊的解法的思想,也是特别重要的。

例题

本题目列表会持续更新。

posted @ 2022-01-31 22:37  CaO氧化钙  阅读(59)  评论(0编辑  收藏  举报