Manacher算法:最长回文问题
1. 前言
最长回文子串问题[力扣第五题]:
常见的做法有:
- 中心扩散
- 从每个位置逐一向两边扩散,直到到达字符串边界或者不匹配的位置,就能找到当前位置为中心的最大回文范围,对每个位置逐一尝试就能找到最大回文子串
- 最差情况下,每个位置都需要扩散到边界[字符全部相同],时间复杂度 O ( n 2 ) O(n^2) O(n2);
- 动态规划
f[i][j]
表示s[i:j+1]
的是否是回文字符串
,f[i - 1][j + 1]
就可以通过f[i][j] && cs[i - 1] == cs[j + 1]
得到;- i的范围是[0, n], j的范围是[i, n],动态规划需要遍历每个状态,时间复杂度为 O ( n 2 ) O(n^2) O(n2)
中心扩散法的优化【manacher的前置知识】:
中心扩散中,传统做法需要考虑到奇扩散和偶扩散,每个位置的最大回文子串实际上是奇扩散和偶扩散的较大者。
为了避免复杂的扩散逻辑,通过添加虚字符
的方式,将扩散过程统一成奇扩散,具体做法是:
- 设计一个 2 × n + 1 2\times n + 1 2×n+1的字符串str,表示原来的字符串s每个字符左右两边加上一个
#
的新字符串[会重叠];- 将#本身也视为普通字符,参与奇中心扩散,包括最大回文子串的计数;
- 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
- 证明:由于str是s每个字符穿插两个
#
得到的,可以确定s的子串长度len映射到str的对应字符串长度为len * 2 + 1
,而str的每个位置下标i也会因为i左边字符【包括i】都多了一个#
导致下标增加了一倍;- 推论:穿插的字符
#
可以是任意字符【包含出现的】,并不影响最终结果【因为都需要除以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. 算法及证明
变量定义
- 最长回文半径数组[rad]: r a d [ i ] rad[i] rad[i]表示i位置的最长回文字符串的半径,半径需要包含i位置这个字符自己;
- 当前遍历到的最右回文右边界[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;
- 实际编码中,为了编码方便,会将R定义为最右回文右边界的右边一个位置,即 i + r a d [ i ] i+rad[i] i+rad[i],这样做可以让好几个变量的计算变得更加直观方便,等会看代码就能明白。
- R取值时该回文子串的回文中心[C]:与R配套使用,R更新C必然更新。
分支流程
表示约定:
- i:当前遍历到的位置;
- i’:i相对于C的对称点;
模型图示:
-
根据i是否在R的左边【为了方便理解,这里的R取得是原始定义,即回文右边界
自身
,并不是右边一个位置】,即 i ≤ R i\le R i≤R是否成立来划分两大种情况:-
i在R右边,即 i > R i > R i>R:
-
利用中心扩散法来暴力计算最大回文子串的半径长度 r a d [ i ] rad[i] rad[i]
-
证明:
i右边是之前未曾探测过的,因此无法确定和左边相等与否,只能进行扩散探测。
-
-
i在R左边,即 i ≤ R i \le R i≤R, 这个分支根据 i + r a d [ i ′ ] i+rad[i'] i+rad[i′]的值分为三种情况
-
i + r a d [ i ′ ] < R i+rad[i']<R i+rad[i′]<R
-
结论: r a d [ i ] = r a d [ i − 1 ] rad[i] = rad[i - 1] rad[i]=rad[i−1]
-
证明:
如图,假设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
-
-
i + r a d [ i ′ ] > R i+rad[i'] > R i+rad[i′]>R
-
结论: r a d [ i ] = R − i + 1 rad[i]=R-i+1 rad[i]=R−i+1 【注:R改变定义后,这个表达式简化为 r a d [ i ] = R − i rad[i]=R-i rad[i]=R−i】
-
证明:
将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的中间
-
-
i + r a d [ i ′ ] = R i + rad[i'] = R i+rad[i′]=R
-
结论:
rad[i]大小至少为R-i+1
, 且长度可以根据从q,b开始进行中心扩散来进行扩张。 -
证明:
类似情况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
}
难点解析:
- i ′ = 2 ∗ C − i i' = 2 * C - i i′=2∗C−i
- d = i − C d = i - C d=i−C表示i到C的距离,故 i ′ = C − d = C − ( i − C ) = 2 ∗ C − i i' = C - d = C - (i - C) = 2 * C - i i′=C−d=C−(i−C)=2∗C−i
rad[i] = R > i ? Math.min(R - i, rad[2 * C - i]) : 1;
- 一句话说明了上面四种情况:
- R <= i:i在右边界外面【再次强调,此时的R是改变定义的R】,故需要进行中心扩散,但是单个字符算是回文串,所以定义已知的回文半径长度为1;
- R>i: i在右边界内:
- 若符合小情况1:取值
rad[2 * C - i]
, 此时这个值肯定小于 R − i R-i R−i;- 若符合小情况2:取值 R − i R-i R−i;
- 若符合小情况3,:取值 R − i R-i R−i或者 r a d [ 2 ∗ C − i ] rad[2 * C - i] rad[2∗C−i],是等价的;
- 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[i−rad[i]]==cs[i+rad[i]])
- 这里需要特别注意一下下标:i-rad[i]实际上是i位置的回文子串左外侧的位置,同理i+rad[i]就是右外侧;
- 同理, s t a r t = i − r a d [ i ] + 1 ; start = i - rad[i] + 1; start=i−rad[i]+1;就很好理解了,由于结束循环时,i-rad[i]实是i位置的回文子串左外侧的位置,因此回文串起始位置要加一;
- s最大有效回文子串长度为max-1【最大str回文子串半径-1】
- n位的有效回文子串经过特殊处理后,长度为 2 ∗ n + 1 2*n+1 2∗n+1, 而回文半径r定义为包含回文中心在内的半径字符数量,故 r ∗ 2 = ( 2 ∗ n + 1 ) + 1 = > n = r − 1 r*2 = (2 * n + 1) + 1 => n = r - 1 r∗2=(2∗n+1)+1=>n=r−1
- 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+max−1);
- 这个前言部分解释过,start左边多了一样长度的
#
, 回文串中多了n+1个#
- 故起始下标需要去掉一半长度;回文串长度len由于一定是奇数,根据整除的性质会将多余的1去掉,向下取整,即
len/2 = (len - 1) / 2
4. 总结
Manacher并不只是用于求最大回文串,最精华的部分在于经过这个算法处理出来的rad数组;
可以将rad数组每个元素-1,就是每个位置的回文串长度,这对于解决某些难题非常有用。
具体的,可以在力扣搜索回文
关键词,有一些题目就可以利用这个数组做极大的复杂度优化。