Manacher算法(求最长回文子串)


  Manacher的主要用途是求一个字符串中包含的最长回文子串。

一、常见的求回文串方法

  如果要我们自己写求最长回文串方法,我们的求法应该是这样的:

  1. 遍历每个位置i
  2. 先看有没有以位置i为中心的回文串(举例ABCBA)。所以我们要比较i+1和i-1是否相等,i+2和i-2是否相等,一直比较到字符串某一端点结束,或者中途发现不等的字符
  3. 再看有没有以位置i为对称中心的回文串(举例ABBA)。所以我们要先看i和i+1等不等,如果等,那再看i-1和i+2是否相等,i-2和i+3是否相等,一直比较到字符串某一端点结束,或者中途发现不等的字符

二、字符串形式改进

  上面的内容你也看到了,很麻烦,因为我们不能确定回文的形式,所以每个位置都要找两种形式存不存在,那能不能改变一下原始字符串,让我们在每个位置搜索的时候只找一种形式就好了呢?
  方法就是在原始字符串每个字母的左边都插入一个标志符,再在字符串最后补上一个标识符即可,注意这个标志符不能是原始字符串中有的字符。
—————————————————————————————————
  举个例子:
  比如原始字符串是"ABCBA",我们挑一个标识符"#",在每个字母的左边都插入一个"#",再在最后补一个"#",这样字符串就变成了"#A#B#C#B#A#"。
  比如原始字符串是“ABBA”,我们挑一个标识符"#",在每个字母的左边都插入一个"#",再在最后补一个"#",这样字符串就变成了"#A#B#B#A#"。
你发现插入新的标志字符之后,字符串不可能会出现左右两个字符相等的状况(因为标识符和原始字符串中任意字符都不相同),也就是不存在对称中心的回文串了,只存在以某个位置为中心的回文串这一种方式。
那插入了之后会不会影响回文串内容呢?
  例子一中"#A#B#C#B#A#",我们遍历每个位置i,查看i+1和i-1是否相等,用这种方法,我们发现最长的回文子串就是"#A#B#C#B#A#",回文中心是C,我们把最长的回文子串提取出来,再去掉"#",不就是它本来的样子么?
  例子二中"#A#B#B#A#",我们遍历每个位置i,查看i+1和i-1是否相等,用这种方法,我们发现最长的回文子串就是"#A#B#B#A#",回文中心是#,把它提取出来再去掉"#"同样也恢复原来的样子。
  所以说添加标志符并不会影响原有的回文串的内容。

三、搜索时的优化

  上述改进看起来是方便了很多,但其实还隐藏了一个问题。
  举个例子:
  我们在一个字符串中遍历时
  以i=5为中心匹配发现t[4]=t[6],但是t[3]≠t[7],所以我们获得了一个以i=5为中心的回文串,回文串总长度是3。
  以i=7为中心匹配时用同样的方法一直匹配到t[3]=t[11],但t[2]≠t[12],所以我们获得了一个以i=7为中心的回文串,总长度为9。
在这里插入图片描述
  那请问当我们在遍历i=9的时候,还用去匹配i=8和i=10是否相等吗?
  答案当然是不用。
  因为我们在i=7时获得了一个回文串【3-11】,它告诉我们以7为中心对称,也就是说【3-6】和【8-11】是完全对称的,那我们在5的时候知道了【4-6】是个回文串,那在9的时候我们也应该知道【8-10】是个回文串。
  所以,如果我们只是简单的一个位置一个位置逐一遍历,那我们会重复了很多冗余工作,马拉车的原理其实就是清除这些冗余工作,将效率提升。

四、优化情况分析

  其实你大概也感觉到了,优化的情况主要针对的就是长回文串包住了短回文串的情况,这样前面已经搜到的回文串其实根据对称性就一定会出现在后面,所以我们要优化,优化的情况其实分成几种。
—————————————————————————————————
  在讨论之前我们先说一个概念:

回文半径:指从回文中心向最右端(或最左端)延伸的长度,包括中心字符

  举个例子,字符串"ABCBA",回文中心是C,往右延伸2个字符,加上中心自己就是3个字符,所以回文半径是3。
  再比如,字符串"A",虽然只有1个元素,我们仍然认为是回文串,回文中心是A自己,往右延伸0个字符,加上中心就是1个元素,所以回文半径是1。
  你可能会问,那"ABBA"这样没有回文中心的回文半径是多少呢?这就是为什么我们要进行字符串改造,我们加了"#“号之后,就会变成”#A#B#B#A#",你会发现,不存在挨着的两个字符相同的情况出现,你一定能找到回文中心,也就规避了"ABBA"这种情况出现,"#A#B#B#A#“的回文半径是5。
  我们设置一个数组P[i]来记录以i为中心的回文串的半径。
  所以就是我们一边遍历回文中心一边在改变,回文半径也一边在改变。
  我们以字符串"12212321"为例,在前期处理之后字符串变为”#1#2#2#1#2#3#2#1#",然后我们用数组P[i]来记录以i为中心的回文串的半径(包括i本身)。比如:

i012345678910111213141516
t[i]#1#2#2#1#2#3#2#1#
P[i]12125214121612121

  同时设置几个变量,方便我们来分析优化的几种状况,我们定义长回文串的回文中心为id,长回文串的左边界为min,右边界为max。
—————————————————————————————————
  1.短回文串全部包含在长回文串之内
在这里插入图片描述
  有一个长的回文串【3-11】,回文中心是7,回文半径是5,也就是P[7]=5。
  其中包含了一个短的回文串【4-6】,回文中心是5,回文半径是2,也就是P[5]=2。
  当我们搜索到i=9的时候,i关于id的对称点是j,也就是i+j=2*id。

  • 以j为中心的回文半径为2,所以t[4]=t[6],t[3]≠t[7]
  • 因为关于id对称,所以t[8]=t[10],t[7]≠t[11]

  所以如果短的回文串包含在长的回文串之内,在搜索到短的回文串的对称点的时候,回文情况和之前短的一模一样。这时候本来我们应该比较的下一组数是i+P[j]和i-P[j]是否相等,当然结果是不等的。
即如果j-P[j]+1>min,那么P[i]=P[j]。

  2.短回文串在长回文串的边缘
在这里插入图片描述
  有一个短的回文串【3-5】,回文中心是4,回文半径是2,也就是P[4]=2。
  当我们搜索到i=10的时候,i关于id的对称点是j。

  • 以j为中心的回文半径为2,所以t[3]=t[5],t[2]≠t[6]
  • 以id为中心的回文半径为5,所以t[6]=t[8],t[2]≠t[12]
  • 因为关于id对称,所以t[9]=t[11]
  • 总结可知t[9]=t[11],t[2]≠t[8],t[2]≠t[12]
  • t[8]和t[12]什么关系呢?那我们就只能重新判断了
       所以你会发现,在长的回文串之内的对称性我们可以知道,但是一旦超过了就不知道了,就要重新匹配,也就是i的回文半径至少可以探到长回文串的边缘处。正常我们要匹配的下一组数应该是i+P[j]和i-P[j]是否相等。
      即如果j-P[j]+1=min时,我们先令P[i]=max-i+1,然后开始比较t[i+P[j]]和t[i-P[j]]是否相等,如果相等则P[i]++,然后一直比较直到不等或者到头。

  3.短回文串超出了长回文串的范围
在这里插入图片描述
有一个短的回文串【2-6】,回文中心是4,回文半径是3,也就是P[4]=3。
当我们搜索到i=10的时候,i关于id的对称点是j。

  • 以j为中心的回文半径为3,所以t[3]=t[5],t[2]=t[6],t[1]≠t[7]
  • 以id为中心的回文半径为5,所以t[6]=t[8],t[2]≠t[12]
  • 因为关于id对称,所以t[9]=t[11]
  • 总结可知t[9]=t[11],t[2]=t[8],t[2]≠t[12]
  • 所以t[8]≠t[12]

可以看到,如果短字符串超出了长字符串的范围的话,对称点i的回文半径只包含在长字符串内,超出的部分就不等了。这时候本来我们应该比较的下一组数是i+P[j]和i-P[j]是否相等,当然结果是不等的。
即j-P[j]+1<min时,P[i]=max-i+1。

所以我们总结一下:

  • 在id位置有一个回文半径为P[id]的回文串,其左右边界分别为min和max,其中min=id-P[id]+1,max=id+P[id]-1
  • i关于id的对称点是j,也就是i=2*id-j
  • j-P[j]+1>min,P[i]=P[j],这时候我们要比较的下一个数是i+P[j]和i-P[j],当然这两个数不等。
  • j-P[j]+1=min,P[i]=max-i+1,这时候我们要比较的下一个数是i+P[j]和i-P[j],如果相等则P[i]++,逐渐往后比较。
  • j-P[j]+1<min,P[i]=max-i+1,这时候我们要比较的下一个数是i+P[j]和i-P[j],当然这两个数不等。

探讨一下规律发现:
首先

三、辅助变量

  (1)id
  id表示的是当前探测到的回文子串的中心点。
i d 变 换 的 条 件 是 新 检 测 到 的 回 文 串 超 出 了 当 前 回 文 串 的 范 围 。 id变换的条件是新检测到的回文串超出了当前回文串的范围。 id
  (2)mx
  mx表示的是当前探测到的回文子串的右边界(不是边界本身,而是最右值+1)。
m x 的 计 算 方 法 是 m x = i + P [ i ] 。 mx的计算方法是mx=i+P[i]。 mxmx=i+P[i]

  我们来分析一下上图的示例。
  当i=1时,令P[i]=1,然后看i+1和i-1是否相等,发现不等,判定结束。所以P[i]=1,此时id=1,mx=2。
  当i=2时,令P[i]=1,然后看i+1和i-1是否相等,发现相等,P[i]+=1,然后看i+2和i-2是否相等,发现不等,判定结束。我们得到一个回文串"#1#",P[i]=2,此时新的回文串右边界为4,超过了原来的mx,所以我们需要更新id=2,mx=4。
  当i=3时,P[i]=1,此时新的右边界为4,没有超过mx,所以id和mx不更新。
  当i=4时,我们检测到新的回文串"#2#",P[i]=2,此时新的回文串右边界为6,超过了原来的mx,所以我们需要更新id=4,mx=6。
  当i=5时,我们检测到新的回文串"#1#2#2#1#2#",P[i]=6,此时新的回文串右边界为11,超过了原来的mx,所以我们需要更新id=5,mx=11。

四、核心方法

  当你看上面的求解过程,你会觉得跟平常的回文串求解步骤是一样的。别急,下面就是Manacher的优化的地方了。先上图。
在这里插入图片描述

  当前我们探测到的回文串为now_palindrome,中心点为id,右边界为mx,左边界为mx相对于id的对称点,即2*id-mx。当我们检测完id,i向后移动的过程中,我们可以利用回文串的对称性来缩减运算。图中i相对于回文串中心id的对称点是j,也就是i+j=2*id,即j=2*id-i。
  所以可以知道的一个情况是:
在 n o w _ p a l i n d r o m e 的 范 围 内 , i 和 j 的 回 文 半 径 是 相 同 的 。 在now\_palindrome的范围内,i和j的回文半径是相同的。 now_palindromeij
  我们分情况来解释这句话:
  (1)j是回文串中心,左半径在now_palindrome内。也就是说以j为中心的回文串最左边没有超过mx的对称点,即左半径在图中橙色方块内。这时候由对称性可知P[i]=P[j],这样我们就不用对i进行逐个比较了。
  (2)j是回文串中心,但左半径超出了now_palindrome。这时候我们只能确定的一件事是以i为中心的回文串右半径至少到达了mx。也就是P[i]>=mx-i。至于超过mx的部分是否仍然对称,就需要我们去逐个判断了。这样我们节省了mx之内的判断步骤。

  用数学表达式来写就是:
  (1)j的左半径越界
j − P [ j ] < = 2 ∗ i d − m x j-P[j]<=2*id-mx jP[j]<=2idmx 2 ∗ i d − i − P [ j ] < = 2 ∗ i d − m x 2*id-i-P[j]<=2*id-mx 2idiP[j]<=2idmx 即 m x − i < = P [ j ] 即mx-i<=P[j] mxi<=P[j]
  这时先令P[i]=mx-i,也就是i的半径至少从i到mx这么长。然后比较mx和2*i-mx是否相等,如果相等P[i]加1,然后接着比较mx+1和2*i-mx-1是否相等,如果相等P[i]加1,这样一直比较直到遇到两个不相等的数,P[i]计算完毕。
  (2)j的左半径不越界
即 m x − i > P [ j ] 即mx-i>P[j] mxi>P[j]
  此时P[i]=P[j]。当然这时候如果你接着从i+P[i]的位置比较一定不相等,因为P[j]已经证实了这一点。

  看到这的时候我们必须要理解一个前提:
一 旦 i d 变 换 , i 的 移 动 过 程 就 会 重 置 。 一旦id变换,i的移动过程就会重置。 idi
  也就是我们讨论的是当id固定的时候,i的移动情况,但是实际过程中有可能i移动两下id就换了,这样当然i又会从id+1的位置开始判定;但还存在一种情况就是i移动到mx了,id仍然保持不变。
  我们上面说过了id更换的条件是新检测到的回文串超出了原来回文串的范围,而当i移动到mx时,此时检测到的回文串肯定超出了原来的范围了,所以是相当于自然重置了。所以当i>=mx时,我们应该先令P[i]=1,然后逐个比较i+1和i-1,i+2和i-2……一直比较到两个数不相等,这样就能够计算出P[i]。

  总结起来就是:

if(mx>i)
{
	if(mx-i<=P[j])
	{
		P[i]=mx-i
		比较mx和2*i-mx,mx+1和2*i-mx-1
	}
	else
	{
		P[i]=P[j]
		比较i+P[i]和i-P[i]//其实不用比较,由P[j]可知一定不等
	}
}
else
{
	P[i]=1;
	比较i+1和i-1,i+2和i-2……
}

  你会发现无论什么情况,开始比较的位置一定都是i+P[i],因为P[i]已经告诉我们i的半径探到哪了,所以这就为我们节省了时间,把代码优化一下就变成:

P[i]=mx>i?min(mx-i,P[2*id-i]):1;
while(S[i+P[i]]==S[i-P[i]])
	P[i]++;	

  可以看到我们用min(mx-i,P[2*id-i])统一 了mx-i<=P[j]和mx-i>P[j]的情况。其实仔细想一下就能知道,如果j左半径探出去了,那P[i]=mx-i,此时mx-i<P[j];如果j没探出去,那P[i]=P[j],P[j]<mx-i。

五、总体代码

void Manacher(string str)
{
    string s=str;
    if(s=="")
        return ;
    for(int i=0;i<s.length();i+=2)//前期处理
        s.insert(i,1,'#');
    s="@"+s+"#";
    int P[s.length()];
    int id=0,mx=0;
    int max_num=0;//记录最大半径
    int max_id=0;//记录最大半径下标
    for(int i=1;i<s.length();i++)
    {
       P[i]=mx>i?min(P[2*id-i],mx-i):1;//初始赋值
       while(s[i+P[i]]==s[i-P[i]])//继续搜索P[i]之外的地方
            P[i]++;
       if(i+P[i]>mx)//判定id是否更新
       {
           mx=i+P[i];
           id=i;
       }
       if(P[i]>max_num)
       {
           max_num=P[i];
           max_id=i;
       }
    }
    cout<<"最长回文子串长度为:"<<max_num-1<<endl;
    cout<<"最长回文子串为:"<<str.substr((max_id-P[max_id])/2,max_num-1);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值