Manacher算法

544 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

前言

Manacher算法是解决在一个字符串中找最长回文子串的一个算法,并且速度很快!

暴力解

从0位置开始,以每个位置作为中心点往左右两边扩,看能扩到什么位置!但是此方法只适用于回文长度是奇数个的时候。

偶数的话怎么办呢?

用特殊字符把源字符串加工处理一下,比如字符串“121aaaa232aa”就变成

#1#2#1#a#a#a#a#2#3#2#a#a#

在源字符串开始和结束位置加上一个特殊字符,每两个字符之间也插入特殊字符,再从0位置开始,以每个位置为中心点往左右两边扩,看能扩到什么位置!得到结果后除2就是最终结果。(除2是向下取整)。

如果不用特殊字符,用源字符串中的任意一个字符会影响结果吗?

不会!因为源字符只会跟源字符比较,特殊字符只会跟特殊字符比较,没有任何时候,源字符会跟特殊字符比较!!!

暴力解时间复杂度

举一个最差例子:aaaaa,加工后变成

#a#a#a#a#a#

每个位置能扩到的距离分别是0、1、2、3、4、5、4、3、2、1、0,开头到中间和中间到结尾分别都是等差数列,显然,时间复杂度为O(N^2)。

暴力解之所以暴力是因为,每个位置能扩多远这个结果无法指导接下来的位置继续扩充,每个位置都是独立的自己在计算。而Manacher算法就是当前位置的结果可以加速下一个位置的计算。所以跟KMP算法的思想有点类似,都是因为每个位置自己算出来的结果无法指导下一个位置。

回文直径和回文半径

比如字符串

abc12321def

回文直径就是5,回文半径就是3(半径不是直径的一半!!!)

如果回文串长度是偶数个,比如

abc1221def

回文直径就是4,回文半径就是2

如果是加工后的字符串:

#1#2#2#1#

回文直径是9,回文半径是5

回文半径数组

在加工后的字符串的基础上,从左往右求每一个位置为中心向左右两边扩的长度,每一次得到的答案都放到一个回文半径数组parr里面

最右回文边界R

用R表示,一开始设置为-1。表示0位置还没有开始扩,所以就认为右边界在-1位置。

所以最右回文边界表示的就是每一次扩出来的回文区域的最右边。

取得最右回文边界时候的中心点C在什么位置

一开始设置为-1。当最右回文边界更新的时候,C就是负责记录是哪个中心点扩的时候让R更新的。C跟R是伴生的,R不更新C就不会更新;一旦R更新C就会跟着更新。

Manacher算法流程(O(N))

Manacher算法流程跟暴力解的大过程是一样的,也是从0位置开始往左右两边扩,然后从1位置开始,从2位置开始...,但关键是Manacher在扩的时候是有加速的。

注意:Manacher仍然是针对处理过的源字符串!!!

假设以当前来到的 i 位置为中心向左右两边扩,毫无疑问,存在两种情况:

1、i 没有被 R 罩住,此时无法优化,只能暴力扩,看左右两边的字符一不一样,一样就继续阔,不一样就停。 2、i 被 R 罩住了,此种情况又可以细分三种情况。

R 会扩住这个 i,或者正好压着 i,那么中心点 C 一定是在 i 的左边,因为一定是之前某个中心点扩了一个比较远的距离,才把 i 罩住的。那么此时一定能够做出一个关于C的对称点,i' 和 L。 在这里插入图片描述 特殊情况,i 和 R 重合,但也存在上述对称关系: 在这里插入图片描述 存在上述关系后( i 被 R 罩住的情况),接下来的小情况分类。根据 i' 自己扩出来的回文区域分。i' 是挨着左边的位置,说明之前求过 i' 扩出来的大小,并且把答案存在回文半径数组里面。

1)、i' 扩出来的回文区域彻底在 L..R 内。 在这里插入图片描述 举个例子,为了好看,就不加特殊字符了,但是其实应该是针对处理过的字符串的。 在这里插入图片描述 此时,i位置能扩的区域一定跟 i' 扩的区域一样大!

证明 在这里插入图片描述 2)、i' 扩出来的回文区域跑到 L 外头去了,举个例子: 在这里插入图片描述 此种情况下,i 位置能扩多远,i 位置的回文半径就是 i ~ R。

证明 在这里插入图片描述 3)i' 扩出来的回文区域的左边界和 L 重合,举个例子: 在这里插入图片描述 此种情况下,i 的回文半径大小至少跟 i' 的回文半径大小一样,(这一段无需验证)但是会不会更大?需要接着验证。

Manacher时间复杂度

在这里插入图片描述

coding上的细节

// 讲述中:R代表最右的扩成功的位置
// coding:最右的扩成功的位置的,再下一个位置
int R = -1;

在这里插入图片描述

pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
// pArr[2 * C - i]

在这里插入图片描述

// max是加了#字符后的处理串的 回文半径
// max-1就是原字符串的最长回文大小
return max - 1;

在这里插入图片描述

完整代码

package com.harrison.class17;

/**
 * @author Harrison
 * @create 2022-03-30-10:59
 * @motto 众里寻他千百度,蓦然回首,那人却在灯火阑珊处。
 */
public class Code01_Manacher {
    public static int manacher(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        // "12321" -> "#1#2#3#2#1"
        char[] str = manacherString(s);
        // 回文半径大小
        int[] pArr = new int[str.length];
        int C = -1;
        // 讲述中:R代表最右的扩成功的位置
        // coding:最右的扩成功的位置的,再下一个位置
        // 只有R>i才表示i被R罩住了!!!
        int R = -1;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i < str.length; i++) {
            // R第一个违规的位置,i>=R
            // i位置扩出来的答案,i位置扩的区域,至少是多大(也就是说哪个区域是我不用验的)
            // Math.min(pArr[2 * C - i], R - i) i'的回文半径 和 R到i的距离谁小谁就是不用验的区域
            pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
            // 继续验证再往外的右 和 再往外的左,如果一样,回文半径大小加1;如果不一样,那就扩失败了
            // 但是大情况中的情况1)和情况2)不用验也知道答案,此时进入while就会直接break;
            while (i + pArr[i] < str.length && i - pArr[i] > -1) {
                if (str[i + pArr[i]] == str[i - pArr[i]]) {
                    pArr[i]++;
                } else {
                    break;
                }
            }
            // 如果R被推得更往右了,同时跟新R和C
            if (i + pArr[i] > R) {
                R = i + pArr[i];
                C = i;
            }
            // 记录下每一步的回文半径大小
            max = Math.max(max, pArr[i]);
        }
        // max是加了#字符后的处理串的 回文半径
        // max-1就是原字符串的最长回文大小
        return max - 1;
    }

    public static char[] manacherString(String s) {
        char[] str = s.toCharArray();
        char[] res = new char[2 * str.length + 1];
        int index = 0;
        for (int i = 0; i != res.length; i++) {
            res[i] = (i & 1) == 0 ?'#':str[index++];
        }
        return res;
    }

    public static int right(String s){
        if (s == null || s.length() == 0) {
            return 0;
        }
        char[] str=manacherString(s);
        int max=0;
        for(int i=0; i<str.length; i++){
            int L=i-1;
            int R=i+1;
            while(L>=0 && R<str.length && str[L]==str[R]){
                L--;
                R++;
            }
            max=Math.max(max,R-L-1);
        }
        return max/2;
    }

    public static String getRandomString(int possibilities, int size) {
        char[] ans = new char[(int) (Math.random() * size) + 1];
        for (int i = 0; i < ans.length; i++) {
            ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
        }
        return String.valueOf(ans);
    }

    public static void main(String[] args) {
        int possibilities = 5;
        int strSize = 20;
        int testTimes = 5000000;
        System.out.println("test begin");
        for (int i = 0; i < testTimes; i++) {
            String str = getRandomString(possibilities, strSize);
            if (manacher(str) != right(str)) {
                System.out.println("Oops!");
            }
        }
        System.out.println("test finish");
    }
}

本文出自:Manacher算法