最长回文子串
leetcode-5 - 中等
给你一个字符串 s,找到 s 中最长的回文子串。
参考资料:
经查阅,该题一般有四种解法:
- 暴力法
- 中心扩展法
- 动态规划
- Manacher算法
暴力法
暴力解法思路清晰、编写简单,但是时间复杂度高,这里不再深入分析。
-
时间复杂度:O(N3),子串的左右边界以及判断子串是否为回文字串。
-
空间复杂度:O(1),只需几个变量值做记录即可。
中心扩展法
中心扩展法通过枚举可能出现的回文子串的“中心位置”,从“中心位置”尝试尽可能扩散出去,得到一个回文串。
子串的“中心位置”有两种情况:
-
中心为单个字符
-
中心为两个字符的间隙
图 1 :奇数回文串与偶数回文串
因此,字符串的每个字符以及字符之间的间隙都要作为中心进行扩展寻找回文子串。
图 2:枚举可能的所有回文中心
- 时间复杂度:O(N2),遍历字符与间隙为O(N),每个字符或间隙扩展寻找最长回文子串为O(N)
- 空间复杂度:O(1)
class Solution {
String longSubStr = "";
int len = 1;
public String longestPalindrome(String s) {
for (int i = 0; i < s.length(); i++) {
// 查找最长的单中心回文子串
int left = i;
int right = i;
core(s, left, right);
// 查找最长的双中心回文子串
if (i < s.length()-1) {
left = i;
right = i + 1;
core(s, left, right);
}
}
return longSubStr;
}
private void core(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
if (right - left + 1 >= len) {
len = right - left + 1;
longSubStr = s.substring(left, right+1);
}
left--;
right++;
}
}
}
动态规划法
如果用 f[1][2]保存子串从 i 到 j 是否是回文子串,那么在求 f[i][j] 的时候如果 j-i>=2 时,且 f[i][j] 为回文,那么 f[i+1][j-1],也一定为回文,否则 f[i][j] 不为回文。如下图:
图 3:回文子串关系图
因此得动态转移方程:
图 4:回文子串动态转移方程
- 时间复杂度:O(N2),需要填满二维 f 数组的一半
- 空间复杂度:O(N2),二维 f 数组占用空间为O(N2)
从动态转移方程可知,只需要二维数组 f[i][j] (0<=i<=j<s.length()) 的一半来记录子串从 i 到 j 是否为回文串即可,并且上一行的值又依赖下一行的值,因此二维数组行从下向上推导,代码如下:
class Solution {
int longestLen = 1;
String longestStr = "";
String longestPalindrome(String s) {
int len = s.length();
boolean[][] dp = new boolean[len][len];
for (int i = len-1; i >= 0; i--) {
for (int j = i; j < len; j++) {
if (s.charAt(i) == s.charAt(j) && (j <= i + 1 || dp[i+1][j-1])) {
dp[i][j] = true;
if (j - i + 1 >= longestLen) {
longestLen = j - i + 1;
longestStr = s.substring(i, j+1);
}
}
}
}
return longestStr;
}
}
说明:
动态规划并非最长回文子串的最佳算法,即便与中心扩展法相比,其空间复杂度也较高。但是,使用动态规划解答最长回文子串,可以较好的理解如何得到动态规划的状态转移方程。
Manacher算法
Manacher算法本质上也是中心扩展法,但是Manacher算法巧妙的利用了回文子串的对称性,使得在确定某些中心位置的最长回文子串时不必再向左右扩展一遍。
预处理:
为了解决回文子串的中心位置可能为单个字符也可能为字符间隙的问题(也即奇偶子串的问题),Manacher需要对子串进行预处理,以保证回文子串的中心位置都是一个具体的字符。具体的做法就是使用特殊字符'#'将字符串的各个字符进行隔离。
图 5:原始字符串与Manacher字符串的对应关系
Manacher字符串有如下特点:
-
Manacher字符串的回文子串一定是奇数长度,并且子串的两端字符一定是特殊字符'#'
-
Manacher字符串的任意回文子串在原始字符串中一定能找到唯一的一个回文子串与之对应(撤掉特殊字符'#'即可),因此Manacher字符串的最长回文子串撤掉特殊字符'#'后就是原始字符串的回文子串
-
原始字符串与Manacher字符串字符的下标位置有如图所示的对应关系
概念:
这里解释一下Manacher算法的几个概念:
-
回文半径和回文直径:因为处理后回文字符串的长度一定是奇数,所以回文半径是包括回文中心在内的回文子串的一半的长度,回文直径则是回文半径的2倍减1。比如对于字符串 "aba",在字符 'b' 处的回文半径就是2,回文直径就是3。
-
最右回文边界 R:在遍历字符串时,每个字符遍历出的最长回文子串都会有个右边界,而R则是所有已知右边界中最靠右的位置,也就是说R的值是只增不减的。
-
回文中心 C:取得当前R的第一次更新时的回文中心。由此可见R和C时伴生的。
-
半径数组:这个数组记录了原字符串中每一个字符对应的最长回文半径。
下面寻找Manacher字符串的最长回文子串:
Manacher算法的两个要点:
-
寻找新字符为中心的最长回文子串,如果新字符在最右回文边界的右侧,则使用中心扩展法
-
如果新字符在最右回文边界的左侧(包含最右回文边界),则该新字符的最长回文半径至少等于其关于回文中心 C 对称的字符的最长回文半径,此时可以从该回文半径处继续对新字符使用中心扩展法
现在对上面的第二点进行分析:
- 如图所示,由于回文子串的对称性,当 i' 的回文子串在LR内时,i 的回文子串必与 i' 一样,因为 i' 回文子串的左右两侧的字符不一样, i 回文子串左右两侧的字符也一定不一样。
图 6:i' 的回文子串在LR内
- 如果 i' 的回文子串超出LR,则 i 的回文子串的回文半径必然是 i 到 R。如图,x 和 y 分别是 L 和 L' 的左侧和右侧的字符,若 x=y,而由于回文字符串的对称性可知, y=k,若 k=z ,则有 x=z,那么回文中心C的最长回文子串就不可能是LR,因此必然有 k≠z,也即 i 的回文半径就是 i 到 R。
图 7:i' 的回文子串超出LR
- 如果 i' 的回文左边界刚好与 L 重合,则 i 的回文半径至少是 i 到 R,需要在此基础上继续中心扩展。
图 8:i' 的回文子串左边界在L处
Manacher算法code
public String longestPalindrome(String s) {
String manacherString = getManacherString(s);
int[] p = new int[manacherString.length()];
int maxIndex = 0;
int max = 0;
int c = -1;
int r = -1;
// 以Manacher字符串的每个字符为中心进行扩展
for (int i = 0; i < p.length; i++) {
// 回文半径数组,其与回文长度L的关系为L=2*p[i]-1
p[i] = 1;
// 回文子串左边界,其与回文半径的关系为left=i-p[i]+1
int left = i - 1;
// 回文子串右边界,其与回文半径的关系为right=i+p[i]-1
int right = i + 1;
// 如果回文中心字符不在回文最右边界的右侧,则可以利用半径数组使回文左右边界跳跃扩展,但右边界最大扩展到r
// left和right分别指向回文子串左右边界的下一个字符
if (i <= r) {
if (i+ p[2*c-i] - 1 < r) {
left = i - p[2*c-i];
right = i + p[2*c-i];
p[i] = p[2*c-i];
} else {
left = 2*i-r-1;
right = r+1;
p[i] = r-i+1;
}
}
// 对回文子串左右边界进行扩展
while (left >= 0 && right < p.length) {
if (manacherString.charAt(left) == manacherString.charAt(right)) {
p[i] += 1;
left--;
right++;
} else {
break;
}
}
// 更新回文半径和回文中心
if (right - 1 > r) {
r = right - 1;
c = i;
}
// 更新最长回文子串中心索引
if (p[i] > max) {
maxIndex = i;
max = p[i];
}
}
return manacherString.substring(maxIndex - p[maxIndex] + 1, maxIndex + p[maxIndex] - 1).replace("#", "");
}
private String getManacherString(String s) {
StringBuffer sb = new StringBuffer();
sb.append("#");
for (int i = 0; i < s.length(); i++) {
sb.append(s.charAt(i));
sb.append("#");
}
return sb.toString();
}