概述
求解最长回文子串的方法有很多,我们这里说两种解法,一种是暴力求解,我就直说思路了,还有一种就是今天的正菜——Manacher算法的求解。
思路分析
一、暴力解法
如果想要暴力求解最长回文子串,我们先想到的就是分别从每一个位置开始往两边扩,找到这些中可以扩的位置最多的那一组,就是最长回文子串了,如对于字符串"abcba"来说,先从第一个元素a开始扩,左边什么也没有,所以它能扩出的长度就是本身。对于第二个元素,他能扩出的长度也是本身,因为它的左边是'a',右边是'c',两个字符不相等,对于第三个元素c来说,它左边第一个字符与右边第一个字符都是'b',继续扩,左边第二个字符是和右边第二个字符都是'c',所以以c为中心能扩出的总长度就是5。同理继续向后,就不一一例举了,最终得到最长回文子串的长度就是5。如下图所示,第二行数字表示以对应的字符为中心向两边扩展可以得到的最长回文串的长度。
中心 | a | b | c | b | a |
长度 | 1 | 1 | 5 | 1 | 1 |
但这个时候会产生一个问题,就是偶回文的情况,如果如果应用上述方法来计算偶回文会出现如下的情况,以字符串"eeqq"为例:
中心 | e | e | q | q |
长度 | 1 | 1 | 1 | 1 |
而我们知道,eeqq这一字符串的长度为4,图上明显不符合原则。我们想要解决这一情况可以对初始字符串进行预处理,即向整个字符串的开头、结尾以及每两个字符中间加上同一个字符,如我们将eeqq中间加上一个'#'字符,整个字符串就变成了"#e#e#q#q#",再次计算时,如下表所示:
中心 | # | e | # | e | # | q | # | q | # |
长度 | 1 | 3 | 5 | 3 | 1 | 3 | 5 | 3 | 1 |
由于我们加上了字符,所以最后的结果就是最大的数5除以2等于2,最长回文子串长度为2。为了编码方便,我们也可以将奇回文做同样的处理,只要最后将结果除以2就是正确答案,到此一个时间复杂度为的解法思路就完成了。
一点说明:加上的字符不一定是‘#’任意字符都可以,就算是与字符串中的字符重复也可以,因为与新添加的字符进行比对构成回文的一定也是新添加的字符。
二、Manacher解法
再揭开这一神秘的面纱之前我们还需要知道几个概念。
- 回文半径:以某个元素为中心向一侧扩出来而形成的半个回文串的长度;
我们将以每一个元素为中心,扩出来的回文半径记录在一个数组里arr[];
- 回文最右边界:以每一个位置开始扩,能向右扩到最远的长度(所有回文半径中最靠右的位置),以"#e#e#q#q#"为例,下标为0的元素能扩出的回文右边界就是本身,下标为1的元素能扩出的回文右边界就是下标为2的位置,下标为2的元素能扩出的回文右边界是下标为4的位置......,我们会动态的修改,每次保证回文最右边界是以当前元素或当前元素之前的元素扩出来的最右的位置。
中心 | # | a | # | b | # | a | # |
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
最右边界下标 | 0 | 2 | 2 | 6 | 6 | 6 | 6 |
因为以字符b为中心扩展的回文最右边界就是最后一个字符了,所以后面的字符能扩出的边界一定小于它。所以后面的#a#所得到的回文最右边界也是字符b扩出来的6。
-
回文半径的中心:如果当前回文最右边界最早是由位置mid扩出来的,那么这个mid就是当前回文半径的中心。
这三个概念建立好了,我们就可以正式的开始玩Manacher了,话不多说,来个小漩涡......
重头戏开始,Manacher算法能实现时间复杂度为是因为其中间加速的过程,我们现在来一层一层一层的拨开它的心......
这里我们将分为四种情况进行分析,我们以当前位置cur为中心向两边扩,所有情况都考虑到都可以分为四种情况:
1.当cur不在当前回文最右边界里:
直接暴力扩即可。
以下三种情况为cur在当前回文最右边界内的情况。
2.以当前位置cur做关于mid的对称点cur',通过回文半径数组arr,找到cur'对应的回文半径,且cur'的回文半径完全在mid对应的回文半径内:
如字符串“edabacabaef”
字符 | e | d | a | b | a | c | a | b | a | d | f |
位置 | L | cur'_L | cur' | cur'_R | mid | cur | R |
其中R和mid以及cur是已知的,cur'_R表示cur'位置的回文半径即arr[cur'],L以及cur'_L可以通过简单的数学运算算出结果。
这种情况下cur位置的回文半径的长度直接等于cur'的回文半径的长度,即arr[cur]=arr[cur']。
解释:从cur'_L到cur'_R一定是回文(之前的结论),在整个L到R的范围内L到mid与mid到R是对称的,所以以cur为中心能扩出的回文串一定的从cur'_R到cur'_L(cur_L到cur_R为cur'_L到cur'_R的逆序排列)。
3.以当前位置cur做关于mid的对称点cur',通过回文半径数组arr,找到cur'对应的回文半径,且cur'的回文半径有一部分不在mid对应的回文半径内:
如字符串“acabacabad”
字符 | a | c | a | b | a | c | a | b | a | d |
位置 | cur'_L | L | cur' | mid | cur'_R | cur | R |
这种情况下cur的回文半径为cur到R,arr[cur]=R-cur+1。
解释:因为L和R是以mid为原点扩出的最长的回文串,所以L位置的前一个字符与R位置的后一个字符一定不相等,所以就导致了对称点cur‘的回文半径不能完全生效,因为cur'_L<L,而以cur'到L的距离为半径,以cur'为原点的字符串一定是回文串,所以,以cur为中心的回文半径的距离就是cur'到L的距离。
4.以当前位置cur做关于mid的对称点cur',通过回文半径数组arr,找到cur'对应的回文半径,且cur'左边的回文半径与L重合:
如字符串“dabacabac”
字符 | d | a | b | a | c | a | b | a | c |
位置 | L(cur'_L) | cur' | cur'_R | mid | cur | R |
这种情况下,我们由情况2和情况3可以知道cur到R一定是回文串,但是不知道再向右扩是否还能形成更长的回文串,所以,我们以cur为原点,cur到R为半径,继续向右暴力扩,得到最终结果。
注:以上三种情况与mid与cur'_L的相对位置并无关联,到此,我们有关Manacher的解法思路就说完了,有梦想的同学现在也开始拿起电脑,选择一门语言进行编码了!
编码
先介绍完全按照解题思路的复杂代码编写,再介绍代码优化后的写法。
复杂写法:
/**
*将字符串加上#字符构成新串并返回
*/
public String newString(String str) {
StringBuilder sb = new StringBuilder();
sb.append("#");
for (int i = 0; i < str.length(); i++) {
sb.append(str.charAt(i));
sb.append("#");
}
return sb.toString();
}
/**
*暴力扩,以i为回文中心,(cur_L和cur_R为适配情况4,表示已经扩展的部分)
*/
public int extend(String str, int i, int cur_L, int cur_R) {
int R = -1;
while (cur_L >= 0 && cur_R < str.length()) {
if (str.charAt(cur_L) == str.charAt(cur_R)) {
R = cur_R;
} else {
return R;
}
cur_L--;
cur_R++;
}
return R;
}
/**
*Manacher主体
*/
public void manacher(String str) {
str = newString(str);
// 回文半径数组
int[] arr = new int[str.length()];
int R = -1, L = -1;
int mid = -1;
int max = Integer.MIN_VALUE;
int RR;
for (int i = 0; i < str.length(); i++) {
// 情况1
if (i > R) {
RR = extend(str, i, i, i);
if (R < RR) {
R = RR;
mid = i;
L = 2 * mid - R;
}
arr[i] = R - mid + 1;
} else {
int ii = mid * 2 - i;
int cur_L = ii - arr[ii] + 1;
if (cur_L > L) {
// 情况2
arr[i] = arr[ii];
} else if (cur_L < L) {
// 情况3
arr[i] = R - i + 1;
} else {
// 情况4
RR = extend(str, i, 2 * i - R, R);
if (RR > R) {
R = RR;
mid = i;
L = 2 * mid - R;
}
arr[i] = R - i + 1;
}
}
max = max < arr[i] ? arr[i] : max;
}
System.out.println(max - 1);
}
优化后算法:
注意!!!为了编码方便,这里的R表示回文最右边界的下一位置
/**
* 加入#构造新数组
* @param str 原始字符串
* @return 新串
*/
public static char[] newString(String str) {
char[] arr = str.toCharArray();
char[] res = new char[arr.length * 2 + 1];
int index = 0;
for(int i = 0; i < res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : arr[index++];
}
return res;
}
/**
* manacher主体
* @param s
* @return 最长回文子串的长度
*/
public static int manacher(String s) {
if(s == null || s.length() == 0) {
return 0;
}
char[] str = newString(s);
//定义回文半径数组
int[] arr = new int[str.length];
int mid = -1,R = -1;
int max = Integer.MIN_VALUE;
for(int cur = 0; cur < arr.length; cur++) {
/*
* 结合情况2和情况3,情况2是直接等于cur关于mid的对称点的回文半径
* 情况3是等于cur到R的距离,一定大于等于情况2,所以取二者最小值即可,
* 如果需要暴力扩展,则先将cur回文半径数组的值置为1,
*/
arr[cur] = cur < R ? Math.min(arr[mid * 2 - cur], R - cur) : 1;
// 暴力向外扩,cur+arr[cur]为以cur为原点,从当前回文半径开始向外扩,
// 满足情况1和情况4,其中情况2和情况3进行第一次if判断一定会失败。
while(cur + arr[cur] < arr.length && cur-arr[cur] > -1) {
if(str[cur+arr[cur]] == str[cur-arr[cur]]) {
arr[cur]++;
}else {
break;
}
}
// 更新回文最右边界以及该边界的中心。
if(cur + arr[cur] > R) {
R = cur + arr[cur];
mid = cur;
}
max = Math.max(max, arr[cur]);
}
return max - 1;
}
但是我在HDU3068WA了几发,还没找到原因....,LeetCode过题是没问题的。
总结
大神脑回路真是厉害啊!!!也希望有大佬可以解释一下为什么在HDU没有通过....