📝 题目描述

题目链接最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

回文性:如果字符串向前和向后读都相同,则它满足回文性。
子字符串:字符串中连续的非空字符序列。

示例:

1
2
3
4
5
6
7
8
9
10
示例 1:

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

输入:s = "cbbd"
输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

💡 解题思路

方法一:动态规划

对于一个子串而言,如果它是回文串,并且长度大于 2,那么将它首尾的两个字母去除之后,它仍然是个回文串。例如对于字符串 “ababa”,如果我们已经知道 “bab” 是回文串,那么 “ababa” 一定是回文串,这是因为它的首尾两个字母都是 “a”。

我们令 P(i,j) 表示字符串 s 的第 ij 个字母组成的串是否为回文串,则我们就可以写出动态规划的状态转移方程:

P(i,j)=P(i+1,j1)(s[i]==s[j])P(i,j)=P(i+1,j−1)∧(s[i]​==s[j]​)

上文的所有讨论是建立在子串长度大于 2 的前提之上的,我们还需要考虑动态规划中的边界条件,即子串的长度为 1 或 2。对于长度为 1 的子串,它显然是个回文串;对于长度为 2 的子串,只要它的两个字母相同,它就是一个回文串。因此我们就可以写出动态规划的边界条件。

{P(i,i)=trueP(i,i+1)=(s[i]==s[i+1])\begin{cases} P(i,i)=true \\ P(i,i+1)=(s[i]​==s[i+1]​)​ \end{cases}

根据这个思路,我们就可以完成动态规划了,最终的答案即为所有 P(i,j)=trueP(i,j)=trueji+1j−i+1(即子串长度)的最大值。需要注意的是,在状态转移方程中,我们是从长度较短的字符串向长度较长的字符串进行转移的,因此一定要注意动态规划的循环顺序。

方法二:中心扩展算法

我们仔细观察一下方法一中的状态转移方程:

{P(i,j)=P(i+1,j1)(s[i]==s[j])P(i,i)=trueP(i,i+1)=(s[i]==s[i+1])\begin{cases} P(i,j)=P(i+1,j−1)∧(s[i]​==s[j]​) \\ P(i,i)=true \\ P(i,i+1)=(s[i]​==s[i+1]​)​ \end{cases}

找出其中的状态转移链:

P(i,j)P(i+1,j1)P(i+2,j2)某一边界情况P(i,j)←P(i+1,j−1)←P(i+2,j−2)←⋯←某一边界情况

可以发现,所有的状态在转移的时候的可能性都是唯一的。也就是说,我们可以从每一种边界情况开始扩展,也可以得出所有的状态对应的答案。

边界情况即为子串长度为 1 或 2 的情况。我们枚举每一种边界情况,并从对应的子串开始不断地向两边扩展。如果两边的字母相同,我们就可以继续扩展,例如从 P(i+1,j1)P(i+1,j−1) 扩展到 P(i,j)P(i,j);如果两边的字母不同,我们就可以停止扩展,因为在这之后的子串都不能是回文串了。

可以发现,边界情况对应的子串实际上就是我们扩展出的回文串的回文中心。此方法的本质即为:我们枚举所有的回文中心并尝试扩展,直到无法扩展为止,此时的回文串长度即为此回文中心下的最长回文串长度。我们对所有的长度求出最大值,即可得到最终的答案。

★方法三:Manacher 算法

为了表述方便,我们定义一个新概念臂长,表示中心扩展算法向外扩展的长度。如果一个位置的最大回文字符串长度为 2 * length + 1,其臂长为 length

下面的讨论只涉及长度为奇数的回文字符串。长度为偶数的回文字符串我们将会在最后与长度为奇数的情况统一起来。

在中心扩展算法的过程中,我们能够得出每个位置的臂长。那么当我们要得出以下一个位置 i 的臂长时,能不能利用之前得到的信息呢?

答案是肯定的。具体来说,如果位置 j 的臂长为 length,并且有 j + length > i,如下图所示:

当在位置 i 开始进行中心拓展时,我们可以先找到 i 关于 j 的对称点 2 * j - i。那么如果点 2 * j - i 的臂长等于 n,我们就可以知道,点 i 的臂长至少为 min(j + length - i, n)。那么我们就可以直接跳过 ii + min(j + length - i, n) 这部分,从 i + min(j + length - i, n) + 1 开始拓展。

我们只需要在中心扩展法的过程中记录右臂在最右边的回文字符串,将其中心作为 j,在计算过程中就能最大限度地避免重复计算。

那么现在还有一个问题:如何处理长度为偶数的回文字符串呢?

我们可以通过一个特别的操作将奇偶数的情况统一起来:我们向字符串的头尾以及每两个字符中间添加一个特殊字符 #,比如字符串 aaba 处理后会变成 #a#a#b#a#。那么原先长度为偶数的回文字符串 aa 会变成长度为奇数的回文字符串 #a#a#,而长度为奇数的回文字符串 aba 会变成长度仍然为奇数的回文字符串 #a#b#a#,我们就不需要再考虑长度为偶数的回文字符串了。

注意这里的特殊字符不需要是没有出现过的字母,我们可以使用任何一个字符来作为这个特殊字符。这是因为,当我们只考虑长度为奇数的回文字符串时,每次我们比较的两个字符奇偶性一定是相同的,所以原来字符串中的字符不会与插入的特殊字符互相比较,不会因此产生问题。

🔧 代码实现

1、动态规划

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size(), maxL = 1, start = 0;
// 创建dp数组
vector<vector<bool>> dp(n, vector<bool>(n, false));
// 单个字符必为回文串
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 开始动态规划,按照字符长度遍历,从2开始
for (int L = 2; L <= n; L++) {
for (int left = 0; left < n; left++) {
int right = left + L - 1;
// 判定1:right越界,直接跳过
if (right >= n) {
break;
}
if (s[left] == s[right]) {
// 判定2:字符串长度
if (L == 2) {
dp[left][right] = true;
} else {
dp[left][right] = dp[left + 1][right - 1];
}
}
// 记录最长回文串
if ((L > maxL) && (dp[left][right])) {
maxL = L;
start = left;
}
}
}
return s.substr(start, maxL);
}
};

2、中心扩展算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
pair<int, int> expandPalindrome(string& s, int left, int right) {
while ((left >= 0) && (right < s.size()) && (s[left] == s[right])) {
left--;
right++;
}
return {++left, --right};
}
string longestPalindrome(string s) {
int n = s.size(), maxL = 1, start = 0;
// 开始扩展
for (int i = 0; i < n; i++) {
auto [left1, right1] = expandPalindrome(s, i, i);
auto [left2, right2] = expandPalindrome(s, i, i + 1);
// 记录最长回文串
if ((right1 - left1 + 1) > maxL) {
maxL = right1 - left1 + 1;
start = left1;
}
if ((right2 - left2 + 1) > maxL) {
maxL = right2 - left2 + 1;
start = left2;
}
}
return s.substr(start, maxL);
}
};

3、Manacher 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public:
int expand(const string& s, int left, int right) {
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
return (right - left - 2) / 2;
}

string longestPalindrome(string s) {
int start = 0, end = -1;
string t = "#";
for (char c: s) {
t += c;
t += '#';
}
t += '#';
s = t;

vector<int> arm_len;
int right = -1, j = -1;
for (int i = 0; i < s.size(); ++i) {
int cur_arm_len;
if (right >= i) {
int i_sym = j * 2 - i;
int min_arm_len = min(arm_len[i_sym], right - i);
cur_arm_len = expand(s, i - min_arm_len, i + min_arm_len);
} else {
cur_arm_len = expand(s, i, i);
}
arm_len.push_back(cur_arm_len);
if (i + cur_arm_len > right) {
j = i;
right = i + cur_arm_len;
}
if (cur_arm_len * 2 + 1 > end - start) {
start = i - cur_arm_len;
end = i + cur_arm_len;
}
}

string ans;
for (int i = start; i <= end; ++i) {
if (s[i] != '#') {
ans += s[i];
}
}
return ans;
}
};

📊 复杂度分析

1、暴力解法

  • 时间复杂度O(n2)O(n^2),其中 nn 是字符串的长度。动态规划的状态总数为 O(n2)O(n^2),对于每个状态,我们需要转移的时间为 O(1)O(1)
  • 空间复杂度O(n2)O(n^2),即存储动态规划状态需要的空间。

2、优化解法

  • 时间复杂度O(n2)O(n^2),其中 nn 是字符串的长度。长度为 1 和 2 的回文中心分别有 nnn1n−1 个,每个回文中心最多会向外扩展 O(n)O(n) 次。
  • 空间复杂度O(1)O(1),仅使用了常数个变量。

3、Manacher 算法

  • 时间复杂度O(n)O(n),其中 nn 是字符串的长度。由于对于每个位置,扩展要么从当前的最右侧臂长 rightright 开始,要么只会进行一步,而 rightright 最多向前走 O(n)O(n) 步。
  • 空间复杂度O(n)O(n),我们需要 O(n)O(n) 的空间记录每个位置的臂长。

🎯 总结

  • 核心思想:记住该题目的动态规划算法思路。