编辑距离——二维 DP 的“最小操作“艺术

horse → ros 只要 3 步?这道 Hard 的背后是二维 DP 的完整方法论:状态设计、转移推导、滚动数组、路径回溯。本文从 1143 LCS 铺垫一路讲透,三语言 + 空间优化 + 操作序列还原。

引子:两个字符串变一样,最少几步?

word1 = "horse"word2 = "ros",允许三种操作(插入/删除/替换,各代价 1)。把 word1 变成 word2 最少几步?3 步

复制代码
horse → rorse(把 h 替换成 r)
rorse → rose(删除 r)
rose  → ros (删除 e)

这是 LeetCode 72 编辑距离(Hard) ------Levenshtein 1965 年提出的经典算法,拼写纠正、文本相似度、DNA 比对的基石12。理解它的钥匙,是 DSA 系列第一次完整展开的二维动态规划

一、先学会"找相同":1143 最长公共子序列

1.1 LCS 定义与 DP

LCS :不要求连续、只要求保持相对顺序的公共子序列。"abcde""ace" 的 LCS 是 "ace"(长度 3)3

复制代码
dp[i][j] = "s1 前 i 个" 与 "s2 前 j 个" 的 LCS 长度
if s1[i-1] == s2[j-1]:
    dp[i][j] = dp[i-1][j-1] + 1
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

1.2 为什么先学 LCS

LCS 教给我们:处理双序列,DP 状态用双下标,每步只关心两个序列的"末尾"。先学会"找相同",才能理解"变相同"。

二、状态设计:dpij 到底代表什么

2.1 一句话定义状态

dpij = 将 word1 的前 i 个字符,转换成 word2 的前 j 个字符,所需的最少编辑距离。4

word1="horse"word2="ros" 时,dp[4][2] = 把 "hors" 变成 "ro" 的最少步数。

状态含义明确,转移就自然------最后一步一定发生在两个序列的末尾字符之间。

2.2 边界条件

  • dp[0][j] = j:空串变成长度 j 的串,插入 j 次

  • dp[i][0] = i:长度 i 的串变空串,删除 i 次

三、转移方程推导:相等与不等两种分支

3.1 字符相等:直接继承

word1[i-1] == word2[j-1] → 末尾已相同,无需操作:

复制代码
dp[i][j] = dp[i-1][j-1]

3.2 字符不等:三种"最后一步"取最小

最后一步 含义 对应
删除 word1i-1 删掉 word1 末尾 dpi-1j + 1
插入 word2j-1 word1 末尾插入 word2 末尾 dpij-1 + 1
替换 word1i-1→word2j-1 末尾替换 dpi-1j-1 + 1
复制代码
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

手算验证horseros,填表后 dp[5][3] = 32

四、三语言实现 + 空间优化

4.1 C++

复制代码
class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size(), n = word2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for (int j = 0; j <= n; ++j) dp[0][j] = j;
        for (int i = 0; i <= m; ++i) dp[i][0] = i;
        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (word1[i-1] == word2[j-1])
                    dp[i][j] = dp[i-1][j-1];
                else
                    dp[i][j] = 1 + min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]});
            }
        }
        return dp[m][n];
    }
};

4.2 Python

复制代码
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for j in range(n + 1): dp[0][j] = j
        for i in range(m + 1): dp[i][0] = i
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
        return dp[m][n]

4.3 Java

复制代码
class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length(), n = word2.length();
        int[][] dp = new int[m + 1][n + 1];
        for (int j = 0; j <= n; j++) dp[0][j] = j;
        for (int i = 0; i <= m; i++) dp[i][0] = i;
        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++) {
                if (word1.charAt(i-1) == word2.charAt(j-1))
                    dp[i][j] = dp[i-1][j-1];
                else
                    dp[i][j] = 1 + Math.min(dp[i-1][j],
                              Math.min(dp[i][j-1], dp[i-1][j-1]));
            }
        return dp[m][n];
    }
}

4.4 空间优化:滚动数组

dp[i][j] 只依赖上一行与当前行 → 两行即可,空间 O(mn)→O(n) 6。再进一步,单数组 + prev 缓存 dp[i-1][j-1]

复制代码
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        if m < n:
            word1, word2, m, n = word2, word1, n, m  # 让 n 更小
        dp = list(range(n + 1))          # 初始 dp[0][j] = j
        for i in range(1, m + 1):
            prev = dp[0]                 # dp[i-1][j-1] 初始 = dp[i-1][0]
            dp[0] = i                    # dp[i][0] = i
            for j in range(1, n + 1):
                temp = dp[j]             # 保存旧 dp[j] 供下一轮 prev
                if word1[i-1] == word2[j-1]:
                    dp[j] = prev
                else:
                    dp[j] = 1 + min(dp[j], dp[j-1], prev)
                prev = temp
        return dp[n]

关键:prev 保存"本列更新前的 dpj",恰好是下一列需要的 dp[i-1][j-1]

五、进阶:路径回溯还原操作序列

dp[m][n] 倒推来源格子7

复制代码
若 s1[i-1] == s2[j-1]:无操作,i--, j--
否则比较三种来源:
  dp[i-1][j-1] 最小 → 替换,i--, j--
  dp[i][j-1] 最小  → 插入 s2[j-1],j--
  dp[i-1][j] 最小  → 删除 s1[i-1],i--

horse → ros 回溯结果:

复制代码
1. 替换 h → r
2. 删除原位置的 r
3. 删除 e
→ 共 3 步

多个来源并列最小时,回溯路径可能不唯一,任选其一。

六、应用、变式与我的观点

6.1 不止是字符串题:DNA 序列比对

Needleman-Wunsch(1970)把编辑距离推广到生物信息:DNA/蛋白质全局比对,匹配/错配/空位分别赋权2今天写的 dpij,是基因组学序列比对的基础。

6.2 变式一:只允许插入/删除(无替换)

编辑距离与 LCS 的关系8

复制代码
dist = m + n - 2 × LCS(word1, word2)

"保留 LCS"之外的所有字符,要么从 word1 删掉,要么插入 word2。

6.3 变式二:自定义操作代价

替换代价可设为 2 等------DP 框架不变,成本模型可插拔。

6.4 我的三条心得

心得一:状态定义是二维 DP 的灵魂。 "前 i 个变前 j 个"写清楚,转移就是水到渠成。

心得二:滚动数组不是炫技,是"依赖分析"。 空间优化的本质是看一个格子依赖哪些格子------只依赖上一行与当前行,就能压缩。一切二维 DP 通用。

心得三:回溯让 DP 从"算答案"变成"给过程"。 能讲出"horse 到 ros 具体怎么变",比报一个 3 更有说服力------那证明你真的懂表格怎么填的。

编辑距离把"状态设计、转移推导、空间优化、路径回溯"四个 DP 核心能力串成一道题。吃透它,二维 DP 的地基就稳了。

参考资料

  • 1 Levenshtein 1965《Binary codes capable of correcting deletions, insertions, and reversals》(一级)

  • 2 LeetCode 72 官方题解背景(一级)

  • 3 LeetCode 1143 官方题解(一级)

  • 4 LeetCode 72 官方题解:dpij 状态定义(一级)

  • 5 LeetCode 72 官方题解:三种操作转移(一级)

  • 6 力扣讨论区:滚动数组优化(二级)

  • 7 力扣讨论区:路径回溯(二级)

  • 8 编辑距离与 LCS 等价关系(二级)

  • 9 Needleman-Wunsch 1970(一级)

相关推荐
OPEN-F1 小时前
ROS2系列教程:tf2坐标变换详解(C++/Python)
开发语言·c++·python
暖焰核心1 小时前
迭代器与模板的结合——stl的list
c++·windows·list
j7~1 小时前
【C++】C++的IO流--详解
c++·c++io流·c++流的概念·stringstream的介绍·c语言的输入与输出
zh_xuan2 小时前
c++ string_view
开发语言·c++
FFZero12 小时前
[mpv架构] (4) demux 线程到底在“预读“什么?
c++·架构·音视频·多媒体
西西弗Sisyphus2 小时前
C++ 实现 替代 OpenCV resize INTER_LINEAR 的一种方式
c++·opencv
wabs6662 小时前
关于二叉树【力扣107.二叉树的层序遍历II的思考】
数据结构·c++·算法·leetcode·二叉树·层序遍历
galaxy_strive2 小时前
Qt C++插件化编写项目(1)
开发语言·c++·qt
见叶之秋3 小时前
【C++】C++ 核心进阶(一):泛型编程基石 —— 模板初阶与 STL 体系开篇
开发语言·c++