最小插入次数构造回文串问题(标准版)
字数 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:理解问题核心与子问题分解

  1. 回文串性质:一个回文串,其首尾字符是相同的,并且去掉首尾字符后,剩下的子串仍然是回文串。
  2. 子问题思考:对于字符串 s[i...j](表示从索引 ij 的子串),我们想知道将其变成回文串的最小插入次数,记作 dp[i][j]
  3. 如何求解 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

步骤 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]) + 1
    
    注意:对于 s[i] == s[j]j - i < 2 的情况(即子串长度为1或2且首尾相同),它已经是一个回文串,插入次数为0。

步骤 3:确定计算顺序与边界条件

  1. 计算顺序:由于 dp[i][j] 依赖于 dp[i+1][j-1]dp[i+1][j]dp[i][j-1],即依赖于左下方正下方正左方的值。因此,我们必须按照从下到上 (i 从大到小)从左到右 (j 从小到大) 的顺序来计算 dp 表。通常 in-1 遍历到 0ji+1 遍历到 n-1。这样可以确保计算 dp[i][j] 时,它所依赖的子问题都已经被计算出来了。
  2. 边界条件
    • i == j 时,dp[i][i] = 0(单个字符是回文)。
    • i > j 时,这是一个空串,也是回文,dp[i][j] = 0。在我们的循环中 ji+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" 为例,手动推导:

  1. dp[4][4], dp[3][3] ... dp[0][0] 都是 0。
  2. 计算长度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. 计算长度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. 计算长度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. 计算整个字符串(长度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的一个非常典型的应用。其核心思想是:

  1. 定义状态dp[i][j] 表示子串变成回文的最小插入次数。
  2. 状态转移:比较首尾字符。
    • 若相同,则问题转化为内部子串。
    • 若不同,则尝试在头或尾插入一个字符来匹配另一端,转化为规模更小的子问题,并取最优解。
  3. 计算顺序:按照子串长度从短到长(或索引 i 从大到小,j 从小到大)进行填表。

通过这种方式,我们能够高效地解决这个问题,并且其思想可以推广到很多其他“字符串编辑成为目标形式”的区间DP问题上。

好的,我将为您讲解一道在您提供的“已讲过题目列表”中未曾出现的经典区间动态规划题目: 最小插入次数构造回文串问题(标准版) 题目描述 给定一个字符串 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 。 步骤 2:定义动态规划状态与状态转移方程 状态定义 : dp[i][j] 表示将子串 s[i...j] 变成回文串所需的最少插入次数。 状态转移方程 : 注意 :对于 s[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 来存储结果。 步骤 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问题上。