“马拉车”Manacher算法,十分通俗,解释详细

“马拉车”Manacher算法,十分通俗,保证看完会写

笔者默认读者知道双指针复杂度为:O(n²)的算法,因为那个方法和本方法没有什么思路上的重复,所以这篇着重讲马拉车。

因为我希望着重讨论思路,所以我会把一些固定的计算公式做成函数,这样可以让读者在看主函数的时候,不被一些固定的计算公式扰乱思路。

下面开始:

目的

首先确定马拉车算法的输出结果是什么:可以获得当前字符串中,最长回文串的长度以及开始和结束的下标。

输入字符串:"babad"
输出:
- 最长回文串:"bab"
- 长度:3
- 开始下标:0
- 结束下标:2
另一个可能的答案是 "aba"

或者:

输入字符串:"cbbd"
输出:
- 最长回文串:"bb"
- 长度:2
- 开始下标:1
- 结束下标:2

思路

第一步:修改原字符串

首先我们看到,字符串的长度分奇数和偶数,那我们写一个写法很可能要考虑两种情况,这就有点恶心了,那我们使用修改原字符串的方法,将其永远变为奇数个:
将原字符串的每个字符之间加入“#”,随后在开头和结尾再加入不相同的特殊字符,如“¥%&@”都可以。
举例:

  1. 对于奇数长度的字符串:
原字符串:     "aba"
加入#:       "#a#b#a#"
加入特殊字符:  "$#a#b#a#@"
  1. 对于偶数长度的字符串:
原字符串:     "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。


第三步:引入三个核心概念(关键)

这是马拉车算法的核心,我们需要用到两个重要的变量:

  1. R:当前访问到的所有回文子串,所能触及的最右一个字符的下标
  2. 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的中心点。
所以,马拉车不会直接保存每个回文串的长度,每次比较的也不是回文串的长度。


复习一下:现在我们知道

  1. p[i]表示以第i个字符为中心的最长回文串的半径(不包括中心点)
  2. int 原回文串下标 = (i - p[i]) / 2; 默认的向下取整
  3. p[i] 刚好也是去掉 "#$@" 的原回文字符串的总长度 程序最后才用,现在可以忽略
  4. R:当前访问到的所有回文子串,所能触及的最右一个字符的下标
  5. center:当前R的回文串的中心下标
  6. 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 (最右边界)(先别管怎么来的)

我们可以观察到:

  1. i_mirror和i下标关于center对称
  2. 以i_mirror为中心的回文串是"#a#",p[i_mirror] = 1
  3. 由于对称性,以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();
}

这样,我们就处理了所有可能的情况,既利用了对称性来优化,又在必要时使用中心扩展法来处理特殊情况。

欢迎批评讨论。

虽然给定引用中未直接给出马拉算法的C++实现代码,但可根据其原理给出示例代码。马拉算法Manacher's Algorithm)用于在字符串中查找最长回文子串,核心思想是利用已知的回文信息来减少不必要的比较,以达到线性时间复杂度O(n) [^1]。 以下是马拉算法的C++实现代码: ```cpp #include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; // 预处理字符串,将字符串转换为包含特殊字符的形式 string preprocess(const string& s) { string t = "^#"; for (char c : s) { t += c; t += '#'; } t += '$'; return t; } // 马拉算法核心函数 string longestPalindrome(const string& s) { string t = preprocess(s); int n = t.length(); vector<int> p(n, 0); int c = 0, r = 0; // 遍历处理后的字符串 for (int i = 1; i < n - 1; ++i) { int i_mirror = 2 * c - i; if (r > i) { p[i] = min(r - i, p[i_mirror]); } else { p[i] = 0; } // 尝试扩展以i为中心的回文串 while (t[i + 1 + p[i]] == t[i - 1 - p[i]]) { ++p[i]; } // 如果以i为中心的回文串扩展超过了r,更新c和r if (i + p[i] > r) { c = i; r = i + p[i]; } } // 找到最大回文半径及其中心位置 int maxLen = 0; int centerIndex = 0; for (int i = 1; i < n - 1; ++i) { if (p[i] > maxLen) { maxLen = p[i]; centerIndex = i; } } // 计算原始字符串中最长回文子串的起始位置和长度 int start = (centerIndex - maxLen) / 2; return s.substr(start, maxLen); } int main() { string s = "babad"; string result = longestPalindrome(s); cout << "最长回文子串是: " << result << endl; return 0; } ``` ### 代码解释 1. **preprocess函数**:该函数将输入的字符串转换为包含特殊字符`#`的形式,这样可以统一处理奇数长度和偶数长度的回文串。 2. **longestPalindrome函数**: - 调用`preprocess`函数对输入字符串进行预处理。 - 初始化变量`c`和`r`,分别表示当前回文串的中心和右边界。 - 遍历处理后的字符串,利用已知的回文信息更新`p[i]`,并尝试扩展以`i`为中心的回文串。 - 若扩展超过了当前右边界`r`,则更新`c`和`r`。 - 最后找到最大回文半径及其中心位置,计算原始字符串中最长回文子串的起始位置和长度。 3. **main函数**:调用`longestPalindrome`函数并输出结果。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值