题目描述

:::fold-green 相关原题

LeetCode 原题: ✅ 72. 编辑距离

LeetCode 原题中插入、删除、替换的费用均为 1;本文分别使用参数 ic、dc、rc 指定费用。

:::

给你两个字符串 a 和 b,返回将 a 转换成 b 所需的最小总费用。

你可以对 a 进行以下操作:

  • 插入一个字符,费用为 ic。
  • 删除一个字符,费用为 dc。
  • 替换一个字符,费用为 rc。

字符相同时可以保留原字符,费用为 0。

示例 1:

输入: a = "abc", b = "adc", ic = 5, dc = 3, rc = 2
输出: 2
解释: 将字符串 a 中的第二个字符 b 替换为 d,花费 2,得到字符串 "adc"。

示例 2:

输入: a = "", b = "ab", ic = 5, dc = 3, rc = 2
输出: 10
解释: 插入 a、b 两个字符,每次花费 5。

提示:

  • a、b 均包含 0 到 5000 个 Unicode 码点,允许空串。
  • 0 <= ic, dc, rc <= 10000。
  • 字符按 Unicode 码点比较,结果使用 64 位整数。

题意分析

需要将源串前缀逐步变为目标串前缀,每个前缀对可能由多种编辑过程到达,只保留费用最小者即可。插入、删除、替换费用不同,不能再把每步都按 1 计算。

以最后一次操作分类,可得到删除源末字符、插入目标末字符、对齐两端字符三类转移。空前缀同样需要按实际费用初始化;插入与删除费用不同时,也不能随意交换两串来节省空间而保持费用不变。

解法:带独立操作费用的滚动编辑距离

核心思路

[!blue]

面试先用二维状态讲清楚,再解释为什么能只保留一行。设 F(i,j) 表示把 a 的前 i 个码点变成 b 的前 j 个码点的最小费用。

最后一步只有三类:删除 a 的末字符,来自 F(i-1,j) + dc;插入 b 的末字符,来自 F(i,j-1) + ic;保留或替换末字符,来自 F(i-1,j-1) + cost,相等时 cost = 0,否则为 rc。三者取最小值。空串边界为 F(0,j)=j*ic、F(i,0)=i*dc。

滚动更新到第 i 行第 j 列时:old = dp[j] 仍是上一行同列,dp[j-1] 已是本行左侧,diagonal 保存上一行左上角。先用这三个旧含义计算新 dp[j],再令 diagonal = old,供下一列使用。

例如 a = "a"、b = "b",若替换费用为 10,删除和插入各为 2,递推会选择总费用 4 的删加插,不会因为有“替换”操作就固定优先替换。

解题步骤

  1. 初始化 dp[j] = j * ic,表示空串变成目标前缀的插入费用。
  2. 开始新一行前把旧 dp[0] 存入 diagonal,再写入当前空目标前缀的删除费用 i * dc。
  3. 逐列先保存 old = dp[j],比较删、插、保留或替换的费用后覆盖该格,再令 diagonal = old。
  4. 返回最后一列的费用;码点数组和滚动数组分别承担字符访问与状态保存。

代码实现

class Solution {
    public long editCost(String a, String b, int ic, int dc, int rc) {
        int[] x = a.codePoints().toArray();
        int[] y = b.codePoints().toArray();
        long[] dp = new long[y.length + 1];

        for (int j = 1; j <= y.length; j++) {
            dp[j] = (long) j * ic;
        }

        for (int i = 1; i <= x.length; i++) {
            long diagonal = dp[0];

            dp[0] = (long) i * dc;

            for (int j = 1; j <= y.length; j++) {
                long old = dp[j];
                long replace = diagonal + (x[i - 1] == y[j - 1] ? 0 : rc);

                dp[j] = Math.min(replace, Math.min(old + dc, dp[j - 1] + ic));
                diagonal = old;
            }
        }

        return dp[y.length];
    }
}
func editCost(a, b string, ic, dc, rc int64) int64 {
    x, y := []rune(a), []rune(b)
    dp := make([]int64, len(y)+1)
    for j := 1; j <= len(y); j++ {
        dp[j] = int64(j) * ic
    }
    for i := 1; i <= len(x); i++ {
        diagonal := dp[0]
        dp[0] = int64(i) * dc
        for j := 1; j <= len(y); j++ {
            old := dp[j]
            cost := rc
            if x[i-1] == y[j-1] {
                cost = 0
            }
            dp[j] = min(diagonal+cost, min(old+dc, dp[j-1]+ic))
            diagonal = old
        }
    }
    return dp[len(y)]
}

复杂度分析

  • 时间复杂度:$O(mn)$。
  • 空间复杂度:$O(m+n)$,包含字符数组;滚动DP本身占O(n)。

关键点总结

[!green]

滚动数组更新前保存旧值,避免覆盖左上角;替换比先删除再插入更贵时,递推会自然选择后者。

易错点总结

[!yellow]

初始化也要乘费用,不能统一加1。Java和Go均按Unicode码点比较,避免UTF-8多字节拆分造成费用差异。

相似题目

题目 难度 关联与区别
72. 编辑距离 中等 复用前缀编辑距离状态,插入、删除、替换分别加各自费用,初始化也要按费用计算。
583. 两个字符串的删除操作 中等 当替换不优于删除加插入时,可以对照保留公共子序列后的删插代价,不能固定优先替换。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/17645635
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!