Manacher算法
manacher 算法主要解决的就是 最长回文子串长度的问题。但凡涉及到求回文串方面的,manacher算法就是这方面的天花板算法。
下面,就以求最长回文子串长度作为问题,来解释何为manacher
什么是回文
回文简单来说,就是正着念和反着念是一样的。
例如字符串 121, 相当于就是2作为分割线,往俩边扩,扩的时候俩边字符相等
例如字符串1221,相当于在俩个2之间有一条虚拟的分割线,往俩边扩,扩的时候俩边字符相等
暴力方式
知道了什么是回文之后,如何通过暴力方式来求最长回文子串的长度呢?
假设给定的字符串是 a b 1 2 1 k f,它的最长回文子串长度是3。
对于每个位置来说,都作为分割线的情况下,都尝试同时向俩边扩,看看最远能扩多远。
例如a字符,它左边没有东西,右边是b,扩不动,所以a作为分割线的情况下,能够形成的最长回文长度是1。
再看看b字符,它能形成的也是1。
依次类推,最后,每个位置都会有一个答案,那么全局最大就是最长回文子串长度。
但是,注意,如果字符串是 a b 1 2 2 1 k f,最长回文子串长度是偶数的话,那上面的方法就行不通了。
所以,在这种情况下,就对字符串进行加工,变成:
# a # b # 1 # 2 # 2 # 1 # k # f #
加工成了这种字符串的话,那就可以采用上述的方式,去暴力求每个位置作为分割线的情况下,得到的答案了。
因为加工了,所以最后得到的答案再除以2,就是原先未加工之前字符串的最长回文子串的长度了。
前置概念
上述的暴力方式时间复杂度是0(N^2),而manacher算法的时间复杂度是0(N)
不管是暴力方法还是manacher算法,都要对字符串进行加工,在加工的基础上进行操作。
在具体解析manacher算法之前,需要知道以下几个前置概念
回文半径和回文直径
例如字符串 a b 1 2 1 k f 1 2 2 1
其中,有回文子串 121,它的回文直径就是3,回文半径就是2
还有回文子串1221,它的回文直径就是4,回文半径就是2
回文半径数组
对于加工后的字符串来说,每个位置作为分割线的情况下,最远能够扩多远,形成的回文串的回文半径,记在数组里。这就是回文半径数组。
最右回文边界
一个int类型,命名为R,初始是-1。
表示的意思就是,每来到一个位置,如果它能扩出去的长度大于了R的话,那R就变成当前位置扩出去的最右边界
例如: # 1 # 1 # 2 # 1 # 1 # k #
刚开始,R = -1
来到0位置,扩不出去,它形成的回文串的右边界就是0,发现大于R,那R = 0
来到1位置,形成回文串右边界是2,发现大于R,那R = 2
依次类推…
当来到字符为2的位置的时候,它扩的最远,R直接到了11位置
对于从字符2的位置的后续来说,它们所形成的回文串,最远也不可能超过R了,所以R不会变化
最右回文边界的触发下标
一个int类型,记作C。
会随着R的改变而改变。
简单来说,它就是哪个下标尝试扩的时候,把R改变了,那就用C记录下这个下标
例如上述的例子当中,发现0位置把R改变了,那么C = 0
1位置把R改变了,那么C = 1
Manacher算法的实现
有了前置的几个概念,下面就是阐述manacher算法了。
当前来到了i位置,根据i位置是否被R包括来分为俩大情况。
没被R包括
没被R包括,就是 i > R
这种情况,没有任何技巧。就是i位置分割线,然后往左右俩边扩,看看能扩多远
被R包括
被R包括,也就是 i <= R
这种情况,说明i位置的字符必然根据C有个对应的ii下标,这个ii下标的字符等于i位置的字符,并且也是在R以内的。
另外,R位置的字符根据C,也有个对应的L,L位置字符等于R位置的字符,以C作为中间点。
所以,就会形成如下大概的样子
L …ii … C … i … R
在这种大情况下,就可以根据不同小情况,来加速获取i位置形成的回文子串长度了。
ii形成的回文串的左边界是大于L的
例如字符串:
a b c d c k s t s k c d c b a
L ii C i R
上面例子可以看到,当前求i位置能够形成回文长度有多长的时候,发现对应映射的ii位置的长度是3,也就是cdc,这个可以直接从回文半径数组当中直接拿到了。并且回文串的左边界是大于L的。
那么,在这种情况下,其实i位置的回文长度就等于ii位置的回文长度。
ii形成的回文串的左边界小于L的
例如字符串:
可以发现,i对应的ii位置所形成的回文串左边界已经越过了L。
在这种情况下,其实i位置能够形成的回文长度的回文半径就是i到R的距离
ii形成的回文串的左边界等于L
例如:
等于L,其实就是ii位置形成的回文串的左边界刚好压到的是L位置
在这种情况下,对于i位置能够形成的回文串,对于这个回文串来说,至少的回文半径是i到R位置的距离。
然后,在这基础之上,再尝试往左右俩边去扩,看看能不能扩的更远,同时注意更新R和C。
总结
总体分为了俩大情况,第二大情况里面,又根据i位置对应的映射下标ii位置所形成的回文串的左边界和L位置的关系,又分为了三个小情况。
在求出了回文半径数组之后,那么最长回文子串的长度就可以直接根据这个数组拿到了。
代码实现
public static int manacher(String s) {
String manacherStr = getManacherString(s);
char[] str = manacherStr.toCharArray();
int[] preArr = new int[str.length];
int C = -1;
// 这里是R指的是最右的回文边界的下一个位置,方便coding
int R = -1;
int max = Integer.MAX_VALUE;
for (int i = 0; i < preArr.length; i++) {
// R-1其实才是此时最右的回文边界
// R大于i,必然i是被扩住的,根据扩住的情况,知道当前i位置的回文半径至少长度
// 要么是对称的的那个下标对应的回文长度,要么就是i到R位置的距离
// 如果R <= i ,说明没有扩住,那起码回文半径长度是1
preArr[i] = R > i ? Math.min(preArr[2 * C - i], R - i) : 1;
// 尝试向俩边扩
while (i + preArr[i] < str.length && i - preArr[i] >= 0) {
if (str[i + preArr[i]] == str[i - preArr[i]]) {
preArr[i]++;
}
else {
break;
}
}
if (i + preArr[i] > R) {
R = i + preArr[i];
C = i;
}
max = Math.max(max, preArr[i]);
}
// 最大的回文半径长度-1就是原本字符串的最长回文子串的长度了
return max - 1;
}
ak;
}
}
if (i + preArr[i] > R) {
R = i + preArr[i];
C = i;
}
max = Math.max(max, preArr[i]);
}
// 最大的回文半径长度-1就是原本字符串的最长回文子串的长度了
return max - 1;
}