什么是马拉车算法?

引言

文章相关代码已收录至我的github,欢迎star:lsylulu/myarticle
有这样一个问题,给定一个字符串,返回最长回文的子串的长度?要求时间复杂度为O(n)。
正常情况下我们会这么做,先将字符串进行特殊处理比如11311处理成#1#1#3#1#1#,然后遍历每个索引,找最长回文数。结果/2为正确答案。
那么,如何找每个索引的最长回文数呢?
通常情况下是设置一个start和end变量,start向右走,end向左走,每走一步比较start与end指向的元素是否相等,并且注意一下越界情况。start-end便是回文长度。但是,这种算法的时间复杂度为O(n^2)。能不能设计一个T=O(n)的算法呢?答案是Manacher算法。

文章导读

  • Manacher基本概念
  • Manacher执行流程
  • Manacher对应的代码实现
  • 对Manacher的思考
  • Manacher扩展
  • 总结

一、Manacher的基本概念

R--记录回文最右边界。随着字符数组的遍历,R的值必然不会减小,要么与上一次相等,要么被推向右边。
来看一个例子助于理解:
a b d b a b d b a
0 1 2 3 4 5 6 7 8
刚开始,R的值默认是-1。随着数组的遍历,R的值将做如下变化。R=-1->0->1->4->4->8->8->8->8->8
C--回文中心。C与R是相辅相成的,C是R对应的回文中心,R一旦改变,C也会跟着变化。刚开始,C的值默认也是-1。继续上个例子C的值将做如下变化。C=-1->0->1->2->2->4->4->4->4->4
curArr[ ]--长度与给定字符串相同(准确来说是'#'处理后的字符串)记录每一个索引的最大回文长度。
i--当前遍历的索引。
i’--i关于C的对称点。

二、Manacher算法的执行流程

对于每一个索引,都有相应的C和R。我们所要做的就是知道该索引后,给出currArr[i]的粗略值,根据这个粗略值将currArr[i]精确计算。再完善C与R的值。
每一次的遍历,大致分为两种情况:
1)i在R的外部
遇到这种情况,R一定会往右走的。先让curArr[i]=1(也就是说把自己作为回文中心,且回文长度是自己),然后尝试向右扩一个,看看是否满足以第i个为中心的回文。满足则curArr[i]++,不满足则下来确定C与R的值,此时C必然是i,R=i+curArr[i]。
2)i在R的内部

  • i'回文半径彻底在L与R内

什么是马拉车算法?_第1张图片

对于这种情况,先让curArr[i]=curArr[i'],然后再开始试探性的往两边扩,如果相同则curArr[i]++,不同则下来确定C与R的值。以i为中心的回文右边界超过了R时,c=i,R=i的右边界。

  • i'回文半径在L或R外

什么是马拉车算法?_第2张图片

先让curArr[i]=R-i,然后再试探性的扩,具体步骤与上一种情况一样。

  • i'回文半径和L或R相同

什么是马拉车算法?_第3张图片

先让curArr[i]=R,再试探性的扩,具体步骤与上一种情况一样。

三、实现Manacher

流程大概理清楚了,我们来结合我的详细注释看看具体代码。
首先需要处理给定的字符串,因为会有奇回文和偶回文的问题。

    /**
     * 处理原始字符串使之更方便操作
     * @param str
     * @return
     */
    public static char[] manacherString(String str) {
        char[] charArr = str.toCharArray();
        char[] res = new char[str.length() * 2 + 1];
        int index = 0;
        for (int i = 0; i != res.length; i++) {
            res[i] = (i & 1) == 0 ? '#' : charArr[index++];
        }
        return res;
    }

然后再求处理后的最长回文数,返回的是回文半径-1,刚好就是最长回文的长度。

/**
* 返回str的最长回文子串的长度
* @param str
* @return
 */
public static int maxLcpsLength(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArr = manacherString(str);
        int[] curArr = new int[charArr.length];
        //回文中心
        int C = -1;
        //回文右边界
        int R = -1;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i != charArr.length; i++) {
            //i在回文外部,直接扩
            if(i>=R){
                curArr[i]=1;
                //尝试向右扩一个,看看是否满足以第i个为中心的回文
                while (i+curArr[i]-1){
                    //满足则当前回文右边界+1
                    if (charArr[i + curArr[i]] == charArr[i - curArr[i]]){
                        curArr[i]++;
                    }
                    else {
                        break;
                    }
                }
                //满足条件则设置回文中心和右边界
                    C=i;
                    R=i+curArr[i];
                max = Math.max(max, curArr[i]);
            }//i在回文右边界内
            else{
                //当前回文半径至少是R-i与对应点回文半径的最小值
                //这里就是马拉车算法减少时间复杂度的原因
                //2*C-i就是i'的索引,具体自己画个图就明白啦
                curArr[i]=Math.min(curArr[2 * C - i], R - i);
                //尝试进行扩充,判断回文长度能不能再长一点
                while (i + curArr[i] < charArr.length && i - curArr[i] > -1) {
                    if (charArr[i + curArr[i]] == charArr[i - curArr[i]]){
                        curArr[i]++;
                    }
                    else {
                        break;
                    }
                }
            }

            //统计回文半径
            if (i + curArr[i] > R) {
                R = i + curArr[i];
                C = i;
            }
            max = Math.max(max, curArr[i]);
        }
        return max - 1;
    }

仔细看代码会发现,当i在回文右边界内的这种情况,之前分析可以分3中情况,但我合并了一下。原因是处理的细节几乎一样,只有在刚开始curArr[i]的赋值上是不同的。强行分开必然会冗余。
我们再利用上述条件,利用三目运算符简化代码,得到下面的精简版:

    /**
     * 返回str的最长回文子串的长度
     * @param str
     * @return
     */
    public static int maxLcpsLength(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArr = manacherString(str);
        //回文半径数组
        int[] curArr = new int[charArr.length];
        //当前回文中心的索引
        int C = -1;
        //当前回文右边界
        int R = -1;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i != charArr.length; i++) {
            //i'的回文到r的距离,哪个小哪个就是回文的区域
            //2*index-i是当前索引关于回文中心的对称点即i'
            curArr[i] = R > i ? Math.min(curArr[2 * C - i], R - i) : 1;
            //要检验的区域没越界,且当前索引对应回文的左边边界也没有越界
            while (i + curArr[i] < charArr.length && i - curArr[i] > -1) {
                //扩充之后的左右两个值相等,回文半径+1
                //利用之前求出的半径加速判断
                if (charArr[i + curArr[i]] == charArr[i - curArr[i]]){
                    curArr[i]++;
                }
                else {
                    break;
                }
            }
            //统计回文半径
            if (i + curArr[i] > R) {
                R = i + curArr[i];
                C = i;
            }
            max = Math.max(max, curArr[i]);
        }
        return max - 1;
     }

四、为什么Manacher能够如此迅速?

Manacher算法是一致公认的解字符串回文的最佳算法,我们回头思考一下,为什么Manacher能够加速判断?
原因是充分利用遍历过索引的最长回文半径的信息,减少中心点后续回文的判断长度。由于遍历到i时,i'的最长回文半径是已知的,所以可以确定i的回文半径一定大于等于i'的回文半径与R(整体回文右边界)-i(当前索引)的最小值。

五、Manacher拓展题

给定一个有回文的字符串,只能向字符串右边添加字符,如何在添加最少的情况下是整个字符串变成回文串?
利用manacher的思想,在从左到右依次遍历,当有一个索引的回文右边界刚好与字符串右边界相等时,将回文左边界的子串逆序之后添加到字符串的后面。具体实现就不贴啦!

总结

学习算法的过程是痛苦的,但是弄明白之后是幸福的。最近看左神的课,写篇文章强化一下。自认为对于Manacher的解释还是挺详细的,如有不当之处,欢迎各路大佬评论指出。

文章若有不当之处,欢迎评论指出~
如果喜欢我的文章,欢迎关注知乎专栏Java修仙道路~

你可能感兴趣的:(算法)