[TOC]
一、题目描述
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-palindromic-substring
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
二、解题算法
1. 暴力法
思路:
我不是针对谁,显然在座的各位都会判别一个给定的字符串是不是回文串,那我只要枚举 s 的全部子串,然后逐个判断是不是回文串不就好了吗?题意要求最长的回文子串,我还可以按子串长度从长到短去枚举,提升效率,美滋滋啊!
但粗略估计一下,需要枚举长度、左端点,然后再遍历子串,时间复杂度需要到,性能好像不是很耐看?(我没有莽过,也许可以过,哪位大佬有兴趣可以试试?)
2. 动态规划
2.1 常规操作
思路:
既然暴力法不是很划算,我们就得进一步思考一下如何进行优化。
暴力法主要的问题在于冗余计算。若不是回文串,那肯定不是回文串,而暴力法无法有效地利用这个信息。
嗯,虽然上面只是举了一个例子,但这个例子好像透露出一股优化方案的味道?因为照这种方法,对任意子串,我们可以在的时间内来判别其是否为回文串,而不像暴力法中需要用的时间来判断。
记为子串的回文长度(非回文串长度为0,回文串为子串长度),我们显然可以得到下述状态转移方程:
有了状态转移方程,下面就是如何初始化这个数组的问题了。
我们可以注意到,这种思路其实是从某个中心子串出发,向两边扩张的过程。所以我们只要初始化最根本的中心,剩下的都可以靠扩张过程得到。
那么显然,所有长度为1的子串都是中心。同时我们需要注意到,如果将长度为1的子串作为中心,由于每次扩张都会纳入2个新的字符,所以它只能扩张为奇数长度的子串。为了保证同时考虑到偶数长度的子串,我们必须将所有长度为2的子串也作为中心。于是有了如下初始化方程:
时间复杂度,空间复杂度。
代码:
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty())
return "";
vector> dp(s.length(), vector(s.length()));
for (auto i = 0; i < dp.size(); ++i)
dp[i][i] = 1;
for (auto i = 0; i < dp.size() - 1; ++i)
dp[i][i + 1] = (s[i] == s[i + 1]) ? 2 : 0;
for (auto len = 3; len <= s.length(); ++len)
for (auto i = 0; i < s.length() - len + 1; ++i)
if (dp[i + 1][i + len - 2] > 0 &&
s[i] == s[i + len - 1])
dp[i][i + len - 1] = len;
auto ans = 0, begin = 0, end = 0;
for (auto i = 0; i < dp.size(); ++i)
for (auto j = 0; j < dp[i].size(); ++j)
if (ans < dp[i][j]) {
ans = dp[i][j];
begin = i;
end = j;
}
return s.substr(begin, end - begin + 1);
}
};
2.2 修正一个小的缺陷
思路:
2.1的解决方案有点小瑕疵,观察代码我们发现在计算数组的双重循环中,先遍历的是,后遍历的。这样更容易出现缓存不命中的情况,因而可以考虑对代码进行调整,让其先遍历,再遍历。
示意图以长度为5的字符串为例。○表示长度为1的子串,×表示长度为2的子串,以此类推。
按照2.1的逻辑,我们的计算过程是每次外循环决定计算哪一条斜线(一条斜线代表一种子串长度),内循环再去将该斜线中的节点从上自下算出(该长度的所有子串)。且代码中计算需要利用到,在图中形象的来说就是计算从△起(因为○跟×是初始化的,无需计算)的任一节点,都必须要用到它左下角的节点。
因此,如果我们想整行整行的遍历,不能从上往下,因为每个节点依赖于它的左下角的节点,只能从下往上。
时间复杂度,空间复杂度。
代码:
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty())
return "";
vector> dp(s.length(), vector(s.length()));
for (auto i = 0; i < dp.size() - 1; ++i) {
dp[i][i] = 1;
dp[i][i + 1] = (s[i] == s[i + 1]) ? 2 : 0;
}
dp.back().back() = 1;
for (auto i = int(dp.size()) - 1; i > -1; --i)
for (auto len = 3; i + len - 1 < s.length(); ++len)
if (dp[i + 1][i + len - 2] > 0 &&
s[i] == s[i + len - 1])
dp[i][i + len - 1] = len;
auto ans = 0, begin = 0, end = 0;
for (auto i = 0; i < dp.size(); ++i)
for (auto j = 0; j < dp[i].size(); ++j)
if (ans < dp[i][j]) {
ans = dp[i][j];
begin = i;
end = j;
}
return s.substr(begin, end - begin + 1);
}
};
然而虽然我这么处理了,但速度提升并不明显,只是有些轻微的提升,让我有点蒙圈。或许瓶颈其实并不在缓存不命中这里?
2.3 滚动数组
思路:
行吧,那我放弃挣扎了,好不容易想到个方案想提速结果竟然没啥明显的作用,说好的学会利用缓存会有奇效的呢?都是骗人的!生气!
老老实实的优化我那瘆人的空间复杂度吧,导致在我的几次提交之中,基本都要消耗186MB的内存,这太恐怖了,必须控制一下。
按照滚动数组常规的思考方向,动态规划中用来保存状态的数组我们可以看到他只用了上三角部分来保存数据,下三角部分完全没有使用。且,在2.2中我们描述过,对从△起的任一节点,我们都只要额外知道他左下角的节点就可以了。这就给了我们一种思考方向,或许,我们只需要2行,便可以计算出数组。
另外,由于需要左下角节点,那我们也必须像2.2中一样,将数组从下往上倒过来计算。而且,由于只使用2行,如何初始化数组也需要注意一下。
我们知道按照2.1和2.2的初始化方法,我们必然要在处理每一行的时候初始化和(想想数组表示的状态是什么),初始化方式和正常初始化一样。但由于倒数第一行只有1个节点,所以我们需要把这一行单独拿出来计算。
时间复杂度,空间复杂度。
代码:
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty())
return "";
auto cur = 1, prev = 0, ans = 1, begin = 0, end = 1;
vector> dp(2, vector(s.length(), 0));
dp[0].back() = 1;
for (auto i = int(s.length()) - 2; i > -1; --i) {
dp[cur][i] = 1;
dp[cur][i + 1] = s[i] == s[i + 1] ? 2 : 0;
if (dp[cur][i + 1] > ans) {
ans = dp[cur][i + 1];
begin = i; end = i + 2;
}
for (auto j = i + 2; j < s.length(); ++j)
if (dp[prev][j - 1] > 0 && s[i] == s[j]) {
dp[cur][j] = dp[prev][j - 1] + 2;
if (dp[cur][j] > ans) {
ans = dp[cur][j];
begin = i; end = j + 1;
}
}
else
dp[cur][j] = 0;
prev = cur;
cur = 1 - cur;
}
return s.substr(begin, end - begin);
}
};
嗯?我明明只是优化了内存,所以内存从168MB优化到9.5MB合情合理。但为什么执行速度也提高了?从300ms+缩减到了132ms?
天地良心,除了使用的内存减少了,算法基本上没做任何太大改动啊。也就是最多把求解和的过程移动到了循环内。但这也是平方次的比较,会有很大的差别吗?难道瓶颈在于之前开辟数组的时候?又或者因为这次因为内存足够小,所以提高了缓存的执行性能?
好迷啊,估摸着回头有时间真的得用宇宙第一IDE来调试看看瓶颈到底在哪了,总比我这瞎猜来得强。
3. 中心扩展算法
3.1 常规操作
思路:
其实思路已经没什么好说的了吧,毕竟动态规划的解法里已经把思路都说了。防止有大佬看不起我动态规划的解法,这里还是把思路再稍微提一下。
中心扩展算法,说白了就是枚举回文子串的中心,然后从中心往两边扩展,得到以该节点为中心所能构成的最长回文子串。唯一要注意以下的就是回文中心分为奇数长度与偶数长度两种,其长度分别为1和2。
设字符串长度为,在枚举长度为1的中心时,这个都被枚举了。而枚举长度为2的中心时,一共有组中心可以被枚举。因而总共枚举个中心,每次枚举中心最坏要检查个字符。然而不需要额外空间记录搜索状态。
时间复杂度,空间复杂度。
代码:
class Solution {
public:
int count(string& s, int lhs, int rhs) {
while (lhs > -1 && rhs < s.length() && s[lhs] == s[rhs]) {
lhs--; rhs++;
}
return rhs - lhs - 1;
}
string longestPalindrome(string s) {
if (s.length() < 2)
return s;
auto begin = 0, end = 0, max_len = 0;
for (size_t i = 0; i < s.size(); ++i) {
auto len1 = count(s, i, i);
auto len2 = count(s, i, i + 1);
max_len = max(len1, len2);
if (max_len > end - begin + 1) {
begin = i - (max_len - 1) / 2;
end = i + max_len / 2;
}
}
return s.substr(begin, end - begin + 1);
}
};
3.2 如果你对奇偶两种中心如鲠在喉
思路:
嗯,如果看到这的话,那你一定是真的对上面需要处理两种中心的情况心怀不满了,觉得不够优雅。那么这里提供一个小的技巧可以将奇偶两种情况转变为一种。
郑重声明:该技巧使用后,时间复杂度与空间复杂度的系数或者数量级均会有不同程度的上升,性能是有所下降的。这里仅仅是当做扩展思路、开拓眼界而提及的(显然这并不是我原创的)。另外,之所以不在动态规划引入,是因为对动态规划来说,再使用这个技巧,代价有点难以承受。
我们之所以会遇到奇偶的问题,很显然是因为回文子串长度它可能是奇数,也可能是偶数。如果,我是说如果,我们能够将回文子串的长度统一成奇数或者偶数,那不就可以不用分情况讨论了吗?
对,比如说将,变为。那它们就显然被统一成了奇(长度的)(回文)子串,就只要考虑中心为单个字符的情况了。
那现在问题来了,我怎么把子串长度从扩展到呢?其实很简单,往原串插入字符就好了,用某个特定的字符去扩张原串。
注意,这里选择#作为扩展字符并没有什么特殊的含义,只要能保证用来扩展的字符不存在于原串之中,你爱选啥就选啥。
我们可以看到,
对于每一个实线方框内,扩展串的下标,只要取,便能得到原串中的下标。这便于我们之后通过扫表扩展串,来从原串中找到对应的子串。同理,扩展子串长度若为,只要取,便能得到原子串的长度。
对于每一个虚线方框,不管其对应原串是偶子串还是奇子串,在扩展串中都是奇子串,并且左右两端一定是#。唯一区别是原偶子串在扩展串中,中心为#。而原奇子串在扩展串中,中心为(原串中)奇子串对应的中心。之后在计算子串对应边界时会用到这个性质。
好了,接着对扩展串采用中心扩展算法的思路,就可以得到最长的那个扩展子串。接下来只要想办法将其还原成原子串就好了。
原子串的中心和长度上面已经描述过计算方法了,问题就在于如何求左右两个端点。
可能有同志说,有中心坐标,也有字串长度了,直接,不是很明显的吗?
果真如此吗?不要忘了,原子串是分奇偶两种子串的,而该公式只适用于原子串为奇子串的情况。因为根据的奇偶,并不是一个定值。因此这里采用了,。这样,无论的奇偶如何,。
时间复杂度,空间复杂度。
代码:
class Solution {
public:
int count(vector& s, int lhs, int rhs) {
while (lhs > -1 && rhs < s.size() && s[lhs] == s[rhs]) {
lhs--; rhs++;
}
return rhs - lhs - 1;
}
string longestPalindrome(string s) {
if (s.size() < 2)
return s;
vector str(2 * s.size() + 1, '#');
for (auto i = 0; i < s.size(); ++i)
str[i * 2 + 1] = s[i];
auto begin = 0, end = 0, max_len = 0;
for (auto i = 0; i < str.size(); ++i) {
auto len = count(str, i, i);
if (len > max_len) {
max_len = len;
len /= 2;
auto center = i / 2;
begin = center - (len / 2);
end = center + len - (len / 2);
}
}
return s.substr(begin, end - begin);
}
};
4. Manacher(马拉车)算法
思路:
马拉车算法的大名不用介绍了吧,我只能说想出这算法的人真是个天才。
该算法可以看作是中心扩展算法的一个优化,其点睛之笔在于利用了回文串的对称性。
一图胜千言:
当前计算的字符我们称为当前点,如果当前点落在了之前计算过的某个回文子串之内,我们便可以利用关于回文子串中心对称的那个对称点,计算出当前点回文子串的长度。(只要小回文串完全落入回文串中,由于对称性,两个小回文串显然是完全一致的,于是便可以跳过当前点的计算)[情况1]
那从分类讨论的角度来看,显然还剩下两种情况:
- 小回文串部分落入回文串,部分在回文串之外(当前点还在回文串之内)[情况2]
- 当前点直接就超出了回文串的范围[情况3]
针对[情况2],我们可以利用对称点先得到小回文串在回文串内的长度,然后再利用中心扩展算法求出小回文串在回文串外面部分的长度,二者相加就是完整的小回文串长度。
针对[情况3],由于无法利用对称性,只能直接使用中心扩展算法直接计算。
是不是觉得简直天才?
上面的讨论中我们提到需要判断当前点是否落入回文串之内,那我如何快速判断是否落入了某个回文串内呢?
由于字符串从左往右遍历,我们可以选择已计算的右边界索引最大的回文串作为图中的回文串。只要记录下它的中心点和回文半径,就可以快速的判断当前点是否落入回文串内,同时也可以快速判断小回文串是全部落在回文串之内,还是只有部分落入。
另外由于需要中心点、对称点的回文半径,所以我们可以采用一个数组保存全部字符的回文半径。
时间复杂度,空间复杂度。
PS:讲道理我不明白时间复杂度是怎么算的,因为只有一种情况是可以完全利用对称性的,其余两种都需要做一定程度的中心扩展。但由于对称性的利用,我们可以确定最坏的情况也不过中心扩展算法,所以一定会比中心扩展算法更快。
代码:
class Solution {
public:
string longestPalindrome(string s) {
// 扩展字符串,这样只需考虑奇子串了
vector str(s.size() * 2 + 1, '#');
for (auto i = 0; i < s.size(); ++i)
str[i * 2 + 1] = s[i];
// 回文半径数组
vector radius(str.size(), 0);
// 以下四个变量都是在扩展串中的,
// max_c为回文串中心下标,max_r为回文串最右下标,
// ans_c为最长回文串中心下标,ans_r为最长回文串回文半径
auto max_c = 0, max_r = 0, ans_c = 0, ans_r = 0;
for (auto i = 0; i < str.size(); ++i) {
// 计算当前点回文半径,分三种情况讨论
radius[i] = i < max_r ? min(radius[max_c * 2 - i], max_r - i) : 0;
// 防止遇到情况2、情况3,修正当前字符的回文半径
while (-1 < i - radius[i] && i + radius[i] < str.size() &&
str[i + radius[i]] == str[i - radius[i]])
radius[i]++;
// 由于上面循环会将形如"a"的小回文串设置回文半径为1,需修正
radius[i]--;
// 修正回文串半径
if (i + radius[i] > max_r) {
max_c = i;
max_r = i + radius[i];
}
// 修正最长回文串的中心和回文半径
if (radius[i] > ans_r) {
ans_c = i;
ans_r = radius[i];
}
}
// 中心扩展算法3.2中已经推导过
auto len = (ans_r * 2 + 1) / 2;
auto center = ans_c / 2;
auto begin = center - (len / 2);
auto end = center + len - (len / 2);
return s.substr(begin, end - begin);
}
};