深入理解 Manacher 算法:高效计算回文子串个数

235 阅读2分钟

Manacher 算法 是用于在 O(n) 时间复杂度内解决“最长回文子串”等问题的高效算法。它的核心思想是利用回文的 对称性,通过巧妙的扩展和对称性推导,避免了暴力算法中重复计算的浪费。

本文将通过题目 647. 回文子串 来逐步讲解如何应用 Manacher 算法解决回文子串问题。

什么是回文?

回文是指正序与逆序完全一致的字符串,例如 abaabba。回文的长度可以是奇数或偶数:

  • 奇数长度回文:如 aba,以 b 为中心,两侧字符对称。
  • 偶数长度回文:如 abba,正好左右对称。

统一奇数与偶数回文的处理方法

为了解决奇数和偶数回文的区别,我们在字符之间插入特殊符号 #,使回文判断统一。这样,无论是奇数还是偶数长度的回文,都可以通过对称的方式处理:

  • 对于奇数长度回文:以原字符为中心,两侧字符对称。
  • 对于偶数长度回文:以 # 为中心,两侧字符对称。

例如:

  • aba -> #a#b#a#
  • abba -> #a#b#b#a#

代码实现

s = `#${s.split('').join('#')}#`;

定义回文半径数组 P

回文半径 表示以中心点为起点(不包括中心字符)到回文边缘的字符个数。通过这个回文半径,我们可以得知每个中心点处回文的最大扩展范围

以字符串 #a#b#b#a# 为例,其中一个回文子串是 #b#,中文点是 b,回文半径就是 1。

代码实现

初始化数组 P,存储每个索引对应的回文半径值:

const P = new Array(s.length).fill(0);     // 回文半径
return P.reduce((a, b) => a + Math.floor((b + 1) / 2), 0);   // 最大扩展范围对应的回文个数

规律

手动补全回文半径值,如下表所示:

序号012345678
字符#a#b#b#a#
P010141010

可以观察到以下规律:

  • 回文半径是原回文子串的长度:例如 #a#b#b#a# 回文半径是 4,对应的原字符串 abba 的长度也为 4,# 符号恰好补齐了剩余字符。
  • 回文半径对称性:回文有对称性,中心点左右两侧的数据完全一致,两个相对称节点的回文半径也存在对称性。

计算右侧节点的回文半径值

根据回文半径的对称性,当我们知道了回文左侧节点的回文半径后,就可以推导出右侧半径的初始值,避免从头开始计算。

初始化右侧节点回文半径

通过对称性推导,我们可以得到右侧节点的回文半径初始值。中心点为 center,索引 i 对应的左侧节点是 2 * center - i,左侧节点的回文半径就是 P[2 * center - i]

// 因为左右两侧节点到中心点的距离相等
center - left = i - center;
left = 2 * center - i;

右边界限制

对称性只保证在回文范围内,如果超出回文范围,左右节点内容不一致,就无法保证其对称性了。

例如下图,回文对称左侧节点 a 的回文半径是 3,但其半径范围超过了当前回文的范围,就需要回缩右侧节点的回文初始值,使用 right - i 限制回文半径的初始值

image.png

代码实现

if(i < right) {
    P[i] = Math.min(P[2 * center - i], right - i);
}

扩展回文半径

通过初始化,我们确定了以 i 为中心点的初始回文区间是 [i - P[i], i + P[i]],在这个基础上,我们向外扩展回文半径,直到左右的值不相等为止

代码实现

// 继续扩展回文
let leftIndex = i - P[i] - 1;
let rightIndex = i + P[i] + 1;
while(leftIndex >= 0 && rightIndex < s.length && s[leftIndex] === s[rightIndex]) {
    P[i]++;
    leftIndex--;
    rightIndex++
}

更新中心节点和右边界值

每次回文扩展后,如果右边界值超过了当前的 right,我们需要更新 centerright,以保证后续的计算能够利用之前的对称性,避免重新计算。

// 更新中心值、右边界值
if(i + P[i] > right) {
    center = i;
    right = i + P[i]; 
}

完整代码

function countSubstrings(s: string): number {
    s = `#${s.split('').join('#')}#`;
    const P = new Array(s.length).fill(0);     // 回文半径

    let center = 0;     // 回文中心点
    let right = 0;     // 回文右边界值
    for(let i = 0; i < s.length; i++) {
        // 获取初始值
        if(i < right) {
            P[i] = Math.min(P[2 * center - i], right - i);
        }

        // 继续扩展回文
        let leftIndex = i - P[i] - 1;
        let rightIndex = i + P[i] + 1;
        while(leftIndex >= 0 && rightIndex < s.length && s[leftIndex] === s[rightIndex]) {
            P[i]++;
            leftIndex--;
            rightIndex++
        }

        // 更新中心值、右边界值
        if(i + P[i] > right) {
            center = i;
            right = i + P[i]; 
        }
    }

    return P.reduce((a, b) => a + Math.floor((b + 1) / 2), 0);
};

总结

Manacher 算法利用回文的对称性和中心扩展思想,将时间复杂度降低到 O(n),非常适合解决回文子串等相关问题。通过巧妙的更新和扩展回文半径,算法能高效地计算回文子串个数,并且避免了重复计算。

拓展练习