最近刷 LeetCode,碰到了 1312 这道标着 Hard 的字符串 DP 题。题面不长:给定一个字符串 s,每次你可以在任意位置插入任意字符,问最少插入几次,能让 s 变成一个回文串。链接就是 LeetCode 1312,题目名称叫“让字符串成为回文串的最少插入次数”。
第一眼看到这个题,大多数人的直觉反应是“模拟插入”:从左往右扫,发现哪个地方不对称,就在对应位置补一个字符。我一开始也是这么干的,结果越推越乱,因为每插入一个字符,后面所有字符的位置都会变,匹配关系全乱套了。后来换了个完全相反的视角,才发现这题之所以是 Hard,考的不是你多想插字符,而是你能不能把“加法”变成“减法”。这篇文章打算把这道题的完整思考路径、三种等价解法、代码实现、空间优化思路以及调试经验一次讲清楚,适合正在刷动态规划的读者,也适合面试前想快速搞定回文串类 DP 套路的人。
1. 题目理解与问题本质
1.1 先读懂题面:回文串与插入操作
回文串是那种从前往后读和从后往前读完全一致的字符串,比如aabaa、zzazz、aba。题目允许你在任意位置插入任意字符,目标是用最少的插入次数把给定字符串变成回文。
看几个例子帮助理解:
- 输入
s = "zzazz",它本身已经是回文串,答案就是 0。 - 输入
s = "mbadm",答案不是 1,而是 2。一种可行结果是"mbdadbm",在中间补了d,又在末尾补了m。 - 输入
s = "leetcode",答案是 5。你可以构造"leetcodocteel"这类结果,但至少需要 5 次插入。
从这些例子能看出,题目不要求你输出具体怎么插入,只要求最少次数。这其实是一个典型的“最优化”问题,天然适合动态规划来做。
1.2 为什么“模拟插入”是个死胡同
我见过不少人在评论区说这题暴力模拟也能过,其实那是没卡大数据。真要顺着“缺哪补哪”的思路去实现,会遇到三个大麻烦:
第一,插入位置会动态变化。你在位置 2 插入一个字符后,原本位置 2 之后的所有字符全部右移一位,之前计算好的对称关系全部失效,需要重新扫描。
第二,贪心策略不成立。你扫描到某个字符不匹配时,可以补在左边,也可以补在右边,这两种选择可能导向完全不同的后续结果。贪心随便选一个方向,大概率不是最优解。
第三,搜索空间爆炸。如果每遇到一个不对称点都有两种补法,整个决策树是指数级的,n 稍微大一点就彻底跑不动。
所以,“模拟插入”这条路看着近,实际上全是坑。真正能解的思路,是把问题反过来想。
1.3 核心转化:答案等于 n 减去最长回文子序列长度
整个题最漂亮的一步,是发现“插入最少的字符”等价于“尽量保留原串中已经能构成回文的部分”。原串里那些本来就能形成回文的字符,可以原封不动地保留下来;剩下没被保留的每个字符,都需要在对称位置插入一个相同的字符来配对。
于是问题变成了:s里最长的回文子序列有多长?这里的子序列不要求连续,只要保持相对顺序就行。比如s = "mbadm",最长回文子序列是"mam",长度是 3。总长度 5 减去 3,等于 2,恰好就是答案。
为什么这个公式成立?设想你已经找到了一个长度为 L 的回文子序列,把它当作回文核心。核心左侧那些没有被选中的字符,需要逐个在右侧对称位置插入镜像字符;核心右侧同理。换句话说,每个未被选中的字符,正好对应一次插入操作。所以最少插入次数就是n - L。
这一步“把加法变减法”是整个题的精髓,理解了它,后面三种解法都只是求最长回文子序列的不同姿势而已。
2. 三种解法思路对比与选型
2.1 解法一:区间 DP 直接求最少插入次数
最直观的动态规划,是直接定义dp[i][j]表示把子串s[i..j]变成回文串所需的最少插入次数。
转移分两种情况:
- 如果
s[i] == s[j],说明两端已经匹配,中间s[i+1..j-1]变成回文就行,所以dp[i][j] = dp[i+1][j-1]。 - 如果
s[i] != s[j],两端没法直接匹配,只能先解决一边:要么先把s[i+1..j]变成回文,然后在右侧补一个s[i];要么先把s[i..j-1]变成回文,然后在左侧补一个s[j]。两种方案取更优的那个,再加上这次插入,所以dp[i][j] = min(dp[i+1][j], dp[i][j-1]) + 1。
边界条件很简单:dp[i][i] = 0,单个字符本身已经是回文,不需要插入。空区间也可以认为是 0 次插入,这在长度为 2 且两端相等时会用到。
这种解法的好处是状态定义和题目操作直接对应,写思路时面试官容易跟得上。
2.2 解法二:先求最长回文子序列,再减
如果先做了第一步的思维转化,代码可以更精简:定义lps[i][j]表示s[i..j]的最长回文子序列长度,然后答案就是n - lps[0][n-1]。
lps的状态转移也很自然:
- 如果
s[i] == s[j],两端字符都可以纳入回文子序列,长度在中间基础上加 2,所以lps[i][j] = lps[i+1][j-1] + 2。 - 如果
s[i] != s[j],两端不能同时要,只能取删掉左端或删掉右端后的较大值,所以lps[i][j] = max(lps[i+1][j], lps[i][j-1])。
边界是lps[i][i] = 1,单个字符本身就是一个长度为 1 的回文子序列。
这个写法写起来最接近最长公共子序列(LCS),也最容易背。很多题解都是这个思路。
2.3 解法三:LCS 视角,原串与逆序串做最长公共子序列
还有一个更“绕”但代码最简的结论:s的最长回文子序列长度,等于s与reverse(s)的最长公共子序列长度。
直觉上可以这样理解:回文子序列顺着读和倒着读完全一致,所以它必须同时出现在原串和逆序串的对应位置上。反过来,一个同时出现在两个方向的公共子序列,一定能映射回原串中的一组镜像匹配,形成回文结构。所以求最长公共子序列,本质上就是在找最长回文子序列。
利用这个结论,答案可以写成n - LCS(s, reverse(s))。代码直接用 LCS 模板,只有最后答案不一样。虽然这个结论不是一眼能看穿的,但它给了你一个完全不同的验证视角:三种写法最后算出来的答案必须一致,这对调试非常有帮助。
2.4 三种思路的等价性对比
三种解法的时间复杂度都是 O(n^2),空间也都是 O(n^2),区别只在于状态定义和思考方式:
| 思路 | 状态定义 | 核心转移 | 最终答案 |
|---|---|---|---|
| 区间 DP | 最少插入次数 | 两端相等则看中间,不相等则取两侧较小值加 1 | dp[0][n-1] |
| LPS 法 | 最长回文子序列长度 | 两端相等则中间加 2,不相等则取两侧较大值 | n - lps[0][n-1] |
| LCS 法 | 原串与逆序串的最长公共子序列 | 字符匹配则左上加 1,否则取上方和左方较大值 | n - lcs[n][n] |
实际面试时,我推荐先讲“解题一的区间 DP”,因为状态定义贴合题目;然后补一句“这个题还可以转化为求最长回文子序列”,展示你理解了本质。至于 LCS 视角,作为加分项提一句即可,不必一开始就拿出来,容易把自己绕晕。
3. 完整代码实现与核心细节
3.1 区间 DP 的 C++ 实现
先上最直接的解法,C++ 版本:
class Solution { public: int minInsertions(string s) { int n = s.size(); vector<vector<int>> dp(n, vector<int>(n, 0)); // 按区间长度从小到大枚举,保证计算大区间时小区间已经就绪 for (int len = 2; len <= n; ++len) { for (int i = 0; i + len - 1 < n; ++i) { int j = i + len - 1; 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]; } };注意这里len从 2 开始,因为长度为 1 的区间已经在初始化时赋成 0 了。当len = 2且s[i] == s[j]时,dp[i+1][j-1]也就是dp[i+1][i],对应空区间,正好是 0,代码里二维数组初始化的 0 直接派上了用场。
3.2 LPS 写法的 Java 实现
再看先求最长回文子序列、再做减法的写法:
class Solution { public int minInsertions(String s) { int n = s.length(); int[][] lps = new int[n][n]; // 长度为 1 的子串,最长回文子序列就是它本身 for (int i = 0; i < n; i++) { lps[i][i] = 1; } for (int len = 2; len <= n; len++) { for (int i = 0; i + len - 1 < n; i++) { int j = i + len - 1; if (s.charAt(i) == s.charAt(j)) { lps[i][j] = lps[i + 1][j - 1] + 2; } else { lps[i][j] = Math.max(lps[i + 1][j], lps[i][j - 1]); } } } return n - lps[0][n - 1]; } }这个版本和 LCS 模板长得几乎一样,区别只在s[i] == s[j]时是加 2 而不是加 1,因为回文子序列两端一起取,长度加 2 而不是加 1。
3.3 LCS 写法的 Python 实现
最后来看用原串与逆序串做 LCS 的极简写法:
class Solution: def minInsertions(self, s: str) -> int: n = len(s) # dp[i][j] 表示 s[:i] 和 rev[:j] 的 LCS 长度 dp = [[0] * (n + 1) for _ in range(n + 1)] rev = s[::-1] for i in range(1, n + 1): for j in range(1, n + 1): if s[i - 1] == rev[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return n - dp[n][n]LCS 写法里,dp 数组比字符串长度多开了一行一列,这样能天然处理好空串边界,不用额外判i == 0或j == 0的情况。
3.4 为什么遍历顺序必须从小到大
很多人写区间 DP 翻车,翻在循环顺序上。比如这样写:
for (int i = 0; i < n; i++) { for (int j = i; j < n; j++) { // 计算 dp[i][j] } }表面上很自然,实际上算dp[i][j]时,它依赖的dp[i+1][j-1]、dp[i+1][j]这些子区间根本还没算出来。因为你当前在遍历 i 递增的方向,而i+1那行是后面才会扫到的。
这里可以打个比方:区间 DP 像盖楼,你要先打好地基再盖顶层。这里的“地基”就是长度为 2、3 的小区间,“顶层”是长度为 n 的最大区间。如果你一头扎进去从左上角往右下角填,相当于还没打地基就开始盖二十层,肯定塌。所以外层循环一定要枚举区间长度len,内层才是枚举起点i。
3.5 边界情况验证
写完代码,用几个边界情况验证一下:
- 空串
s = "":三个版本都能直接返回 0。 - 单字符
s = "a":区间 DP 里dp[0][0] = 0;LPS 里lps[0][0] = 1,答案1 - 1 = 0。 - 两个相同字符
s = "aa":区间 DP 走s[i] == s[j]分支,dp[0][1] = dp[1][0] = 0,答案 0。 - 两个不同字符
s = "ab":区间 DP 走不相等分支,dp[0][1] = min(dp[1][1], dp[0][0]) + 1 = 1,插入一次变成"aba"或"bab",正确。 - 全相同字符
s = "aaaaaa":任何长度下两端都相等,答案始终是 0。
这些用例跑通过,代码的基础正确性就有保证了。
4. 复杂度分析与优化思路
4.1 时间复杂度与空间复杂度
三种解法的时间复杂度毫无悬念,都是 O(n^2),因为要填一个 n 行 n 列的二维表。空间复杂度也是 O(n^2)。
| 解法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 区间 DP | O(n^2) | O(n^2) |
| LPS 法 | O(n^2) | O(n^2) |
| LCS 法 | O(n^2) | O(n^2) |
当 n 在 500 到 1000 的规模时,二维数组完全没压力。但如果 n 到 5000,二维表就要 2500 万个 int,内存直接飙到约 100MB,这就得考虑滚动数组优化了。
4.2 空间压缩:滚动数组
以 LPS 写法为例,观察依赖关系:lps[i][j]依赖lps[i+1][j-1]、lps[i+1][j]和lps[i][j-1],也就是下一行的同一列和左边一列,以及当前行的左边一列。这意味着计算当前行时,只需要保留下一行的数据,再往上走的老数据可以丢掉。
一维滚动数组的核心思路是:用prev数组保存下一行的结果,用cur数组保存当前行的结果。内层循环从左到右扫,cur[j-1]就是lps[i][j-1],prev[j]就是lps[i+1][j],prev[j-1]就是lps[i+1][j-1]。关键提醒:prev[j-1]必须在被覆盖之前取出来,最好先在临时变量里存一下。
下面这个 C++ 版本是我实测过没问题的写法:
class Solution { public: int minInsertions(string s) { int n = s.size(); vector<int> prev(n, 0), cur(n, 0); // 外层 i 从后往前枚举区间左端点 for (int i = n - 1; i >= 0; --i) { prev[i] = 1; // 长度 1 的子序列 for (int j = i + 1; j < n; ++j) { if (s[i] == s[j]) { cur[j] = prev[j - 1] + 2; } else { cur[j] = max(prev[j], cur[j - 1]); } } cur[i] = 1; swap(prev, cur); } return n - prev[n - 1]; } };这里有一个很容易踩的坑:prev[j-1]在j-1位置的值,有可能在上一轮的swap之后已经不是原来的lps[i+1][j-1]了。所以一定要确保外层i是从n-1倒着扫的,并且内层j从左到右更新时,prev数组始终保留着第i+1行的数据。我建议初学阶段先老老实实写二维版本,能 AC 再考虑滚动数组,面试时口头提一句“空间可以优化到 O(n)”就足够加分。
4.3 从这道题延伸出的 DP 建模套路
回文串类 DP 在 LeetCode 上是一个小家族。最经典的是 5 最长回文子串、516 最长回文子序列、647 回文子串,以及今天这道 1312 最少插入次数。这些题的共同套路是:区间 DP 里只关心两端字符是否相等,相等是一种走法,不相等是另一种走法。
如果你把 516 和 1312 放在一起看,会发现 1312 的区间 DP 转移方程其实就是“516 的镜像”:516 求最长,所以不相等时取max;1312 求最少插入,所以不相等时取min再加 1。一个加字符,一个减字符,状态转移的骨架完全一样。
为什么这个套路这么通用?因为回文结构天然有“两端对称”的属性,区间 DP 每次只拆两个端点,剩下的还是一个更短的区间,天然满足无后效性。所以以后遇到回文串相关题目,可以条件反射般地往“两端匹配”这个方向想。
5. 常见问题与调试技巧实录
5.1 常见问题速查表
我把自己在刷这题和相关题目时遇到的高频问题整理成了表格,方便快速定位:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 答案比预期大很多 | 还在用模拟插入的思路,没有转成“n - 最长回文子序列” | 先想清楚问题本质,再写代码 |
| 数组越界或访问到未初始化位置 | 区间 DP 直接访问dp[i+1][j-1],但i+1 > j-1 | 外层按区间长度 len 从 2 开始枚举 |
| dp 结果错乱 | 遍历顺序不是按区间长度从小到大 | 外层枚举 len,内层枚举起点 i |
| 把子串和子序列搞混 | 误以为必须连续取字符 | 明确“回文子序列不要求连续” |
| 滚动数组版本结果不对 | prev[j-1]被覆盖后还在用 | 用临时变量提前保存,或者先用二维版本验证 |
| 边界用例出错 | 没有处理空串、单字符、全相同字符 | 初始化阶段单独处理长度 1 的边界 |
这个表里的问题我基本都踩过,尤其遍历顺序和滚动数组覆盖这两项,是最容易让人调到头秃的地方。
5.2 如何系统化验证自己的实现
写完后,不要只拿示例用例跑一遍就完事。我会用这三类用例做验证:
第一类是手工构造的小数据。s = "abc",期望值是 2,因为可以变成"abcba"。s = "aab",期望值是 1,变成"baab"。s = "ab",期望值是 1。这些用例在脑子里就能手推,用来快速定位基础错误。
第二类是极端数据。空串、单字符、全相同字符、两个不同字符、完全逆序的字符串。这些用例能逼出边界条件的问题,比如dp[i+1][j-1]在空区间时会不会越界。
第三类是对拍验证。先写一个二维版的标准解法,再写一个滚动数组优化版,然后用随机字符串跑同样的输入,对比两个版本的输出是否完全一致。如果出现不一致,说明优化版在某条状态转移上覆盖错了顺序,这个方法在动态规划题里特别实用。
5.3 刷题心得与经验小抄
刷多了这类题,我最大的心得是:看到“最少操作次数”不要着急写代码,先想清楚两个问题。第一,这个操作能不能直接模拟?第二,如果我反过来做“不操作的部分”,问题会不会更简单?
回到 1312,直接模拟插入非常痛苦,反过来找“不用动的字符”,立刻变成了一个经典的最长回文子序列问题。这种“加法不好做就做减法”的思路,不止在这题有用,在很多字符串编辑类题目里都能派上用场。
另外一个小建议:面试时不要一上来就憋代码,先把状态定义、转移方程、初始化、遍历顺序这四件事在白纸上讲清楚。因为对区间 DP 来说,这四件事只要错一件,代码就是错的。而面试官其实更想看到你能把“为什么按区间长度遍历”讲明白,而不是背诵模板。
我个人在实际操作中的体会是:1312 这道题真正难的并不是动态规划本身,而是你敢不敢放弃“模拟插入”这个直觉。我第一次做的时候,在这上面耗了快一个小时,一直想怎么用双指针扫描加插入,结果怎么补都补不对。后来把原串和逆序串并排写在纸上,划掉那些能配对的部分,剩下孤零零的字符数量恰好就是答案,那一瞬间才彻底醒悟。
所以最后再分享一个小技巧:遇到区间 DP 拿不准状态转移时,拿一个短字符串手动填一次 dp 表。从左上角开始,一格一格推导到右下角,所有依赖关系都会变得非常清楚,比任何调试工具都有用。这个习惯我到现在还在用,每次都能救我一命。