【算法笔记】Manacher算法

Manacher算法: 求解字符串的最长回文子串的长度

1、Manacher算法

  • Manacher算法中的概念:

    • 1、回文子串:指的是一个字符串从左到右和从右到左读是完全相同的字符串,例如"aba"、"abba"等。
    • 2、回文子串的长度:指的是回文子串的字符数量,例如"aba"的长度为3,"abba"的长度为4。
    • 3、回文半径:指的是回文子串的中心到回文子串的边界的距离,奇数长度的回文半径为其回文长度的一半加1,偶数长度的回文半径为其回文长度的一半。
    • 例如"aba"的回文半径为2,"abba"的回文半径为2。
    • 4、回文半径数组:指的是每个位置的回文半径所组成的数组,例如"aba"的回文半径数组为[1,2,1],"abba"的回文半径数组为[1,2,2,1]。
    • 可以看出,奇数长度的回文子串在计算某个位置的回文时,只需要考虑其左右两边对称位置是否相同,
    • 而偶数长度的回文子串在计算某个位置的回文时,还需要考虑其自身与下个位置或者上个位置是否相同,相比较而言比奇数的更加复杂。
    • Mancher算法就是通过统一奇偶处理,将无论是奇数还是偶数长度的字符,都转为了奇数长度来处理的。
  • Manacher算法的核心思想:

    • Mancher算法的核心思想主要有两个,一个是统一奇偶处理,一个是利用对称性进行优化计算方法。
    • 下面单独分析每一个思想方法。
  • 统一奇偶处理:

    • 为了方便处理奇数长度和偶数长度的回文子串,Manacher算法通过在原始字符串的每个字符之间插入一个特殊字符(如'#'),
    • 统一将所有字符串都转换为奇数长度。例如,原始字符串"aba"转换为"#a#b#a#",原始字符串"abba"转换为"#a#b#b#a#"。
    • 这样做的好处是,无论是奇数长度还是偶数长度的回文子串,都可以通过统一的计算方法来处理,无需单独考虑。
    • 此时,计算出来的回文字符的长度就是加入字符后的回文长度/2的结果。
    • 同时,因为这样处理完以后,都是奇数的长度了,而加入的字符恰好都是在转换后的字符的奇数位置上,所以在对比的时候,都是加入的字符和加入的字符之间的对比,
    • 原来的字符和原来的字符做对比,所以即使加入的字符在原来的字符中包含,也不会影响最终的结果。
  • 利用对称性进行优化计算方法:

    • Manacher算法利用回文子串的对称性,通过记录已经计算过的回文半径,避免重复计算

    • (因为无论是统一奇偶处理后的字符串,还是原来是奇数长度的字符串都是一样的逻辑,这里在举例的时候我们就用原来是奇数长度的字符串做例子,要不然加入很多#看起来不太清楚)。

    • 具体的做法就是从左往右依次处理字符串中的每一个字符,

    • 新增一个回文半径数组pArr,用来记录每个字符的回文半径。

    • 新建一个变量R,用来记录计算中遇到的回文右边界的最右边的位置(对应整个字符串,并不是每一个字符串都有对应一个R)。

    • 新建一个变量C,用来记录对应R的中心的字符串位置(对应整个字符串,和R是对应的)。

    • 在这个时候,如果处理字符串的i位置,就会有以下几种情况:

    • 1、i在R的右边:

    • 此时,因为i在R的右边,所以i的回文半径至少是1(也就是它本身),此时i已经超过了我们记录的回文右边界R,无法进行优化,

    • 所以我们需要从i位置开始,向左右两边扩展,计算i位置的回文半径,并更新R和C的值,记录下i位置的回文右边界的最右边的位置R和i位置的中心位置C,

    • 并计算出i位置的回文半径pArr[i]。

    • 2、i在R的左边:

    • 此时i在R的左边,也就是说我们在计算i前面位置的时候,已经扩展到了R的位置,是可以进行优化的,具体要分情况来讨论。

    • 首先,此时的C记录的是对应R的中心位置,此时在C的左边必然有一个R相对于C的对称位置,我们记为L;

    • 因为i以前已经算扩展到了R的位置,所以在C的左边也必然有一个i相对于C的对称位置,我们记为i',并且,i'的回文半径我们已经计算过了,就是pArr[i'];

    • 因为i'的回文半径我们已经计算过了,所以i'的回文必然是有一个左右边界的,我们记为a和b,那么a和b是相对于i'对称的,

    • 此时对于i'的左边界a,就有3中情况,我们分开讨论:

    • 2.1、如果i'的左边界a在L的右边,也就是被L包含,此时i位置的回文半径和i'位置的回文半径是相同的,即pArr[i] = pArr[i']。

    • 因为i'的回文是被L包含的,整个L和R也是相对于C对称的,i和i'也是相对于C对称的,所以i的回文字符是和i'的回文字符是相同的。

    • 比如(字符串不包含空格,空格只是为了对应位置比较直观)

    • 加入L在最左侧位置,R已经是最右侧,L和R包含了整个字符串,那么中间位置就是t对应的位置,

    • 加入此时要计算倒数第一个d对应的字符的回文,此时对应的点i'就是从左侧起第一个d的位置,其包含在L的范围内,此时i'的回文就是cdc,对应i的回文也就是cdc。

    • 2.2、如果i'的左边界a在L的左边,也就是i'的回文超出了L的范围,此时i位置的回文半径就是i到R的距离。

    • 因为i和i'是对应于C对称的,L和R也是相对于C对称的,既然R无法再往右扩了,也就是L-1位置的字符和R+1位置的是不一样的,

    • 又因为a和b是关于i'对称的,a又小于L,即L位置在i'的回文里面,其关于i'的对称位置记为L',同时算出R关于i的对称点R',

    • 此时L'+1的位置和L-1的是相同的,R'-1的位置和L'+1也是相同的(关于C对称),而L-1的位置和R+1又是不同的,所以R'-1和R+1是不同的。

    • 即此时的i位置的回文半径就是i到R的距离。

    • 比如(字符串不包含空格,空格只是为了对应位置比较直观)

    • 2.3、如果i'的左边界a和L的边界重合,此时i位置的回文半径是不确定的,需要从i位置开始往左右扩。

    • 比如(字符串不包含空格,空格只是为了对应位置比较直观)

    • 此时X位置和Y位置的字符串可能相等,也可能不相等,都是能成立的。

  • 总结:

    • 首先,我们分各种情况来讨论只是为了优化,并不是说到了i位置,就不能用暴力方法往左右两边扩了。还是可以扩的。
    • 讨论的意义在于能优化就优化,不能优化就暴力扩展。
    • 从上面的情况1和情况2,已经2.1、2.2、2.3的几种情况综合来说,情况1和2.3是不能优化的,可以直接从i位置扩展,
    • 情况2.1和2.2是可以优化的,而且2.2是包含在2.1里面的,所以我们在写代码的时候,就可以方便的从两种情况中取到最小值,
    • 然后从i位置按照条件往右扩即可,这样有了优化,也不至于遗漏掉情况,也省去了繁杂的计算条件。
    • 当然,在实际的写代码的过程中,我们一般会让R位置是C位置对称点的下一个位置,也就是不包含R的位置的字符,这样计算C到R的距离直接用R-C即可。

3、Manacher算法的实现

java 复制代码
    /**
     * 利用Manacher算法求字符串s的最长回文子串的长度
     */
    public static int manacher(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        // 获取统一奇偶处理后的字符串数组
        char[] charArr = manacherString(s);
        // 回文半径数组
        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++) {
            // 首先取到情况2.1和2.2的最小值
            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]);
        }
        // 回文中都是奇数,所以回文长度是charArr.length/2,
        // 回文半径是charArr.length/2+1
        // max是最大的回文半径,所以回文长度是max-1
        return max - 1;
    }

    /**
     * 获取统一奇偶处理后的字符串数组
     */
    private 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;
    }

整体代码和测试如下:

java 复制代码
/**
 * Manacher算法: 求解字符串的最长回文子串的长度
 * Manacher算法中的概念:
 * 1、回文子串:指的是一个字符串从左到右和从右到左读是完全相同的字符串,例如"aba"、"abba"等。
 * 2、回文子串的长度:指的是回文子串的字符数量,例如"aba"的长度为3,"abba"的长度为4。
 * 3、回文半径:指的是回文子串的中心到回文子串的边界的距离,奇数长度的回文半径为其回文长度的一半加1,偶数长度的回文半径为其回文长度的一半。
 * 例如"aba"的回文半径为2,"abba"的回文半径为2。
 * 4、回文半径数组:指的是每个位置的回文半径所组成的数组,例如"aba"的回文半径数组为[1,2,1],"abba"的回文半径数组为[1,2,2,1]。
 * 可以看出,奇数长度的回文子串在计算某个位置的回文时,只需要考虑其左右两边对称位置是否相同,
 * 而偶数长度的回文子串在计算某个位置的回文时,还需要考虑其自身与下个位置或者上个位置是否相同,相比较而言比奇数的更加复杂。
 * Mancher算法就是通过统一奇偶处理,将无论是奇数还是偶数长度的字符,都转为了奇数长度来处理的。
 * <br>
 * Manacher算法的核心思想:
 * Mancher算法的核心思想主要有两个,一个是统一奇偶处理,一个是利用对称性进行优化计算方法。
 * 下面单独分析每一个思想方法。
 * <br>
 * 统一奇偶处理:
 * 为了方便处理奇数长度和偶数长度的回文子串,Manacher算法通过在原始字符串的每个字符之间插入一个特殊字符(如'#'),
 * 统一将所有字符串都转换为奇数长度。例如,原始字符串"aba"转换为"#a#b#a#",原始字符串"abba"转换为"#a#b#b#a#"。
 * 这样做的好处是,无论是奇数长度还是偶数长度的回文子串,都可以通过统一的计算方法来处理,无需单独考虑。
 * 此时,计算出来的回文字符的长度就是加入字符后的回文长度/2的结果。
 * 同时,因为这样处理完以后,都是奇数的长度了,而加入的字符恰好都是在转换后的字符的奇数位置上,所以在对比的时候,都是加入的字符和加入的字符之间的对比,
 * 原来的字符和原来的字符做对比,所以即使加入的字符在原来的字符中包含,也不会影响最终的结果。
 * <br>
 * 利用对称性进行优化计算方法:
 * Manacher算法利用回文子串的对称性,通过记录已经计算过的回文半径,避免重复计算
 * (因为无论是统一奇偶处理后的字符串,还是原来是奇数长度的字符串都是一样的逻辑,这里在举例的时候我们就用原来是奇数长度的字符串做例子,要不然加入很多#看起来不太清楚)。
 * 具体的做法就是从左往右依次处理字符串中的每一个字符,
 * 新增一个回文半径数组pArr,用来记录每个字符的回文半径。
 * 新建一个变量R,用来记录计算中遇到的回文右边界的最右边的位置(对应整个字符串,并不是每一个字符串都有对应一个R)。
 * 新建一个变量C,用来记录对应R的中心的字符串位置(对应整个字符串,和R是对应的)。
 * 在这个时候,如果处理字符串的i位置,就会有以下几种情况:
 * 1、i在R的右边:
 * 此时,因为i在R的右边,所以i的回文半径至少是1(也就是它本身),此时i已经超过了我们记录的回文右边界R,无法进行优化,
 * 所以我们需要从i位置开始,向左右两边扩展,计算i位置的回文半径,并更新R和C的值,记录下i位置的回文右边界的最右边的位置R和i位置的中心位置C,
 * 并计算出i位置的回文半径pArr[i]。
 * 2、i在R的左边:
 * 此时i在R的左边,也就是说我们在计算i前面位置的时候,已经扩展到了R的位置,是可以进行优化的,具体要分情况来讨论。
 * 首先,此时的C记录的是对应R的中心位置,此时在C的左边必然有一个R相对于C的对称位置,我们记为L;
 * 因为i以前已经算扩展到了R的位置,所以在C的左边也必然有一个i相对于C的对称位置,我们记为i',并且,i'的回文半径我们已经计算过了,就是pArr[i'];
 * 因为i'的回文半径我们已经计算过了,所以i'的回文必然是有一个左右边界的,我们记为a和b,那么a和b是相对于i'对称的,
 * 此时对于i'的左边界a,就有3中情况,我们分开讨论:
 * 2.1、如果i'的左边界a在L的右边,也就是被L包含,此时i位置的回文半径和i'位置的回文半径是相同的,即pArr[i] = pArr[i']。
 * 因为i'的回文是被L包含的,整个L和R也是相对于C对称的,i和i'也是相对于C对称的,所以i的回文字符是和i'的回文字符是相同的。
 * 比如(字符串不包含空格,空格只是为了对应位置比较直观)
 * 字符串:   a b c d c k s t s k c d c b a
 * 对应位置:L    a i'b     C       i       R
 * 加入L在最左侧位置,R已经是最右侧,L和R包含了整个字符串,那么中间位置就是t对应的位置,
 * 加入此时要计算倒数第一个d对应的字符的回文,此时对应的点i'就是从左侧起第一个d的位置,其包含在L的范围内,此时i'的回文就是cdc,对应i的回文也就是cdc。
 * 2.2、如果i'的左边界a在L的左边,也就是i'的回文超出了L的范围,此时i位置的回文半径就是i到R的距离。
 * 因为i和i'是对应于C对称的,L和R也是相对于C对称的,既然R无法再往右扩了,也就是L-1位置的字符和R+1位置的是不一样的,
 * 又因为a和b是关于i'对称的,a又小于L,即L位置在i'的回文里面,其关于i'的对称位置记为L',同时算出R关于i的对称点R',
 * 此时L'+1的位置和L-1的是相同的,R'-1的位置和L'+1也是相同的(关于C对称),而L-1的位置和R+1又是不同的,所以R'-1和R+1是不同的。
 * 即此时的i位置的回文半径就是i到R的距离。
 * 比如(字符串不包含空格,空格只是为了对应位置比较直观)
 * 字符串:   a b c d e d c b a t s t a b c d e d c f
 * 对应位置: a   L   i'  L'   b     C    R'  i   R
 * 2.3、如果i'的左边界a和L的边界重合,此时i位置的回文半径是不确定的,需要从i位置开始往左右扩。
 * 比如(字符串不包含空格,空格只是为了对应位置比较直观)
 * 字符串:   X   a   b c b a s t s a b c b a Y
 * 对应位置:    L(a)   i'      C       i   R
 * 此时X位置和Y位置的字符串可能相等,也可能不相等,都是能成立的。
 * 总结:
 * 首先,我们分各种情况来讨论只是为了优化,并不是说到了i位置,就不能用暴力方法往左右两边扩了。还是可以扩的。
 * 讨论的意义在于能优化就优化,不能优化就暴力扩展。
 * 从上面的情况1和情况2,已经2.1、2.2、2.3的几种情况综合来说,情况1和2.3是不能优化的,可以直接从i位置扩展,
 * 情况2.1和2.2是可以优化的,而且2.2是包含在2.1里面的,所以我们在写代码的时候,就可以方便的从两种情况中取到最小值,
 * 然后从i位置按照条件往右扩即可,这样有了优化,也不至于遗漏掉情况,也省去了繁杂的计算条件。
 * 当然,在实际的写代码的过程中,我们一般会让R位置是C位置对称点的下一个位置,也就是不包含R的位置的字符,这样计算C到R的距离直接用R-C即可。
 */
public class Manacher {

    /**
     * 利用Manacher算法求字符串s的最长回文子串的长度
     */
    public static int manacher(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        // 获取统一奇偶处理后的字符串数组
        char[] charArr = manacherString(s);
        // 回文半径数组
        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++) {
            // 首先取到情况2.1和2.2的最小值
            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]);
        }
        // 回文中都是奇数,所以回文长度是charArr.length/2,
        // 回文半径是charArr.length/2+1
        // max是最大的回文半径,所以回文长度是max-1
        return max - 1;
    }

    /**
     * 获取统一奇偶处理后的字符串数组
     */
    private 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;
    }

    // for test
    public static int comparator(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 void main(String[] args) {
        int possibilities = 5;
        int strSize = 50;
        int testTimes = 5000000;
        System.out.println("test begin");
        for (int i = 0; i < testTimes; i++) {
            String str = getRandomString(possibilities, strSize);
            int manacher = manacher(str);
            int comparator = comparator(str);
            if (manacher != comparator) {
                System.out.println("测试失败!");
                System.out.printf("字符串:%s,manacher:%d,comparator:%d\n", str, manacher, comparator);
                break;
            }
        }
        System.out.println("test finish");
    }

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


}

3、题目:添加字符后成回文

  • 题目:添加字符后成回文
  • 给定一个字符串str,只能在str的后面添加字符,想让str整体变成回文串,返回至少要添加几个字符
  • 思路:
    • 在字符串str后面添加字符,让str整体变成回文串,就是从左到右找每个字符的回文串,找到一个字符包含了最右侧的字符,然后将其补全即可。
    • 结合Mancher算法,就是找到最右侧的R包含str最后一个字符的C,然后将0到L的字符逆序补全到str后面即可。

整体代码如下:

java 复制代码
/**
 * 题目:添加字符后成回文
 * 给定一个字符串str,只能在str的后面添加字符,想让str整体变成回文串,返回至少要添加几个字符
 */
public class Q1_AddShortestEnd {

    /**
     * 思路:
     * 在字符串str后面添加字符,让str整体变成回文串,就是从左到右找每个字符的回文串,找到一个字符包含了最右侧的字符,然后将其补全即可。
     * 结合Mancher算法,就是找到最右侧的R包含str最后一个字符的C,然后将0到L的字符逆序补全到str后面即可。
     */
    public static String shortestEnd(String str) {
        if (str == null || str.length() == 0) {
            return null;
        }
        // 获取统一奇偶处理后的字符串数组
        char[] charArr = manacherString(str);
        // 回文半径数组
        int[] pArr = new int[charArr.length];
        // 中心位置
        int C = -1;
        // 回文右边界的下一个位置,回文不包含R位置
        int R = -1;
        // 最大包含最右侧字符的回文半径
        int maxContainsEnd = -1;
        for (int i = 0; i < charArr.length; i++) {
            // 获取到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;
            }
            // R到了最右侧,更新最大的回文半径
            if (R == charArr.length) {
                // 最大的回文半径就是当前i位置的回文半径
                maxContainsEnd = pArr[i];
                // 找到后不再寻找,直接退出
                break;
            }
        }
        // 已经找到了经过处理后的回文半径,此时str中回文串的长度就是maxContainsEnd-1,
        // 所以此时要添加的长度就是str.length()-(maxContainsEnd-1)= str.length() - maxContainsEnd + 1
        // 只需要将0到L-1的字符逆序补全到str后面即可,从charArr中取,就是取偶数位置的字符,因为奇数位置都是填充的#
        char[] res = new char[str.length() - maxContainsEnd + 1];
        for (int i = 0; i < res.length; i++) {
            // res从后往前放,所以charArr从前往后取
            res[res.length - 1 - i] = charArr[i * 2 + 1];
        }
        return String.valueOf(res);
    }

    /**
     * 获取统一奇偶处理后的字符串数组
     */
    private 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 void main(String[] args) {
        String str1 = "abcd123321";
        System.out.println(shortestEnd(str1));
    }

}

后记

个人学习总结笔记,不能保证非常详细,轻喷

相关推荐
梦未2 小时前
Spring控制反转与依赖注入
java·后端·spring
喜欢流萤吖~2 小时前
Lambda 表达式
java
ZouZou老师2 小时前
C++设计模式之适配器模式:以家具生产为例
java·设计模式·适配器模式
瑶光守护者3 小时前
【学习笔记】5G RedCap:智能回落5G NR驻留的接入策略
笔记·学习·5g
你想知道什么?3 小时前
Python基础篇(上) 学习笔记
笔记·python·学习
monster000w3 小时前
大模型微调过程
人工智能·深度学习·算法·计算机视觉·信息与通信
曼巴UE53 小时前
UE5 C++ 动态多播
java·开发语言
小小晓.3 小时前
Pinely Round 4 (Div. 1 + Div. 2)
c++·算法
SHOJYS3 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法