回文 / 括号
收缩扩展·端点匹配
本课摘要
回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断回文 / 括号的适用条件与状态边界
- 围绕“收缩扩展·端点匹配”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
回文,与「藏在串里」的最长回文
回文串就是正着读、反着读一模一样的串:、、。 本节要问的不是「整串是不是回文」,而是一个更有嚼头的问题:给一个不一定回文的串,从中按原次序挑出若干字符(不必相邻),能拼出的最长回文有多长?这条挑出来的子序列,就叫最长回文子序列(LPS)。
拿 做例子。把下标 的字符挑出来是 ——正反都一样,是长度 5 的回文。能不能更长?把所有挑法试遍,最长就是 5。注意它两端对称:最外一对 相同,往里一对 相同,正中留一个 。
为什么不能贪心地扫一遍随手配对?因为此刻配哪一对,取决于内层还能配出多长——把某个字符过早用掉,可能挤掉里面一段更优的对称。穷举呢?长度 的串子序列有 条,逐条判回文,指数爆炸。 但那句「回文从两端向内成对」正是区间的味道:只盯住一段连续区间的两个端点,就能把大问题剥成更短的子区间——这就是区间 DP 的入口。
状态与转移:只看区间的两个端点
定状态。设 表示子串 这段连续区间里,最长回文子序列的长度。 要算它,只需盯住这段区间最外的两个字符 与 ——它俩相不相等,决定两条截然不同的路。
两端相等():这对字符可以且值得做回文的最外一层。把它俩裹在内层 的最长回文两侧,长度在内层基础上 :
两端不等():这两个端点做不成同一对,那么最长回文里 与 至少有一个用不上。于是要么丢掉左端(转成 ),要么丢掉右端(转成 ),谁大取谁:
边界:(单个字符自成回文),空区间记 。答案:。 和其余区间 DP 一样, 依赖的三个来源(、、)都是更短的子区间。所以递推不能按 或 顺序走,必须按区间长度由短到长——或等价地,让 从大到小、 从小到大。
本质
回文的最长回文子序列被「连续区间 + 只看两端」拆成一张 的三角表:每格只依赖左下、下、左三个更短的子区间,一步 。 的枚举就此压进 个上三角格子。「端点相等则收缩 +2,不等则丢一端取大」——这条「收缩 vs 丢弃」的分野是全表的灵魂,也是后面括号匹配、涂色一整族区间问题的共同骨架。
跟着算一遍
用一个短串 (下标 )走几步,重点盯住长度由短到长、以及每格是「收缩」还是「取大」:
看三角表一层一层长出来 · 相等收缩、不等取大
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
深化:最少插入构回文,与「括号 / 涂色」同族
换一个看似不同的问题:给一个串,每次可在任意位置插入一个字符,问最少插入几次能让整串变回文?它和最长回文子序列其实是同一枚硬币的两面——
道理很直白:串里那条最长回文子序列本就对称,一个字符都不用动;剩下的 个「落单」字符,每个补一个镜像伙伴即可配对。所以求最少插入,等价于求最长回文子序列,再用总长减去它。 也可以直接写一张区间 DP: = 把 补成回文的最少插入;端点相等则 ,不等则 ——与上面的收缩 / 取大结构一模一样,只是把 +2 换成 +0、把 max 换成 min+1。
这套「端点相等省一步、不等再拆」的骨架,正是一整族区间问题的共同模板。括号 / 序列匹配同理: 与 若能配成一对括号,问题落到内层 ,否则枚举分割点拆两段。涂色(区间刷漆)也一样:两端颜色相同则一笔顺带覆盖、省一次(),不同则枚举分割点两段相加——正是例题 P4170。 记住这条主线:区间 DP 的两端,要么配对内缩、要么拆点分治;回文是它最干净的入门形态。
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
别混淆:回文子序列 vs 回文子串
本节的 求的是最长回文子序列(字符可不相邻,端点相等就 )。另有一类求最长回文子串(必须连续),转移与判定都不同:区间 DP 版本记 = 「 整段是否回文」,,专业解法还有 Manacher 的 。两者状态含义不同、答案不同,别把「子序列」的 套到「子串」上。本页只讲子序列一族。
为什么按长度递推:填表顺序与复杂度
回文区间 DP 的表是个上三角(只有 才是合法区间)。 的三个来源 、、 的区间长度都比 短。 只要先把所有短区间算完,长区间要用的就都已就绪。这就是「外层枚举长度 、内层枚举左端点 」的由来——最长回文子序列没有分割点那层枚举,故是 (括号 / 涂色因带分割点枚举升到 ):
for i = 0 … n-1: // 长度 1:单字符自成回文
dp[i][i] = 1
for 长度 L = 2 … n: // ★外层枚举区间长度,由短到长
for 左端点 i = 0 … n-L:
j = i + L - 1
if s[i] == s[j]: // 端点相等 → 收缩内层再 +2
dp[i][j] = (L == 2 ? 0 : dp[i+1][j-1]) + 2
else: // 端点不等 → 丢一端取大
dp[i][j] = max(dp[i+1][j], dp[i][j-1])这份「外层长度、端点决定收缩或取大」的骨架,把它记死:它是回文、最少插入、括号匹配、涂色一整族题的通用模具。
例题
#include <iostream>
#include <cstring>
using namespace std;
#define MX 1005
char s[MX];
int len;
int dp[MX][MX]; // dp[i][j]:把子串 s[i..j] 补成回文的最少插入次数
int main()
{
cin >> (s + 1); // 1-based:字符放在 s[1..len]
len = strlen(s + 1);
// ★按区间长度由短到长递推;长度 1 的子串已是回文,dp 默认 0
for (int L = 2; L <= len; L++)
{
for (int i = 1; i + L - 1 <= len; i++)
{
int j = i + L - 1;
if (s[i] == s[j]) // 两端天然对称,直接内缩
{
dp[i][j] = dp[i + 1][j - 1];
}
else // 补一端与对端配对,代价 +1,取较省的一侧
{
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
}
cout << dp[1][len] << endl; // dp[1][len] 即整串补成回文的最少插入次数
return 0;
}
// TAG: 区间DP 回文 最少插入#include <iostream>
#include <cstring>
using namespace std;
#define MX 55
char s[MX];
int len;
int dp[MX][MX]; // dp[i][j]:把区间 s[i..j] 刷成目标颜色的最少次数
int main()
{
cin >> (s + 1);
len = strlen(s + 1);
memset(dp, 0x3f, sizeof(dp));
for (int i = 1; i <= len; i++)
{
dp[i][i] = 1; // 单格必刷一次
}
for (int L = 2; L <= len; L++)
{
for (int i = 1; i + L - 1 <= len; i++)
{
int j = i + L - 1;
if (s[i] == s[j]) // ★端点同色:一笔可顺带覆盖,省一次
{
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]);
}
else // 否则枚举分割点,两段各自刷再相加
{
for (int k = i; k <= j - 1; k++)
{
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
}
}
cout << dp[1][len] << endl;
return 0;
}
// TAG: 区间DP 回文 端点同色 涂色
