原理
一般回文有这么几个解决思路:马拉车,区间dp,中心拓展。
前者是最强大的, O ( n ) O(n) O(n),后两者都是 O ( n 2 ) O(n^2) O(n2)的,但中心拓展比区间dp常数小(只用o1空间,访存更快),更好写,并且很多时候,中心拓展是一个很重要思想(后面会举例),所以还是很有学习价值。
大致思想就是枚举回文中心,然后往两侧拓展,直到遇到不相等的,就停止,这样也能计算出每个回文中心的最大回文半径,这类似马拉车。
实现
基于回文中心的写法也会遇到奇偶回文串的问题,想要一次循环解决这两类回文串,可以这样写。
这样可以交替枚举起始位置 l = r , l + 1 = r l=r,l+1=r l=r,l+1=r的情况,也就是枚举奇偶回文串。
c
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
int L = 0, R = 0;
for (int i = 0; i < 2 * n - 1; i++) {
int l = i / 2, r = (i + 1) / 2;
while (l >= 0 && r < n && s[l] == s[r]) {
l--;
r++;
}
if (r - l - 1 > R - L + 1) {
L = l + 1, R = r - 1;
}
}
return s.substr(L, R - L + 1);
}
};
例题
划分型dp
每段长度不小于k,都是回文,问最大划分段数。
可以区间dp或者中心拓展预处理每个区间的回文情况。
但这样写,仍然需要 O ( n 2 ) O(n^2) O(n2)空间,没有发挥中心拓展的 O ( 1 ) O(1) O(1)空间优势,被拉到和区间dp一个水平线了
c
class Solution {
public:
int maxPalindromes(string s, int k) {
int n = s.size();
vector<vector<int>> f(n + 1, vector<int>(n + 1));
for (int i = 0; i < 2 * n - 1; i++) {
int l = i / 2, r = (i + 1) / 2;
while (l >= 0 && r < n && s[l] == s[r]) {
f[l + 1][r + 1] = 1;
l--;
r++;
}
}
vector<int> g(n + 1);
for (int i = 1; i <= n; i++) {
g[i] = g[i - 1];
for (int j = 1; i - j + 1 >= k; j++) {
if (f[j][i]) {
g[i] = max(g[j - 1] + 1, g[i]);
}
}
}
return g[n];
}
};
所以更好的写法是,在中心拓展的过程中,每次拓展一步,意味着找到一个回文,就转移一次,这样空间 O ( 1 ) O(1) O(1)了,一般来讲这种 O ( 1 ) O(1) O(1)空间相比于 O ( n 2 ) O(n^2) O(n2)空间,即使时间复杂度相同,常数也会差十倍左右,这就是访存开销带来的,所以优化内存访问也是很重要的一环。
然后这里的转移就是,每新增一个点,可以不选它,那么答案直接继承上一轮的。这是查表,后面中心拓展里的转移时刷表。这里查表刷表混用,但由于我们是从左到右枚举回文中心的,不会有问题。
最后还有个小优化,如果中心拓展长度不小于k了,这个回文中心的转移就可以结束了,因为后面即使有更长的回文串,对答案的贡献同样是1,并且占用的区间更大,不会更优。加上这个优化常数又变小十倍。
c
class Solution {
public:
int maxPalindromes(string s, int k) {
int n = s.size();
vector<int> f(n + 1);
for (int i = 0; i < 2 * n - 1; i++) {
int l = i / 2, r = (i + 1) / 2;
f[l + 1] = max(f[l + 1], f[l]);
while (l >= 0 && r < n && s[l] == s[r]) {
if (r - l + 1 >= k) {
f[r + 1] = max(f[r + 1], f[l] + 1);
break;
}
l--;
r++;
}
}
return f[n];
}
};
附运行时间截图

3615. 图中的最长回文路径
在图上找一个最长路径,形成的字符串是个回文串。
图不大,记搜可过。比较朴素的方法是从一个点出发,每次把一个点加入路径,但这样的问题是,想判断回文需要到最后才能判断,也就是我们要把目前的串作为参数传递下去,而串的个数是不同路径条数, O ( n ! ) O(n!) O(n!)的,无法记忆化。
这就是前面说的,有时候中心拓展法是一个重要的思想。考虑回文串的构造过程,处理每次在末尾添加一个字符,然后保存已构成的字符串,判断是否回文。
另一种方式是中心拓展的思想,每次在两侧加两个相同字符,这样是一个最优子结构,我们无需关心回文串内部长什么样子,只要知道两端是哪两个点,就能拓展。虽然由于是简单路径,每个点只能用一次,还需要一个mask记录哪些点用了,但这样的状态复杂度只有 O ( n 2 2 n ) O(n^22^n) O(n22n),相比原来的 O ( n ! ) O(n!) O(n!)已经是很大进步了。
搜索内部,枚举当前回文串两个端点的邻居对,要求点不重复,点上字符相同,这是一个 O ( n 2 ) O(n^2) O(n2)的枚举,整体复杂度 O ( n 4 2 n ) O(n^42^n) O(n42n), n = 14 n=14 n=14,计算量约为 6 e 8 6e8 6e8,可过。
由于lc的整体+部分计时法,还需要卡常一下。
- 完全图可以特判掉,完全图所有路径都是可得到的,转化成贪心问题。
- 可以假设这个图是个完全图,算出来答案上界,某次循环后达到上界直接返回
- 枚举点对时保证 x < y x<y x<y,减少一半的枚举常数。
c
class Solution {
public:
int maxLen(int n, vector<vector<int>>& e, string s) {
int cnt[26]{};
for (char c : s) {
cnt[c - 'a']++;
}
int odd = 0;
for (int c : cnt) {
odd += c % 2;
}
int mx = n - max(odd - 1, 0);
if (e.size() == n * (n - 1) / 2) {
return mx;
}
vector<vector<int>> g(n);
for (auto& t : e) {
int u = t[0], v = t[1];
g[u].push_back(v);
g[v].push_back(u);
}
vector<int> f(n * n * (1 << n), -1);
auto&& dfs = [&](this auto self, int u, int v, int mask) -> int {
int cur = u + v * n + mask * n * n;
if (f[cur] != -1)
return f[cur];
int res = 0;
for (int x : g[u]) {
if (mask >> x & 1)
continue;
for (int y : g[v]) {
if (mask >> y & 1)
continue;
if (x == y || s[x] != s[y])
continue;
res = max(res, self(x, y, mask | (1 << x) | (1 << y)) + 2);
}
}
return f[cur] = res;
};
int ans = 0;
for (int i = 0; i < n; i++) {
ans = max(ans, dfs(i, i, 1 << i) + 1);
if (ans == mx)
return mx;
}
for (int i = 0; i < n; i++) {
for (int j : g[i]) {
if (i <= j && s[i] == s[j]) {
ans = max(ans, dfs(i, j, (1 << i) | (1 << j)) + 2);
if (ans == mx)
return ans;
}
}
}
return ans;
}
};