算法问题引入
Manacher算法是我在做LeetCode647题时所看到的,该算法的目的是为了更快的查找一字符串中的所有回文子串。
在网上看了不少文章和视频,都觉得比较晦涩难懂,所以这里我决定自己写一篇博客来解释这个算法,不仅仅是作为一个讲解,也是加深自己的理解,为了之后更好的复习与回顾。
下面,我们便来一步步的解释这个算法。
首先,我们要明确,什么是回文字符串。
百度百科中这样说:“回文串”是一个正读和反读都一样的字符串,比如“level”或者“noon”等等就是回文串。对于这两个单词,我们可以看到回文串的特点就在于对称二字,在level中,l与l对应,e与e对应,v与自己对应(它是中心点),而noon则是n与n对应,o与o对应。
这两个例子几乎涵盖了所有的回文串的两种情况,即奇数与偶数长度的情况。
然后是关于回文子串。对于一个回文串level,很明显的其子串eve也是一个回文串,其子串v也是一个回文串。所以对于level来说,它的回文子串总共有3个(考虑自身),我们可以用半径这个概念来形容回文串,比如level,其中心点到左右任意边界是3个单位(考虑自身),所以半径就是3,而noon,半径是2。所以一个回文串,它的回文子串的个数等于它的半径。
我们的问题在于,给定一个字符串,然后我们要去寻找它其中所有回文子串的个数。
那么一个很好的思路(中心扩散)就出来了:
我们去遍历这个字符串的每个字符,然后把每个字符当做中心点来,然后向两边扩散,每次比较两边的数相等不相等,如果相等,结果+1(意味着找到一个新的回文子串),直到不相等为止,继续遍历。
如此一来便可以找到所有的回文子串了,这里存在两个问题。
问题1在于奇偶。奇数长度的回文子串中心点是单独一个,我们只要围绕中心点,然后比较中心点两侧的字符是否相同就可以了,所以奇数是我们比较喜欢的,因为简单。而偶数长度就比较麻烦,比如noon,中心点是哪一个呢,不好说,当然,我们可以这样两个指针如果是偶数,则一个指向前一个o,一个指向后一个o,而如果是奇数,都指向level中的v。这样每次奇偶来讨论也可以,不过要麻烦一些,而且复杂度也要高一些。
问题2在于,整体的复杂度比较高,最坏情况下是O(n^2),这就不喜欢了。
所以这时manacher算法就来救场了,它便是解决这两个问题。
简单的说,所谓manacher算法,就是我们上面的比价暴力的中心扩散方法的一个改进,加了一些判断条件,使得其中很多步骤不用重复计算,从而一定程度上减少时间复杂度。(就像dp一样,记录下算过的值,防止重复计算)。
Manacher算法核心所在
为了解决上述两个问题。
manacher算法分别采取了如下的操作。
首先是关于回文串奇偶的问题。该算法采取了这样一个操作。它对字符串进行修改,eg:将 ’#‘ 插入字符串中(#只是一个辅助符号,它不会对原字符串造成信息上的干扰)。
这样一来,我们就不用考虑奇数偶数问题了。可以看到我们需要处理的字符串,无论一开始是奇是偶,现在统一都成了奇数了,这是我们想看到的情况。
然后我们再来看一下我们插入了多少个#。对于长为n的字符串,首先我们在中间插入n-1个,然后在两边插入2个,所以我们总共插入了n+1个,所以我们最终经过处理的字符串长度是2*n+1。
然后是关于第二个问题,我们如何去重复,来减小时间复杂度。
实际上,我们先来看这个重复情况,看完这个重复情况,你大概就能理解整个manacher算法的核心了。
之前说过,manacher算法是中心扩散思路的一种改进。我们现在遍历处理好的字符串,继续使用中心扩散来寻找以每个点为中心的回文串。
假设现在我们当前已经找到了一个回文串,它的右边界是最大的。
如上图所示,我们找到的回文串的中心命名为iMax,其右边界命名为rMax,左边界我标出来了,下面命名为了lMax。
那么我当前遍历的中心点是i,要在i做中心扩散来找回文串,但是想这样一个问题,我们真的有必要一个一个的去扩散比较吗?
如上图,我们要紧扣住回文字符串的对称特性。上图中s1与s2是两个关于iMax对称的且包含于[lMax,rMax]区间之内的(lMax是我临时取得,懂得都懂)。所以他们两必定是对称相等的。若s1是回文串,则s2也一定是回文串。
那么很明显,我目前的i肯定是大于iMax的(因为iMax是之前遍历的加上中心扩散找到的嘛),也很明显,s1之前我也已经确定过是回文串(假设),那么此时我就没有必要去再判断一遍s2是不是回文串了,因为对称,所以它一定是!
如此一来,相比你差不多已经明白了manacher算法为何能够降低时间复杂度了。原因在于我是从左到右遍历,左边已经判断过的,右边又何必再判断一次呢?直接因为对称所以一样就完事了。(是不是很像求对称函数的单调性?)
当然这个东西没那么简单,要去实现它,我们需要辅助的数据结构以及多种情况判断。
Manacher算法的逻辑分析
首先我如何去知道s1那段是什么情况呢?也就是说s1这个回文串多长?中心点是谁?半径多大?
这里算法里用了一个数组f,f的长度是2*n+1(和处理后的字符串一样,相对应)。
f[i]存储的是以当前点为中心点而能形成的最长回文串的半径。
那么假设我们遍历到m这个点的时候,我们只需要看其关于iMax对称的n点的f[n],就能知道f[m]是大于等于f[n]的了(以m为中心点的回文串的半径至少是f[n]了,然后再中心扩散,直接从m+f[n]+1开始比较,从而省去了f[n]-m次比较)。
当然,情况没这么简单,这里有多种情况要分类讨论。
情况1:我们的i是小于rMax的
那么因为对称,我们便能通过与i关于iMax对称的2*iMax-i点来获取f[i]。
但是这存在两种情况。
情况1之小情况A:
当我们i的对称点所找到的最大回文串也在我们的[lMax,rMax]内,那么理所应当,f[i] = f[2*rMax-i]。
这没问题。
但是,情况1之小情况B:
明显,这里s1的范围超出了[lMax,rMax],超出的部分就不满足对称特性了,所以我们这里给f[i]的赋值只能是我们能够确定的最大值,即rMax-i+1。
情况2:
这个就很好理解了,i>rMax。
显然此时我们没法在[lMax,rMax]区间内找一个与i关于iMax对称的点,没法以此来给f[i]赋值,但是我们知道,一个字母也是回文串,那么它的半径是1(考虑自身),所以此时我们令f[i]=1。
以上便是我们需要考虑所有的重复情况以及应对方式。
在确定了f[i]之后,并不意味着我们的以i为中心的最长回文串就找到了,我们还需要以f[i]为基础,继续中心扩散,来寻找最长。
同时,当我们新的边界超过了rMax时,我们还要更新iMax与rMax。
到此,manacher算法的逻辑就讲完了,下面我们来看代码,以下代码是java的。
Manacher代码实现
class Solution {
public int countSubstrings(String s) {
int n =s.length();StringBuffer t = new StringBuffer("$#");//这里左右多给了字符$!是为了保证数组从1开始,与rMax初始值避开//构建新的字符串,用#来填充for(int i =0; i<n; i++){
t.append(s.charAt(i));t.append('#');}n = t.length();t.append('!');//f[i]int[] f = new int[n];int iMax= 0, rMax = 0, ans = 0; //iMax为边界最大的那个的中心,rMax为最大的右边界for(int i=1;i<n;i++){
//初始化f[i] f[i] = i<rMax? Math.min(rMax-i+1,f[2*iMax-i]) : 1;//情况1(min是小情况A与B的判断) 与情况2的判断//中心扩展 暴力扩展while(t.charAt(i+f[i])==t.charAt(i-f[i])){
f[i]++;}//对iMax和rMax进行更新if(i+f[i]-1>rMax){
iMax = i;rMax=i+f[i]-1;}//统计答案,当前贡献为f[i]-1/2向上取整 有多少个子串,同时因为多加入了#,要排除这个干扰ans += f[i]/2;}return ans;}
}