“马拉车”Manacher算法,十分通俗,保证看完会写
笔者默认读者知道双指针复杂度为:O(n²)的算法,因为那个方法和本方法没有什么思路上的重复,所以这篇着重讲马拉车。
因为我希望着重讨论思路,所以我会把一些固定的计算公式做成函数,这样可以让读者在看主函数的时候,不被一些固定的计算公式扰乱思路。
下面开始:
目的
首先确定马拉车算法的输出结果是什么:可以获得当前字符串中,最长回文串的长度以及开始和结束的下标。
输入字符串:"babad"
输出:
- 最长回文串:"bab"
- 长度:3
- 开始下标:0
- 结束下标:2
另一个可能的答案是 "aba"
或者:
输入字符串:"cbbd"
输出:
- 最长回文串:"bb"
- 长度:2
- 开始下标:1
- 结束下标:2
思路
第一步:修改原字符串
首先我们看到,字符串的长度分奇数和偶数,那我们写一个写法很可能要考虑两种情况,这就有点恶心了,那我们使用修改原字符串的方法,将其永远变为奇数个:
将原字符串的每个字符之间加入“#”,随后在开头和结尾再加入不相同的特殊字符,如“¥%&@”都可以。
举例:
- 对于奇数长度的字符串:
原字符串: "aba"
加入#: "#a#b#a#"
加入特殊字符: "$#a#b#a#@"
- 对于偶数长度的字符串:
原字符串: "abba"
加入#: "#a#b#b#a#"
加入特殊字符: "$#a#b#b#a#@"
那么此时字符串长度一定是奇数。
你问我为什么?有没有数学推导好让我安心相信你说的?当然有:
已知字符串长度为len,那么也就是说有len个字符。那么字符和字符之间的空位有多少个?len-1个。
那第一步我插入#号是不是往空位里面插入的?那插入了多少个?len-1个#。
随后我是不是还在两边又插入了一对"##"和一对乱七八糟的符号(这里是$和@)?那么是不是固定的4个?
那最后有多少个字符呢:原来的len + len-1个# + 4 = 2len + 3。
一定是奇数个
好了,我们现在已经写出来了马拉车的一部分了,字符串预处理
代码:
//字符串预处理
public String preProcess(String s) {
// 如果字符串为空,直接返回特殊字符
if (s.length() == 0) {
return "$@";
}
// 使用StringBuilder优化字符串拼接
StringBuilder ret = new StringBuilder();
ret.append('$'); // 添加左边界
for (int i = 0; i < s.length(); i++) {
ret.append('#').append(s.charAt(i));
}
ret.append('#').append('@'); // 添加右边界
return ret.toString();
}
之后我们讨论的“字符串”都是通过preProcess函数修改后的字符串,也就是那个2len+3永远为奇数的字符串。原来的长度为len的字符串我们先丢掉,只有在分析的时候和代码写到最后求原字符串下标的时候才用。
第二步:核心思路
我们现在规定一个数组p[],他的长度和字符串的长度相同(2len+3),其中p[i]表示以第i个字符为中心的最长回文串的半径(不包括中心点)。
也就是$#a#b@的a的半径是1,因为回文串为#a#。而我们不算中心点,所以为1。
怎么得到的?之后再说
为什么要用半径?举个例子说明一下:
下标index: 0 1 2 3 4 5 6 7 8
处理后的字符串: $ # a # b # a # @
p[]数组的值: 0 0 1 0 3 0 1 0 0
以下标4(字符’b’)为例:
- p[4] = 3 表示以b为中心,向左右展开3个位置都是回文串
- 实际的回文串是 “a#b#a”
这样设计p数组的目的是:
存在公式:
公式A:
用 P 的下标 i 减去 P [ i ],再除以 2 ,就是原字符串(len)的开头下标了。
读者可以自己减去试一试
也就是
int 原回文串下标 = (i - p[i]) / 2;
遇到不能整除的,就默认按照java的向下取整。比如这个算法套到上面的例子,求b符号的原字符串起始下标就为:(4 - 3) / 2 = 0,也就是原字符串一开始就是回文字符串。aba,还真是。
公式B:
p[i] 保存从中心扩展的最大个数,而它刚好也是去掉 “#” 的原字符串的总长度
还是上述例子,我为了读者看起来方便,搬到下面来:
下标index: 0 1 2 3 4 5 6 7 8
处理后的字符串: $ # a # b # a # @
p[]数组的值: 0 0 1 0 3 0 1 0 0
这里我们知道b为中心就是一个回文字符串,那么在原串里,这个回文串多长呢?就是p[i]的值
上述例子:p[4] = 3。原来的回文串为 aba,还真是。
公式A+B:
那么我们知道了一个p[i],就可以求出整个回文串在原字符串中的起点和终点。
这里为什么还是要求“原”字符串的起点和终点呢?因为题目给的是原字符串len,我们返回的内容肯定也要是原字符串相关的嘛。
起点:(i - p[i]) / 2
终点:(i - p[i]) / 2 + p[i] - 1
起点 + 长度 - 1可不就是终点下标嘛,你问我为什么要-1? 我们来算算
上面的例子,起点为0,长度为3,那么终点下标是2。是要-1。
第三步:引入三个核心概念(关键)
这是马拉车算法的核心,我们需要用到两个重要的变量:
- R:当前访问到的所有回文子串,所能触及的最右一个字符的下标
- center:当前R的回文串的中心下标
那么R和center还有p[i]的关系是: R = C + P[ i ]
还是上面的例子,为了读者方便,我拿下来了:
下标index: 0 1 2 3 4 5 6 7 8
处理后的字符串: $ # a # b # a # @
p[]数组的值: 0 0 1 0 3 0 1 0 0
针对字符b,R = 7,center = 4 , p[4] = 3 。满足7 = 4 + 3。 完美
我们通过这两个概念可以知道,马拉车算法并不会直接保存回文串的最大值,而是保存能到达的最右端和到达了最右端的回文串的中心。
也就是每遇到一个新的回文串newLen,都去看看这个newLen回文串的最右边的下标是否大于R,如果大于,更新R,并且让center变量等于newLen的中心点。
所以,马拉车不会直接保存每个回文串的长度,每次比较的也不是回文串的长度。
复习一下:现在我们知道
p[i]表示以第i个字符为中心的最长回文串的半径(不包括中心点)int 原回文串下标 = (i - p[i]) / 2;默认的向下取整p[i] 刚好也是去掉 "#$@" 的原回文字符串的总长度程序最后才用,现在可以忽略R:当前访问到的所有回文子串,所能触及的最右一个字符的下标center:当前R的回文串的中心下标R = C + P[ i ]
第四步:计算p[i]
通过上面的内容,我们发现,都是围绕着p[i]数组走的,那么这个数组肯定是关键。我们的整体思路,是通过一个for(i从0到字符串结尾)的循环,把每个p[i]都求出来。
for(int i = 0 ; i < 2len + 3 ; i++)
但其实,第一位和最后一位可以不要,因为第一位恒定为$,所以
for(int i = 1 ; i < 2len + 2 ; i++)
最终,写法就是:
//正如我前面所说,我们处理的都是preProcess函数之后的字符串,所以这里的len表示的就是修改后的字符串
for(int i = 1 ; i < len - 1 ; i++)
同时,我这里还要引入一个小变量,i_mirror,他是i相对于当前center的对称过去的下标。
按照java规范,应当写成驼峰式,但是为了读者好记忆和理解,我写成了python的风格,见谅
还是上面的例子:
下标index: 0 1 2 3 4 5 6 7 8
处理后的字符串: $ # a # b # a # @
p[]数组的值: 0 0 1 0 3 0 1 0 0
假设当前i = 6,center = 4 , 那么i_mirror = 2 * center - i;
这个记住就行,或者写成一个函数:
private int get_i_mirror(int i , int center){
return 2 * center - i;
}
随后我们每for循环遍历到一个下标i的时候,我们进行如下运算:
int i_mirror = get_i_mirror(i,center);
p[i] = p [ i_mirror ];
为什么这样呢?因为我们现在的中心是center(先别管怎么来的),
那关于center左右两边都是对称的,直到R。
换句话说,[center - R,center+R]的下标范围都是对称的。
那么 在i附近的领域 [i - x , i + x]是不是和 [i_mirror - x , i_mirror + x]的领域是对称的。
还是那个例子说明i和i_mirror的领域对称关系:
下标index: 0 1 2 3 4 5 6 7 8
处理后的字符串: $ # a # b # a # @
p[]数组的值: 0 0 1 0 3 0 1 0 0
↑ ↑ ↑
| | |
i_mirror C i
假设当前状态:
- center = 4 (字符’b’的位置)
- i = 6 (右边字符’a’的位置)
- i_mirror = 2 (左边字符’a’的位置)
- R = 7 (最右边界)(先别管怎么来的)
我们可以观察到:
- i_mirror和i下标关于center对称
- 以i_mirror为中心的回文串是"#a#",p[i_mirror] = 1
- 由于对称性,以i为中心的回文串也应该是"#a#",p[i] = 1
这就是为什么我们可以直接让 p[i] = p[i_mirror]
再举一个更复杂的例子:
下标index: 0 1 2 3 4 5 6 7 8 9 10
处理后的字符串: $ # b # a # a # b # @
p[]数组的值: 0 0 1 0 1 4 1 0 1 0 0
↑ ↑ ↑
| | |
i_mirror center i
当center = 5(第一个’a’的位置)时:
- 如果i = 7,那么
i_mirror = 2 * center- i = 2 * 5 - 7 = 3 - 由于对称性,p[7]应该等于p[3]
- 这就是为什么我们可以利用已经计算出的p[i_mirror]来确定p[i]的初始值
也就是说,我们可以这么写:
private int get_i_mirror(int i , int center){
return 2 * center - i;
}
public void main(){
for(int i = 1 ; i < len - 1 ; i++){
int i_mirror = get_i_mirror(i,center);
p[i] = p[i_mirror];
}
}
这种对称性是马拉车算法提高效率的关键所在,因为我们不需要对每个位置都重新从头计算回文半径,而是可以利用已经计算出的对称位置的信息。
《但是》
这个方法在三种情况下不适用:
情况A,当前i为中心的回文串的右边界超出了R(大于而不是大于等于)
情况B,P [ i_mirror ] 遇到了原字符串的左边界
情况C,i 等于了 R
下面开始分别讨论:
情况A:当前i为中心的回文串的右边界超出了R
这种情况下,我们无法直接使用对称性,因为超出R的部分是未知的。例如:
啊,明白了!让我用这个更好的例子来说明:
情况A:当前i为中心的回文串的右边界超出了R
下标index: 0 1 2 .............. 11 12 13 14 15 16 17 18 19 20 21 22
处理后的字符串: ^ # b # a # b # c # b # a # b # c # b # a # c # b # c # $
p[]数组的值: 0 0 1 0 3 0 1 0 7 0 1 0 9 0 1 0 ...
↑ ↑ ↑ ↑
| | | |
i_mirror center i R
在这个例子中:
- center = 11
- R = 20
- i = 15
- i_mirror = 7 (2center - i = 211 - 15 = 7)
- p[i_mirror] = 7
如果我们直接使用对称性让p[i] = p[i_mirror] = 7,那么i的右边界将会是i + 7 = 22,这已经超出了当前的R = 20。
这种情况下,我们只能确定从i到R这部分是回文的(因为对称性,当前的回文领域只是[center - R , cneter + R]),所以p[i]的初始值最大应该是R-i = 20-15 = 5(被限制了),然后再用中心扩展法继续尝试向下标为R以外的地方扩展,也就是拓展到 [ i - p[i] , i + p[i] ] 之外的位置。
情况B:P[i_mirror]遇到了原字符串的左边界
这种情况下,虽然i还没有触及右边界,但是我们不能直接使用p[i_mirror]的值,因为i_mirror的扩展是被左边界限制的,而i的位置可能还能继续扩展。例如:
下标index: 0 1 2 3 4 5 6 7 8 9 10
处理后的字符串: $ # a # a # a # a # @
↑ ↑ ↑
| | |
i_mirror C i
此时p[i_mirror] = 1 ,但是p[i]肯定大于1,目测应该是3。这种情况,p[i]应当从p[i_mirror]的值继续向外扩展,使用中心扩展法
情况C:i等于了R
这种情况下,我们完全处于未知区域,必须重新开始扩展。例如:
下标index: 0 1 2 3 4 5 6 7 8 9 10
处理后的字符串: $ # a # b # a # b # @
↑ ↑
| |
C i=R
此时的R右侧我们完全不知道,因为`R:当前访问到的所有回文子串,所能触及的最右一个字符的下标``,随后使用中心拓展法拓展。
对于这三种情况,我们都需要使用中心扩展法来继续计算p[i]的值。中心扩展法的代码如下:
// T是处理后的字符串
//能利用center和R 扩展 的最远的回文范围就是 [ i - p[i] , i + p[i] ],再往外就只能手动判断了
while (T.charAt(i + 1 + P[i]) == T.charAt(i - 1 - P[i])) {
//如果真的相同,那么以i为中心的领域半径增加,也就是p[i]增加
P[i]++;
}
// 如果新计算的回文串的右边界超过了(大于而不是大于等于)当前的R
if (i + P[i] > R) {
center = i; // 更新中心center
R = i + P[i]; // 更新最右边界R
}
所以完整的代码逻辑应该是:
public String manacher(String s) {
String T = preProcess(s);
int[] P = new int[T.length()];
int center = 0, R = 0;
for (int i = 1; i < T.length() - 1; i++) {
int i_mirror = get_i_mirror(i,center);
//先看看是不是情况3C:i已经和R重合
if (R > i) {
//如果不是,那就先忽略情况AB,因为情况AB最终都是通过中心拓展法解决的
P[i] = Math.min(R - i, P[i_mirror]);
} else {
//如果重合,那么按照上述说的,只能等着之后自己使用 中心拓展法拓展了,i_mirror和R帮不到你了
P[i] = 0;
}
//万一遇到了情况AB呢?试一试中心拓展法
// 中心扩展法
while (T.charAt(i + 1 + P[i]) == T.charAt(i - 1 - P[i])) {
P[i]++;
}
// 如果超出了R,更新center和R
if (i + P[i] > R) {
center = i;
R = i + P[i];
}
}//for循环结束
// 收尾:找出 P[i] 的最大值
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < T.length() - 1; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
//起点:(i - p[i]) / 2
//终点:(i - p[i]) / 2 + p[i] - 1
//这里的centerIndex就是最长回文串的i,maxLen就是最长回文串的p[i]
int start = (centerIndex - maxLen) / 2; //最开始讲的求原字符串下标
//因为subString是左闭右开,所以start+maxLen 正好就是 (i - p[i]) / 2 + p[i] - 1
return s.substring(start, start + maxLen);
}
private int get_i_mirror(int i , int center){
return 2 * center - i;
}
private String preProcess(String s) {
// 如果字符串为空,直接返回特殊字符
if (s.length() == 0) {
return "$@";
}
// 使用StringBuilder优化字符串拼接
StringBuilder ret = new StringBuilder();
ret.append('$'); // 添加左边界
for (int i = 0; i < s.length(); i++) {
ret.append('#').append(s.charAt(i));
}
ret.append('#').append('@'); // 添加右边界
return ret.toString();
}
这样,我们就处理了所有可能的情况,既利用了对称性来优化,又在必要时使用中心扩展法来处理特殊情况。
欢迎批评讨论。
314

被折叠的 条评论
为什么被折叠?



