- 最长回文子串
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: “babad” 输出: “bab” 注意: “aba” 也是一个有效答案。
示例 2:
输入: “cbbd” 输出: “bb”
1、暴力法
暴力法将遍历子串集合,判断每个子串是否回文。
复杂度分析
- 时间复杂度:O(N3),对于长度N的字符串,其子串总数是N(N-1)/2,而验证每个子串是否回文需要O(N),所以时间复杂度O(N2·N)
- 空间复杂度:O(1)
2、动态规划
2.1、动态规划+从左向右遍历2次
设状态dp[j][i]表示索引j到索引i的子串是否是回文串。则转移方程为:
则dp[j][i]
为true时表示索引j
到索引i
形成的子串为回文子串,且子串起点索引为j
,长度为i - j + 1
。
算法时间复杂度为O(N ^ 2)
#include <iostream>
#include <cstring>
using namespace std;
string longestPalindrome(string s)
{
const int n = s.size();
bool dp[n][n];
memset(dp, 0, sizeof(dp));
int maxlen = 1; //保存最长回文子串长度
int start = 0; //保存最长回文子串起点
for(int i = 0; i < n; ++i)
{
// i是右指针,j是左指针,必须要j<=i
for(int j = 0; j <= i; ++j)
{
if(i - j < 2)
{
dp[j][i] = (s[i] == s[j]);
}
else
{
dp[j][i] = (s[i] == s[j] && dp[j + 1][i - 1]);
}
// 更新最大回文串的长度,并记录起点位置
if(dp[j][i] && maxlen < i - j + 1)
{
maxlen = i - j + 1;
start = j;
}
}
}
return s.substr(start, maxlen);
}
int main()
{
string s;
cout << "Input source string: ";
cin >> s;
cout << "The longest palindrome: " << longestPalindrome(s);
return 0;
}
2.2、动态规划+从中心向两边拓展
利用之前迭代获得的信息,帮助后续迭代减少不必要的重复计算。考虑 “ababa” 这个示例。如果我们已经知道 “bab” 是回文,那么很明显,“ababa” 一定是回文,因为它的左首字母和右尾字母是相同的。
第一步:思考状态dp[i]
结合题意,要求最长回文子串,那么
令dp[i]
表示一个回文子串的长度,则该回文子串可以被表示成s[i, i + dp[i])
例如:dp[0] = 1
,表示s[0, 0 + 1)
是一个回文字串,长度为1
第二步:思考状态转移方程
从中心向两边伸展,遇到了新的2个字符,如果这2个字符相等,则
dp[i] = dp[i - 1] + 2
Q:i是从左向右吗?
第三步:思考起始条件(中心拓展的起始状态)
起始子串的长度可以有两种情况,例如:
- length=1时,“a”–>“aba”
- length=2时,“bb”–>“abba”
那么,需要对长度为N的字符串,分别以起始长度为1,2的情况遍历2次,总共遍历 N+(N-1) 次。
复杂度分析
- 时间复杂度:遍历时间O(N+(N-1)),延伸子串并判断是否回文串时间最坏情况下是O(N/2),那么总的时间复杂度为:O((N+(N-1))·(N/2)),即O(N2)
- 空间复杂度:由于每次迭代,当前状态只与前一刻的状态有关,因此
dp
状态向量可以重复使用,向量长度是N,因此空间复杂度是O(N)
程序思路导读:
-
需要对长度为N的字符串,分别以起始长度为1,2的情况遍历2次
-
start
指针用来遍历,代表子串的首字母在整串中的位置 -
i j
是start的偏移,分别代表了子串左右伸展的长度i j 向左右伸展,那么要时刻注意数组越界~~
-
每次遍历时,移动start,然后对i j指向的字符做判断,相等则对
dp[i-1]+2
这里是对dp[i-1]操作而不是dp[i+1]操作,是个注意点~~ 因为 i 是向左移动的!
-
由于dp[x]的值是x为起点的回文子串的长度,因此s[x:x+dp[x]]是一个回文串!
class Solution {
public:
string longestPalindrome(string s) {
// 特殊判例
if (s.size() == 0) return "";
// i代表子串的左指针,j代表子串的右指针,start代表子串首字符在S中的位置
int start, i, j;
int L = 0, max_length = 1;
// dp表示该子串的最大回文长度
int *dp = new int[s.size()]{};
// 情况1:起始子串长度=1
//cout << "情况1" << endl;
for (start = 0; start < s.size(); ++start){
// i代表子串的左指针,j代表子串的右指针,start代表子串首字符在S中的位置
i = 0, j = 0;
// 起始子串长度=1,即s[0, 0 + 1)长度为1
dp[start] = 1;
// 迭代,以start为中心,向两头延伸子串
// “ababa” ,如果我们已经知道 “bab” 是回文,那么“ababa” 一定是回文,因为它的左首字母和右尾字母是相同的。
while(dp[start + i] > 0){
--i;
++j;
//printf("start = %d, i = %d, j = %d\n", start, i, j);
// 如果当前索引会超出边界,则迭代结束
if (start + i < 0 || (j - i + 1) > s.size()){
//printf("break\n");
break;
}
// 若两头的字符匹配,则更新结果,否则结束迭代
if (s[start + i] == s[start + j]){
//printf("i == j, %d == %d \n", i, j);
// 状态转移,以左边界为索引[start + i],更新以该左边界为起点的回文子串的长度
dp[start + i] = dp[start + i + 1] + 2;
//printf("max_length = %d, length = %d\n", max_length, dp[start + i]);
// 如果当前子串的长度更长,则更新结果
if (max_length < dp[start + i]){
max_length = dp[start + i];
L = start + i;
}
} else {
break;
}
}
}
// 情况2:起始子串长度=2
// i代表子串的左指针,j代表子串的右指针,start代表子串在S中的位置
// dp表示该子串的最大回文长度
//cout << "情况2" << endl;
for (start = 0; start < s.size() - 1; ++start){
i = 0;
j = 1;//区别1:j=1
dp[start] = 0;
if (s[start + i] == s[start + j]){ //区别2:j=1要判断2个初始字符
// 这是初始状态
dp[start + i] = 2;
// 输出结果的更新
if (max_length < dp[start + i]){
// 如果当前子串的长度更长,则更新结果
max_length = dp[start + i];
L = start + i;
}
}
// 迭代,向两头延伸子串
while(dp[start + i] > 0){
--i;
++j;
//printf("start = %d, i = %d, j = %d\n", start, i, j);
// 如果当前索引会超出边界,则迭代结束
if (start + i < 0 || (j - i + 1) > s.size()){
//printf("break\n");
break;
}
// 若两头的字符匹配,则更新结果,否则结束迭代
if (s[start + i] == s[start + j]){
//printf("i == j, %d == %d \n", i, j);
// 状态转移,注意,每次迭代时,对i-1,因此,i+1表示上一个状态
dp[start + i] = dp[start + i + 1] + 2;
if (max_length < dp[start + i]){
// 如果当前子串的长度更长,则更新结果
max_length = dp[start + i];
L = start + i;
}
} else {
break;
}
}
}
delete[] dp;
// 字符串切片s.substr(a, length)返回的是s[a, a + length),左闭右开
return s.substr(L, max_length);
}
};
3、Manacher’s Algorithm 马拉车算法
马拉车算法 Manacher‘s Algorithm 是用来查找一个字符串的最长回文子串的线性方法,由一个叫 Manacher 的人在 1975 年发明的,这个方法的最大贡献是在于将时间复杂度提升到了线性。
第一步:预处理,得到奇数串
首先我们解决下奇数和偶数的问题,在每个字符间插入 “#”,并且为了使得扩展的过程中,到边界后自动结束,在两端分别插入 “^” 和 “$”,两个不可能在字符串中出现的字符,这样中心扩展的时候,判断两端字符是否相等的时候,如果到了边界就一定会不相等,从而出了循环。经过以上插入字符的处理,字符串的长度永远都是奇数了。
- 求最长回文子串的长度
首先我们用一个数组 P 保存字符从中心扩展的最大次数,而它刚好也是去掉 “#” 的原字符串的总长度。
从中心扩展的最大次数 = 最长回文子串长度
例如下图中下标是 6 的地方,可以看到 P[ 6 ] 等于 5,所以它是从左边扩展 5 个字符,相应的右边也是扩展 5 个字符,也就是 “#c#b#c#b#c#”。而去掉 # 恢复到原来的字符串,变成 “cbcbc”,它的长度刚好也就是 5。
得到了子串的回文长度,怎么知道该子串的首字母的位置??
- 求原字符串下标
用 P 的下标 i 减去 P [ i ],再除以 2,就是原字符串的首字母下标了。
例如我们找到 P[ i ] 的最大值为 5,也就是回文串的最大长度是 5,对应的下标是 6,所以原字符串的开头下标是(6 - 5 )/ 2 = 0。所以我们只需要返回原字符串的第 0 到第(5 - 1)位就可以了。
第二步:求数组P
P[i]的值代表了使用中心拓展法向外拓展的次数,也对应了回文字串的长度。
用 C 表示回文串的中心,用 R 表示回文串的右边界。所以 R = C + P[ i ]。C 和 R 所对应的回文串是当前循环中 R 最靠右的回文串。
使用 i 从做向右扫描求P,让我们考虑求 P [ i ] 的时候,如下图。
用 i_mirror 表示当前需要求的第 i 个字符以 C 为镜像对应的下标。
我们现在要求 P [ i ],利用前一个子串C 的对称性,之前已经知道了,回文串 C 的长度为5,显然是覆盖了当前字符b
。i 关于 C 的对称点是 i_mirror,P [ i_mirror ] = 3,所以 P [ i ] 也等于 3
。
但是有三种情况将会造成直接赋值为 P [ i_mirror ] 是不正确的,下边一一讨论。
-
超出了 R
当我们要求 P [ i ] 的时候,P [ mirror ] = 7,而此时 P [ i ] 并不等于 7,为什么呢,因为我们从 i 开始往后数 7 个,等于 22,已经超过了最右的 R,此时不能利用对称性了。但我们一定可以扩展到 R 的(必然),所以 P [ i ] 至少等于 R - i = 20 - 15 = 5,会不会更大呢,我们只需要比较 T [ R+1 ] 和 T [ R+1 ]关于 i 的对称点就行了,就像中心扩展法一样一个个扩展。 -
P [ i_mirror ] 遇到了原字符串的左边界
此时P [ i_mirror ] = 1,但是 P [ i ] 赋值成 1 是不正确的,出现这种情况的原因是 P [ i_mirror ] 在扩展的时候首先是 “#” == “#”,之后遇到了 “^” 和另一个字符比较,也就是到了边界,才终止循环的。而 P [ i ] 并没有遇到边界,所以我们可以继续通过中心扩展法一步一步向两边扩展就行了。 -
i 等于R
此时我们先把 P [ i ] 赋值为 0,然后通过中心扩展法一步一步扩展就行了。
第三步:考虑 C 和 R 何时更新?C和R同时更新
就这样一步一步的求出每个 P [ i ],当求出的 P [ i ] 的右边界大于当前的 R 时,我们就需要更新 C 和 R 为当前的回文串了。因为我们必须保证 i 在 R 里面,所以一旦有更右边的 R 就要更新 R。
此时的 P [ i ] 求出来将会是 3,P [ i ] 对应的右边界将是 10 + 3 = 13,所以大于当前的 R,我们需要把 C 更新成 i 的值,也就是 10,R 更新成 13。继续循环。
复杂度分析
-
时间复杂度:for 循环里边套了一层 while 循环,难道不是 O(N2)?不!O(N)。不严谨的想一下,因为 while 循环中,访问 R 右边的数字用来扩展,也就是那些还未求出的节点,然后不断向两头扩展,而期间访问的节点下次就不会再进入 while 了,下次可以利用对称性直接得到解,所以每个节点访问都是常数次,所以是 O( n )。
-
空间复杂度:O(n)。
class Solution {
public:
string preProcess(string s) {
int n = s.length();
if (n == 0) {
return "^$";
}
string ret = "^";
for (int i = 0; i < n; i++)
ret = ret + '#' + s[i];
ret += "#$";
return ret;
}
// 马拉车算法
string longestPalindrome(string s) {
// 调用预处理函数
string T = preProcess(s);
int n = T.length();
// 数组 P 保存从中心扩展的最大次数
int* P = new int[n];
// C是当前子串的中心,R是子串的右边界,什么时候更新C和R呢?
int C = 0, R = 0;
// 填充P时,略过P的头尾
for (int i = 1; i < n - 1; ++i) {
int i_mirror = 2 * C - i;
if (R > i) {
P[i] = min(R - i, P[i_mirror]);// 防止超出 R
} else {
P[i] = 0;// 等于 R 的情况
}
// 碰到三种特殊情况时,P[i]的值会出错,这里需要利用中心扩展法检验,错了则会修复
while (T[i + 1 + P[i]] == T[i - 1 - P[i]]) {
P[i]++;
}
// 判断是否需要更新 R
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
// 找出 P 的最大值
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < n - 1; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
int start = (centerIndex - maxLen) / 2; //最开始讲的求原字符串下标
return s.substr(start, maxLen);
}
};