题目链接:最长回文子串
给定一个字符串 s,找到 s 中最长的回文子串。
解法一:暴力解法
暴力解法就是判断原字符串的每一个substring是不是回文串,返回最长的回文子串。暴力解法的时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
解法二:最长公共子串
回文串的定义——正着和反着读一样。那么把原来的字符串 s 反过来得到 reverse_s,找到它们的最长公共子串是不是就是满足条件的回文子串了。例如s="caba"
, 那么reverse_s="abac"
,最长公共子串为"aba"
, 是满足条件的最长回文子串。
求公共子串的时候,首先初始化一个二维数组,遍历并比较字符串s和 reverse_s的字符,相等的话dis[i][j] = dis[i-1][j-1] +1
。dis[i][j]
就是尾下标为i的子串的长度。代码如下:
public String longestPalindrome(String s) {
if(s.length() < 1) return "";
String revers = new StringBuilder(s).reverse().toString(); //反转字符串
int length = s.length();
int maxLen = 0, end = 0;
int[][] dis = new int[length][length];
for(int i = 0; i < length; i++){ // 遍历寻找最长公共子串
for(int j = 0; j < length; j++){
if(revers.charAt(j) == s.charAt(i)){
if(i == 0 || j == 0) dis[i][j] = 1;
else dis[i][j] = dis[i-1][j-1] + 1;
}
if(dis[i][j] > maxLen){ // 更新长度和结尾下标
maxLen = dis[i][j];
end = i;
}
}
}
return s.substring(end - maxLen + 1, end + 1);
}
但是也有出问题的时候,例如s="aacdefcaa"
,这样reverse_s="aacfedcaa"
,他们的最长公共子串为"aac"
显然这不是一个回文子串。这时候需要判断一下找到的子串在原字符串中的下标和反转后字符串中的下标是不是匹配。修改后的代码如下:
public String longestPalindrome(String s) {
if(s.length() < 1) return "";
String revers = new StringBuilder(s).reverse().toString(); //反转字符串
int length = s.length();
int maxLen = 0, end = 0;
int[][] dis = new int[length][length];
for(int i = 0; i < length; i++){ // 遍历寻找最长公共子串
for(int j = 0; j < length; j++){
if(revers.charAt(j) == s.charAt(i)){
if(i == 0 || j == 0) dis[i][j] = 1;
else dis[i][j] = dis[i-1][j-1] + 1;
}
if(dis[i][j] > maxLen){
int before = length - j - 1; //找到的子串结尾字符在原字符串中的下标
if(before + dis[i][j] - 1 == i){ // 只有下标匹配才是回文串,更新长度和结尾下标
maxLen = dis[i][j];
end = i;
}
}
}
}
return s.substring(end - maxLen + 1, end + 1);
}
上面程序找到最长回文子串的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),由于需要一个二维数组来存储长度,空间复杂度为$O(n^2)。但是空间复杂度还可以进一步减小。
分析:上面的程序中我们用了二维数组来存储得到的子串的长度——首先i=0, j=1,2,…,length-1,这个过程中更新了二维数组的第一行,接下来i=1,利用第一行的结果更新数组的第二行;再接下来i=2,利用第二行的结果更新第三行……也就是说只需要维护一个一维数组就可以了。更新的方式和之前一样,dis[j]=dis[j-1]+1
。但是如果仍旧从j=0开始更新,在计算dis[j]
的时候用的实际上是更新后的d[j-1]
的值,因此会出现错误。因此更新的时候从j=length-1
开始,这样更新dis[j]
的时候使用前一次的d[j-1]
,更新完dis[j]
后再更新dis[j]
就没问题了。程序如下
public String longestPalindrome(String s) {
if(s.length() < 1) return "";
String revers = new StringBuilder(s).reverse().toString(); //反转字符串
int length = s.length();
int maxLen = 0, end = 0;
int[] dis = new int[length];
for(int i = 0; i < length; i++){
for(int j = length - 1; j >= 0; j--){ //需要倒着更新
if(revers.charAt(j) == s.charAt(i)){
if(i == 0 || j == 0) dis[j] = 1;
else dis[j] = dis[j-1] + 1;
}
else{
dis[j] = 0; // 不相同的时候需要置0
}
if(dis[j] > maxLen){
int before = length - j - 1;
if(before + dis[j] - 1 == i){
maxLen = dis[j];
end = i;
}
}
}
}
return s.substring(end - maxLen + 1, end + 1);
}
上面程序的时间复杂度仍旧为 O ( n 2 ) O(n^2) O(n2),由于使用了一维数组空间复杂度降低到了 O ( n ) O(n) O(n)。
解法三:动态规划
暴力解法在每次判断每个子串的时候都是通过遍历子串的每个字符来判断这个子串是不是一个回文子串,因此造成了一个额外的
O
(
n
)
O(n)
O(n)的开销。通过利用规律可以降低这部分时间开销。
首先定义一下:
P
(
i
,
j
)
=
{
true
s
[
i
,
j
]
是
回
文
串
false
s
[
i
,
j
]
不
是
回
文
串
P(i, j)=\left\{\begin{array}{ll} {\text {true}} & {\mathrm{s}[\mathrm{i}, \mathrm{j}] 是回文串 } \\ {\text { false }} & {\mathrm{s}[\mathrm{i}, \mathrm{j}]不是回文串} \end{array}\right.
P(i,j)={true false s[i,j]是回文串s[i,j]不是回文串
s
[
i
,
j
]
\mathrm{s}[\mathrm{i}, \mathrm{j}]
s[i,j]表示从i到j的子串。那么显然
P
(
i
,
j
)
=
(
P
(
i
+
1
,
j
−
1
)
&
&
s
[
i
]
=
=
s
[
j
]
)
P(i, j)=(P(i+1, j-1) \& \& s[i]==s[j])
P(i,j)=(P(i+1,j−1)&&s[i]==s[j]) 也就是说,如果我们已经知道
s
[
i
+
1
,
j
−
1
]
\mathrm{s}[\mathrm{i+1}, \mathrm{j-1}]
s[i+1,j−1]是一个回文子串,那么
s
[
i
,
j
]
\mathrm{s}[\mathrm{i}, \mathrm{j}]
s[i,j]一定也是一个回文子串(子串长度为1或者2的情况另外讨论),这样通过空间换时间可以将暴力法的时间复杂度降低到
O
(
n
2
)
O(n^2)
O(n2)。代码如下:
public String longestPalindrome(String s) {
String longest = "";
int length = s.length(), maxLen = 0;
boolean[][] flag = new boolean[length][length];
for(int end = 0; end < length; end++){
for(int start = 0; start <= end; start++){
int len = end - start + 1; // 当前子串的长度
flag[start][end] = (len == 1 || len == 2 || flag[start+1][end-1]) && s.charAt(start) == s.charAt(end);
if(flag[start][end] && len > maxLen){
maxLen = len;
longest = s.substring(start, end + 1);
}
}
}
return longest;
}
这种方法通过空间换时间降低了时间复杂度但是由于需要二维数组存放子串的结果,空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
事实上,空间复杂度还可以进一步降低:
public String longestPalindrome(String s) {
String longest = "";
int length = s.length(), maxLen = 0;
boolean[] flag = new boolean[length];
for(int start = length - 1; start >= 0; start--){
for(int end = length - 1; end >= start; end--){
flag[end] = s.charAt(start) == s.charAt(end) && (end - start < 3 || flag[end-1]); // 重点是这一行
if(flag[end] && end - start + 1 > maxLen){
maxLen = end - start + 1;
longest = s.substring(start, end + 1);
}
}
}
return longest;
}
用一维数组代替了原来的二维数组,很明显空间复杂度降低为
O
(
n
)
O(n)
O(n),但是这个代码比较难理解。
子串的起始和末尾下标都是从后向前,flag数组每次只存储以当前start开始,end结束的子串是否是回文子串。来看最关键的一行代码:
flag[end] = s.charAt(start) == s.charAt(end) && (end - start < 3 || flag[end-1]);
首先,如果 s.charAt(start) == s.charAt(end) && (end - start < 3)
,那么就表示 s[start, end]是一个长度小于等于三且两端字符相等的子串,这显然是一个回文子串;那么如果 (end - start >= 3)
呢?说明当前子串长度大于三,此时flag[end] = s.charAt(start) == s.charAt(end) && (flag[end-1]);
判断 s.charAt(start) == s.charAt(end) 就不用说了,关键看这个&&(flag[end-1]);
由于下标是从后往前走的,所以在更新当前start对应的flag[end] 的时候,flag[end-1]还没有更新,因此是start+1对应的 flag[end-1]的值,再具体一点,此时的flag[end-1]表示的是 s[start+1, end-1]是不是一个回文子串。是不是豁然开朗!
解法四:中心扩展
中心扩展方法跟上面的动态规划法思想有很大的相似之处。下面这张图可以直观地理解一下:

每次选择一个循环中心向两边扩展,根据满足条件的回文子串的长度和中心更新起止下标,最后得到最大长度的回文子串。唯一一个需要注意的点就是需要同时考虑偶数子串和奇数子串,因此每次需要进行偶数扩展和奇数扩展。
public String longestPalindrome(String s) {
if(s == null || s.length() < 1) return "";
int start = 0, end = 0;
for(int i = 0; i < s.length(); i++){
int length1 = expandAroundCenter(s, i, i); // 奇数扩展
int length2 = expandAroundCenter(s, i, i + 1); //偶数扩展
int len = Math.max(length1, length2); // 最大长度
if(len > end - start){
start = i - (len - 1) / 2; // 更新起止下标
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right){
int L = left, R = right;
while(L >= 0 && R < s.length() && s.charAt(L) == s.charAt(R)){
L--;
R++;
}
return R - L - 1;
}
自己简单总结搬运了一下,leetcode上原题解图文并茂解释的更清楚.