深蓝黑背景封面。左侧是字符串 b b b a b 的区间 DP 上三角网格:横纵轴都是字符下标,对角线格子填 1 表示单字符必为回文,越靠近右上角区间越长;右上角格子 dp[0][4]=4 以青色高亮,旁注「最长回文子序列长 4,只需删 1 个字符」。网格上方标注「dp[i][j]:子串 i 到 j 的最长回文子序列长度」。右侧大标题「删掉最少的字符,把串变成最长回文」,副标题「等价于最长回文子序列:区间 DP」。左上标注「笔试真题 · 算法」,右上圆角胶囊标注「Day 07 · 11-24」。 图 1:答案藏在上三角表的最右上角。

先说明属性:这是一篇历史笔试题复盘,题目出自本地笔试题集资料,与任何公司的当前招聘无关。这道题的第一层陷阱不在算法,而在题意转换——很多人读完题就开始枚举删除方案,方向从一开始就错了。

第一步:把「最少删除」翻译成「最长保留」。

题目要求:给定字符串 s,可以删一些字符,让剩下的串是回文,输出最少要删几个。枚举删除方案是指数级的,走不通。换个角度看:删得最少,等价于留下的回文最长。所以答案就是 n 减去「最长回文子序列」的长度,问题瞬间变成经典 DP。

这里必须咬死一个概念:子序列不等于子串。子串要求连续,子序列只要求相对顺序不变,中间可以跳格。「bbbab」的最长回文子序列是「bbbb」——跳过那个 a,四个 b 隔着位置照样算数。正因为允许跳格,中心扩展、Manacher 这些为连续子串设计的武器全部失效,这类题的唯一正解是区间 DP。

第二步:设计区间 DP 的状态与转移。

定义 dp[i][j] 为子串 s[i..j] 内最长回文子序列的长度。转移只看区间两端:

  • 两端相等,s[i] == s[j]:这对字符可以同时收进回文,dp[i][j] = dp[i+1][j-1] + 2;
  • 两端不等:至少有一端用不上,舍弃较亏的一端,dp[i][j] = max(dp[i+1][j], dp[i][j-1])。

边界是对角线:单字符区间 dp[i][i] = 1,任何单字符本身就是回文。注意每个格子只依赖左、下、左下三个更短的区间,所以填表必须按区间长度从小到大递推——先填所有长度 2 的格子,再填长度 3、4,直到填出右上角 dp[0][n-1]。拿「bbbab」走一遍:长度 2 的区间里「bb」填 2、「ba」「ab」填 1;逐层向上,「bbba」两端不等取 max 得 3,最终 dp[0][4] = 4,答案 5 - 4 = 1,删掉唯一的 a 即可。

第三步:代码实测。

下面用 Node.js 22.15.0 运行(node lps.js),除了例题再补几个串验证边界:

function minDeletionsToPalindrome(s) {
  const n = s.length;
  // dp[i][j] = 子串 s[i..j] 的最长回文子序列长度
  const dp = Array.from({ length: n }, () => new Array(n).fill(0));
  for (let i = 0; i < n; i++) dp[i][i] = 1;            // 单字符必为回文
  for (let len = 2; len <= n; len++) {                 // 按区间长度递推
    for (let i = 0; i + len - 1 < n; i++) {
      const j = i + len - 1;
      if (s[i] === s[j]) {
        dp[i][j] = dp[i + 1][j - 1] + 2;               // 两端收入回文
      } else {
        dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); // 舍弃一端
      }
    }
  }
  return { lps: dp[0][n - 1], del: n - dp[0][n - 1] };
}
for (const s of ["bbbab", "abca", "racecar", "aebcbda"]) {
  const r = minDeletionsToPalindrome(s);
  console.log(`${s}: 最长回文子序列长 ${r.lps}, 最少删除 ${r.del}`);
}

实测输出:

bbbab: 最长回文子序列长 4, 最少删除 1
abca: 最长回文子序列长 3, 最少删除 1
racecar: 最长回文子序列长 7, 最少删除 0
aebcbda: 最长回文子序列长 5, 最少删除 2

「bbbab」与手工填表一致;「racecar」本身已是回文,删 0 个;「aebcbda」最长回文子序列是「abcba」,删 2 个。一个小细节:长度 2 且两端相等时会读 dp[i+1][i],这是「空区间」位置,初始化为 0 正好天然正确,不需要特判。

深蓝黑背景概念图。左侧是字符串 b b b a b 的完整区间 DP 上三角表,对角线全为 1,值沿对角线方向逐层增长到右上角的 4;表旁用三条弧箭头标注填表顺序:先填长度为 2 的区间,再填长度 3、4,最后填长度 5。右侧两张卡片:转移方程卡写明「两端相等 s[i]=s[j]:dp[i][j] = dp[i+1][j-1] + 2;不等:dp[i][j] = max(dp[i+1][j], dp[i][j-1])」,并注「每个格子只依赖左、下、左下三个已算好的格子」;答案卡写明「最少删除数 = n − dp[0][n−1] = 5 − 4 = 1,删掉唯一的 a 得到回文 bbbb」。底部总结条:子序列不要求连续,所以不能用中心扩展;区间 DP 按长度递推,时间 O(n²)、空间 O(n²)。 图 2:按区间长度递推,每个格子只看左、下、左下。

收尾的复杂度与优化空间。

时间 O(n²)、空间 O(n²):格子数约 n²/2,每格 O(1)。空间可以优化——转移只用长度 len-1 和 len-2 两层,滚动数组能压到 O(n),但笔试场上先把 O(n²) 写对远比炫技重要。同一个状态定义还能顺手解出另一道常见变体「最少插入几次让串变回文」,答案同样是 n - dp[0][n-1],删除和插入在这道题里是对称的。

笔试答题的建议顺序:先一句话完成题意转换(最少删除 = n - 最长回文子序列),再声明子序列不连续所以用区间 DP,给出状态定义和两条转移,最后补复杂度。面试官如果追问「为什么不用中心扩展」,标准回答是:中心扩展枚举的是连续回文子串,而这题允许跳格,两者解的不是同一个问题。你第一次做这题时有没有把子序列当成子串?后来是怎么发现自己想岔了的?欢迎说说。