最长回文子串-马拉车算法

首先放一张leetcode题
image.png

回文子串的意思是,一个字符串正着读和逆着读一样(上海自来水来自海上)。

最直观的解法是暴力求解,列举所有的子串,维护一个子串长度最大值并返回最长的子串。但是这种解法的时间复杂度为O(n3),显然效率比较低;
还容易想到的解法是,中心扩展法。先贴一下代码

class Solution {
    public String longestPalindrome(String s) {
        if (s == null || s.length() < 1) return "";
        int start = 0, end = 0;
        for (int i = 0; i < s.length(); i++) {
            int len1 = expandAroundCenter(s, i, i);
            int len2 = expandAroundCenter(s, i, i + 1);
            int len = Math.max(len1, len2);
            if (len > end - start) {
                start = i - (len - 1) / 2;
                end = i + len / 2;
            }
        }
        return s.substring(start, end + 1);
    }

    private int expandAroundCenter(String s, int left, int right) {
        int L = left, R = right;
        while (L >= 0 && R < s.length() && s.charAt(L) == s.charAt(R)) {
            L--;
            R++;
        }
        return R - L - 1;
    }
}

直观理解,就是在遍历每个节点和每个相邻的节点对(因为aba和abba都是回文,对应代码中的len1和len2),寻找一个拥有最长回文的中心点或中心点对。边寻找,边维护对应的起点和终点最后返回。这种方式的时间复杂度为O(n2),已经是一种比较合理的解法了。

接下来将介绍一种时间复杂度和空间复杂度都为O(n)的解法:Manacher 算法

一、预处理
回文字符串以其长度来分,可以分为奇回文(其长度为奇数)、偶回文(其长度为偶数),一般情况下需要分两种情况来寻找回文,马拉车算法为了简化这一步,对原始字符串进行了处理,在每一个字符的左右两边都加上特殊字符(肯定不存在于原字符串中的字符),让字符串变成一个奇回文。例如:

原字符串:abba,长度为4
预处理后:#a#b#b#a#,长度为9

原字符串:aba,长度为3
预处理后:#a#b#a#,长度为7

二、计算最长回文子串长度


回文长度

如图所示,假如一个字符串原本为cabba,那么我们通过插入#可以得到一个长度为11的新字符串。同时可以新建一个长度也为11的数组p[i],每个数组对应值是以arr[i]中的字符为中心的回文半径。比如第一个#,就是0,因为只有他自己是回文串。比如中间的i=6是最长的,因为左右两边都为#a#b,所以值为4。
这个数组的最大值和原字符串的最长回文子串有什么关系呢?已经可以发现原字符串的最长回文子串是4,而数组最大值也是4。是否可以假设最长子串长度为数组最大值呢?结论是正确的。其实回到这个数组中去分析也能够直观看出来这个结论。

三、计算最长回文子串起始索引
知道了最长回文子串的长度,我们还需要知道它的起始索引值,这样才能截取出完整的最长回文子串。

首先我们解决下奇数和偶数的问题,在每个字符间插入"#",并且为了使得扩展的过程中,到边界后自动结束,在两端分别插入 "@" 和 "¥",两个不可能在字符串中出现的字符,这样中心扩展的时候,判断两端字符是否相等的时候,如果到了边界就一定会不相等,从而出了循环。经过处理,字符串的长度永远都是奇数。

比如我们的字符串是aba,如下图。
奇数字符串

加入特殊值的字符串

这样,其实我们p[i]的其他值是不会变的。这时我们再回顾一下刚开始的那个cabba,这时候是这样的。


加入特殊值的字符串

我们可以来分一下i-p[i]的意义。其实我们的i - p[i]得到的不是起点坐标,而是在最长子序列之前的普通序列。比如在这个例子中就是c以及c之前的特殊符号。这个普通序列长度值是我们想要得到的子序列起点坐标的两倍。这点大家可以自己造几个字符串去分析一下。所以说我们直接把这个值除以2,就可以得到起点坐标了。

总结一下,起点坐标可以通过 (i - p[i])/ 2来得到。

四、求p[i]
其实这一步是这个算法中最难的部分。这里我也是阅读了比较多的文章,选择用其中写的最清晰的一篇来讲解。
首先我们需要引入两个参数,C和R。我们用 C 表示回文串的中心,用 R 表示回文串的右边半径。所以 R = C + P[ i ] 。C 和 R 所对应的回文串是当前循环中 R 最靠右的回文串。
让我们考虑求 P [ i ] 的时候,如下图。
用 i_mirror 表示当前需要求的第 i 个字符关于 C 对应的下标。


求p[i]

我们现在要求 P [ i ], 如果是用中心扩展法,那就向两边扩展比对就行了。但是我们其实可以利用回文串 C 的对称性。i 关于 C 的对称点是 i_mirror ,P [ i_mirror ] = 3,所以 P [ i ] 也等于 3 。
但是有三种情况将会造成直接赋值为 P [ i_mirror ] 是不正确的,下边一一讨论。

  1. 超出了 R


    右边越界

    当我们要求 P [ i ] 的时候,P [ mirror ] = 7,而此时 P [ i ] 并不等于 7 ,为什么呢,因为我们从 i 开始往后数 7 个,等于 22 ,已经超过了最右的 R ,此时不能利用对称性了,但我们一定可以扩展到 R 的,所以 P [ i ] 至少等于 R - i = 20 - 15 = 5,会不会更大呢,我们只需要比较 T [ R+1 ] 和 T [ R+1 ]关于 i 的对称点就行了,就像中心扩展法一样一个个扩展。

2.P [ i_mirror ] 遇到了原字符串的左边界


左边越界

此时P [ i_mirror ] = 1,但是 P [ i ] 赋值成 1 是不正确的,出现这种情况的原因是 P [ i_mirror ] 在扩展的时候首先是 "#" == "#" ,之后遇到了 "^"和另一个字符比较,也就是到了边界,才终止循环的。而 P [ i ] 并没有遇到边界,所以我们可以继续通过中心扩展法一步一步向两边扩展就行了。

  1. i 等于了 R
    此时我们先把 P [ i ] 赋值为 0 ,然后通过中心扩展法一步一步扩展就行了。

考虑 C 和 R 的更新:

就这样一步一步的求出每个 P [ i ],当求出的 P [ i ] 的右边界大于当前的 R 时,我们就需要更新 C 和 R 为当前的回文串了。因为我们必须保证 i 在 R 里面,所以一旦有更右边的 R 就要更新 R。
image.png

此时的 P [ i ] 求出来将会是 3 ,P [ i ] 对应的右边界将是 10 + 3 = 13,所以大于当前的 R ,我们需要把 C 更新成 i 的值,也就是 10 ,R 更新成 13。继续下边的循环。
public String preProcess(String s) {
    int n = s.length();
    if (n == 0) {
        return "^$";
    }
    String ret = "^";
    for (int i = 0; i < n; i++)
        ret += "#" + s.charAt(i);
    ret += "#$";
    return ret;
}

// 马拉车算法
public String longestPalindrome2(String s) {
    String T = preProcess(s);
    int n = T.length();
    int[] P = new int[n];
    int C = 0, R = 0;
    for (int i = 1; i < n - 1; i++) {
        int i_mirror = 2 * C - i;
        if (R > i) {
            P[i] = Math.min(R - i, P[i_mirror]);// 防止超出 R
        } else {
            P[i] = 0;// 等于 R 的情况
        }

        // 碰到之前讲的三种情况时候,需要利用中心扩展法
        while (T.charAt(i + 1 + P[i]) == T.charAt(i - 1 - P[i])) {
            P[i]++;
        }

        // 判断是否需要更新 R
        if (i + P[i] > R) {
            C = i;
            R = i + P[i];
        }

    }

    // 找出 P 的最大值
    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.substring(start, start + maxLen);
}

看完代码,可能大家和我的第一反应一样:这不就是O(n2)的时间复杂度吗?明明在for里面还有一层while呀。但是仔细想想,实际上同一个点只会经历常数次中心扩展。因为每当它进行了扩展后,对应的R和C都被更新了。期间访问的节点下次就不会再进入 while 了,可以利用对称得到自己的解,所以每个节点访问都是常数次,所以是O ( n )。

本文参考了知乎文章:https://zhuanlan.zhihu.com/p/70532099

你可能感兴趣的:(最长回文子串-马拉车算法)