区间 DP:f[i][j] 表示把 s[i..j] 补成回文的最少插入数,两端相同继承内层、不同则补一侧,O(n²) 求解;同一答案也等于 l − 最长回文子序列。
OJ: luogu
题目 ID: P1435
难度:普及
标签:区间DP动态规划最长公共子序列
创建: 2026-10-23 23:09
更新: 2026-10-28 21:42
目录
形式化题目
给定字符串
暴力解法
思路
最直接的做法是模拟插入过程:当前串不是回文时,枚举在每个空隙插入每个可能出现的字符,递归搜索,直到得到回文串,取最少的插入次数。
这个搜索是指数级的:长度 Ab3bd 外层 A…d 处理完之后,剩下的工作只取决于内层 b3b 这一段;换一条插入路径,问题还是落到同一个内层子串上。
「剩余内层区间」才是有信息量的状态。朴素搜索没有记住它,做了大量重复计算;正解要消除的就是这部分。
写这段搜索时有两处会让它稍微能跑一点。一是候选字符只取原串里出现过的:最优解里新补的字符总和一个原串字符配对(若两个新补的字符互相配对,把这一对删掉照样是回文),所以补原串没有的字符没有意义。二是插入次数从
对拍说明:
brute.cpp就是这个指数搜索,brute2.cpp是另一个独立模型(,见「等价模型」一节)。真正跑对拍用的是后者:指数版到 个字符就开始拖,撑不完几百组;LCS 版既快,又和正解没有共用思路。两者在长度 、字符集 上互相验证了 组。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-28 21:25
* update_at: 2026-10-28 21:29
*/
// brute.cpp:最朴素的模拟。当前串不是回文时,在每个空隙插入一个可能用到的字符,
// 用「限制插入次数的深搜 + 逐层加深」求最少插入数。指数级,只用来观察重复求解。
#include <bits/stdc++.h>
using namespace std;
string s;
string alphabet; // 原串里出现过的字符(插原串没有的字符不可能更优)
int limit_depth; // 本轮允许的插入次数上限
bool is_palindrome(const string &t) {
int i = 0, j = (int)t.size() - 1;
while (i < j) {
if (t[i] != t[j]) return false;
++i;
--j;
}
return true;
}
// cur 是当前串,depth 是已经插入的字符数;返回能否在 limit_depth 次内变成回文
bool dfs(const string &cur, int depth) {
if (is_palindrome(cur)) return true;
if (depth == limit_depth) return false;
for (int pos = 0; pos <= (int)cur.size(); ++pos) {
for (char c : alphabet) {
string nxt = cur;
nxt.insert(nxt.begin() + pos, c); // 在第 pos 个空隙插入 c
if (dfs(nxt, depth + 1)) return true;
}
}
return false;
}
int main() {
cin >> s;
bool seen[256] = {false};
for (char c : s) {
if (!seen[(unsigned char)c]) {
seen[(unsigned char)c] = true;
alphabet += c;
}
}
// 插入次数从 0 往上试:第一次成功就是最少次数
for (limit_depth = 0; limit_depth <= (int)s.size(); ++limit_depth) {
if (dfs(s, 0)) {
cout << limit_depth << "\n";
break;
}
}
return 0;
}复杂度与瓶颈
时间复杂度指数级:每层最多 -O2)样例 Ab3bd 是
瓶颈:同一个内层子串被不同插入路径重复求解。子问题的结果只由「剩余区间」决定,与到达它的插入顺序无关。
正解
关键观察
把问题看成「从外向内配对」:回文串的最外侧两个字符互相配对,去掉它们后剩下的部分仍是回文。反过来看原串
- 若
,这两个字符可以直接配对,问题变成内层 ; - 若
,它们最终无法互配,各需要与一个插入字符配对。最优方案中只需现在处理其中一个(同时补两侧必然不如保留原字符更划算),也就是在左端外侧补一个 去消化右端,或右端外侧补一个 去消化左端,各花一次插入。
于是「剩余未配对的区间」
样例 Ab3bd 上取
格子上方的灰色小数字是原串下标,A 与 d 不相等,只能先处理其中一端。左支补一个 A,橙色虚线弧把它和 d 让 A 找伙伴,两支最后都落到 b3b,而 b3b 自己就是回文(
思路
定义
转移:
初始化:单字符区间
遍历顺序:区间长度从
正确性依据:最优方案中
样例 Ab3bd 的 DP 表(行是左端点 — 表示
| i\j | A | b | 3 | b | d |
|---|---|---|---|---|---|
| 1 (A) | 0 | 1 | 2 | 1 | 2 |
| 2 (b) | — | 0 | 1 | 0 | 1 |
| 3 (3) | — | — | 0 | 1 | 2 |
| 4 (b) | — | — | — | 0 | 1 |
| 5 (d) | — | — | — | — | 0 |
读法示例:b3b,而 b3b 已是回文)。
这张表还能换个说法:答案等于
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-23 23:14
* update_at: 2026-10-23 23:14
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXL = 1005;
string s;
ll f[MAXL][MAXL]; // f[i][j]:把 s[i..j] 变成回文串最少需要的插入字符数(i、j 为 1 开始的下标)
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> s;
ll n = s.size();
// 下标整体右移一位,方便按题意从 1 开始
string t = " " + s;
// 长度为 1 的区间本身是回文,插入数为 0
for (ll i = 1; i <= n; ++i) f[i][i] = 0;
// len 是区间长度,从 2 开始,从小区间推出大区间
for (ll len = 2; len <= n; ++len) {
for (ll i = 1; i + len - 1 <= n; ++i) {
ll j = i + len - 1;
if (t[i] == t[j]) {
// 两端已经配对,成本等于去掉两端后的子区间
f[i][j] = f[i + 1][j - 1];
} else {
// 两端不配对:在左边补一个 t[j],或在右边补一个 t[i],取较小者
f[i][j] = min(f[i + 1][j], f[i][j - 1]) + 1;
}
}
}
cout << f[1][n] << "\n";
return 0;
}复杂度
- 时间:状态
个,每个转移 ,总计 。 时约 次转移,实测 CPU 约 ms。 - 空间:
为 个 long long,约MB,定义为全局数组避免爆栈。
等价模型:最长回文子序列,以及正串与反串的 LCS
直觉
「
骨架有两种等价叫法:Ab3bd 为例,反串是 db3bA,公共部分是 b3b(长度
两种叫法为什么等价
设
反过来,若
两种叫法问的是同一件事:找一个正反两读都认的骨架,它有多长。
为什么答案恰好是
上界(想少补做不到):假设补成的是长度
下界(用
- 长度
: , ,成立。 : ,而 ,由归纳假设 ,两边相等。 : ,而 ,把 换成 再取补,同样相等。
由归纳法
最长回文子序列怎么求
以 abca 为例(行是左端点,列是右端点,— 表示空区间):
| i\j | a | b | c | a |
|---|---|---|---|---|
| 1 (a) | 1 | 1 | 1 | 3 |
| 2 (b) | — | 1 | 1 | 1 |
| 3 © | — | — | 1 | 1 |
| 4 (a) | — | — | — | 1 |
aca),答案 b 得 abcba。顺带一个反例:abca 的最长回文子串只有
代码
这一模型的实现是同目录的 brute2.cpp,它按 main.cpp 的区间 DP 没有共用思路。
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-23 23:14
* update_at: 2026-10-23 23:14
*/
// brute2.cpp:对拍用的独立实现。用「最少插入数 = n - 最长回文子序列长度」,
// 而最长回文子序列 = 原串与反串的最长公共子序列(LCS),O(n^2),与区间 DP 互相独立。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXL = 1005;
string s;
ll f[MAXL][MAXL]; // f[i][j]:s 前 i 个字符与反串前 j 个字符的 LCS 长度
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> s;
ll n = s.size();
string r(s.rbegin(), s.rend()); // 反串
for (ll i = 1; i <= n; ++i) {
for (ll j = 1; j <= n; ++j) {
if (s[i - 1] == r[j - 1]) f[i][j] = f[i - 1][j - 1] + 1;
else f[i][j] = max(f[i - 1][j], f[i][j - 1]);
}
}
// 最长回文子序列以外的字符各需要补一个配对字符
cout << n - f[n][n] << "\n";
return 0;
}复杂度
- 时间:
与 都是 个状态、 转移,与区间 DP 同阶。 - 空间:
,压维后可做到 。三种模型的复杂度瓶颈一样,选哪个只看哪种更好写、更好调。
空间优化:两行滚动
为什么要压
主解把整张 long long,约
只涉及「下一行」和「本行左邻」。若把同一份代码用到
关键约束
是本行刚算好的值,所以同一行内 必须从小到大扫。 与 在旧行里。按行号 从大到小扫,旧行整行完整、不会提前被覆盖,这是最省心的写法;也可以按区间长度递增、按 的奇偶分配两行。 - 只留一行原地覆盖是不够的:
是斜对角,覆盖到这一格时它已经被同行的新值挤掉,得额外暂存一个变量。两行写法避开这个坑。 - 单字符区间
:本行写到 时才会读 ,那个格子从不被写,保持数组初值 正好。
三条约束对应到格子上就是下图:
本行正在算的 cur = nxt,旧行换成刚写好的本行。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-28 21:20
* update_at: 2026-10-28 21:20
*/
// main2.cpp:与 main.cpp 完全相同的转移,只是把 O(l^2) 的表压成两行,空间 O(l)。
// 下标从 1 开始;i 表示区间左端点,j 表示右端点。
#include <bits/stdc++.h>
using namespace std;
const int MAXL = 1005;
string t; // t[1..n] 是原串
int f[2][MAXL]; // f[cur][*] 存 f[i+1][*];f[cur^1][*] 用来写 f[i][*]
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
int n = s.size();
t = " " + s;
// 起点:i+1 = n+1,区间为空,整行都是 0(数组初值即为 0)
int cur = 0;
// i 从右往左扫,保证算 f[i][*] 时 f[i+1][*] 那一行已经算好
for (int i = n - 1; i >= 1; --i) {
int nxt = cur ^ 1; // 本行的目标位置
// 本行的下标 i 不去写,读到时仍是初值 0,正好对应单字符区间 f[i][i] = 0
for (int j = i + 1; j <= n; ++j) {
if (t[i] == t[j]) {
// 两端配对,答案等于内层区间 [i+1, j-1],在旧行里
f[nxt][j] = f[cur][j - 1];
} else {
// 在右侧补 t[i](消掉左端)或在左侧补 t[j](消掉右端)
f[nxt][j] = min(f[cur][j], f[nxt][j - 1]) + 1;
}
}
cur = nxt; // 本行变成下一轮的旧行
}
cout << f[cur][n] << "\n";
return 0;
}复杂度
- 时间:
,与主解相同(常数略小,缓存更友好)。 - 空间:
, 个 int,约KB。
易错点
- 把「子序列」当成「子串」。 回文骨架可以不连续,最长回文子串是完全不同的问题。反例
abca:最长回文子串只有个字符,按它会算出 ,而正确答案是 (补成 abcba)。 - 输入里没有长度。 本题输入只有一行字符串,
要自己用 strlen/s.size()得到。见过按「先读再读串」写的代码,在别的 OJ 上能过,搬到洛谷会直接读错。 - 数组开太大。
int f[10001][10001]是MB, int f[5001][5001]也有MB。本题 ,开 [1005][1005]足够;内存给得紧就压成两行。 - 调试图忘了删。 写完总想打一张 DP 表出来核对转移,但交之前得删:答案前面多出一片数字,直接 WA 在格式上。
- 大小写敏感。 题目写明区分大小写,不要顺手
tolower。
总结
- 状态取「还没配对的区间」;这样一来,指数搜索里重复计算的那些内层串只算一次。
- 区间 DP 套路:按区间长度从小到大递推,两端相等继承内层、不等则枚举消掉哪一端。
- 同一答案的独立模型(
减最长回文子序列,即正串与反串的 LCS)可用于交叉验证;本地已用 300 组随机数据对拍通过(见 problem-analysis-workspace/duipai-report.md)。 - 「补几个字符」和「最长回文子序列多长」记的是同一张表:
,所以两个看起来不同的 DP 能用一次归纳对上。 - 只依赖上一行和本行左邻的区间 DP,行按
递减扫就能压成两行;斜对角依赖是唯一的坑。
参考与致谢
下列洛谷题解用于交叉核查和对照视角;标「主要参考」的几篇在讲法或写法上影响过本文。
| 作者(uid) | 题解 | 赞 | 主要参考的内容 |
|---|---|---|---|
| gyf12138(48717) | 题解 P1435 | 376 | 主要参考: |
| wisdom_grass(23842) | 题解 P1435 | 180 | 主要参考: |
| AlexanderPitt(138088) | 题解 P1435 | 34 | 按「以谁为回文中心」枚举插入方案的直观;记忆化写法求的是最长回文子序列(原文误称子串) |
| d3ac(164319) | 题解 P1435 | 29 | 按 |
| hovny(83564) | 题解 P1435 | 19 | 把问题转成 |
| Parabola(55537) | 题解 P1435 | 10 | 超长串与短串求 LCS 的 next 数组技巧(本题 |
| 梅川丘库(22233) | 题解 P1435 | 8 | |
| Jelly_Goat(122927) | 题解 P1435 | 7 | 最长回文子序列的区间 DP 转移与 |
| lqx_bnu(84837) | 题解 P1435 | 7 | 拼接反串、删掉公共部分来构造回文的直观演示 |
| 张佳(7869) | 题解 P1435 | 6 | 用「已消耗前缀 |