Manacher算法

101 阅读6分钟

一夜过得特别长,因为我好像同时在和两个人说话。我再分不清到底她是慕容燕,还是慕容嫣。她问我,你最喜欢的女人到底是谁?我只给她一个答案,不就是你嘛!曾经也有一个女人这么问我,但是我没有回答,换成了黄药师的身份,我觉得那几个字,原来并不是很难说出口。那天晚上睡觉,我又感觉到有人摸我。我知道她想摸的人不是我,她不过当我是另外一个人,而我又何尝不是呢?那晚之后,再也没有人见过慕容燕和慕容嫣。数年之后,江湖上出现了一个奇怪的剑客,没有人知道她的来历,只知道她喜欢跟自己的倒影练剑。她有一个特别的名字,叫独孤求败。 --《东邪西毒》

当你看镜子的时候,你会发现镜里的人和你长得很像,如果你的左脸和右脸完全对称,那简直一模一样。现在有一串字符串,从任意一个字符串i开始,如果它左边逆序排列的一些字符和右边顺序排列的一些字符完全对称,我们称以i为中心,包括左右对称部分的整串字符串为回文串,如下图示例: image.png

此时,以下标为2为中心,蓝色括号括起来的连续一串字符称为回文串,即cabac是回文串。基于此,现在提出问题: 有一串由数字和英文字母组成的字符串,求出这个字符串中最长的回文子串并返回。 针对此问题,我们很容易的想到,遍历字符串数组的每个位置,看其左边的字符是否和右边字符匹配,如果匹配,则继续拿左边的下一个字符和右边的下一个字符比较,直至左边来到位置的字符和右边来到位置的字符不匹配为止,然后记录此时的回文串长度,同时还存在一个记录回文串最大值的全局变量,如果发现本次记录的回文串比全局变量记录的大,则更新全局变量的值为本次回文串的值。如此下去,遍历完整个字符串数组,可能会得到答案。注意,是可能得到答案,为何是可能得到答案呢?因为存在两种可能性。

  • 情况一:以某个字符串a为中心进行比较的那个字符的左一个位置或者右一个位置都和a不一样,此时能得到答案(以下标2为中心),如下图所示:

image.png

  • 情况二:以某个字符串a为中心进行比较的那个字符的左一个位置或者右一个位置和a一样,此时无法得到答案(我们可以很容易的看到最长回文子串是cabbac,但是如论是以下标2为中心还是以下标3为中心,都无法得到正确值),如下图所示。

image.png

因此,我们需要对原始字符串处理,使其按照我们目前的解题思路,仍能得到正确答案。我们只需在字符串数组的每个位置的左一个位置和右一个位置添加一个符号(添加任意一种符号,这里用空字符)即可。如将情况二的字符串数组进行处理,得到下图所示:

image.png 其代码如下所示:

public static String longestPalindrome(String s) { 
    if (s == null || s.length() == 0) { 
        return ""; 
    } 
    char[] arr = wrapString(s); 
    int li = 0;  // 用于记录最大回文串的左边界下标
    int ri = 0;  // 用于记录最大回文串的右边界下标
    int max = 0; // 用于记录最大回文串长度
    int l; // 以i为中心往左移动的下标
    int r; // 以i为中心往右移动的下标
    for (int i = 0; i < arr.length; i++) { 
        l = i; 
        r = i; 
        while (l >= 0 && r < arr.length) { 
            if (arr[l] == arr[r]) { 
                if (max < r - l) { 
                    max = r - l; 
                    li = l; 
                    ri = r; 
                } 
                l--; 
                r++; 
            } else { 
                break; 
            } 
        } 
    } 
    return s.substring(li/2, ri/2); 
} 
/** 
* 将原始字符串变成处理串,因为处理串新加的每个位置字符不会影响最终结果,默认使用分配时候的 
* 比如原始字符串为:abcd 处理后为:[,a,,b,,c,,d,] 
* @param s 
* @return 
*/ 
private static char[] wrapString(String s) { 
    char[] src = s.toCharArray(); 
    int n = src.length; 
    char[] arr = new char[2 * n + 1]; 
    for (int i = 0; i < n; i++) { 
        arr[2 * i + 1] = src[i]; 
    } 
    return arr; 
 }

从代码很直观的看出,我们从字符串数组的首地址开始遍历整个数组,并且每个位置都会拿其左边的字符和其右边字符进行比较,直至左边字符和右边字符不匹配才结束。因此,最差的情况下(字符串所有字符都一样,原始串的长度为N,那么处理串的长度为2N+1)需要进行比较的次数为N^2+2N+1,计算过程如下图所示:

image.png 因此,算法的时间复杂度为O(N^2),我们且将这个时间复杂度的算法称为一般算法,对于这个时间复杂度,我们并不满意,我们希望把时间复杂度做到O(N),而Manacher算法能做到。Manacher算法的核心思路是利用前向比较得出的结果指导加速后续比较的过程,基于此,必定存在一个数组,用于保存前向得出的结果。在介绍Manacher算法完整流程之前,需要给出几个概念。

  • 回文半径数组(pArr表示)
  • 数组中回文半径最右边界(R表示)
  • 关于回文半径最右边界的中心点(C表示)
  • 以回文半径最右边界的中心点为中心的关于数组中回文半径最右边界的最左对称点(L表示)

回文半径数组用于记录遍历到的每个位置的回文半径,如果来到的位置第一次比较的左右字符就不匹配,则记录为1,否则在1的基础上累加即可,如下图所示:

image.png

数组中回文半径最右边界记录的是之前遍历的位置中回文半径能来到的最右位置下标的最大值,而关于回文半径最右边界的中心点则是伴随数组中回文半径最右边界产生的,即为取得数组中回文半径最右边界时的回文串中心,以回文半径最右边界的中心点为中心的关于数组中回文半径最右边界的最左对称点则更加直观,如下图所示:

image.png

清楚了上面这些概念后,下面可以开始讲Manacher算法流程了。Manacher算法大体流程和前文提到的一般算法一样,区别的部分是当遍历的位置(用i表示)比R小的时候,可以利用pArr加速比较过程。当i比R小时,我们求出i关于C的对称点i',如下图所示(为简化图示,后续所有画图的处理串当成原始串给出):

image.png

我们决定选取i'(因为当i小于R时,此时以C为中心的回文串是最大的,而i'是以C为中心关于i的对称点,因此,以i'为中心的回文串和以i为中心的回文串,必定存在部分或全部一致的关系)作为加速比较的判断条件,那么现在可以将Manacher算法的整体流程过一遍了。

  1. 当i大于等于R时,和一般算法流程一致;
  2. 当i小于R时,我们用i'作为i加速匹配的参考条件,关于i'的回文串左边界下标l'和L存在三种可能:
    • l' < L
      此时,关于i的回文串半径大小,等于i'的,即为pArr[i'],并且不会大于以C为中心时的回文串大小,因此R不变,C也不变。如下图所示: image.png 证明:假设l'的左一个位置值为甲,以i'为中心的回文串的右边界r'的下一个值为乙,当初以i'为中心的最大回文串是从l'到r',因此甲!=乙。以i为中心的回文串存在一串和l'到r'一致的回文串,用l、r表示,假设l左一个位置值为丙,r的右一个位置为丁,那么在以C为中心,R为半径的回文串里,甲==丁,乙==丙,又由于甲!=乙,因此丙!=丁,所以关于i的回文串大小,等于i'的,证毕。

    • l' > L
      此时,关于i的回文串半径大小为R-i+1,并且不会大于以C为中心时的回文串大小,因此R不变,C也不变。如下图所示: image.png 证明:假设以C为中心的回文串半径左边界L的左一个位置为甲,以i'为中心做关于甲的对称点乙,由于i'的回文半径左边界值比L小,因此,甲==乙。以i为中心,取范围和甲到乙同样大小的子串,左右分别设为丙、丁。当初以C为中心,最大的回文串左边界只能到L,右边界只能到R,说明甲!=丁,又由于甲==乙,因此丙!=丁,所以关于i的回文串半径大小为R-i+1,证毕。

    • l' == L
      此时,关于i的回文串半径大小,至少能确定为等于i'的,但是会不会比i',需要分情况讨论,因此R和C可能发生变化,如下图所示: image.png 证明,情况一时,i'回文串的左边界下一个值甲!=乙,从以C为中心的回文串得到,甲!=丁,但是丙==丁,此时R会变大,需要进行下一个位置的比较,直到左边不等于右边为止。情况二时,i'回文串的左边界下一个值甲!=乙,从以C为中心的回文串得到,甲!=丁,此时丙!=丁,因此R不变,C也不变,所以关于i的回文串半径大小,至少能确定为等于i'的,证毕。

通过分析整个Manacher算法流程可知,当遍历来到的位置大于等于R时,R会增大,pArr数组直接记录此时的回文半径大小;当遍历来到的位置小于R时,遍历位置的回文半径取值在l'<L和l'>L时能直接从pArr中得到,只有当l'==L时,才会继续比较,但是,R至少是不会发生回退的,因此,时间复杂度为O(N)。完整的代码如下:

public String longestPalindrome(String s) {
    if (s == null || s.isEmpty()) {
        return "";
    }
    int[] lr = manacher(s);
    return s.substring(lr[0], lr[1]);
}
/**
 * 返回获得到的最长回文串的开始和结束位置
 *
 * @param s
 * @return
 */
public int[] manacher(String s) {
    if (s == null || s.isEmpty()) {
        return new int[2];
    }
    int[] lrIndices = new int[2];
    char[] arr = wrapString(s);
    int R = -1;
    int C = -1;
    int n = arr.length;
    int[] pArr = new int[n];
    int max = 0;
    int ml = 0;
    int mr = 0;
    for (int i = 0; i < n; i++) {
        // 不需要比较的最小范围
        // 只有i < R时才存在加速过程,即pArr能做为指导
        // 并且经过证明,当i < R时,只有当以C为中心,做i的对称点_i(即2 * C - i),取对称点的回文半径左边等于C回文半径左边时才需要对比 ①
        pArr[i] = i < R ? Math.min(pArr[2 * C - i], R - i) : 1;
        while (i - pArr[i] >= 0 && i + pArr[i] < n) {
            // 只有①情况下或 i>=R才有可能出现下面==的情况,否则直接跳出循环
            if (arr[i + pArr[i]] == arr[i - pArr[i]]) {
                pArr[i]++;
            } else {
                break;
            }
        }
        if (pArr[i] + i > R) {
            R = pArr[i] + i;
            C = i;
        }
        if (max < pArr[i]) {
            max = pArr[i];
            ml = i - pArr[i];
            mr = i + pArr[i];
        }
    }
    lrIndices[0] = (ml + 1) / 2;
    lrIndices[1] = mr / 2;
    return lrIndices;
}

/**
 * 将原始串进行处理,在每个位置前后加上一个默认的字符(新建char数组的默认值)
 * eg., [a,b,c] => [,a,,b,,c,]
 *
 * @param s
 * @return
 */
private char[] wrapString(String s) {
    char[] src = s.toCharArray();
    int n = src.length * 2 + 1;
    char[] arr = new char[n];
    for (int i = 0; i < src.length; i++) {
        arr[i * 2 + 1] = src[i];
    }
    return arr;
}