- 回文子串
- 最长回文子串
- [分割回文串 IV](#分割回文串 IV)
- [分割回文串 II](#分割回文串 II)
- 最长回文子序列
- 让字符串成为回文串的最少插入次数
回文子串
题目描述
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
示例 1:
输入:s = "abc"
输出:3
解释:三个回文子串: "a", "b", "c"
示例 2:
输入:s = "aaa"
输出:6
解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"
提示:
- 1 <= s.length <= 1000
- s 由小写英文字母组成
动态规划
动态规划解法并非这题的最优解法,但是能帮助我们提供一个解决其他回文串问题的思路。
首先我们以dp[i][j]表示以i位置为起点,j位置为结尾的子串是否为回文子串。
那么我们来看三种情况:
- i==j,是回文子串
- i==j-1,即只有两个元素,那么当s[i]==s[j]时,是回文子串
- 其他,那么当s[i]==s[j]时,问题就能转化为[i+1,j-1]部分是不是回文子串,若是则[i,j]也是。
因此我们需要用右下角开启填表,具体实现:
cpp
class Solution {
public:
int countSubstrings(string s) {
int ret=0,n=s.size();
vector<vector<bool>>dp(n,vector<bool>(n));
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
if(j-i<=1)dp[i][j]=s[i]==s[j];
else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
ret+=dp[i][j];
}
cout<<endl;
}
return ret;
}
};
时间复杂度O(n2),空间复杂度O(n2)。
中心拓展算法
中心拓展算法也很好理解,我们选定i为中心,开始比较s[i-len]和s[i+len]是否相等。如果相等就让len++。最终就能求出以i为中心的最大回文子串长度。
具体实现:
cpp
class Solution {
public:
int countSubstrings(string s) {
int ret=0,n=s.size();
for(int i=0;i<n;++i){
int left=i,right=i;
while(left>=0&&right<n&&s[left]==s[right]){
--left;
++right;
++ret;
}
left=i;
right=i+1;
while(left>=0&&right<n&&s[left]==s[right]){
--left;
++right;
++ret;
}
}
return ret;
}
};
时间复杂度O(n2),空间复杂度O(n)。
Manacher
马拉车算法是解决这个问题的最优算法。原理是利用回文子串的对称性:
首先这是一个以z为中心的最长回文子串:

那么他红色部分一定相等,这是以s为中心的最长回文子串:

那么根据对称性,s在z对称位置应当长这样:

所以我们确定了z右边的s为中心的最长回文子串长度。
在考虑另一种情况:

这是以k为中心的最长回文子串,这个子串不完全在z为中心的回文子串当中。这时候我们对称性知道:

这三个字符一定是相等的,因此我们就确定了以z右边的k为中心的回文子串长度是多少,那么下次到了k,我们就能从不能确定的位置开始比较:

由此,我们的算法需要记录当前回文子串能覆盖的最右边界max_right,和对应的中心center。当我们遍历到cur的时候,计算出cur关于center的对称位置:

并将min(cnt[mirror], max_right - i)赋值给cnt[cur]->这里赋较小值是为了刚刚提到的不要超过有边界。
假如赋值后是这样:

我们就从不确定是否回文的地方开始比较,最后拓展完毕:

此时cur的回文子串有边界超出了max_right,就要更新max_right和center:

此外,为了不分类讨论奇数情况和偶数情况的回文串,我们在回文串中插入一个不在回文串范围内的字符,比如'#':

这时候不管是奇数回文串还是偶数回文串,都变成了奇数回文串,且新串的拓展次数就是原串长度:


具体代码实现:
cpp
class Solution {
public:
int countSubstrings(string s) {
// 1. 预处理字符串:统一奇偶回文
string tmp = "#";
for (char e : s) {
tmp += e;
tmp += "#";
}
int n = tmp.size();
int ret = 0;
vector<int> cnt(n, 0); // cnt[i]:以i为中心的最长回文半径(步数)
// Manacher 核心变量:当前最右回文边界 + 对应中心
int max_right = 0;
int center = 0;
for (int i = 0; i < n; ++i) {
// 2. 利用对称性初始化cnt[i](核心优化)
if (i < max_right) {
int mirror = 2 * center - i; // 镜像位置
// 复用镜像位置的半径,但不超过max_right - i(避免超出当前覆盖范围)
cnt[i] = min(cnt[mirror], max_right - i);
}
// 3. 暴力扩展(仅扩展必要的部分)
int left = i - cnt[i] - 1;
int right = i + cnt[i] + 1;
while (left >= 0 && right < n && tmp[left] == tmp[right]) {
cnt[i]++;
left--;
right++;
}
// 4. 更新max_right和center
if (i + cnt[i] > max_right) {
max_right = i + cnt[i];
center = i;
}
// 5. 统计结果:预处理后,半径cnt[i]对应原字符串的回文子串数为 (cnt[i]+1)/2
ret += (cnt[i] + 1) / 2;
}
return ret;
}
};
时间复杂度O(n),空间复杂度O(n)。
最长回文子串
题目描述
给你一个字符串 s,找到 s 中最长的 回文 子串。
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd"
输出:"bb"
提示:
- 1 <= s.length <= 1000
- s 仅由数字和英文字母组成
动态规划
这题和上一题没什么区别,我们还是用dp[i][j]表示[i,j]是否为回文子串,具体实现:
cpp
class Solution {
public:
string longestPalindrome(string s) {
int n=s.size(),ret=0,begin=0;
vector<vector<bool>>dp(n,vector<bool>(n));
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
if(j-i<=1)dp[i][j]=s[i]==s[j];
else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
if(dp[i][j]&&j-i+1>ret){
ret=j-i+1;
begin=i;
}
}
}
return s.substr(begin,ret);
}
};
时间复杂度和空间复杂度都是O(n2)。
注意到我们每次都只关心下一行的dp表,所以我们可以实行空间优化:
cpp
class Solution {
public:
string longestPalindrome(string s) {
int n=s.size(),ret=0,begin=0;
vector<bool>dp(n);
bool cur,prev;
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
cur=dp[j];
if(j-i<=1)dp[j]=s[i]==s[j];
else if(s[i]==s[j])dp[j]=prev;
else dp[j]=false;
if(dp[j]&&j-i+1>ret){
ret=j-i+1;
begin=i;
}
prev=cur;
}
}
return s.substr(begin,ret);
}
};
时间复杂度O(n2),空间复杂度O(n)。
中心拓展
具体实现:
cpp
class Solution {
public:
string longestPalindrome(string s) {
int maxlen = 0, ret = 0;
for (int i = 0; i < s.size(); i++) {
int left = i, right = i;
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
if (right - left - 1 > maxlen) {
maxlen = right - left - 1;
ret = left + 1;
}
if (i + 1 < s.size() && s[i + 1] == s[i]) {
left = i;
right = i + 1;
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
if (right - left - 1 > maxlen) {
maxlen = right - left - 1;
ret = left + 1;
}
}
}
return s.substr(ret, maxlen);
}
};
时间复杂度O(n2),空间复杂度O(1)。
Manacher
具体实现:
cpp
class Solution {
public:
string longestPalindrome(string s) {
string tmp="#";
for(auto e:s){
tmp+=e;
tmp+='#';
}
int n=tmp.size(),center=0,max_right=0,ret=0,begin=0;
vector<int>len(n);
for(int i=1;i<n;++i){
if(i<max_right){
int mirror=2*center-i;
len[i]=min(len[mirror],max_right-i);
}
int left=i-len[i]-1,right=i+len[i]+1;
while(left>=0&&right<n&&tmp[left]==tmp[right]){
--left;
++right;
++len[i];
}
if(len[i]>ret){
ret=len[i];
begin=(i-ret)/2;
}
if(i+len[i]>max_right){
center=i;
max_right=i+len[i];
}
}
return s.substr(begin,ret);
}
};

时间复杂度和空间复杂度都是O(n)。
分割回文串 IV
题目描述
给你一个字符串 s ,如果可以将它分割成三个 非空 回文子字符串,那么返回 true ,否则返回 false 。
当一个字符串正着读和反着读是一模一样的,就称其为 回文字符串 。
示例 1:
输入:s = "abcbdd"
输出:true
解释:"abcbdd" = "a" + "bcb" + "dd",三个子字符串都是回文的。
示例 2:
输入:s = "bcbddxy"
输出:false
解释:s 没办法被分割成 3 个回文子字符串。
提示:
- 3 <= s.length <= 2000
s- 只包含小写英文字母。
算法原理和实现
这题就能用到我们刚开始动态规划[i,j]是否为回文串。
那么我们只需要遍历[0,i][i+1,j][j+1,n-1]是否为回文串即可。
具体实现:
cpp
class Solution {
public:
bool checkPartitioning(string s) {
int n = s.size();
vector<vector<bool>> dp(n, vector<bool>(n));
for (int i = n - 1; i >= 0; --i) {
for (int j = i; j < n; ++j) {
if (j - i <= 1)
dp[i][j] = s[i] == s[j];
else if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1];
}
}
for (int i = 0; i < n - 2; ++i) {
if (dp[0][i]) {
for (int j = i + 1; j < n - 1; ++j) {
if (dp[i + 1][j] && dp[j + 1][n - 1]) {
return true;
}
}
}
}
return false;
}
};
时间复杂度和空间复杂度都是O(n2)。
分割回文串 II
题目描述
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文串。
返回符合要求的 最少分割次数 。
示例 1:
输入:s = "aab"
输出:1
解释:只需一次分割就可将 s 分割成 ["aa","b"] 这样两个回文子串。
示例 2:
输入:s = "a"
输出:0
示例 3:
输入:s = "ab"
输出:1
提示:
- 1 <= s.length <= 2000
- s 仅由小写英文字母组成
算法原理和实现
这题类似之前写过的单词拆分的考量。
我们定义状态dp[i]为,[0,i]被分割成回文子串的最小次数。
那么当[0,i]是回文串时,dp[i]=0。
如果不是我们就需要从i开始往前遍历找到[j,i]是回文子串,则dp[i]=min(dp[i],dp[j-1]+1)
具体实现:
cpp
class Solution {
public:
int minCut(string s) {
int n=s.size();
vector<vector<bool>>isPal(n,vector<bool>(n));
vector<int>dp(n,n);
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
if(j-i<=1)isPal[i][j]=s[i]==s[j];
else if(s[i]==s[j])isPal[i][j]=isPal[i+1][j-1];
}
}
for(int i=0;i<n;++i){
if(isPal[0][i])dp[i]=0;
else{
for(int j=i;j>0;j--){
if(isPal[j][i]){
dp[i]=min(dp[i],dp[j-1]+1);
}
}
}
}
return dp[n-1];
}
};
时间复杂度和空间复杂度都是O(n2)。
最长回文子序列
题目描述
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
示例 1:
输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb" 。
示例 2:
输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb" 。
提示:
- 1 <= s.length <= 1000
- s 仅由小写英文字母组成
算法原理和实现
如果你阅读过我的子序列动态规划系列,你就会发现。我们这次一维动态规划是肯定行不通的。因为我们新增一个字母根本就不知道能不能和前面组成回文串。
所以更好的想法是二维:
dp[i][j]表示区间[i,j]内的最长回文子序列的长度。
那么状态转移方程为:
d p [ i ] [ j ] = 1 w h e r e i = = j dp[i][j]=1\space where\space i==j dp[i][j]=1 where i==j
d p [ i ] [ j ] = 1 + s [ i ] = = s [ j ] w h e r e i = = j − 1 dp[i][j]=1+s[i]==s[j]\space where\space i==j-1 dp[i][j]=1+s[i]==s[j] where i==j−1
d p [ i ] [ j ] = 2 + d p [ i + 1 ] [ j − 1 ] w h e r e s [ i ] = = s [ j ] dp[i][j]=2+dp[i+1][j-1]\space where\space s[i]==s[j] dp[i][j]=2+dp[i+1][j−1] where s[i]==s[j]
d p [ i ] [ j ] = m a x ( d p [ i + 1 ] [ j ] , d p [ i ] [ j − 1 ] ) o t h e r dp[i][j]=max(dp[i+1][j],dp[i][j-1])\space other dp[i][j]=max(dp[i+1][j],dp[i][j−1]) other
具体实现:
cpp
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n=s.size(),ret=0;
vector<vector<int>>dp(n,vector<int>(n));
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
if(i==j)dp[i][j]=1;
else if(i==j-1)dp[i][j]=1+(s[i]==s[j]);
else if(s[i]==s[j])dp[i][j]=2+dp[i+1][j-1];
else dp[i][j]=max(dp[i+1][j],dp[i][j-1]);
ret=max(dp[i][j],ret);
}
}
return ret;
}
};
时间复杂度和空间复杂度都是O(n2)
根据上一题的经验,我们这个dp表每次更新都只需要下一行的元素,所以我们可以进行空间优化:
cpp
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n=s.size(),ret=0,prev,cur;
vector<int>dp(n);
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
cur=dp[j];
if(i==j)dp[j]=1;
else if(i==j-1)dp[j]=1+(s[i]==s[j]);
else if(s[i]==s[j])dp[j]=2+prev;
else dp[j]=max(cur,dp[j-1]);
prev=cur;
ret=max(dp[j],ret);
}
}
return ret;
}
};
现在时间复杂度是O(n2),空间复杂度是O(n)
让字符串成为回文串的最少插入次数
题目描述
给你一个字符串 s ,每一次操作你都可以在字符串的任意位置插入任意字符。
请你返回让 s 成为回文串的 最少操作次数 。
「回文串」是正读和反读都相同的字符串。
示例 1:
输入:s = "zzazz"
输出:0
解释:字符串 "zzazz" 已经是回文串了,所以不需要做任何插入操作。
示例 2:
输入:s = "mbadm"
输出:2
解释:字符串可变为 "mbdadbm" 或者 "mdbabdm" 。
示例 3:
输入:s = "leetcode"
输出:5
解释:插入 5 个字符后字符串变为 "leetcodocteel" 。
提示:
- 1 <= s.length <= 500
- s 中所有字符都是小写字母。
算法原理和实现
我们用dp[i][j]表示[i,j]子串变成回文字符串的最小插入次数。
首先考虑i==j时,dp[i][j]=0。
其次考虑i==j-1时,即只有两个字符,显然当两个字符相等时dp[i][j]=0,不相等时dp[i][j]=1。
最后我们考虑剩余情况。
当s[i]==s[j]时:

末端两个字符已经配对了,那说明我们肯定不需要管这两个字符。所以dp[i][j]=dp[i+1][j-1]。
当s[i]!=s[j]时:

我们这两个元素闭不可能作为回文串的两端,所以至少要插一个字符到两端和他们相匹配,这又分为两种。
第一种是我们在右边插入一个x:

这时我们插入次数就加一了,剩下只需要管i+1,j这部分的最小插入次数,即回到s[i]==s[j+1]的情况。
第二种自然是往左边插入z:

我们只需管[i,j-1]的情况。
所以dp[i][j]=min(dp[i+1][j],dp[i][j-1])+1
具体实现:
cpp
class Solution {
public:
int minInsertions(string s) {
int n=s.size();
vector<vector<int>>dp(n,vector<int>(n));
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
if(i==j)dp[i][j]=0;
else if(i==j-1)dp[i][j]=s[i]!=s[j];
else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
else dp[i][j]=min(dp[i+1][j],dp[i][j-1])+1;
}
}
return dp[0][n-1];
}
};
时间复杂度和空间复杂度都是O(n2)。
不难发现我们每次更新都只需要下一行的状态,所以我们可以进行空间优化:
cpp
class Solution {
public:
int minInsertions(string s) {
int n=s.size(),cur,prev;
vector<int>dp(n);
for(int i=n-1;i>=0;--i){
for(int j=i;j<n;++j){
cur=dp[j];
if(i==j)dp[j]=0;
else if(i==j-1)dp[j]=s[i]!=s[j];
else if(s[i]==s[j])dp[j]=prev;
else dp[j]=min(cur,dp[j-1])+1;
prev=cur;
}
cout<<endl;
}
return dp[n-1];
}
};
时间复杂度O(n2),空间复杂度O(n)。