题目
给你一个字符串 s
,找到 s
中最长的回文子串。
示例 1:
输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。
示例 2:
输入:s = “cbbd”
输出:“bb”
示例 3:
输入:s = “a”
输出:“a”
示例 4:
输入:s = “ac”
输出:“a”
提示:
1 <= s.length <= 1000
s
仅由数字和英文字母(大写和/或小写)组成
解题思路
1、枚举所有的子串(暴力搜索)
枚举出所有的子串,然后判断是否为回文串。
时间复杂度:O(n³) ,这里 n 为字符串的长度;
空间复杂度:O(1),只用到常数个临时变量。
2、枚举所有可能的回文子串的中心位置(暴力搜索/中心扩展)
枚举所有的「回文中心」并尝试「扩展」,直到无法扩展为止,此时的回文串长度即为此「回文中心」下的最长回文串长度。我们对所有的长度求出最大值,即可得到最终的答案。
枚举时需要考虑回文字符串长度的奇偶性
时间复杂度:O(n²),枚举中心位置的个数是 2(n-1),每一次向两边扩散检测是否为回文;
空间复杂度:O(1),只用到常数个临时变量。
3、动态规划
动态规划实际上是在填写一张二维表格。
回文串是天然具有状态转移性质的,一个回文去掉两头以后,剩下的部分仍然是回文。当然,这里说的回文长度严格大于 2 。
如果一个子串的两头不相等,那么可以直接下结论,该子串不是回文。例如:
如果一个子串首尾两个字符相等,要看去掉首尾两个字符的部分是不是回文。
状态:dp[ i ][ j ] 表示子串 s [ i..j ] 是否为回文子串( s[ i ] 和 s[ j ] 都是可以取到的)。
得到状态转移方程:dp[ i ][ j ] = ( s[ i ]==s[ j ] ) and dp[ i+1 ][ j-1 ]
既然是下标访问,就要考虑到下标边界。
边界条件:j - 1 - ( i + 1 ) + 1 < 2 ,整理后得到 j - i < 3 等价于 j - i + 1 < 4 ,其语义是 s [ i..j ] 长度为 2 或 3 时,不用检查子串是否为回文,这个时候不需要状态转移。
如果 j - 1 到 i + 1 长度小于2,不构成区间的的话 dp[ i+1 ][ j-1 ] 没有意义
说到这里你应该也明白了,动态规划比枚举快就快在:借助状态转移方程,每一步的计算都尽可能的利用了上一步计算的结果,这是非常典型的空间换时间的算法思想。
下面我们考虑初始化条件,单个字符一定是回文串,因此对角线的值可以先赋值为 true 。
初始化:dp[ i ][ j ] = true
事实上,初始化的部分是可以省略的,因为我们真正执行一下代码就会发现,对角线上的数值是不会被其他状态值参考的。
下面我们考虑输出,我们在填表的时候只要得到一个状态值为 true 的时候,就记录此时回文子串的起始位置和长度,填表完成以后再截取。
输出:在得到一个状态的值为 true 的时候,记录起始位置和长度,填表完成以后再截取。
最后,回过头来看一下,因为有下标边界的限制,所以对于状态转移方程需要做进一步的修改
原始:dp[ i ][ j ] = ( s[ i ]==s[ j ] ) and dp[ i+1 ][ j-1 ]
改为:dp[ i ][ j ] = ( s[ i ]==s[ j ] ) and ( j - i < 3 or dp[ i+1 ][ j-1 ] )
其语义为:子串是否为回文取决于:在子串首尾两个字符相等的情况下,如果 j - 1 到 i + 1 长度大等于2,构成区间,再根据去掉首尾两个字符后的子串是否为回文串决定原始子串是否为回文。
再重申一遍,动态规划实际上是在填写一张二维表格。
由于 i 和 j 的关系是 i <= j ,所以我们只要填写表格的上半部分。
这个二维表格就记录了我们求解 s 的所有子串问题的所有状态,每一行表示 s 的左边界 ,每一列表示 s 的右边界。左边界和右边界的组合就确定了一个唯一的子串。
首先,单个字符一定是回文串,所以我们可以先对主角线位置的值都赋值为 true 。
我们看一下上面得出的状态转移方程:
dp[ i ][ j ] = ( s[ i ]==s[ j ] ) and ( j - i < 3 or dp[ i+1 ][ j-1 ] )
由于 dp[ i ][ j ] 需要参考 dp[ i+1 ][ j-1] 的值,也就是它左下方的值。所以我们在填表的时候就不能一行一行从左向右填写了。
一种参考方案是:一列一列的填写。这样就能保证左下方的值先计算出来,状态转移才会是正确的。
1)先升序填列 2)再升序填行
填表顺序如下图红色数字所示:
其中最具有代表性的是 dp[ 0 ][ 4 ] 的值的计算,下标 0 和 4 代表了整个字符串,头和尾相等( 0=>b ,4=>b),就要参考中间部分是否是回文,也就是由 dp[ 1 ][ 3 ] 决定。
按照这样的规则,我们就可以填完整张表格。
4、Manacher 算法 / 马拉车算法(终极解法)
绝大多数面试和笔试不考查 Manacher 算法。
答案
1、枚举所有的子串
/**
* 枚举所有子串
*/
class Solution {
public String longestPalindrome(String s) {
// 获取字符串长度
int len = s.length();
// 特殊情况判断
if (len < 2) {
return s;
}
//最长回文子串的起始下标与长度
int begin = 0;
int maxLen = 1;
// s.charAt(i) 每次都会检查数组下标越界,因此先转换成字符数组,这一步非必需
char[] charArray = s.toCharArray();
// 枚举所有长度严格大于 1 的子串 charArray[i..j]
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
// 查看当前遍历到的子串
// printCharArray(charArray, begin, j - i + 1);
// 如果当前子串的长度超过了当前最大回文串的长度,则判断是否为回文串
if (j - i + 1 > maxLen && isPalindromic(charArray, i, j)) {
// 记录回文串的长度
begin = i;
maxLen = j - i + 1;
}
}
}
return s.substring(begin, begin + maxLen);
}
// 验证子串 s[left...right] 是否为回文串
public boolean isPalindromic(char[] charArray, int left, int right) {
while (left < right) {
if (charArray[left] != charArray[right]) {
return false;
}
left++;
right--;
}
return true;
}
// debug 输出指定长度的 char 数组内容
public void printCharArray(char[] charArray, int begin, int len) {
String s = "";
for (int n = begin; n < len; n++) {
s += charArray[n];
}
System.out.println(s);
}
}
2、枚举所有可能的回文子串的中心位置
/**
* 枚举所有回文中心
*/
class Solution {
public String longestPalindrome(String s) {
// 获取字符串长度
int len = s.length();
// 特殊情况判断
if (len < 2) {
return s;
}
//最长回文子串的起始下标与长度
int begin = 0;
int maxLen = 1;
// s.charAt(i) 每次都会检查数组下标越界,因此先转换成字符数组,这一步非必需
char[] charArray = s.toCharArray();
for (int i = 0; i < len - 1; i++) {
// 以单个字符为中心扩散
int oddLen = expandAroundCenter(charArray, i, i);
// 以两个字符为中心扩散
int evenLen = expandAroundCenter(charArray, i, i + 1);
int currentMaxLen = Math.max(oddLen, evenLen);
if (currentMaxLen > maxLen) {
maxLen = currentMaxLen;
begin = i - (maxLen - 1) / 2;
}
}
return s.substring(begin, begin + maxLen);
}
/**
* 中心扩展
*
* @param charArray 原始字符串的字符数组
* @param left 起始做边界(可以取到)
* @param right 起始右边界(可以取到)
* @return 回文串的长度
*/
public int expandAroundCenter(char[] charArray, int left, int right) {
// 当 left = right 时,回文中心是单个字符,回文串的长度是奇数
// 当 right = left +1 时,此时回文中心是两个字符,回文串的长度是偶数
int len = charArray.length;
while (left >= 0 && right < len) {
if (charArray[left] == charArray[right]) {
left--;
right++;
} else {
break;
}
}
// 跳出 while 循环时,恰好满足 s.charAt(left)!=s.charAt(right), left 到 right 中间的部分是回文子串,不包括 left 和 right 指向的字符。
// 回文串的长度是 right - left + 1 - 2 = j - i - 1
return right - left - 1;
}
}
3、动态规划
/**
* 动态规划
*/
class Solution {
public String longestPalindrome(String s) {
// 获取字符串长度
int len = s.length();
// 特殊情况判断
if (len < 2) {
return s;
}
//最长回文子串的起始下标与长度
int begin = 0;
int maxLen = 1;
// s.charAt(i) 每次都会检查数组下标越界,因此先转换成字符数组,这一步非必需
char[] charArray = s.toCharArray();
// 动态规划二维表
// dp[i][j] 表示 s[i..j] 是否是回文
boolean[][] dp = new boolean[len][len];
for (int i = 0; i < len; i++) {
// 主对角线上的值先赋值为 true
dp[i][i] = true;
}
// 左下角先填
for (int j = 1; j < len; j++) {
for (int i = 0; i < j; i++) {
// 当前填写的表格坐标
// System.out.println("dp[" + i + "]" + "[" + j + "]");
if (charArray[i] != charArray[j]) {
// 首尾不相等,直接赋值为 false
dp[i][j] = false;
} else {
// 如果 j - 1 - ( i + 1 ) +1 < 2 不构成区间
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文,此时记录回文长度和起始位置
if (dp[i][j] == true && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substring(begin, begin + maxLen);
}
/**
* 中心扩展
*
* @param charArray 原始字符串的字符数组
* @param left 起始做边界(可以取到)
* @param right 起始右边界(可以取到)
* @return 回文串的长度
*/
public int expandAroundCenter(char[] charArray, int left, int right) {
// 当 left = right 时,回文中心是单个字符,回文串的长度是奇数
// 当 right = left +1 时,此时回文中心是两个字符,回文串的长度是偶数
int len = charArray.length;
while (left >= 0 && right < len) {
if (charArray[left] == charArray[right]) {
left--;
right++;
} else {
break;
}
}
// 跳出 while 循环时,恰好满足 s.charAt(left)!=s.charAt(right), left 到 right 中间的部分是回文子串,不包括 left 和 right 指向的字符。
// 回文串的长度是 right - left + 1 - 2 = j - i - 1
return right - left - 1;
}
}