「力扣」第 5 题:最长回文子串(暴力解法、中心扩散、动态规划)
解释题意
大家好,这里是「力扣」视频题解第 5 题:最长回文子串。
这道题给我们一个字符串 s
,让我们找出这个字符串 s
的最长回文子串,并且告诉我们,s
的最大长度为 1000
。
看到这个提示,我们简单计算一下,
1000
1000
1000 的平方是
10
10
10 的
6
6
6 次方,经验告诉我们,可以设计一个大欧 N
方的算法。(这里需要查相关资料说清楚。)
我们看一下示例 1:
给出的字符串是:babad
,输出 bab
,它是原始字符串的子串,并且 aba
也是一个有效的答案。
示例 2:
输入 cbbd
,输出 bb
。
由示例,我们可以归纳出回文子串的特点:
1、子串(substring):在原始字符串中必须是连续的字符,这一点是区别于子序列的,子序列()只需要保证字符的相对顺序不变,但不要求连续;
2、回文性质(palindromic):简单说就是从左向右读和从右向左读都是一样的。我们中国人是把回文玩得很 6 的,在古时候,我们就有回文对联和回文诗,有兴趣的朋友可以在网上搜索一下,「力扣」上也有很多关于回文串、回文子序列的问题,大家也不妨做一下。
回文性质从形象上说,就是在回文串的中心位置画一条直线,回文串关于这条直线中心对称。
关于回文,我们知道:
1、单个字符串一定是回文;
回文串可以根据其长度的奇偶性分类:
2、奇数长度的回文串:它的中心正好落在回文串的中间位置;
3、偶数长度的回文串:它的中心是两个相等的字符,也可以认为它的中心是这两个字符中间的空隙。
4、判定是否是回文字符串,可以从两边向中间同步遍历,即使用双指针成对地比较位于字符串前面和后面的字符,只要有一对字符不匹配,这个字符串就不是回文,直到双指针相遇,当且仅当全部匹配的时候,字符串才是回文。
另外一种方法是从回文串的中心开始向两边扩散去匹配,这个方法也很简单,要注意的一点是数组下标不能越界。
方法 1:暴力解法
最直接能够想到的方法是:
- 枚举
s
的所有子串; - 然后逐个判断每个子串的回文性质;
- 同时记录最长子串;
- 细节:记录最长子串需要截取,截取有一定性能消耗。替代方式:记录最长回文子串的起始位置
start
和最长回文子串的长度maxLen
,到遍历完成以后,再做截取。
这一版代码交给读者完成。
Java 代码:
public class Solution {
// 暴力解法
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
String res = s.substring(0, 1);
// 枚举所有长度大于等于 2 的子串
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
if (j - i + 1 > maxLen && valid(s, i, j)) {
maxLen = j - i + 1;
res = s.substring(i, j + 1);
}
}
}
return res;
}
private boolean valid(String s, int left, int right) {
// 验证子串 s[left, right] 是否为回文串
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
复杂度分析:
- 时间复杂度: O ( n 3 ) O(n^3) O(n3),这里 n n n 是字符串的长度,枚举字符串的左边界、右边界是 O ( n 2 ) O(n^2) O(n2),然后继续验证子串是否是回文子串,这一步操作是 O ( n ) O(n) O(n);
- 空间复杂度: O ( 1 ) O(1) O(1),只使用到常数个临时变量,与字符串长度无关。
方法 2:中心扩散法
-
刚刚我们提到了判定回文子串的两种方法,其中一种就是从“中心位置”开始判断。
-
为此,我们还可以枚举所有可能的回文子串所在的中心位置,需要枚举的可能的中心位置一共有 2 × n − 1 2 \times n - 1 2×n−1 个,在时间复杂度上降低了一个级别。
-
中心位置可能是一个字符(奇数长度的时候),也可能是两个字符(偶数长度的时候)。
-
中心位置不论是一个字符,还是两个字符的,其实我们都是用两个指针从中间向两边成对地去判断字符是否相等。可以认为中心是一个字符的时候,是两个指针的重合。
-
为此可以设计一个统一的方法,兼容这两种情况。
具体的方法是:
1、如果传入重合的索引编码,进行中心扩散,此时得到的回文子串的长度是奇数;
2、如果传入相邻的索引编码,进行中心扩散,此时得到的回文子串的长度是偶数。
下面我们看一下代码:
Java 代码:
public class Solution2 {
// 中心扩散法
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
int start = 0;
// 中心位置枚举到 len - 2 即可
for (int i = 0; i < len - 1; i++) {
int oddLen = expandAroundCenter(s, i, i);
// System.out.println("oddLen:" + oddLen);
int evenLen = expandAroundCenter(s, i, i + 1);
// System.out.println("evenLen:" + evenLen);
int curMaxLen = Math.max(oddLen, evenLen);
if (curMaxLen > maxLen) {
maxLen = curMaxLen;
start = i - (maxLen - 1) / 2;
}
}
return s.substring(start, start + maxLen);
}
/**
* 回文串的长度
* @param s
* @param left
* @param right
* @return
*/
private int expandAroundCenter(String s, int left, int right) {
// left = right 的时候,此时回文中心是一个字符,回文串的长度是奇数
// right = left + 1 的时候,此时回文中心两个字符,回文串的长度是偶数
int len = s.length();
int i = left;
int j = right;
while (i >= 0 && j < len) {
if (s.charAt(i) == s.charAt(j)) {
i--;
j++;
} else {
break;
}
}
// 这里要小心,跳出 while 循环时,恰好满足 s.charAt(i) != s.charAt(j),
// 此时回文串的长度是 j - i
return j - i - 1;
}
}
复杂度分析:
- 时间复杂度: O ( n 2 ) O(n^{2}) O(n2),枚举中心位置有 2 N 2N 2N 个(这里我们没有分析得特别细致),每一次向两边扩散检测是否回文,时间复杂度都是 O ( n ) O(n) O(n)。
- 空间复杂度: O ( 1 ) O(1) O(1),只使用到常数个临时变量,与字符串长度无关。
方法 3:动态规划
能想到“动态规划”解法,是因为“回文串”是天然具有“状态转移”性质的:
一个回文去掉两头以后,剩下的部分依然是回文(这里暂不讨论边界)。
因此,我们可以使用“动态规划”的方法快速判断一个子串是否是回文子串。“动态规划”的方法,在判断子串的过程中使用,参考子串的子串是否是回文的结果。
“动态规划”最关键的步骤是想清楚“状态如何转移”,我们依然从回文串的定义展开讨论:
1、如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;
2、如果一个字符串的头尾两个字符相等,才有必要继续判断下去:
(1)如果里面的子串是回文,整体就是回文串;
(2)如果里面的子串不是回文串,整体就不是回文串。
即在头尾字符相等的情况下,里面子串的回文性质据定了整个子串的回文性质,这就是状态转移。于是我们把“状态”定义为原字符串的一个子串是否为回文子串。
第 1 步:定义状态
dp[i][j]
:子串 s[i, j]
是否为回文子串,这里 i
和 j
分别表示字符串 s
的左右边界,并且是可以取到的。
第 2 步:思考状态转移方程
根据上面的分析,不难得到状态转移方程:
dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]
先判断左右边界所指向的字符是否相等,如果不相等,就直接下结论,子串不是回文串。如果相等,我们就看去掉了头和尾的那个子串是否是回文子串。注意,这里的逻辑运算符,我们用的是 and
,它有短路的功能。
对于我们这个问题,“动态规划”实际上是在填一张二维表格,i
和 j
的关系是 i <= j
,因此,只需要填这张表的上半部分;
看到 dp[i + 1][j - 1]
就得考虑边界情况。
边界条件是:以 i + 1
为左边界和以 j - 1
为右边界的子串,长度小于等于 1
,因为我们之前说过,单个字符一定是回文串。即在严格小于 2
的情况下,计算不等式 j - 1 - (i + 1) + 1 < 2
,整理得 j - i < 3
。
这个结论很显然:当子串 s[i, j]
的长度等于 2
或者等于 3
的时候,我其实只需要判断一下头尾两个字符是否相等就可以直接下结论了。
- 如果子串
s[i + 1, j - 1]
只有 1 个字符,即去掉两头,剩下中间部分只有 1 1 1 个字符,当然是回文; - 如果子串
s[i + 1, j - 1]
为空串,那么子串s[i, j]
一定是回文子串。
因此,在 s[i] == s[j]
成立和 j - i < 3
的前提下,直接可以下结论,dp[i][j] = true
,否则才执行状态转移。
(这一段看晕的朋友,直接看代码吧。我写晕了,车轱辘话来回说。)
第 3 步:考虑初始化
初始化的时候,单个字符一定是回文串,因此把对角线先初始化为 1
,即 dp[i][i] = 1
。
事实上,初始化的部分都可以省去。因为只有一个字符的时候一定是回文,dp[i][i]
根本不会被其它状态值所参考。
第 4 步:考虑输出
只要一得到 dp[i][j] = true
,就记录子串的长度和起始位置,没有必要截取,因为截取字符串也要消耗性能,记录此时的回文子串的“起始位置”和“回文长度”即可。
第 5 步:考虑状态是否可以压缩
因为在填表的过程中,只参考了左下方的数值。事实上可以压缩,但会增加一些判断语句,增加代码编写和理解的难度,丢失可读性。在这里不做状态压缩。
下面是编码的时候要注意的事项:总是先得到小子串的回文判定,然后大子串才能参考小子串的判断结果。
思路是:
1、在子串右边界 j
逐渐扩大的过程中,枚举左边界可能出现的位置;
2、左边界枚举的时候可以从小到大,也可以从大到小。
这两版代码的差别仅在内层循环,希望大家能够自己动手,画一下表格,思考为什么这两种代码都是可行的,相信会对“动态规划”作为一种“表格法”有一个更好的理解。
Java 代码:
public class Solution {
// 动态规划
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
boolean[][] dp = new boolean[len][len];
for (int i = 0; i < len; i++) {
dp[i][i] = true;
}
int maxLen = 1;
int start = 0;
for (int right = 1; right < len; right++) {
for (int left = 0; left < right; left++) {
if (s.charAt(left) != s.charAt(right)) {
dp[left][right] = false;
} else {
if (right - left <= 2) {
dp[left][right] = true;
} else {
dp[left][right] = dp[left + 1][right - 1];
}
}
if (dp[left][right]) {
int curLen = right - left + 1;
if (curLen > maxLen) {
maxLen = curLen;
start = left;
}
}
}
}
return s.substring(start, start + maxLen);
}
}
复杂度分析:
- 时间复杂度: O ( n 2 ) O(n^{2}) O(n2)。
- 空间复杂度: O ( n 2 ) O(n^{2}) O(n2),二维 dp 问题,一个状态得用二维有序数对表示,因此空间复杂度是 O ( n 2 ) O(n^{2}) O(n2)。
这个方法在实际运行起来,会比中心扩展法要慢,这是因为:
- “动态规划”方法,实际上是“暴力解法”的优化,在判断是否回文这一步,我们“用空间换时间”,把时间复杂度降低了一个数量级;
- 而“中心扩散法”在枚举的中心数量上,比“暴力解法”要少一个数量级;
- 因此,中心扩散法在执行时间上较快于动态规划方法,但它们本质上都是 O ( n 2 ) O(n^{2}) O(n2) 时间复杂度的算法,执行时间不一样是因为 n 2 n^{2} n2 前面那个系数不一样。
最后要向大家提及的是,“最长回文子串”还有线性时间复杂度的算法,这是由著名计算机科学家 Manacher 发明的,这个方法是专门用于解决“最长回文子串”问题的算法,有兴趣的朋友可以在网络上或者是本题的题解区搜索 Manacher 算法的解释,这个算法充分利用回文子串的对称性,也是采用“以空间换时间”的思路,在 O ( n ) O(n) O(n) 的时间复杂度内完成了最长回文子串的搜索。
大家重点还是掌握“中心扩散法”和“动态规划法”。
方法 4:Manacher 算法
(省略)