Manacher算法

Manacher算法:最长回文问题

1. 前言

最长回文子串问题[力扣第五题]:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SR5ksoji-1658679828082)(https://gitee.com/wonderson/picgo/raw/master/img/image-20220724222153553.png)]

常见的做法有:

  1. 中心扩散
    1. 从每个位置逐一向两边扩散,直到到达字符串边界或者不匹配的位置,就能找到当前位置为中心的最大回文范围,对每个位置逐一尝试就能找到最大回文子串
    2. 最差情况下,每个位置都需要扩散到边界[字符全部相同],时间复杂度 O ( n 2 ) O(n^2) O(n2);
  2. 动态规划
    1. f[i][j]表示s[i:j+1]是否是回文字符串, f[i - 1][j + 1]就可以通过f[i][j] && cs[i - 1] == cs[j + 1]得到;
    2. i的范围是[0, n], j的范围是[i, n],动态规划需要遍历每个状态,时间复杂度为 O ( n 2 ) O(n^2) O(n2)

中心扩散法的优化【manacher的前置知识】:

中心扩散中,传统做法需要考虑到奇扩散和偶扩散,每个位置的最大回文子串实际上是奇扩散和偶扩散的较大者。
为了避免复杂的扩散逻辑,通过添加虚字符的方式,将扩散过程统一成奇扩散,具体做法是:

  1. 设计一个 2 × n + 1 2\times n + 1 2×n+1的字符串str,表示原来的字符串s每个字符左右两边加上一个#的新字符串[会重叠];
  2. 将#本身也视为普通字符,参与奇中心扩散,包括最大回文子串的计数;
  3. s的最大回文子串长度m为str的最大回文字符串长度n的一半,即 m = n ÷ 2 m=n\div2 m=n÷2; 同理,s的最大回文字符串的起始位置startS也是str的起始位置startStr的一半: s t a r t S = s t a r t S t r ÷ 2 startS = startStr \div 2 startS=startStr÷2
    1. 证明:由于str是s每个字符穿插两个#得到的,可以确定s的子串长度len映射到str的对应字符串长度为len * 2 + 1,而str的每个位置下标i也会因为i左边字符【包括i】都多了一个#导致下标增加了一倍;
    2. 推论:穿插的字符#可以是任意字符【包含出现的】,并不影响最终结果【因为都需要除以2】。

中心扩散法优化版本代码:

//LPS问题朴素解法:中心扩散法
public int primeLPS(String s) {
    char[] cs = getNewStr(s);
    int n = cs.length;
    int res = 0;
    int start = 0;
    for (int k = 0; k < n; k++) {
        int i = k - 1, j = k + 1;
        while (i >= 0 && j < n) {
            if (cs[i] == cs[j]) {
                i--;
                j++;
            } else {
                break;
            }
        }
        int cur = j - i - 1; //i, j此时在回文串的外界
        if (cur > res) {
            res = cur;
            start = i + 1;
        }
    }
    return res / 2;
    //如果要返回回文串本身:
    // return s.substring(start / 2, start / 2 + res / 2);
}

//用于特殊处理字符的方法
private char[] getNewStr(String s) {
    char[] ss = s.toCharArray();
    int n = ss.length;
    char[] cs = new char[n * 2 + 1];
    cs[0] = '#';

    for (int i = 1; i <= 2 * n; i += 2) {
        cs[i] = ss[i / 2];
        cs[i + 1] = '#';
    }

    return cs;
}

2. 算法及证明

变量定义

  1. 最长回文半径数组[rad]: r a d [ i ] rad[i] rad[i]表示i位置的最长回文字符串的半径,半径需要包含i位置这个字符自己;
  2. 当前遍历到的最右回文右边界[R]:每个位置i都有回文半径 r a d [ i ] rad[i] rad[i] i + r a d [ i ] − 1 i+rad[i]-1 i+rad[i]1就是它的回文右边界;而当前遍历到所有位置中, i + r a d [ i ] − 1 i+rad[i]-1 i+rad[i]1取值最大的就是R;
    1. 实际编码中,为了编码方便,会将R定义为最右回文右边界的右边一个位置,即 i + r a d [ i ] i+rad[i] i+rad[i],这样做可以让好几个变量的计算变得更加直观方便,等会看代码就能明白。
  3. R取值时该回文子串的回文中心[C]:与R配套使用,R更新C必然更新。
分支流程

表示约定:

  1. i:当前遍历到的位置;
  2. i’:i相对于C的对称点;

模型图示:

在这里插入图片描述

  1. 根据i是否在R的左边【为了方便理解,这里的R取得是原始定义,即回文右边界自身,并不是右边一个位置】,即 i ≤ R i\le R iR是否成立来划分两大种情况:

    1. i在R右边,即 i > R i > R i>R:

      1. 利用中心扩散法来暴力计算最大回文子串的半径长度 r a d [ i ] rad[i] rad[i]

      2. 证明:

        i右边是之前未曾探测过的,因此无法确定和左边相等与否,只能进行扩散探测。

    2. i在R左边,即 i ≤ R i \le R iR, 这个分支根据 i + r a d [ i ′ ] i+rad[i'] i+rad[i]的值分为三种情况

      1. i + r a d [ i ′ ] < R i+rad[i']<R i+rad[i]<R

        在这里插入图片描述

        1. 结论: r a d [ i ] = r a d [ i − 1 ] rad[i] = rad[i - 1] rad[i]=rad[i1]

        2. 证明:

          如图,假设i’确定的回文子串左右两边外侧的字符分别是x,y,同理i的是p,q;由于x,y,p,q全部在C确定的回文子串的范围内,因此 x = q , y = p x = q, y = p x=q,y=p, 由于i’的回文子串不包含x,y,因此 x ≠ y x \neq y x=y, 故 p ≠ q p \neq q p=q

      2. i + r a d [ i ′ ] > R i+rad[i'] > R i+rad[i]>R

        在这里插入图片描述

        1. 结论: r a d [ i ] = R − i + 1 rad[i]=R-i+1 rad[i]=Ri+1 【注:R改变定义后,这个表达式简化为 r a d [ i ] = R − i rad[i]=R-i rad[i]=Ri

        2. 证明:
          将C确定的回文子串的两边外侧字符定义为a,b;同时,a相当于i’的对称点为p,b相对于i的对称点为q

          已知: a ≠ b , a = p , p = q a \neq b, a = p, p = q a=b,a=p,p=q
          推出: q ≠ b q \neq b q=b, 因此:i确定的最大回文子串应当在q,b的中间

      3. i + r a d [ i ′ ] = R i + rad[i'] = R i+rad[i]=R

        在这里插入图片描述

        1. 结论:rad[i]大小至少为R-i+1, 且长度可以根据从q,b开始进行中心扩散来进行扩张。

        2. 证明:

          类似情况2,确定紫色范围肯定是最大子串的范围,但是只能知道: a ≠ p , p = q , a ! = b a \neq p, p = q, a != b a=p,p=q,a!=b, 我们无法知道 q , b q, b q,b的相等情况,所以需要中心扩散【不等关系不具有传递性】

3. 代码实现

处理字符串的代码看前面的实现,不再重复

public String longestPalindrome(String s) {
    char[] cs = getNewStr(s);
    int n = cs.length;

    //三个重要变量:
    //1)回文半径数组[回文半径:包含i自己在内的回文串半径]
    int[] rad = new int[n]; 
    //2)最右回文边界“的右边一个位置”
    int R = -1; 
    //3)得到最右回文右边界时,该回文子串的中心
    int C = -1;
    int max = 0, start = 0;

    for (int i = 0; i < n; i++) {
        // 当前中心i至少不必验证回文的长度
        /**
             * R > i: R为最右回文右边界的右边一个位置,R>i就说明i在最右回文右边界内;
             * R - i: i到最右回文右边界的距离【包含i自己】
             * 2 * C - i:i相对于C的对称点
             */
        rad[i] = R > i ? Math.min(R - i, rad[2 * C - i]) : 1;
        while (i - rad[i] >= 0 && i + rad[i] < n) {
            if (cs[i - rad[i]] == cs[i + rad[i]]) { //朴素法中心扩散
                rad[i]++;
            } else {
                break;
            }
        }
        if (rad[i] > max) {
            max = rad[i];
            start = i - rad[i] + 1; // i - rad[i]实际在左边界左边一个位置
        }
        if (i + rad[i] > R) {
            R = i + rad[i];
            C = i;
        }
    }

    return s.substring(start / 2, start / 2 + max - 1); //最长“有效”回文子串的“直径”等于处理最长回文子串的“半径”-1
}

难点解析:

  1. i ′ = 2 ∗ C − i i' = 2 * C - i i=2Ci
    1. d = i − C d = i - C d=iC表示i到C的距离,故 i ′ = C − d = C − ( i − C ) = 2 ∗ C − i i' = C - d = C - (i - C) = 2 * C - i i=Cd=C(iC)=2Ci
  2. rad[i] = R > i ? Math.min(R - i, rad[2 * C - i]) : 1;
    1. 一句话说明了上面四种情况:
      1. R <= i:i在右边界外面【再次强调,此时的R是改变定义的R】,故需要进行中心扩散,但是单个字符算是回文串,所以定义已知的回文半径长度为1;
      2. R>i: i在右边界内:
        1. 若符合小情况1:取值rad[2 * C - i], 此时这个值肯定小于 R − i R-i Ri;
        2. 若符合小情况2:取值 R − i R-i Ri;
        3. 若符合小情况3,:取值 R − i R-i Ri或者 r a d [ 2 ∗ C − i ] rad[2 * C - i] rad[2Ci],是等价的;
  3. i f ( c s [ i − r a d [ i ] ] = = c s [ i + r a d [ i ] ] ) if (cs[i - rad[i]] == cs[i + rad[i]]) if(cs[irad[i]]==cs[i+rad[i]])
    1. 这里需要特别注意一下下标:i-rad[i]实际上是i位置的回文子串左外侧的位置,同理i+rad[i]就是右外侧;
    2. 同理, s t a r t = i − r a d [ i ] + 1 ; start = i - rad[i] + 1; start=irad[i]+1;就很好理解了,由于结束循环时,i-rad[i]实是i位置的回文子串左外侧的位置,因此回文串起始位置要加一;
  4. s最大有效回文子串长度为max-1【最大str回文子串半径-1】
    1. n位的有效回文子串经过特殊处理后,长度为 2 ∗ n + 1 2*n+1 2n+1, 而回文半径r定义为包含回文中心在内的半径字符数量,故 r ∗ 2 = ( 2 ∗ n + 1 ) + 1 = > n = r − 1 r*2 = (2 * n + 1) + 1 => n = r - 1 r2=(2n+1)+1=>n=r1
  5. s . s u b s t r i n g ( s t a r t / 2 , s t a r t / 2 + m a x − 1 ) ; s.substring(start / 2, start / 2 + max - 1); s.substring(start/2,start/2+max1);
    1. 这个前言部分解释过,start左边多了一样长度的#, 回文串中多了n+1个#
    2. 故起始下标需要去掉一半长度;回文串长度len由于一定是奇数,根据整除的性质会将多余的1去掉,向下取整,即len/2 = (len - 1) / 2

4. 总结

Manacher并不只是用于求最大回文串,最精华的部分在于经过这个算法处理出来的rad数组;

可以将rad数组每个元素-1,就是每个位置的回文串长度,这对于解决某些难题非常有用。

具体的,可以在力扣搜索回文关键词,有一些题目就可以利用这个数组做极大的复杂度优化。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值