Manacher算法详解

Manacher

**Manacher算法是一个用来查找一个字符串中的最长回文子串(不是最长回文序列)的线性算法。它的优势就是把时间复杂度为O(n*n)的暴力算法优化到了O(n)。首先先让咱们来看看最原始的暴力扩展,分析其存在的弊端,以此来更好的理解Manacher算法。**java

暴力匹配

暴力匹配算法的原理很简单,就是从原字符串的首部开始,依次向尾部进行遍历,每访问一个字符,就以此字符为中心向两边扩展,记录该点的最长回文长度。那么咱们能够想一想,这样作存在什么弊端,是否是能够求出真正的最长回文子串?ios

答案是显然不行的,咱们从两个角度来分析这个算法算法

1.不适用于偶数回文串数组

咱们举两个字符串作例子,它们分别是 "aba","abba",咱们经过肉眼能够观察出,它们对应的最长回文子串长度分别是3和4,然而咱们要是用暴力匹配的方法去对这两个字符串进行操做就会发现,"aba" 对应的最长回文长是 "131","abba" 对应的最长回文长度是 "1111",咱们对奇数回文串求出了正确答案,可是在偶数回文串上并无获得咱们想要的结果,经过屡次测试咱们发现,这种暴力匹配的方法不适用于偶数回文串测试

2.时间复杂度O(n*n)优化

这里的时间复杂度是一个平均时间复杂度,并不表明每个字符串都是这个复杂度,但由于每到一个新位置就须要向两边扩展比对,因此平均下来时间复杂度达到了O(n*n)。spa

Manacher算法本质上也是基于暴力匹配的方法,只不过作了一点简单的预处理,且在扩展时提供了加速

Manacher对字符串的预处理

咱们知道暴力匹配是没法解决偶数回文串的,可Manacher算法也是一种基于暴力匹配的算法,那它是怎么来实现暴力匹配且又不出错的呢?它用来应对偶数字符串的方法就是——作出预处理,这个预处理能够巧妙的让全部字符串都变为奇数回文串,不论它本来是什么。操做实现也很简单,就是将原字符串的首部和尾部以及每两个字符之间插入一个特殊字符,这个字符是什么不重要,不会影响最终的结果(具体缘由会在后面说),这一步预处理操做后的效果就是原字符串的长度从n改变成了2*n+1,也就获得了咱们须要的能够去作暴力扩展的字符串,而且从预处理后的字符串获得的最长回文字符串的长度除以2就是原字符串的最长回文子串长度,也就是咱们想要获得的结果。3d

这里解释一下为何预处理后不会影响对字符串的扩展匹配code

好比咱们的原字符串是 "aa",假设预处理后的字符串是 "#a#a#",咱们在任意一个点,好比字符 '#',向两端匹配只会出现 'a' 匹配 'a','#' 匹配 '#' 的状况,不会出现原字符串字符与特殊字符匹配的状况,这样就能保证咱们不会改变原字符串的匹配规则。经过这个例子,你也能够发现实际获得的结果与上述符合。blog

Manacher算法核心

Manacher算法的核心部分在于它巧妙的使人惊叹的加速,这个加速一下把时间复杂度提高到了线性,让咱们从暴力的算法中解脱出来,咱们先引入概念,再说流程,最后提供实现代码。

概念:

ManacherString:通过Manacher预处理的字符串,如下的概念都是基于ManasherString产生的。

回文半径和回文直径:由于处理后回文字符串的长度必定是奇数,因此回文半径是包括回文中心在内的回文子串的一半的长度,回文直径则是回文半径的2倍减1。好比对于字符串 "aba",在字符 'b' 处的回文半径就是2,回文直径就是3。

最右回文边界R:在遍历字符串时,每一个字符遍历出的最长回文子串都会有个右边界,而R则是全部已知右边界中最靠右的位置,也就是说R的值是只增不减的。

回文中心C:取得当前R的第一次更新时的回文中心。因而可知R和C时伴生的。

半径数组:这个数组记录了原字符串中每个字符对应的最长回文半径。

流程:

步骤1:预处理原字符串

先对原字符串进行预处理,预处理后获得一个新的字符串,这里咱们称为S,为了更直观明了的让你们理解Manacher的流程操做,咱们在下文的S中不显示特殊字符(这样并不影响结果)。

步骤2:R和C的初始值为-1,建立半径数组pArr

这里有点与概念相差的小误差,就是R实际是最右边界位置的右一位。

步骤3:开始从下标 i = 0去遍历字符串S

分支1:i > R ,也就是i在R外,此时没有什么花里胡哨的方法,直接暴力匹配,此时记得看看C和R要不要更新。

分支2:i <= R,也就是i在R内,此时分三种状况,在讨论这三个状况前,咱们先构建一个模型

L是当前R关于C的对称点,i'是i关于C的对称点,可知 i' = 2*C - i,而且咱们会发现,i'的回文区域是咱们已经求过的,从这里咱们就能够开始判断是否是能够进行加速处理了

状况1:i'的回文区域在L-R的内部,此时i的回文直径与 i' 相同,咱们能够直接获得i的回文半径,下面给出证实

红线部分是 i' 的回文区域,由于整个L-R就是一个回文串,回文中心是C,因此i造成的回文区域和i'造成的回文区域是关于C对称的。

状况2:i'的回文区域左边界超过了L,此时i的回文半径则是i到R,下面给出证实

首先咱们设L点关于i'对称的点为L',R点关于i点对称的点为R',L的前一个字符为x,L’的后一个字符为y,k和z同理,此时咱们知道L - L'是i'回文区域内的一段回文串,故可知R’ - R也是回文串,由于L - R是一个大回文串。因此咱们获得了一系列关系,x = y,y = k,x != z,因此 k != z。这样就能够验证出i点的回文半径是i - R。

状况3:i' 的回文区域左边界刚好和L重合,此时i的回文半径最少是i到R,回文区域从R继续向外部匹配,下面给出证实

由于 i' 的回文左边界和L重合,因此已知的i的回文半径就和i'的同样了,咱们设i的回文区域有边界的下一个字符是y,i的回文区域左边界的上一个字符是x,如今咱们只须要从x和y的位置开始暴力匹配,看是否能把i的回文区域扩大便可。

总结一下,Manacher算法的具体流程就是先匹配 -> 经过判断i与R的关系进行不一样的分支操做 -> 继续遍历直到遍历完整个字符串

时间复杂度:

咱们能够计算出时间复杂度为什么是线性的,分支一的状况下时间时间复杂度是O(n),分支二的前两种状况都是O(1),分支二的第三种状况,咱们可能会出现O(1)——没法从R继续向后匹配,也可能出现O(n)——能够从R继续匹配,即便能够继续匹配,R的值也会增大,这样会影响到后续的遍历匹配复杂度,因此综合起来整个算法的时间复杂度就是线性的,也就是O(n)。

实现代码:

整个代码并非对上述流程的生搬硬套(那样会显得代码冗长),代码进行了精简优化,具体如何我会在代码中进行注释

#include<iostream>
#include<string>
#include<cstring>
#include<algorithm>
#include<vector>
#include<cmath>
using namespace std;
//算法主体
int maxLcsplength(string str) {
    //空字符串直接返回0
    if (str.length() == 0) {
        return 0;
    }
    //记录下原始字符串的长度,方便后面使用
    int len = (int)(str.length() * 2 + 1);
    //开辟动态数组chaArr记录manacher化的字符串
    //开辟动态数组pArr记录每一个位置的回文半径
    char *chaArr = new char[len];
    int* pArr = new int[len];
    int index = 0;
    for (int i = 0; i < len;i++) {
        chaArr[i] = (i & 1) == 0 ? '#' : str[index++];
    }
    //到此完成对原字符串的manacher化
    //R是最右回文边界,C是R对应的最左回文中心,maxn是记录的最大回文半径
    int R = -1;
    int C = -1;
    int maxn = 0;
    //开始从左到右遍历
    for (int i = 0; i < len; i++) {
        //第一步直接取得可能的最短的回文半径,当i>R时,最短的回文半径是1,反之,最短的回文半径多是i对应的i'的回文半径或者i到R的距离
        pArr[i] = R > i ? min(R - i, pArr[2 * C - i]) : 1;
        //取最小值后开始从边界暴力匹配,匹配失败就直接退出
        while (i + pArr[i]<len && i + pArr[i]>-1) {
            if (chaArr[i + pArr[i]] == chaArr[i - pArr[i]]) {
                pArr[i]++;
            }
            else {
                break;
            }
        }
        //观察此时R和C是否可以更新
        if (i + pArr[i] > R) {
            R = i + pArr[i];
            C = i;
        }
        //更新最大回文半径的值
        maxn = max(maxn, pArr[i]);
    }
    //记得清空动态数组哦
    delete[] chaArr;
    delete[] pArr;
    //这里解释一下为何返回值是maxn-1,由于manacherstring的长度和原字符串不一样,因此这里获得的最大回文半径实际上是原字符串的最大回文子串长度加1,有兴趣的能够本身验证试试
    return maxn - 1;
}
int main()
{
    string s1 = "";
    cout << maxLcsplength(s1) << endl;
    string s2 = "abbbca";
    cout << maxLcsplength(s2) << endl;
    return 0;
}

下面附上java代码

public class Manacher {

    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;
     }

    public static int maxLcpsLength(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArr = manacherString(str);
        int[] pArr = new int[charArr.length];
        int C = -1;
        int R = -1;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i != charArr.length; i++) {
            pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
            while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
                if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
                    pArr[i]++;
                else {
                    break;
                }
            }
            if (i + pArr[i] > R) {
                R = i + pArr[i];
                C = i;
            }
            max = Math.max(max, pArr[i]);
        }
        return max - 1;
    }

    public static void main(String[] args) {
        String str1 = "abc123321cba";
        System.out.println(maxLcpsLength(str1));
    }

}
相关文章
相关标签/搜索