写在前面
做题过程中,每次遇到回文问题,第一反应都是:啊,这题我做过,看我分分钟把它解决了 。结果仔细一想又发觉事情并没有这么简单。这次把做过的回文问题总结一遍,也方便后续复习回顾。
注意区分问题中的子串和子序列:
- 子串: 连续;
- 子序列:不连续,但是字符在字符串中的先后顺序不能改变。
1. 最长回文子序列(516. 最长回文子序列)
字符串s, 建立二维动态规划,dp[i][j] 表示从i 到 j 子串范围内的最长回文子序列。
初始状态确定:i == j 时,dp[i][j] = 1.
状态转移:判断s[i] 与 s[j] 是否相等,若相等则 dp[i][j] = dp[i+1][j-1] +2, 若不相等,则看看两个字符分别放进子串 i + 1 到 j - 1上的最长回文子序列长度是多少,取较大的结果,即 dp[i][j] = max(dp[i+1][j], dp[i][j-1]) 。
注意遍历顺序,这部分引用labuladong总结的规律
想求 dp[i][j] 需要知道 dp[i+1][j-1],dp[i+1][j],dp[i][j-1] 这三个位置:
从右图可以直观看出,i 需要从下至上遍历,j 需要从左至右遍历,因此确定遍历顺序:(当然也可以斜着遍历 ,即子串长度从小到大)
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 1));
for (int i = s.size() - 1; i >= 0; --i) {
char c1 = s[i];
for (int j = i + 1; j < s.size(); ++j) {
char c2 = s[j];
if (j - i > 1) {
if (c1 == c2) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
} else {
dp[i][j] = c1 == c2 ? 2 : 1;
}
}
}
return dp[0][s.size() - 1];
}
};
注:不想写这么多判断条件可以初始化时把dp矩阵初始为零,后续再给初始状态赋值,参考官方题解写法。
2. 最长回文子串 (5. 最长回文子串)
方法很多:1、动态规划; 2、中心扩展;3、Manacher算法 (马拉车)
2.1 动态规划
和上题一样,二维动态规划矩阵,dp[i][j] 表示从i 到 j 子串是否为回文子串。仍然根据s[i] 与 s[j] 是否相等进行状态转移。
初始状态: i == j 时,dp[i][j] = true, maxLen = 1.
状态转移:判断s[i] 与 s[j] 是否相等,并且当前长度是否大于2,若满足则 dp[i][j] = dp[i+1][j-1], 若长度小于2且s[i] 与 s[j] 相等,则dp[i][j] = true. 否则dp[i][j] = false。当dp[i][j] = true 并且当前长度大于之前记录的最长长度时,更新起始位置和最长长度,以便最后返回子串。
遍历顺序:和上题分析一样,每次计算需要左下的结果。
2.1.1 反着遍历
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) return s;
vector<vector<bool>> dp(n, vector<bool>(n, false));
int curLen = 0, maxLen = 1;
int startIdx = 0;
for (int i = n - 1; i >= 0; --i) {
dp[i][i] = true;
char c1 = s[i];
for (int j = i + 1; j < n; ++j) {
char c2 = s[j];
if (c1 == c2) {
if (j - i > 1) {
dp[i][j] = dp[i + 1][j - 1];
} else {
dp[i][j] = true;
}
}
// 保存起始位置和最大长度
curLen = j - i + 1;
if (dp[i][j] && curLen > maxLen) {
startIdx = i;
maxLen = curLen;
}
}
}
return s.substr(startIdx, maxLen);
}
};
2.1.2 斜着遍历
以最小长度开始遍历,官方题解:
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i..j] 是否是回文串
vector<vector<int>> dp(n, vector<int>(n));
// 初始化:所有长度为 1 的子串都是回文串
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 递推开始
// 先枚举子串长度
for (int L = 2; L <= n; L++) {
// 枚举左边界,左边界的上限设置可以宽松一些
for (int i = 0; i < n; i++) {
// 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
int j = L + i - 1;
// 如果右边界越界,就可以退出当前循环
if (j >= n) {
break;
}
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substr(begin, maxLen);
}
};
2.2 中心扩展法
通俗点讲,就是遍历字符串,以某一个字符或者相邻两字符为中心(即考虑字符串长度为奇数和偶数的情况),同时向两边扩展,当两边字符不相等时,扩展结束,记录此时回文子串的长度并更新记录结果。
class Solution {
public:
pair<int, int> expandAroundCenter(string& s, int l, int r) {
while (l >= 0 && r < s.size() && s[l] == s[r]) {
--l;
++r;
}
return {l + 1, r - 1}; // 注意这里返回的范围左右都要向内缩 1 .
}
void update(int& start, int& len, int l, int r) {
if (r - l + 1 > len) {
len = r - l + 1;
start = l;
}
}
string longestPalindrome(string s) {
int length = s.size();
int start = 0, len = 1;
for (int i = 0; i < length; ++i) {
auto [l1, r1] = expandAroundCenter(s, i, i);
auto [l2, r2] = expandAroundCenter(s, i, i + 1); // expandAroundCenter里判断了索引是否越界
update(start, len, l1, r1);
update(start, len, l2, r2);
}
return s.substr(start, len);
}
};
2.3 Manacher算法(马拉车)
Manacher可以看出中心扩展法的优化,最大限度地避免重复计算,将时间复杂度从
O
(
n
2
)
O(n^{2})
O(n2) 优化到
O
(
n
)
O(n)
O(n)。
详细讲解参考这篇文章,写的非常清楚明了,我也是看了这篇文章才明白Manacher算法。
不过有些细节处理需要注意一下。
2.3.1 整合奇数偶数情况
在原始字符串每个字符中间插入特殊字符,一般是#,这样不论原始字符串长度是奇数还是偶数,最终长度都是奇数,且不会影响回文判断:
// 字符串预处理
string t = "#";
for (char c: s) {
t += c;
t += '#';
}
s = t;
2.3.2 回文半径与三种情况分析
所谓回文半径指的是,回文子串长度整除 2 (向下取整)。因为预处理过的字符串长度都为奇数,若令回文半径为 radius,则回文子串长度为:
2
×
r
a
d
i
u
s
+
1
2 \times radius + 1
2×radius+1.
若要避免重复计算,则需要利用之前已经计算过的信息来计算当前中心点扩展最终的半径。这里主要分为三种情况,在开始分析之前,我们先定义几个变量:
- maxRight: 即当前已经计算过的中心点,扩展后右边界所能到达的最大位置;
- left: 对应的左边界位置;
- maxCenter: 对应的回文子串中心位置;
- i: 当前要计算的回文中心位置;
- j: i 关于 maxCenter 的对称位置(计算公式: j = 2 × m a x C e n t e r − i j = 2 \times maxCenter - i j=2×maxCenter−i)
此时利用 j 的回文半径来减少 i 的计算,面临三种情况:
- i 在maxRight的左边,并且 j 的最大回文长度左边没有到达 left,此时 i 的回文半径根据对称性应该与 j 相等;
- i 在 maxRight 的左边,并且 j 的最大回文长度左边到达或者超过 left,根据对称性,i 的最小回文长度等于 j - left 也等于maxRight - i,至于最大能有多大,还需要在继续判断;
- i 在 maxRight 的右边,我们就没法利用之前计算的结果了,这个时候就需要一个个判断了;
代码实现:
if (i < maxRight) {
if (radius[2 * maxCenter - i] < maxRight - i) {
radius[i] = radius[2 * maxCenter - i];
} else {
radius[i] = maxRight - i;
while (i - radius[i] >= 0 && i + radius[i] < n &&
t[i - radius[i]] == t[i + radius[i]])
++radius[i]; // 最终存的是回文子串的回文半径+1
}
} else {
radius[i] = 1;
while (i - radius[i] >= 0 && i + radius[i] < n &&
t[i - radius[i]] == t[i + radius[i]])
++radius[i]; // 最终存的是回文子串的回文半径+1
}
优化写法:
radius[i] = i < maxRight ? min(radius[2 * maxCenter - i], maxRight - i) : 1;
while (i - radius[i] >= 0 && i + radius[i] < n && t[i - radius[i]] == t[i + radius[i]])
++radius[i]; // radius保存当前回文子串的回文半径 + 1
2.3.3 由插值后的索引获得原始字符串索引
原字符串中回文串的起始是添加特殊字符之后 (回文中心 - 回文半径 / 2)
原字符串中回文串的长度就是添加特殊字符之后的 回文半径
因为radius存的是回文半径+1,所以最后算索引的时候要注意!
2.3.4 完整代码
class Solution {
public:
string longestPalindrome(string s) {
string t = "#";
for (const char& c : s) {
t += c;
t += "#";
}
int n = t.size();
int maxCenter = 0, maxRight = 0;
int ansCenter = 0, ansRadius = 0;
vector<int> radius(n, 0);
for (int i = 0; i < n; ++i) {
radius[i] = i < maxRight ? min(radius[2 * maxCenter - i], maxRight - i) : 1;
while (i - radius[i] >= 0 && i + radius[i] < n && t[i - radius[i]] == t[i + radius[i]])
++radius[i]; // radius保存当前回文子串的回文半径 + 1
if (i + radius[i] > maxRight) {
maxCenter = i;
maxRight = i + radius[i];
}
if (radius[i] > ansRadius) {
ansCenter = i;
ansRadius = radius[i];
}
}
return s.substr((ansCenter - ansRadius + 1) / 2, ansRadius - 1);
}
};
3. 回文子串 (647. 回文子串)
3.1 动态规划
和上题一样,建立二维动态规划矩阵,dp[i][j] 表示从i 到 j 子串是否为回文子串。仍然根据s[i] 与 s[j] 是否相等进行状态转移。
初始状态:全部为 false;
状态转移方程: 如果s[i] == s[j]的话,分两种情况: 1. (j - i) < 2 则 dp[i][j] = true; 2. j - 1 >= 2 && dp[i + 1][j - 1] = true;
遍历顺序:同上题。
3.1.1 反着遍历
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int ans = 0;
for (int i = s.size() - 1; i >= 0; --i) {
for (int j = i; j < s.size(); ++j) { // 注意这里j要从i开始,因为单个字符也算回文子串
if (s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
++ans;
}
}
}
return ans;
}
};
3.1.2 斜着遍历
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int ans = 0;
// 这里最小长度从1开始
for (int L = 1; L <= s.size(); ++L) {
for (int i = 0; i < s.size(); ++i) {
int j = i + L - 1;
if (j >= s.size()) break;
if (s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
++ans;
}
}
}
return ans;
}
};
3.1.3 阶梯遍历
这是我自己随便起的名字,如果有专业术语的话还请批评指正。因为上面两种遍历顺序都是为了兼顾第一题分析时,状态转移需要使用左、左下、下三个信息。而第二题和这一题在状态转移时其实只需要用到左下的信息。因此这里还有一种可能的遍历顺序,不过这个感觉不是很好想,个人还是推荐上两种遍历顺序,比较好理解也比较好写。代码如下:
class Solution {
public:
int countSubstrings(string s) {
int ans = 0;
vector<vector<bool>> dp(s.length(), vector<bool>(s.length(), false));
for (int j = 0; j < s.length(); ++j) {
for (int i = 0; i <= j; ++i) {
if (s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
++ans;
}
}
}
return ans;
}
};
3.2 中心扩展法
3.2.1 正常写法
和上题并无太大区别
class Solution {
public:
int expandAroundCenter(string& s, int l, int r) {
int cnt = 0;
while (l >= 0 && r < s.size() && s[l] == s[r]) {
--l;
++r;
++cnt;
}
return cnt;
}
int countSubstrings(string s) {
int ans = 0;
for (int i = 0; i < s.size(); ++i) {
ans += expandAroundCenter(s, i, i);
ans += expandAroundCenter(s, i, i + 1);
}
return ans;
}
};
3.2.2 写法优化
针对奇偶上述代码进行了分别处理,其实也可以整合到一起。我们可以看出,一个长度为 length 的字符串,其单字符回文中心可能情况有 length 个,双字符回文中心可能情况有 length - 1 个。因此总的回文中心个数就为
2
×
l
e
n
g
t
h
−
1
2 \times length - 1
2×length−1 个。
遍历
2
×
l
e
n
g
t
h
−
1
2 \times length - 1
2×length−1 个回文中心,设第 center 个回文中心的索引为 center, 接着就是确定中心的左右边界,对于奇数来说,左右边界相等 l = center / 2, r = center / 2;偶数,右边界等于左边界加1,因此l = center / 2, r = center / 2 + 1。整合起来便是 l = center / 2, r = l + (center % 1)。然后开始扩展遍历就好。代码:
class Solution {
public:
int countSubstrings(string s) {
int length = s.length();
int ans = 0;
for (int center = 0; center < 2 * length - 1; ++center) {
int left = center / 2;
int right = left + center % 2;
while ((left >= 0 && right < length) && s[left] == s[right]) {
--left;
++right;
++ans;
}
}
return ans;
}
};
3.3. Manacher算法
class Solution {
public:
int countSubstrings(string s) {
string t = "#";
for (const char& c : s) {
t += c;
t += "#";
}
int maxCenter = 0, maxRight = 0;
vector<int> radius(t.size(), 0);
int ans = 0;
for (int i = 0; i < t.size(); ++i) {
radius[i] = i < maxRight ? min(radius[2 * maxCenter - i], maxRight - i) : 1;
while (i - radius[i] >= 0 && i + radius[i] < t.size() &&
t[i - radius[i]] == t[i + radius[i]])
++radius[i];
if (i + radius[i] > maxRight) {
maxRight = i + radius[i];
maxCenter = i;
}
ans += (radius[i]) / 2; // 回文个数等于处理后字符串回文半径除2向下取整
}
return ans;
}
};
总结
子序列:动态规划
子串:动态规划、中心扩展、Manacher算法。