最小插入次数构造回文串问题(标准版)
字数 2993 2025-12-19 07:55:27
好的,我将为您讲解一道在您提供的“已讲过题目列表”中未曾出现的经典区间动态规划题目:
最小插入次数构造回文串问题(标准版)
题目描述
给定一个字符串 s,你可以通过插入任意字符到字符串的任意位置,来将它转变为一个回文串。每一次插入一个字符的代价为 1。
请你计算,为了使 s 成为一个回文串,所需要进行的最少的插入操作次数。
示例 1:
输入: s = "zzazz"
输出: 0
解释: 字符串 "zzazz" 本身就是一个回文串,所以不需要插入任何字符。
示例 2:
输入: s = "mbadm"
输出: 2
解释: 字符串可以变成 "mbdadbm" 或者 "mdbabdm",都是通过插入 2 个字符完成的。
示例 3:
输入: s = "leetcode"
输出: 5
解释: 通过插入 5 个字符,字符串可以变成 "leetcodocteel" 等回文串。
题目核心: 求一个字符串变成回文串所需的最少插入次数。
解题思路循序渐进
这个问题本质上是在问:一个字符串最少需要补充多少个字符,才能使其对称。
我们可以从最基础的概念入手,逐步构建出动态规划的解法。
步骤 1:理解问题核心与子问题分解
- 回文串性质:一个回文串,其首尾字符是相同的,并且去掉首尾字符后,剩下的子串仍然是回文串。
- 子问题思考:对于字符串
s[i...j](表示从索引i到j的子串),我们想知道将其变成回文串的最小插入次数,记作dp[i][j]。 - 如何求解
dp[i][j]?- 情况 1:如果
s[i] == s[j],那么首尾字符已经配对。我们只需要关心中间部分s[i+1 ... j-1]变成回文串需要多少次插入。即dp[i][j] = dp[i+1][j-1]。 - 情况 2:如果
s[i] != s[j],那么首尾字符不匹配。我们有两种策略来构造回文:
a. 在字符串的末尾插入一个与s[i]相同的字符。这样,外层的s[i]就和这个新插入的字符配对了。接下来我们只需要处理s[i+1 ... j]这个子串即可。操作次数为dp[i+1][j] + 1。
b. 在字符串的开头插入一个与s[j]相同的字符。这样,外层的s[j]就和这个新插入的字符配对了。接下来我们只需要处理s[i ... j-1]这个子串即可。操作次数为dp[i][j-1] + 1。 - 我们选择代价最小的策略,所以
dp[i][j] = min(dp[i+1][j], dp[i][j-1]) + 1。
- 情况 1:如果
步骤 2:定义动态规划状态与状态转移方程
- 状态定义:
dp[i][j]表示将子串s[i...j]变成回文串所需的最少插入次数。 - 状态转移方程:
注意:对于如果 s[i] == s[j]: dp[i][j] = dp[i+1][j-1] (当 j - i >= 2 时) dp[i][j] = 0 (当 j - i == 1 时,即长度为2的相同字符子串,如 "aa") dp[i][j] = 0 (当 j == i 时,即单个字符,它本身是回文) 否则 (s[i] != s[j]): dp[i][j] = min(dp[i+1][j], dp[i][j-1]) + 1s[i] == s[j]且j - i < 2的情况(即子串长度为1或2且首尾相同),它已经是一个回文串,插入次数为0。
步骤 3:确定计算顺序与边界条件
- 计算顺序:由于
dp[i][j]依赖于dp[i+1][j-1],dp[i+1][j],dp[i][j-1],即依赖于左下方、正下方、正左方的值。因此,我们必须按照从下到上 (i从大到小)、从左到右 (j从小到大) 的顺序来计算dp表。通常i从n-1遍历到0,j从i+1遍历到n-1。这样可以确保计算dp[i][j]时,它所依赖的子问题都已经被计算出来了。 - 边界条件:
- 当
i == j时,dp[i][i] = 0(单个字符是回文)。 - 当
i > j时,这是一个空串,也是回文,dp[i][j] = 0。在我们的循环中j从i+1开始,所以不会遇到i > j的情况,但在递归或某些初始化中需要留意。
- 当
步骤 4:算法实现(自底向上填表法)
我们可以用一个二维数组 dp 来存储结果。
def minInsertions(s: str) -> int:
n = len(s)
if n <= 1:
return 0
# 初始化 dp 数组,大小为 n x n,所有值初始为 0
dp = [[0] * n for _ in range(n)]
# 自底向上,从子串长度为 2 开始计算 (因为长度为1的dp[i][i]已经初始化为0)
# 注意遍历顺序:i 从后往前,j 从 i+1 到 n-1
for i in range(n - 1, -1, -1): # i 从最后一个字符开始往前
for j in range(i + 1, n): # j 从 i 的下一个开始往后,保证 j > i
if s[i] == s[j]:
# 当子串长度为2 (j-i==1) 且首尾相同时,已经是回文,不需要插入
# 当长度大于2时,取决于内部子串
dp[i][j] = dp[i + 1][j - 1]
else:
# 首尾不同,选择在左端或右端插入一个字符,取较小值 + 1
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1
# 最终答案是将整个字符串 s[0...n-1] 变成回文所需的最少插入次数
return dp[0][n - 1]
# 测试示例
print(minInsertions("zzazz")) # 输出: 0
print(minInsertions("mbadm")) # 输出: 2
print(minInsertions("leetcode")) # 输出: 5
步骤 5:复杂度分析与优化思路
- 时间复杂度:O(n²),其中 n 是字符串长度。我们需要填充一个 n x n 的二维 DP 表。
- 空间复杂度:O(n²),用于存储 DP 表。
- 优化思路:
可以观察到,在计算dp[i][j]时,我们只需要用到dp[i+1][...](下一行)和dp[i][...](当前行的左侧部分)。因此,我们可以将空间复杂度优化到 O(n),只需要两行数组(滚动数组)甚至一行数组(从右往左更新)来替代整个二维表。但为了清晰理解,上面的实现使用了完整的二维表。
步骤 6:直观理解与验证
以 s = "mbadm" 为例,手动推导:
dp[4][4],dp[3][3]...dp[0][0]都是 0。- 计算长度2的子串:
dp[3][4]("dm"):s[3] != s[4]->min(dp[4][4], dp[3][3]) + 1 = min(0,0)+1 = 1。dp[2][3]("ad"): 同理 -> 1。dp[1][2]("ba"): -> 1。dp[0][1]("mb"): -> 1。
- 计算长度3的子串:
dp[2][4]("adm"):s[2] != s[4]->min(dp[3][4]=1, dp[2][3]=1)+1 = 2。dp[1][3]("bad"):s[1]==s[3](‘a’==‘a’) ->dp[2][2]=0。dp[0][2]("mba"):s[0]!=s[2]->min(dp[1][2]=1, dp[0][1]=1)+1 = 2。
- 计算长度4的子串:
dp[1][4]("badm"):s[1]!=s[4]->min(dp[2][4]=2, dp[1][3]=0)+1 = 1。dp[0][3]("mbad"):s[0]!=s[3]->min(dp[1][3]=0, dp[0][2]=2)+1 = 1。
- 计算整个字符串(长度5):
dp[0][4]("mbadm"):s[0]!=s[4]->min(dp[1][4]=1, dp[0][3]=1)+1 = 2。
最终结果 dp[0][4] = 2,与我们示例一致。
总结
这道最小插入次数构造回文串问题是区间DP的一个非常典型的应用。其核心思想是:
- 定义状态:
dp[i][j]表示子串变成回文的最小插入次数。 - 状态转移:比较首尾字符。
- 若相同,则问题转化为内部子串。
- 若不同,则尝试在头或尾插入一个字符来匹配另一端,转化为规模更小的子问题,并取最优解。
- 计算顺序:按照子串长度从短到长(或索引
i从大到小,j从小到大)进行填表。
通过这种方式,我们能够高效地解决这个问题,并且其思想可以推广到很多其他“字符串编辑成为目标形式”的区间DP问题上。