编辑距离与动态规划:从LeetCode 72到字符串相似度实战

发布时间:2026/10/13 11:52:05
编辑距离与动态规划:从LeetCode 72到字符串相似度实战
从做字符串匹配的日常需求出发我几乎每天都会碰到两个文本到底有多像这类问题。早年用暴力逐个字符比对效率低还容易误判后来接触到了编辑距离这个思路一下子把模糊匹配、拼写纠错、文本查重这些场景统一到了一个公式框架下。LeetCode 72题就是最经典的那道编辑距离Edit Distance也是动态规划入门里绕不开的硬骨头。它要求计算把一个字符串变成另一个字符串所需的最少编辑操作次数操作只有三种插入、删除、替换。这篇文章我打算把这道题从直觉、递推、表格推演到代码落地、空间优化、实际工程应用完整拆一遍适合正在刷题准备面试的同学也适合想在项目中用模糊匹配但又不知道从哪下手的开发者。1. 从两个字符串有多像说起编辑距离到底在算什么1.1 编辑操作的定义插入、删除、替换编辑距离最初由一位苏联学者在1965年提出所以它也叫莱文斯坦距离Levenshtein distance。它的核心定义特别朴素给定两个字符串s和t允许你对s做三种操作问最少操作几次能让s变成t。三种操作分别是插入在任意位置插入一个字符删除删除任意位置的一个字符替换把任意位置的字符换成另一个字符这里有个容易忽略的细节每一种操作都只算一次代价也就是每次操作计1。替换一个字符如果是不同的字符代价是1如果字符相同替换就没有意义代价为0。这样一个距离定义天然具备对称性和三角不等式的近似性质所以它很适合做相似度的度量。1.2 一个不直观的例子为什么只看最少操作次数我们拿经典的kitten到sitting来感受一下。直观上这两个词很像前三个字符只有第一个不同但真实的最少编辑距离是3kitten - sitten把k替换成ssitten - sittin把e替换成isittin - sitting末尾插入g为什么不是直接数不同字符的个数因为不同字符个数只考虑了替换完全没考虑增删对字符串结构的影响。举个更极端的例子空字符串变成abc不同字符个数根本没法算但编辑距离很清楚——需要3次插入。这就是编辑距离比海明距离更通用的原因它不管字符串长度是否一致都能给出有意义的距离值。2. 从暴力递归到记忆化为什么盲目递归注定超时2.1 朴素递归的直觉与指数爆炸凡是动态规划题最自然的起点都是写递归。对于编辑距离从两个字符串的末尾往前看如果我最后一步是想让s[i]和t[j]对齐那么可能的情况只有三种已经对齐了s前i-1个和t前j-1个最后看s[i]和t[j]是否相等不等就替换已经对齐了s前i-1个和t前j个说明s[i]是被删除的字符已经对齐了s前i个和t前j-1个说明t[j]是靠插入得到的于是递归式就写出来了。这种写法本质上是把一个大问题拆成三个子问题但缺点非常明显s和t的每个位置组合都可能被反复计算调用树呈指数级膨胀。我试过用sabcdefghij, tjihgfedcba跑朴素递归机器直接卡死连终端都半天没反应。两个长度只有10的字符串递归分支就已经到了天文数字更别说LeetCode上的测试数据长度能到几百。2.2 记忆化搜索先解决重复计算再谈状态设计在写DP表格之前有一个折中方案叫记忆化搜索。它本质上还是递归但加了一个缓存表每个(i, j)组合只计算一次后面再遇到直接查缓存。这样时间复杂度从指数级降到O(mn)和动态规划完全一致只是实现方式不同。这里我特别想提醒一点递归转记忆化不是加了缓存就万事大吉。Python里如果递归深度超过1000默认会报RecursionError两个长度500的字符串就可能触发。所以工程上我更推荐直接用自底向上的DP循环既没有递归栈溢出风险代码也更容易做空间优化。不过理解记忆化搜索仍然很有价值因为它的状态定义方式和二维DP完全同构能帮你确认自己有没有想清楚状态转移。3. 二维DP表的核心推演状态定义与转移方程3.1 dp[i][j]的含义与初始化边界动态规划的第一要义是搞清楚状态数组每个格子代表什么。对编辑距离我建议这样定义dp[i][j] 表示 s 的前 i 个字符变换到 t 的前 j 个字符所需要的最少编辑次数。注意这个定义里i和j都是前缀长度不是下标。这是个约定俗成的设计好处是天然处理空串情况。初始化就变得非常直观dp[0][j] j因为空字符串要变成前j个字符只能一直插入j次dp[i][0] i因为前i个字符要变成空串只能一直删除i次这个边界是很多人写错的重灾区。如果你定义dp[i][j]时把i和j当成下标而不是前缀长度后面初始化就会乱套索引还容易越界。我习惯在代码里先用一个m和一个n变量存长度然后开(m1)行、(n1)列的二维数组就是为了让边界条件写起来不别扭。3.2 三种操作的转移逻辑从最后一步倒推状态转移是这道题的核心。考虑dp[i][j]这个格子我们从s的前i个字符到t的前j个字符最后一步操作无非三种第一种是删除我先让s的前i-1个字符完成变换变成t的前j个字符然后删掉s的第i个字符。操作数是dp[i-1][j] 1因为这次删除额外花了一次操作。第二种是插入我先让s的前i个字符完成变换变成t的前j-1个字符然后插入t的第j个字符。操作数是dp[i][j-1] 1。第三种是替换或不动我先让s的前i-1个字符完成变换变成t的前j-1个字符然后处理s的第i个字符和t的第j个字符。如果它们相等不需要操作就是dp[i-1][j-1]如果不等需要一次替换就是dp[i-1][j-1] 1。最后取三种里的最小值。用公式写出来就是dp[i][j] min(dp[i-1][j] 1, dp[i][j-1] 1, dp[i-1][j-1] (s[i-1] t[j-1] ? 0 : 1))3.3 手推一张表以horse到ros为例光讲公式抽象我觉得还是得拿实例走一遍。LeetCode 72的官方示例就是s horset ros答案是3。我建议你拿起笔在纸上画一个6行4列的表格先填第一行和第一列。第一行表示空字符串变到r、ro、ros需要1、2、3次插入所以填1、2、3。第一列表示h变成空串、ho变成空串、hor变成空串...是逐字删除分别填1、2、3、4、5。填好边界后按行从上往下、从左往右填每个格子都套用上面的转移公式。我们快速推几个关键格子dp[1][1]h变成rmin(01删除, 11插入, 01替换) 1dp[2][2]ho变成ro删除支路是dp[1][2]1213插入支路是dp[2][1]1213替换支路取字符o等于o代价dp[1][1]01所以结果是1dp[3][3]hor变成ros这一步推出来是2因为在ho变ro的基础上最后r和s不同替换就是2一直推到dp[5][3]答案是3这个手推过程比任何语言描述都有用。我当时学这道题就是靠一遍遍推表格把删除、插入、替换三个方向在格子上的对应关系彻底搞明白后面写代码就再也没犹豫过。4. 代码落地与空间优化从O(mn)到O(n)4.1 标准的二维DP实现理解了表格推演写二维DP就只是翻译公式。下面是我在Python里最常写的版本def minDistance(s: str, t: str) - int: m, n len(s), len(t) # dp[i][j] 表示 s 前 i 个字符变成 t 前 j 个字符的最少操作数 dp [[0] * (n 1) for _ in range(m 1)] for i in range(m 1): dp[i][0] i for j in range(n 1): dp[0][j] j for i in range(1, m 1): for j in range(1, n 1): if s[i - 1] t[j - 1]: cost 0 else: cost 1 dp[i][j] min( dp[i - 1][j] 1, # 删除 s[i-1] dp[i][j - 1] 1, # 在 s 中插入 t[j-1] dp[i - 1][j - 1] cost # 替换或不动 ) return dp[m][n]这段代码的时间复杂度是O(mn)空间复杂度是O(mn)。如果你只是刷题这样提交就能过。但面试官通常不会就此收手下一步一定会问能不能把空间省下来4.2 滚动数组为什么只用两行就够观察转移公式你会发现dp[i][j]只依赖三样东西左边的dp[i][j-1]、上边的dp[i-1][j]、左上角的dp[i-1][j-1]。也就是说计算当前行时我只需要上一行的数据更早的行用完就可以扔掉。于是可以用一个只有两行的二维数组用cur[j]表示当前行prev[j]表示上一行。每算完一行把cur复制给prev。这就是滚动数组优化空间复杂度降到O(n)。def minDistance(s: str, t: str) - int: m, n len(s), len(t) prev list(range(n 1)) # 第 0 行空串到 t 的前 j 个字符 curr [0] * (n 1) for i in range(1, m 1): curr[0] i # 当前行第 0 列s 前 i 个字符变成空串 for j in range(1, n 1): cost 0 if s[i - 1] t[j - 1] else 1 curr[j] min( prev[j] 1, # 删除 curr[j - 1] 1, # 插入注意用的是当前行的左边 prev[j - 1] cost # 替换或不动 ) prev, curr curr, prev return prev[n]这里有个隐蔽的坑插入操作用的是curr[j-1]也就是当前行已经算出来的那个值而不是prev[j-1]。因为让s的前i个字符变成t的前j-1个字符再插入t[j-1]这个方案建立在当前行左边格子的基础上。我第一次写滚动数组就栽在这儿一直用prev[j-1]当插入支路结果答案怎么都对不上后来把一个中间状态打印出来才看出问题。4.3 单数组极致优化一维数组怎么处理左上角既然只用到了上一行和当前行你能不能干脆只用一个数组可以但要注意左上角的值会被覆盖需要用一个临时变量提前存下来。我实测过这种写法代码更短但要维护的临时变量很容易把人绕晕面试时如果表达不清楚反而容易扣分。单数组版本的思路是当我们要更新dp[j]时数组里原来的d[j]是上一行第j列原来在d[j-1]的是当前行第j-1列而上一次循环保留下来的temp是上一行第j-1列。稍不留神就会把方向搞混。我的建议是面试中优先讲滚动数组清晰且足够优有时间再补充单数组作为进阶方案展示你理解得更深。5. 编辑距离的真实战场从拼写纠错到DNA比对5.1 拼写纠错与模糊搜索中的阈值判定很多搜索引擎、IDE的拼写提示底层都有一层编辑距离计算。用户敲了helo词库里有hello、help、hero编辑距离分别是1、2、2系统把距离最小的hello排在最前面。实际工程里不会对所有词库跑完整DP而是配合前缀树或BK树做剪枝因为词库可能百万级全量计算O(mn)太奢侈。我在一个小工具项目里试过5000词的词库全量计算一次才几十毫秒但到10万词以上就明显吃力了后来做了分桶和长度过滤效率提升非常明显。5.2 DNA序列比对中的相似度概念编辑距离在生物信息学里对应的是全局序列比对问题。DNA序列可以看作由A、T、C、G四个字母组成的字符串插入、删除、替换对应生物学术语gap、gap和mismatch。两条序列的编辑距离越小说明它们在进化上可能越接近。实际比对时还会引入打分矩阵比如替换的代价不一定都是1嘌呤和嘧啶之间的转换与颠换代价不同这就要给每种替换单独定权重比LeetCode 72的均匀代价复杂得多。5.3 面试追问变种替换代价不同怎么办允许交换怎么办面试官很喜欢在编辑距离上做变体。最常见的两个第一个是把替换代价改成2插入删除代价还是1。这本质上只是把转移方程里的cost从0/1改成0/2其他逻辑完全不变。第二个是问如果允许一次相邻字符交换也算一次操作怎么做。这就是经典的Damereau-Levenshtein距离状态设计会复杂很多因为交换操作可能依赖i-2和j-2的状态。我在真实面试中被问过这个变体当时因为时间仓促没写完整但把思路讲清楚了当s[i-1]t[j-2]且s[i-2]t[j-1]时可以在dp[i-2][j-2]基础上加1还要排除连续交换造成的重复计数。这个细节如果不推演很容易答错。6. 实操经验与易错点总结6.1 边界条件与索引错位九成bug的来源我在LeetCode上提交这道题的历次错误记录里几乎全是边界问题。最典型的有三类初始化时把dp[i][0]i写成dp[i][0]0导致空串变换结果全错比较字符时用了s[i]和t[j]但此时i和j已经是前缀长度应该用s[i-1]和t[j-1]忘记处理s或t为空串的情况虽然dp数组本身天然支持但如果提前判断len(s)0直接返回len(t)代码更清晰6.2 递推方向不能反为什么必须从小到大编辑距离的状态依赖是从小索引到大索引的所以外层循环必须从小到大遍历i和j。如果你试图从大到小推dp[i][j]依赖的dp[i-1][j]还没算出来结果就是错的。这一点对理解了前缀长度定义的人来说挺自然但如果只背代码没理解含义笔试时一紧张就可能写反。6.3 实际工程中的性能取舍刷题O(mn)能过但真实生产环境里两个几千字符的长文本做全量编辑距离可能会卡几百毫秒甚至更久。我的经验是先用长度差做一个粗筛如果abs(m-n)已经大于预设阈值直接判定不相似根本不用算DP。另一个技巧是限制最大编辑距离k只维护对角线附近的带状DP窗口复杂度降到O(k*min(m,n))。这个优化在模糊搜索场景非常实用我做的纠错模块就是这么干的效果很稳。最后再说一个我自己的体会编辑距离这道题刷一遍可能只是背下来但如果你能把表格推演、滚动数组、变种扩展全部亲手写一遍它对动态规划的理解会有一个质的提升。后来我再遇到其他字符串DP题比如最长公共子序列、正则表达式匹配心态明显稳了很多因为底层都是同一套最后一步思考法在起作用。如果这篇文章能帮你把这道题吃透那我觉得比单纯贴出答案有价值得多。