大白话 Manacher算法

Manacher 算法

Manacher 算法简介

Manacher 算法用于解决字符串中最长回文子串最大长度问题。

解释一下:

  • 回文:这个很好理解了,就是字符串正序和逆序是相同的

    • 字符串长度为奇数时,该回文字符串一定会有个中心对称轴,例如 “abcba” 关于中间的 ‘c’ 左右对称

    • 字符串长度为偶数时,只有一个虚拟的对称轴,例如"abba",只需要左右相同即可。

  • 子串:字符串中连续的字符序列

    • 一定注意了,必须得是连续的,例如对于字符串 “abcde”,“abc”、“bcd”…都是它的子串,但是类似"abd"、"ace"这种就不是它的子串了。

例如:

str=“123”,其中的最长回文子串为"1"、“2"或者"3”,所以返回 1。

str=“abbac”,其中的最长回文子串为"aabb",所以返回 4。

str=“abc1234321ab”,其中的最长回文子串为"1234321",所以返回 7。

整体思路

从左到右遍历字符串,遍历到每个字符的时候,都看看以这个字符作为中心能够产生多大的回文字符串。比如 str=“abacaba”,以 str[0]=='a’为中心的回文字符串最大长度为 1,以 str[1]==‘b’ 为中心的回文字符串最大长度为 3,……其中最大的回文子串是以 str[3]=='c’为中心的时候。

字符串预处理

上面的处理方式中会有一个问题,当回文字符串长度为偶数时,由于没有实际存在的中心轴,就没有办法通过上面方式求解了。

我们可以通过填充值的方式来避免出现偶数的回文串,可以对str 进行处理,把每个字符开头、结尾和中间插入一个特殊字符’#'来得到一个新的字符串数组。比如 str=“bcbaa”,处理后为"#b#c#b#a#a#",然后从每个字符左右扩出去的方式找最大回文子串就方便多了。

  • 对奇回文来说,不这么处理也能通过扩的方式找到,比如"bcb",从’c’开始向左右两侧扩出去能找到最大回文。处理后为"#b#c#b#",从’c’开始向左右两侧扩出去依然能找到最大回文。

  • 对偶回文来说,不处理而直接通过扩的方式是找不到的,比如"aa",因为没有确定的轴,但是处理后为"#a#a#",就可以通过从中间的’#'扩出去的方式找到最大回文。

代码如下

    private static char[] manacherString(String s) {
        char[] chars = s.toCharArray();
        int n = (chars.length << 1) + 1;
        char[] ans = new char[n];
        int index = 0;
        for (int i = 0; i < n; i++) {
            ans[i] = (i & 1) == 0 ? '#' : chars[index++];
        }
        return ans;
    }

几个概念

假设 str 处理之后的字符串记为 charArr。对每个字符(包括特殊字符)都进行“优化后”的扩充过程。

为了后面能更加方便地介绍 Manacher 算法,这里先介绍几个概念。

回文半径

由于charArr中回文串长度都是奇数,那么它一定会有一个中心对称轴,回文半径就是该中心对称轴到两端的距离(包括端点)。

例如,对于"#a#b#c#b#a#",它中心为 ‘c’,中心点+左边是 “#a#b#c”,中心点+右边是 “c#b#a#”,它们的长度都是6,因此回文半径长度是6。可以看到,如果还原为原始字符串,那么原始字符串的长度为5,因此原始回文字符串长度=回文半径-1

再比如,对于"#a#b#b#a#",它中心为正中间的 ‘#’,中心点+左边是 “#a#b#”,中心点+右边是 “#b#a#”,它们的长度都是5,因此回文半径长度是5。可以看到,如果还原为原始字符串,那么原始字符串的长度为4,因此也有原始回文字符串长度=回文半径-1

所以,无论原始回文字符串长度为奇数还是偶数,它的长度都是扩展后回文半径-1

回文半径数组pArr

长度与 charArr 长度一样。

pArr[i]的意义是以 i 位置上的字符(charArr[i])作为回文中心的情况下,扩出去得到的最大回文半径是多少。

举例说明,对"#c#a#b#a#c#"来说,pArr[0…9]为[1,2,1,2,1,6,1,2,1,2,1]。整个过程就是在从左到右遍历的过程中,依次计算每个位置的最大回文半径值。

最右扩展位置pR及对应中心c

这个变量的意义是之前遍历的所有字符的所有回文半径中,最右即将到达的位置。

还是以 “#c#a#b#a#c#” 为例来说,还没遍历之前,pR 初始设置为-1。

  • charArr[0]==‘#’:回文半径为 1,所以目前回文半径向右只能扩到位置 0,回文半径最右即将到达的位置变为 1(pR=1),此时对应的中心位置更新,也即是 c == 0。

  • charArr[1]=='c’的回文半径为 2,此时所有的回文半径向右能扩到位置 2,所以回文半径最右即将到达的位置变为 3(pR=3),此时对应的中心位置更新,也即是 c == 1。

  • charArr[2]=='#'的回文半径为 1,所以位置 2 向右只能扩到位置 2,回文半径最右即将到达的位置不变,仍是 3(pR=3),c不变。

  • charArr[3]=='a’的回文半径为 2,所以位置 3 向右能扩到位置 4,回文半径最右即将到达的位置变为 5(pR=5),此时对应的中心位置更新,也即是 c == 3。

  • charArr[4]=='#'的回文半径为 1,所以位置 4 向右只能扩到位置 4,回文半径最右即将到达的位置不变,仍是 5(pR=5),c不变。

  • charArr[5]=='b’的回文半径为 6,所以位置 4 向右能扩到位置 10,回文半径最右即将到达的位置变为 11(pR=11),此时对应的中心位置更新,也即是 c == 5。此时已经到达整个字符数组的结尾,所以之后的过程中pR 和c也都将不再变化。

换句话说,pR 就是遍历过的所有字符中向右扩出来的最大右边界。只要右边界更往右,pR 就更新。

image

算法流程

主流程

只要能够从左到右依次算出数组 pArr 每个位置的值,最大的那个值实际上就是处理后的 charArr 中最大的回文半径,根据最大的回文半径,再对应回原字符串,整个问题就解决了。

假设现在计算到位置 index 的字符 charArr[i],在 i 之前位置的计算过程中,都会不断地更新pR 和 c 的值,即位置 index 之前的 c 这个回文中心扩出了一个目前最右的回文边界 pR。

再次强调一下,pR是c点所在回文区域最右侧+1的位置

走到index会有以下几种情况:

情况1index < pR,此时,之前的回文串已经罩不住index了

image

情况2index >= pR,此时,之前的回文串还能罩住index

此时 i 关于 c 的对称点为 i’,此时又可以分为以下三种情况

image

情况2-1:i’ 的回文半径覆盖区域(下图绿色区域)还仍在c覆盖区域内部

image

情况2-2:i’ 的回文半径覆盖区域(下图绿色区域)已经越过了 c 点覆盖的区域

image

情况2-3:i’ 的回文半径覆盖区域(下图绿色区域)刚好到达c点覆盖区域的左侧

image

具体分析

情况1:之前的回文串已经罩不住index

此时,没有优化空间,我们从 index 位置开始,向左右两侧扩出去检查,此时的“扩”过程没有获得加速。

情况2-1:之前的回文串能罩住index,i’ 回文范围未到达c回文范围左侧

我们假设i’ 回文半径覆盖范围,记做A,A左边为a位置,右边为b位置

那么一定可以在 i 周围画一个和 i’ 回文半径覆盖相同半径的区域,记做X,假设X区域左边是x位置,右边是y位置。

那么由于原始字符串关于c在 left ~ pr-1 范围内都是对称的,则 A 区域与 X 区域构成的字符串必然是逆序的,又因为 A 区域关于 i’ 是对称的,那么 X 区域一定是关于 i 对称的。则pArr[i] 至少与 pArr[i’] 是相等的。

那么 pArr[i] 能不能 > pArr[i’] 呢?答案是不能的。

我们假设 pArr[i] > pArr[i’],则必然有 x 位置的值 == y位置的值

由于对称性,a位置的值 == y 位置的值,b位置的值 == x 位置的值

那么可以得到 a位置的值 == b 位置的值,阔到了i’ 回文覆盖的范围外面了,明显矛盾了。

image

也就是说,这种情况下,pArr[i] = pArr[i’],且 pR以及c 都不变

情况2-2:之前的回文串能罩住index,i’ 回文范围越过了c回文范围左侧

我们假设c覆盖的回文的最左侧为 left 位置,left位置左边为a位置(a=left-1),a关于i’的对称位置为b。

再假设pr指向的位置为y位置,y关于i对称的位置为x。

现在要证明 pArr[i] == pr - index。

首先,由于i’的范围已经越过 left 了,在 left ~ pr-1范围内 i 与 i’ 又是对称的,那么i点的回文半径必定是可以取到 pr - index 的。

现在来证明 pArr[i] 不可能 > pr - index。

还是采用反证法,如果可i的回文半径可以 > pr - index,那么y位置的值 == x位置的值

由于 c 的回文范围为 left~pr-1(也就是y-1),那么a位置的值必然 != y位置的值

而由于对称性 a位置的值 == b位置的值 == x位置的值,于是得到:

x位置的值 != y位置的值,这与前面的假设是矛盾的,因此 pArr[i] = pr - index 成立。

image

这种情况下,pR与c也不用进行更新。

情况2-3:之前的回文串能罩住index,i’ 回文范围正好到达c回文范围左边界

这种情况下,无法直接获得答案,需要对 i 的回文范围进行扩充

由于对称性,我们知道 pArr[i] >= pr - index,于是可以将半径从 pr - index + 1 开始逐一比较 i 的左右字符是否相等,直到取到最大的回文半径。

image

代码实现

逐行翻译上面的实现思路,代码如下

    public static int manacher(String s) {
        if (s == null || s.isEmpty()) {
            return 0;
        }
        char[] str = manacherString(s);
        int n = str.length;
        // 之前遍历的所有字符的所有回文半径中,最右即将到达的位置的下一个位置
        // 一开始 pR 还没有扩充,指向-1
        int pR = -1;
        // 当前 pR 对应的中心回文子串的中心位置
        int c = -1;
        // 记录每个位置的回文半径
        int[] pArr = new int[n];
        // 记录回文半径的最大值
        int max = 0;
        // 临时变量
        int r;
        for (int index = 0; index < n; index++) {
            if (index < pR) { // 当前最右点可以包住 index
                // 3  4  5  6  7  8  9  10  11  12
                //       i'    c     i          pR
    
                // i' 正好到达 L 时,半径应当为 pR - i
                int rRange = pR - index;
    
                // i 关于 c 的对称点
                int iLeft = 2 * c - index;
                if (pArr[iLeft] < rRange) { // i'的半径 在 [L..R] 内部,不用扩
                    pArr[index] = pArr[iLeft];
                    // 半径 小于 c 半径,max不用更新
                } else if (pArr[iLeft] > rRange) { // i'的半径 在 [L..R] 外部,不用扩
                    pArr[index] = rRange;
                    // 半径 小于 i‘ 半径,max不用更新
                } else {
                    // 接着扩,半径至少为 rangeR 了
                    r = rRange;
                    while (index - r > -1 && index + r < n && str[index - r] == str[index + r]) {
                        r++;
                    }
                    pArr[index] = r;
                    max = Math.max(max, r);
                }
            } else { // 当前最右点可以包不住 index
                // 3  4  5  6  7  8  9  10  11  12  13  14  15  16
                //                              pR      i
    
                // 一次也匹配不上时的半径也应当为1
                r = 1;
                while (index - r > -1 && index + r < n && str[index - r] == str[index + r]) {
                    r++;
                }
                pArr[index] = r;
                max = Math.max(max, r);
            }
            // 更新 pR
            if (index + pArr[index] > pR) {
                pR = index + pArr[index];
                c = index;
            }
        }
        return max - 1;
    }

当然,上面这段代码在结构上还能略做优化

我们可以将这四种情况都抽象为下面过程:

  • 初始化一个 回文半径

  • 迭代获取最大回文半径(如果有必要)

  • 更新 pArr

  • 更新 pR 以及 c(如果有必要)

    public static int manacher(String s) {
        if (s == null || s.isEmpty()) {
            return 0;
        }
        char[] str = manacherString(s);
        int n = str.length;
        int pR = -1;
        int c = -1;
        int[] pArr = new int[n];
        int max = 0;
        int r = 0;
        for (int index = 0; index < n; index++) {
            // 情况1: r = 1
            // 情况2-1: r = pArr[2 * c - index]
            // 情况2-2: 情况2-3: r = pR - index
            r = index < pR ? Math.min(pR - index, pArr[2 * c - index]) : 1;
            // 情况1、情况2-3才会走进这个循环,然后扩充r
            // 情况2-3以及情况2-2直接略过
            while (index + r < n && index - r > -1 && str[index - r] == str[index + r]) {
                r++;
            }
            // 填充 pArr
            pArr[index] = r;
            max = Math.max(max, r);
            // 如有必要(情况1、情况2-3),更新 pR 以及 c
            if (index + pArr[index] > pR) {
                pR = index + pArr[index];
                c = index;
            }
        }
        return max - 1;
    }

时间复杂度

分析迭代过程,对于情况 2-1 以及 情况 2-2,都只需要有限步都能获取答案,由于 index 取值从 0~n 单增,那么走到这两个分支的情况下,最多只需要 O(n) 的复杂度。

再来看 情况1 以及 情况2-2 两个分支,这两个步骤中迭代次数都是将 qR 从前一个更新到 当前步的次数,而 qR 的取值范围是 -1 ~ n,因此时间复杂度也为O(n)。

由于这几个分支只会走其中一个,因此整体的时间复杂度是 O(n)。

完整代码及对数器

    public class Manacher {
    
        public static void main(String[] args) {
            // comp();
            System.out.println(longestPalindrome("cbbd"));
        }
    
        public static int manacher(String s) {
            if (s == null || s.isEmpty()) {
                return 0;
            }
            char[] str = manacherString(s);
            int n = str.length;
            // 之前遍历的所有字符的所有回文半径中,最右即将到达的位置的下一个位置
            // 一开始 pR 还没有扩充,指向-1
            int pR = -1;
            // 当前 pR 对应的中心回文子串的中心位置
            int c = -1;
            // 记录每个位置的回文半径
            int[] pArr = new int[n];
            // 记录回文半径的最大值
            int max = 0;
            // 临时变量
            int r = 0;
            for (int index = 0; index < n; index++) {
                r = index < pR ? Math.min(pR - index, pArr[2 * c - index]) : 1;
                while (index + r < n && index - r > -1 && str[index - r] == str[index + r]) {
                    r++;
                }
                pArr[index] = r;
                max = Math.max(max, r);
                if (index + pArr[index] > pR) {
                    pR = index + pArr[index];
                    c = index;
                }
            }
            return max - 1;
        }
    
        public static int manacher2(String s) {
            if (s == null || s.isEmpty()) {
                return 0;
            }
            char[] str = manacherString(s);
            int n = str.length;
            // 之前遍历的所有字符的所有回文半径中,最右即将到达的位置的下一个位置
            // 一开始 pR 还没有扩充,指向-1
            int pR = -1;
            // 当前 pR 对应的中心回文子串的中心位置
            int c = -1;
            // 记录每个位置的回文半径
            int[] pArr = new int[n];
            // 记录回文半径的最大值
            int max = 0;
            // 临时变量
            int r;
            for (int index = 0; index < n; index++) {
                if (index < pR) { // 当前最右点可以包住 index
                    // 3  4  5  6  7  8  9  10  11  12
                    //       i'    c     i          pR
    
                    // i' 正好到达 L 时,半径应当为 pR - i
                    int rRange = pR - index;
    
                    // i 关于 c 的对称点
                    int iLeft = 2 * c - index;
                    if (pArr[iLeft] < rRange) { // i'的半径 在 [L..R] 内部,不用扩
                        pArr[index] = pArr[iLeft];
                        // 半径 小于 c 半径,max不用更新
                    } else if (pArr[iLeft] > rRange) { // i'的半径 在 [L..R] 外部,不用扩
                        pArr[index] = rRange;
                        // 半径 小于 i‘ 半径,max不用更新
                    } else {
                        // 接着扩,半径至少为 rangeR 了
                        r = rRange;
                        while (index - r > -1 && index + r < n && str[index - r] == str[index + r]) {
                            r++;
                        }
                        pArr[index] = r;
                        max = Math.max(max, r);
                    }
                } else { // 当前最右点可以包不住 index
                    // 3  4  5  6  7  8  9  10  11  12  13  14  15  16
                    //                              pR      i
    
                    // 一次也匹配不上时的半径也应当为1
                    r = 1;
                    while (index - r > -1 && index + r < n && str[index - r] == str[index + r]) {
                        r++;
                    }
                    pArr[index] = r;
                    max = Math.max(max, r);
                }
                // 更新 pR
                if (index + pArr[index] > pR) {
                    pR = index + pArr[index];
                    c = index;
                }
            }
            return max - 1;
        }
    
        /**
         * 扩展字符串,abba => #a#b#b#a#
         * <p>主要是可以将偶数个数的回文串转换为奇数个中心对称的回文串,方便统一处理
         */
        private static char[] manacherString(String s) {
            char[] chars = s.toCharArray();
            int n = (chars.length << 1) + 1;
            char[] ans = new char[n];
            int index = 0;
            for (int i = 0; i < n; i++) {
                ans[i] = (i & 1) == 0 ? '#' : chars[index++];
            }
            return ans;
        }
    
        /**
         * for test:暴力解,求解每一个位置的回文字符串的长度,时间复杂度O(n^2)
         */
        public static int right(String s) {
            if (s == null || s.isEmpty()) {
                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;
        }
    
        /**
         * for test
         */
        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);
        }
    
        private static void comp() {
            int possibilities = 25;
            int strSize = 20;
            int testTimes = 5000000;
            System.out.println("test begin");
            for (int i = 0; i < testTimes; i++) {
                String str = getRandomString(possibilities, strSize);
                int ans1 = manacher(str);
                int ans2 = manacher2(str);
                int ans3 = right(str);
                if (ans1 != ans2 || ans1 != ans3) {
                    System.out.println(str);
                    System.out.println(ans1);
                    System.out.println(ans2);
                    throw new RuntimeException("Oops!");
                }
            }
            System.out.println("test finish");
        }
    
    }

参考资料

https://www.bilibili.com/video/BV1YH4y1h7MU/

https://leetcode.cn/problems/longest-palindromic-substring/solutions/255195/zui-chang-hui-wen-zi-chuan-by-leetcode-solution/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值