算法总结第三弹 manacher算法,前面讲了两个字符串相算法——kmp和拓展kmp,这次来还是来总结一个字符串算法,manacher算法,我习惯叫他 “马拉车”算法。
相对于前面介绍的两个算法,Manacher算法的应用范围要狭窄得多,但是它的思想和拓展kmp算法有很多共通支出,所以在这里介绍一下。Manacher算法是查找一个字符串的最长回文子串的线性算法。
在介绍算法之前,首先介绍一下什么是回文串,所谓回文串,简单来说就是正着读和反着读都是一样的字符串,比如abba,noon等等,一个字符串的最长回文子串即为这个字符串的子串中,是回文串的最长的那个。
计算字符串的最长回文字串最简单的算法就是枚举该字符串的每一个子串,并且判断这个子串是否为回文串,这个算法的时间复杂度为O(n^3)的,显然无法令人满意,稍微优化的一个算法是枚举回文串的中点,这里要分为两种情况,一种是回文串长度是奇数的情况,另一种是回文串长度是偶数的情况,枚举中点再判断是否是回文串,这样能把算法的时间复杂度降为O(n^2),但是当n比较大的时候仍然无法令人满意,Manacher算法可以在线性时间复杂度内求出一个字符串的最长回文字串,达到了理论上的下界。
给定一个字符串,求出其最长回文子串。例如:
(1)s="abcd", 最长回文长度为 1;
(2)s="ababa", 最长回文长度为 5;
(3)s="abccb", 最长回文长度为 4,即 bccb。
以上问题的传统思路大概是,遍历每一个字符,以该字符为中点向两边查找。其时间复杂度为 ,很不高效。而在 1975 年,一个叫 Manacher 的人发明了一个算法,Manacher 算法,也称马拉车算法,该算法可以把时间复杂度提升到 。下面来看看马拉车算法是如何工作的。
由于回文分为偶回文(比如 bccb)和奇回文(比如 bcacb),而在处理奇偶问题上会比较繁琐,所以这里我们使用一个技巧,在字符间插入一个字符(前提这个字符未出现在串里)。举个例子:s="abbahopxpo"
,转换为s_new="$#a#b#b#a#h#o#p#x#p#o#"
(这里的字符 $ 只是为了防止越界,下面代码会有说明),如此,s 里起初有一个偶回文abba
和一个奇回文opxpo
,被转换为#a#b#b#a#
和#o#p#x#p#o#
,长度都转换成了奇数。
定义一个辅助数组int p[]
,p[i]
表示以s_new[i]
为中心的最长回文的半径,例如:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
s_new[i] | $ | # | a | # | b | # | b | # | a | # | h | # | o | # | p | # | x | # | p | # |
p[i] | 1 | 2 | 1 | 4 | 5 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 6 | 1 | 2 | 1 |
可以看出,p[i]-1
正好是原字符串中最长回文串的长度。
Manacher 算法之所以快,就快在对 p 数组的求法上有个捷径,看下图:
s_new[id]
为中心的最长回文最右边界,也就是
mx=id+p[id]
。
p[i]
,也就是以
s_new[i]
为中心的最长回文半径,如果
i
,如上图,那么:
if (i < mx)
p[i] = min(p[2 * id - i], mx - i);
2 * id -i
其实就是等于 j ,p[j]
表示以s_new[j]
为中心的最长回文半径,见上图,因为 i 和 j 关于 id 对称,我们利用p[j]
来加快查找。
三:代码TOC
- #include
- #include
- #include
- using namespace std;
-
- char s[1000];
- char s_new[2000];
- int p[2000];
-
- int Init()
- {
- int len = strlen(s);
- s_new[0] = '$';
- s_new[1] = '#';
- int j = 2;
-
- for (int i = 0; i < len; i++)
- {
- s_new[j++] = s[i];
- s_new[j++] = '#';
- }
-
- s_new[j] = '\0'; //别忘了哦
-
- return j; //返回s_new的长度
- }
-
- int Manacher()
- {
- int len = Init(); //取得新字符串长度并完成向s_new的转换
- int maxLen = -1; //最长回文长度
-
- int id;
- int mx = 0;
-
- for (int i = 1; i < len; i++)
- {
- if (i < mx)
- p[i] = min(p[2 * id - i], mx - i); //需搞清楚上面那张图含义, mx和2*id-i的含义
- else
- p[i] = 1;
-
- while (s_new[i - p[i]] == s_new[i + p[i]]) //不需边界判断,因为左有'$',右有'\0'
- p[i]++;
-
- //我们每走一步i,都要和mx比较,我们希望mx尽可能的远,这样才能更有机会执行if (i < mx)这句代码,从而提高效率
- if (mx < i + p[i])
- {
- id = i;
- mx = i + p[i];
- }
-
- maxLen = max(maxLen, p[i] - 1);
- }
-
- return maxLen;
- }
-
- int main()
- {
- while (printf("请输入字符串:\n"))
- {
- scanf("%s", s);
- printf("最长回文长度为 %d\n\n", Manacher());
- }
-
- return 0;
- }
四:算法复杂度分析TOC
文章开头已经提及,Manacher 算法为线性算法,即使最差情况下其时间复杂度亦为 ,在进行证明之前,我们还需要更加深入地理解上述算法过程。
定义 mx 为以s_new[id]
为中心的最长回文最右边界,也就是mx=id+p[id]
。j 与 i 关于 id 对称,根据回文的性质,p[i]
的值基于以下三种情况得出:
(1)j 的回文串有一部分在 id 的之外,如下图:
上图中,黑线为 id 的回文,i 与 j 关于 id 对称,红线为 j 的回文。那么根据代码此时
p[i]=mx-i
,即紫线。那么
p[i]
还可以更大么?答案是不可能!见下图:
假设右边新增的紫色部分是
p[i]
可以增加的部分,那么根据回文的性质,a 等于 d ,也就是说 id 的回文不仅仅是黑线,而是黑线 + 两条紫线,矛盾,所以假设不成立,故
p[i]=mx-i
,不可以再增加一分。
(2)
j 回文串全部在 id 的内部
,如下图:
此时
p[i]=p[j]
,那么
p[i]
还可以更大么?答案亦是不可能!见下图:
假设右边新增的红色部分是
p[i]
可以增加的部分,那么根据回文的性质,a 等于 b ,,也就是说 j 的回文应该再加上 a 和 b ,矛盾,所以假设不成立,故
p[i]=p[j]
,也不可以再增加一分。
(3)
j 回文串左端正好与 id 的回文串左端重合
,见下图:
此时
p[i]=p[j]
或
p[i]=mx-i
,并且
p[i]
还可以继续增加,所以需要
while (s_new[i - p[i]] == s_new[i + p[i]])
p[i]++;
根据(1)(2)(3),很容易推出 Manacher 算法的最坏情况,即为字符串内全是相同字符的时候。在这里我们重点研究 Manacher()中的 for 语句,推算发现 for 语句内平均访问每个字符 5 次,即时间复杂度为:。
同理,我们也很容易知道最佳情况下的时间复杂度(最佳情况即字符串内字符各不相同)。推算得平均访问每个字符 4 次,即时间复杂度为:。
综上,Manacher 算法的时间复杂度为 。
1.Manacher算法原理与实现
下面介绍Manacher算法的原理与步骤。
首先,Manacher算法提供了一种巧妙地办法,将长度为奇数的回文串和长度为偶数的回文串一起考虑,具体做法是,在原字符串的每个相邻两个字符中间插入一个分隔符,同时在首尾也要添加一个分隔符,分隔符的要求是不在原串中出现,一般情况下可以用#号。下面举一个例子:

(1)Len数组简介与性质
Manacher算法用一个辅助数组Len[i]表示以字符T[i]为中心的最长回文字串的最右字符到T[i]的长度,比如以T[i]为中心的最长回文字串是T[l,r],那么Len[i]=r-i+1。
对于上面的例子,可以得出Len[i]数组为:

Len数组有一个性质,那就是Len[i]-1就是该回文子串在原字符串S中的长度,至于证明,首先在转换得到的字符串T中,所有的回文字串的长度都为奇数,那么对于以T[i]为中心的最长回文字串,其长度就为2*Len[i]-1,经过观察可知,T中所有的回文子串,其中分隔符的数量一定比其他字符的数量多1,也就是有Len[i]个分隔符,剩下Len[i]-1个字符来自原字符串,所以该回文串在原字符串中的长度就为Len[i]-1。
有了这个性质,那么原问题就转化为求所有的Len[i]。下面介绍如何在线性时间复杂度内求出所有的Len。
(2)Len数组的计算
首先从左往右依次计算Len[i],当计算Len[i]时,Len[j](0<=j
第一种情况:i<=P
那么找到i相对于po的对称位置,设为j,那么如果Len[j]

那么说明以j为中心的回文串一定在以po为中心的回文串的内部,且j和i关于位置po对称,由回文串的定义可知,一个回文串反过来还是一个回文串,所以以i为中心的回文串的长度至少和以j为中心的回文串一样,即Len[i]>=Len[j]。因为Len[j]
如果Len[j]>=P-i,由对称性,说明以i为中心的回文串可能会延伸到P之外,而大于P的部分我们还没有进行匹配,所以要从P+1位置开始一个一个进行匹配,直到发生失配,从而更新P和对应的po以及Len[i]。

第二种情况: i>P
如果i比P还要大,说明对于中点为i的回文串还一点都没有匹配,这个时候,就只能老老实实地一个一个匹配了,匹配完成后要更新P的位置和对应的po以及Len[i]。

2.时间复杂度分析
Manacher算法的时间复杂度分析和Z算法类似,因为算法只有遇到还没有匹配的位置时才进行匹配,已经匹配过的位置不再进行匹配,所以对于T字符串中的每一个位置,只进行一次匹配,所以Manacher算法的总体时间复杂度为O(n),其中n为T字符串的长度,由于T的长度事实上是S的两倍,所以时间复杂度依然是线性的。
下面是算法的实现,注意,为了避免更新P的时候导致越界,我们在字符串T的前增加一个特殊字符,比如说‘$’,所以算法中字符串是从1开始的。
- const int maxn=1000010;
- char str[maxn];//原字符串
- char tmp[maxn<<1];//转换后的字符串
- int Len[maxn<<1];
- //转换原始串
- int INIT(char *st)
- {
- int i,len=strlen(st);
- tmp[0]='@';//字符串开头增加一个特殊字符,防止越界
- for(i=1;i<=2*len;i+=2)
- {
- tmp[i]='#';
- tmp[i+1]=st[i/2];
- }
- tmp[2*len+1]='#';
- tmp[2*len+2]='$';//字符串结尾加一个字符,防止越界
- tmp[2*len+3]=0;
- return 2*len+1;//返回转换字符串的长度
- }
- //Manacher算法计算过程
- int MANACHER(char *st,int len)
- {
- int mx=0,ans=0,po=0;//mx即为当前计算回文串最右边字符的最大值
- for(int i=1;i<=len;i++)
- {
- if(mx>i)
- Len[i]=min(mx-i,Len[2*po-i]);//在Len[j]和mx-i中取个小
- else
- Len[i]=1;//如果i>=mx,要从头开始匹配
- while(st[i-Len[i]]==st[i+Len[i]])
- Len[i]++;
- if(Len[i]+i>mx)//若新计算的回文串右端点位置大于mx,要更新po和mx的值
- {
- mx=Len[i]+i;
- po=i;
- }
- ans=max(ans,Len[i]);
- }
- return ans-1;//返回Len[i]中的最大值-1即为原串的最长回文子串额长度
- }
-
-