O(n)解决查找回文串问题——马拉车manacher算法

通常的思路:按照字符串顺序选取一个字符作为中心,向两边遍历判断是否相同,时间复杂度O(n^2) ;Manacher的方法能在这个通常思路的基础上进行一些修改, 从而将时间复杂度降到O(n)。

本篇文章是边学边写的,将每一个结论推导的背后原理尽力的说明了,有些文章直接给出了结论,我自己在学习的时候就纠结了很久这个公式为什么可以这么写。

当然后文有结论总结,不想深入理解的或者只是复习的,可以直接记结论然后直接用,一般也是当作模板用的。

简单介绍思路:

基础思路是以一个字符为中心向两遍拓展判断是否相同,然后记录最长回文长度,但是复杂度在o(n^2),且奇偶的遍历需要分开思考,于是马拉车算法进行了修改:

  1. 预处理
    但是偶回文的对称轴没法用存入数组的一个具体的下标表示,奇偶方法不一,于是在每个字符的两边都加上判断回文串中不可能出现的特殊字符,将原始字符串扩长至两倍左右,这样偶回文的中间的对称轴就有明确的下标了,该下标对应存的是个特殊字符。同时为了防止越界以及方便判断撞到数组边界了,需要再在两边加上额外的特殊字符。这样处理完之后,整个字符串是奇数串,也不需要考虑奇偶问题了。

  2. 巧妙构造一个p[i]数组
    预处理了之后,正常建立记录最长回文串长度的变量,然后以每个字符为中心的最长回文串的长度的半径建立一个数组p[],p[i]就是该字符为中心最长回文串的半径。p[i]的建立需要充分利用回文串的特性来减去一部分中心拓展法的过程(否则还不如直接暴力中心拓展)。

  3. 利用p[i]数组探索处理后的字符串和原始字符串的关系
    得到p[i]数组之后,可以依据其,来找到原始字符串中最大回文串的具体长度以及起始位置。这样下来,就能精准找出原始字符串了。

预处理

首先,在判断回文串的时候需要考虑奇偶性,因为abcba和abba的形式在判断时有很大的区别,需要考虑中心的特殊情况,所以Mannacher的思路是将字符串内每一个字符的两边都加上字符串中一定不会出现的字符(如$,#)。

这种方式可以让所有的字符串都变成奇数长(两边的特殊符号不影响奇偶性,偶数中间有奇数个特殊符号,奇数中间有偶数个特殊符号,所以加完之后全部变成了奇数长)。

要全部修改成奇数回文串,是为了后续的运算更方便,而用特殊字符翻倍了原字符串之后,偶回文中间空的位置作为中心的情况就可以避免了,可以用特殊字符的位置表示,这个在后续能避免很多分类情况。

例如:
以s=“cbaabf”为例,加完之后变成了arr=“#c#b#a#a#b#f#”
在这里插入图片描述
让改变后的字符串存入arr[],此时需要准备一个int p[]数组,p的长度与arr等长,p[i]用了来表示以arr[i]字符为中心是可以向左右延伸的最长回文串的半径r(是半径,即回文串总共长r*2+1),p[i]==1表示只有arr[i]自身是回文串(单个即回文)。

至于这个p[i]如何构建,在后文有介绍,也是最核心的问题。

具体操作及其原理

具体的思路是:
找出arr中的回文串最大半径和s的最大回文串长度的关系,并确定在s中回文串的起始位置,那么就可以借助长度和起始位置把s中的回文串找出来

01 修改后的回文串最大半径和原始最大回文串长度的关系

首先对比一下最长回文半径(例子中是arr[6]的时候,p[6]=5)和原字符串的关系,其对应的原始回文串是abba,长度为4,也是例子中的最长回文串。如果仔细思考一下,最长半径一定是和最长回文串有关系的,因为加的字符的数量是有规律的。

此前提到,偶数串中间有奇数个特殊符号,奇数串中间有偶数个特殊符号,两边都各有一个特殊符合,导致修改后的字符串arr必然是奇数串,那么减去一个特殊字符后,意味着每一个原始字符都跟着一个特殊字符,所以原始字符串的长度再除以2就好了。总结来看就是,(修改后arr回文的长度-1)/2=原始最长回文串的长度,对应到最长回文半径r=(arr回文-1)*2,就是r-1,其中计算r中的-1是因为一个字符也算一次回文串。

综上只要得出原始字符串中最大回文串的长度 len = p[i] - 1 即可,也是要用的结论

02 找到原始最大回文串的起始位置

主要是依赖最长回文半径的长度以及它的位置i来确认;在这里插入图片描述
为了使两个方案尽量统一,可以考虑加上一个新的特殊字符$或^(不能是#因为会影响回文最长半径的判断 ),但是这样会使得其变成偶数串了,所以需要在后面再加一个@等特殊符号。这样操作完之后,不仅避免了要处理下标为0这种奇怪的情况,还能作为边界判断,避免后续计算越界。

需要注意的是,在字符串前面加$这些额外的特殊符号的时候,同时把p[i]的下标位置往后退了一位。

此处先给出计算起点的通用结论:找到最大的p[i],用p的下标 i 减去p[i],再除以 2 ,就是原字符串的开头下标了,即 (i-p[i])/2,许多讲解文章是直接给出这个结论的,让我很头疼,所以我把我自己思考的结果放在下面;在这里插入图片描述
可以发现,i-p[i]时,可以把起点字符所在的下标到对称轴所在下标(包括)的所有特殊字符的个数和对称轴前所有原始字符的个数全部减掉,此时再去掉从下标0开始到起点字符下标之前的所有特殊字符,就能还原原始数组里起点的位置。

写几个例子就能发现,此时剩余长度只能是特殊字符的2倍(偶)或2倍+1(奇),那么只要把剩余长度/2,整除就会把多余的1省掉,于是能得出一个统一的结论:

Start = ( i - p[i] ) / 2;//这个算法推导出的结论是真的简洁

03 如何构建p[i]数组

这里便是这个算法能降低时间复杂度的核心了,前面所有的铺垫都是为了更好计算p[i]做的准备工作,而且事实上,只要p[i]出来了,按照前面的两个结论公式一套,结果就出来了。

计算p[i]时的思路是顺序遍历,当i从第一位开始,其实每一步都可以拆解, 前面的p[i]已经计算完毕,当前的p[i]可以利用回文串的对称性,根据前面已经判过的p[i]来处理。

这个思路是可行的,因为从p[1]开始遍历的话,前面没有已经处理过的p[i],相当于前面的p[i]已经处理完毕(有点像数学归纳法假设了第一位的条件,或者动态规划处理每一个子问题),只利用了前半段p[i]去判断回文长度,这个算法能降低复杂度的精髓也在这里。

如下图,我们假设现在遍历到了i,假设id是所有回文子串中,能延伸到最右端位置的那个回文子串的中心点位置,mx是该回文串能延伸到的最右端的位置,且mx=id+p[id](对称轴加半径)。然后找到mx关于id的对称点mx’,以及i关于id的对称点j,同时j的下标很容易计算,因为根据对称 i + j = 2 * id,也就是 j = 2 * id - i 。
在这里插入图片描述
从这里开始就要正式利用前面运算得出的id和mx以及p[j]了(初始化都是0,相当于在p[1]之前处理了之后id,mx都在0的位置,p[j]表示的半径长度是0)

本身如果要判一个点的最长半径,需要从这个点开始,往两边同时比较是否对称,即arr[i+a]==arr[i-a],但是可以利用之前做的所有准备,先找到p[i]至少有多长,即找到最小半径,然后再去往外对称比较看还能否延长,找到最大半径,也就是有效利用前面的信息缩短时间复杂度,但是不能暴力让p[i]=p[j],因为有些情况会不满足,需要一点分类讨论。

  1. 如果mx>i,
    a. 如果以i为中心的回文串的最右边也没有超过mx,即以i为中心的回文串(即下图黄色部分)被包在了最右回文串中间,
    在这里插入图片描述
    即图中的情况,那么说明,之前找到的最右回文串已经把当前的i包括了,那么i本身在回文串中,j又是它的对称位置,那么j和i应该是镜像对称的,它们的半径也应该是相同的,也就是p[i]=p[j]。

    b. 如果以i为中心的回文串(黄色部分)的最右边超出了mx,那么这个回文串右边的范围(红色部分)是没法通过之前求过的p[j]来推断的,
    在这里插入图片描述
    但是此时i和j在mx之间的一段还是对称的,所以如果想知道以i为中心的回文串的最长长度,只要从mx开始再往外一个个往远处延长就行了,而不需要从i开始加长。

当然也有p[j]左边界顶到了整个字符串最左端或者超出mx’的情况,这个时候只要比较是mx到i的距离长,还是p[j]到最左端或者mx’的距离长,就能找到确保回文的最短半径长了。

  1. 如果mx<=i,此时没有任何信息能帮助我们减少判别i的半径的情况了,只能老老实实用中心拓展开始遍历。

总结而言,核心算法可以归纳为:

p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1;

其中?前的就是在判断i和mx的关系,如果i<m,则满足判别式,取:左边的式子运算,即查找p[j]和mx-i谁是确保能回文的最短半径,否则取右边的式子,表示i在mx之外,需要更新了。之后的工作就是以此为基础开始向两端对称比较是否能延长回文串的长度了,如果找到当前i为中心对应的字符串比前面记录的长度(要专门设一个变量记录)要长,就可以更新mx的位置和id的位置了。

重要结论:

  1. 最长回文串的起始点下标:Start= (i-p[i])/2 ;
  2. 最长回文串的长度:len=p[i]-1;
  3. 构建p[i] :p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1

代码实现(模板)

#include<stdio.h>
#include<cstdio> 
#include<string.h>
#include<math.h>
#include<algorithm>
#include<iostream>
#include<memory.h>
#include<vector>
#include<string>
#include<stdlib.h>
#include<time.h>

#define INF 0x3f3f3f3f
#define MNF 0xc0c0c0c0
using namespace std;
const int N = 1002;//看题目范围定义

//定义起点和长度的全局变量,这样如果题目需要这两个值的输出也可以一并输出了 
int start = 0;//最长回文串的起点下标;
int maxlen;//最长回文串的长度; 
string s;

void manacher(void)
{
	//长度小于2则只有本身字符
	if (s.length() < 2)
	{
		start = 0;
		maxlen = 1;
	}

	//定义预处理后的字符串
	string t = "$";
	for (unsigned int i = 0; i < s.length(); i++)
	{
		t += '#';// + s.substr(i,1); 会比较慢
		t.push_back(s[i]);//读入s中的原始字符
	}
	t += "#@";
	int n;
	n = t.length();

	//下面开始处理回文半径p数组以及找到maxlen和mid 
	int p[N] = {0};
	int id = 0, mx = 0;//初始化当前最长回文串的中间位置和右边界
	maxlen = -1;//初始化最长回文串的长度,用于后续比较
	int mid = 0;//用来找最长回文串的中间对称轴 
	for (int i = 1; i < n - 1; i++)//去头去尾开始找p[i]的值了 
	{
		p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1;//03的结论,能找到保证p[i]范围内是回文串的最小半径,也就是简化 
		//以下就是无法简化的部分需要用中心拓展法暴力求取半径了 
		while (t[i + p[i]] == t[i - p[i]])
			p[i]++;//有对称则半径延长;

		if (mx < p[i] + i)//当回文串的右边界超出了mx,更新mx和id
		{
			mx = i + p[i];
			id = i;
		}

		//如果找到了某个回文串的长度大于之前记录的maxlen,则更新maxlen和mid
		if (maxlen < p[i] - 1)
		{
			//p[i]是算上对称轴的半径,因为修改后的字符串每一位都跟了一个特殊字符,所以半径正好是原始字符串的2倍+1
			maxlen = p[i] - 1;//01部分 
			mid = i;
		}
	}

	//02部分获得了最长回文串的对称轴位置和长度之后,就可以找到起始位置,截出字符串了
	start = (mid - maxlen) / 2;//02部分的结论 
}

int main(void)
{
	getline(cin, s);
	manacher();
	cout<<s.substr(start, maxlen);//substr函数用来输出s中的子串
	//printf("%d %d",start,maxlen);
	return 0;
}

写在最后

这篇文章写的很长,但是是我自己在学习过程中参考了许多文章,把大部分比较难思考的环节都总结出了其中的原因才写出来的,很多文章结论是直接给出来的看得我云里雾里,所以我自己做了一些归纳,如果有耐心看完一定能解答或多或少的问题,如果写的冗杂了或者质量比较差,还请见谅,这本身只是我自己学习的笔记。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值