Skip to content

编辑距离(Edit Distance / Levenshtein Distance) ​

你用过 git diff 吧?改了几行代码,它就能精准地告诉你"这里删了一行、那里加了一行、还有一行改了几个字"。或者你在搜索引擎里打错了一个字,它依然能猜到你想搜什么,甚至提示你"你是不是想搜 xxx?"。

这些功能的背后,都有一个经典的算法在支撑——编辑距离(Edit Distance),也叫 Levenshtein Distance(莱文斯坦距离)。

为什么需要编辑距离? ​

假设你在做一个拼写检查器。用户输入了 "helo",你要在词库里找到最接近的正确单词。怎么定义"最接近"?

最直觉的办法:看把一个字符串变成另一个字符串,最少需要几步操作。每一步操作可以是:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符
"helo" → "hello"    只需插入一个 'l',距离 = 1
"helo" → "help"     删掉 'o',插入 'p',距离 = 2
"helo" → "world"    要改很多,距离 = 5

这个"最少操作步数"就是编辑距离。距离越小,两个字符串越相似。

听起来很简单?但如果你暴力枚举所有可能的操作序列,复杂度是指数级的。这时候,动态规划就派上用场了。

原理拆解 ​

1. 问题定义 ​

给定两个字符串 word1(长度 m)和 word2(长度 n),求将 word1 转换成 word2 所需的最少操作数。允许的操作:插入、删除、替换,每次算 1 步。

2. 状态定义 ​

这是 DP 的灵魂。我们定义:

dp[i][j] = word1 的前 i 个字符 变成 word2 的前 j 个字符 所需的最少操作数

最终答案就是 dp[m][n]。

3. 状态转移方程 ​

考虑 word1[i-1] 和 word2[j-1](注意是 0-indexed 的第 i 和第 j 个字符):

情况 A:两个字符相同 → 不需要任何操作,直接继承

dp[i][j] = dp[i-1][j-1]

情况 B:两个字符不同 → 三种操作取最小值

dp[i][j] = 1 + min(
    dp[i-1][j],     // 删除:word1 删掉一个字符,然后看剩下的
    dp[i][j-1],     // 插入:word1 插入一个字符变成 word2 的前 j 个,然后看剩下的
    dp[i-1][j-1]    // 替换:把 word1[i-1] 换成 word2[j-1]
)

用 ASCII 图来理解:

           word2: j-1    j
                    ↓     ↓
word1:  ... a ...  |  ... b ...
        i-1 →      |      ← i
                    |
         (a == b?) ─┤
                    |
         是 → dp[i][j] = dp[i-1][j-1]     // 什么都不用做
         否 → dp[i][j] = 1 + min(          // 三选一
                  dp[i-1][j],              //   删 a
                  dp[i][j-1],              //   插 b
                  dp[i-1][j-1]             //   把 a 换成 b
              )

4. 初始化 ​

边界情况必须想清楚,不然代码准出 bug:

dp[0][0] = 0           // 两个空串,距离为 0
dp[i][0] = i           // word1 前 i 个字符 → 空串,需要删 i 次
dp[0][j] = j           // 空串 → word2 前 j 个字符,需要插 j 次

5. 手动模拟 ​

用 word1 = "horse" 和 word2 = "ros" 来走一遍(LeetCode 72):

初始化 dp 表格(6行 × 4列):

        ""    r    o    s
  ""  [  0,   1,   2,   3 ]
  h   [  1,   ?,   ?,   ? ]
  o   [  2,   ?,   ?,   ? ]
  r   [  3,   ?,   ?,   ? ]
  s   [  4,   ?,   ?,   ? ]
  e   [  5,   ?,   ?,   ? ]

逐格填写:

dp[1][1]:'h' vs 'r',不同 → 1 + min(dp[0][1], dp[1][0], dp[0][0]) = 1 + min(1,1,0) = 1
dp[1][2]:'h' vs 'o',不同 → 1 + min(dp[0][2], dp[1][1], dp[0][1]) = 1 + min(2,1,1) = 2
dp[1][3]:'h' vs 's',不同 → 1 + min(dp[0][3], dp[1][2], dp[0][2]) = 1 + min(3,2,2) = 3

dp[2][1]:'o' vs 'r',不同 → 1 + min(dp[1][1], dp[2][0], dp[1][0]) = 1 + min(1,2,1) = 2
dp[2][2]:'o' vs 'o',相同!→ dp[1][1] = 1
dp[2][3]:'o' vs 's',不同 → 1 + min(dp[1][3], dp[2][2], dp[1][2]) = 1 + min(3,1,2) = 2

dp[3][1]:'r' vs 'r',相同!→ dp[2][0] = 2  ❌ 等等,dp[2][0]=2? 让我重算...
         实际 dp[2][0] = 2(前2个字符→空串要删2次),但 dp[2][1]='o'vs'r'=2
         dp[3][1]:'r'vs'r',相同 → dp[2][0] = 2

dp[3][2]:'r' vs 'o',不同 → 1 + min(dp[2][2], dp[3][1], dp[2][1]) = 1 + min(1,2,2) = 2
dp[3][3]:'r' vs 's',不同 → 1 + min(dp[2][3], dp[3][2], dp[2][2]) = 1 + min(2,2,1) = 2

dp[4][1]:'s' vs 'r',不同 → 1 + min(dp[3][1], dp[4][0], dp[3][0]) = 1 + min(2,3,3) = 3
dp[4][2]:'s' vs 'o',不同 → 1 + min(dp[3][2], dp[4][1], dp[3][1]) = 1 + min(2,3,2) = 3
dp[4][3]:'s' vs 's',相同!→ dp[3][2] = 2

dp[5][1]:'e' vs 'r',不同 → 1 + min(dp[4][1], dp[5][0], dp[4][0]) = 1 + min(3,4,4) = 4
dp[5][2]:'e' vs 'o',不同 → 1 + min(dp[4][2], dp[5][1], dp[4][1]) = 1 + min(3,4,3) = 4
dp[5][3]:'e' vs 's',不同 → 1 + min(dp[4][3], dp[5][2], dp[4][2]) = 1 + min(2,4,3) = 3

最终表格:

        ""    r    o    s
  ""  [  0,   1,   2,   3 ]
  h   [  1,   1,   2,   3 ]
  o   [  2,   2,   1,   2 ]
  r   [  3,   2,   2,   2 ]
  s   [  4,   3,   3,   2 ]
  e   [  5,   4,   4,   3 ]

答案:dp[5][3] = 3

翻译成人话:"horse" → "rorse"(替换 h→r)→ "rose"(删除 r)→ "ros"(删除 e),共 3 步。✅

代码实现 ​

TypeScript — 基础二维 DP ​

typescript
function minDistance(word1: string, word2: string): number {
  const m = word1.length;
  const n = word2.length;

  // dp[i][j] = word1 前 i 个字符 变成 word2 前 j 个字符 的最少操作数
  const dp: number[][] = Array.from({ length: m + 1 }, () =>
    new Array(n + 1).fill(0)
  );

  // 初始化:空串 ↔ 非空串
  for (let i = 0; i <= m; i++) dp[i][0] = i;
  for (let j = 0; j <= n; j++) dp[0][j] = j;

  // 填表
  for (let i = 1; i <= m; i++) {
    for (let 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 + Math.min(
          dp[i - 1][j],     // 删除
          dp[i][j - 1],     // 插入
          dp[i - 1][j - 1]  // 替换
        );
      }
    }
  }

  return dp[m][n];
}

// 测试
console.log(minDistance("horse", "ros"));       // 3
console.log(minDistance("intention", "execution")); // 5
console.log(minDistance("", "abc"));              // 3
console.log(minDistance("abc", "abc"));           // 0

TypeScript — 空间优化(滚动数组) ​

上面的二维表用了 O(m×n) 空间。观察状态转移方程,dp[i][j] 只依赖于:

  • dp[i-1][j-1](左上角)
  • dp[i-1][j](正上方)
  • dp[i][j-1](左边)

所以可以用一行数组搞定,空间降到 O(n):

typescript
function minDistanceOptimized(word1: string, word2: string): number {
  const m = word1.length;
  const n = word2.length;

  let prev = new Array(n + 1).fill(0);
  let curr = new Array(n + 1).fill(0);

  // 初始化第一行
  for (let j = 0; j <= n; j++) prev[j] = j;

  for (let i = 1; i <= m; i++) {
    curr[0] = i; // 边界:dp[i][0] = i
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        curr[j] = prev[j - 1];
      } else {
        curr[j] = 1 + Math.min(
          prev[j],       // 删除
          curr[j - 1],   // 插入
          prev[j - 1]    // 替换
        );
      }
    }
    [prev, curr] = [curr, prev]; // 滚动
  }

  return prev[n];
}

如果想进一步压到单数组,需要注意 prev[j-1] 在被覆盖前要先存下来:

typescript
function minDistanceSingleArray(word1: string, word2: string): number {
  const m = word1.length;
  const n = word2.length;

  const dp = new Array(n + 1).fill(0);
  for (let j = 0; j <= n; j++) dp[j] = j;

  for (let i = 1; i <= m; i++) {
    let prevDiag = dp[0]; // 保存 dp[i-1][j-1]
    dp[0] = i;
    for (let j = 1; j <= n; j++) {
      const temp = dp[j]; // 先存下来,下一轮会用到
      if (word1[i - 1] === word2[j - 1]) {
        dp[j] = prevDiag;
      } else {
        dp[j] = 1 + Math.min(dp[j], dp[j - 1], prevDiag);
      }
      prevDiag = temp;
    }
  }

  return dp[n];
}

Python 版本 ​

python
def min_distance(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    
    # 二维 DP
    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 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]

# 测试
print(min_distance("horse", "ros"))          # 3
print(min_distance("intention", "execution")) # 5

复杂度分析 ​

版本时间复杂度空间复杂度
二维 DPO(m × n)O(m × n)
滚动数组O(m × n)O(n)
单数组O(m × n)O(n)

其中 m 和 n 分别是两个字符串的长度。

💡 面试小贴士:面试时先写二维 DP,确保逻辑正确。如果面试官追问优化,再展示滚动数组。不要一上来就写空间优化版——容易把自己绕晕,面试官也看不懂。

回溯:找出具体操作序列 ​

光知道"最少 3 步"有时候不够,你还想知道具体做了哪些操作。怎么回溯?

从 dp[m][n] 开始,倒着走:

if word1[i-1] == word2[j-1]:
    → 来自 dp[i-1][j-1],没有操作,斜着走
else:
    → 看 dp[i][j] 是从哪个方向来的:
       来自 dp[i-1][j-1] → 替换
       来自 dp[i-1][j]   → 删除
       来自 dp[i][j-1]   → 插入
typescript
function printEditOperations(word1: string, word2: string): string[] {
  const m = word1.length;
  const n = word2.length;
  const dp: number[][] = Array.from({ length: m + 1 }, () =>
    new Array(n + 1).fill(0)
  );

  for (let i = 0; i <= m; i++) dp[i][0] = i;
  for (let j = 0; j <= n; j++) dp[0][j] = j;

  for (let i = 1; i <= m; i++) {
    for (let 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 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
      }
    }
  }

  // 回溯找操作序列
  const ops: string[] = [];
  let i = m, j = n;
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && word1[i - 1] === word2[j - 1]) {
      ops.push(`保留 '${word1[i - 1]}'`);
      i--;
      j--;
    } else if (i > 0 && j > 0 && dp[i][j] === dp[i - 1][j - 1] + 1) {
      ops.push(`替换 '${word1[i - 1]}' → '${word2[j - 1]}'`);
      i--;
      j--;
    } else if (j > 0 && dp[i][j] === dp[i][j - 1] + 1) {
      ops.push(`插入 '${word2[j - 1]}'`);
      j--;
    } else if (i > 0 && dp[i][j] === dp[i - 1][j] + 1) {
      ops.push(`删除 '${word1[i - 1]}'`);
      i--;
    }
  }

  return ops.reverse();
}

// 测试
console.log(printEditOperations("horse", "ros"));
// [
//   "替换 'h' → 'r'",
//   "保留 'o'",
//   "保留 'r'",
//   "删除 's'",
//   "删除 'e'"
// ]

注意:当 dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1] 中有多个值相同时,回溯路径不唯一。上面的代码优先走替换,这只是一个合理的 tie-breaking 策略。

变体与进阶 ​

1. 带权重的编辑距离 ​

实际场景中,不同操作的"成本"可能不一样。比如在拼写检查里,键盘上相邻的字母更容易打错(a→s 比 a→z 更常见),替换成本可以设低一些。

typescript
// 替换成本不再是固定的 1,而是根据两个字符的距离来算
function substitutionCost(c1: string, c2: string): number {
  const keyboardRow = "qwertyuiopasdfghjklzxcvbnm";
  const idx1 = keyboardRow.indexOf(c1);
  const idx2 = keyboardRow.indexOf(c2);
  const distance = Math.abs(idx1 - idx2);
  return Math.min(distance, 1); // 键盘距离越近,成本越低
}

2. 只允许插入和删除(不许替换) ​

有些场景下"替换"没有意义(比如 DNA 序列比对,碱基只有 ATCG 四种),可以去掉替换操作:

typescript
dp[i][j] = word1[i-1] === word2[j-1]
  ? dp[i-1][j-1]
  : 1 + Math.min(dp[i-1][j], dp[i][j-1]);  // 只有删除和插入

3. Damerau-Levenshtein 距离 ​

在标准编辑距离的基础上,额外允许相邻字符交换(transposition),比如 "ab" → "ba" 算 1 步而不是 2 步(替换 a→b + 替换 b→a)。

这对于拼写检查更友好——人打字经常手快把相邻字母搞反。

实际应用场景 ​

你可能觉得编辑距离只是面试题,但它在工业界到处都是:

1. 拼写纠错

搜索引擎、IDE 的自动补全、手机输入法,都用编辑距离来找"最接近"的候选词。

2. git diff

git diff 的核心算法之一就是编辑距离。它找出两个文件之间的最少增删改操作,然后展示给你看。

3. DNA 序列比对

生物信息学里,DNA 序列(由 A/T/C/G 组成)的相似度比对大量使用编辑距离,用来判断物种之间的亲缘关系。

4. 模糊搜索 / 容错匹配

Elasticsearch 的 fuzzy 查询、PostgreSQL 的 pg_trgm 扩展,底层都用了编辑距离(或其变体)来实现"搜 helo 也能匹配到 hello"。

5. 机器翻译评估

BLEU score 等翻译质量评估指标,与编辑距离的思想一脉相承——衡量生成文本和参考文本之间的"距离"。

相关 LeetCode 题目 ​

题号题目难度
72Edit DistanceMedium
583Delete Operation for Two StringsMedium
712Minimum ASCII Delete Sum for Two StringsMedium
1143Longest Common SubsequenceMedium
1035Uncrossed LinesMedium

💡 这几道题本质上都是编辑距离的变体或简化版。学会了编辑距离,这些题就是换皮而已。

总结 ​

编辑距离是动态规划中最经典、最实用的算法之一。掌握了它,你不光能解决 LeetCode 上的一串题目,还能理解拼写纠错、git diff、DNA 比对等实际应用背后的原理。

核心记住三点:

  1. 状态定义:dp[i][j] = word1 前 i 个字符变成 word2 前 j 个字符的最少操作数
  2. 转移方程:字符相同就继承斜对角,不同就三选一取最小(删、插、换)
  3. 初始化:dp[i][0] = i,dp[0][j] = j

面试的时候,先画个表格,手动填几个格子,把逻辑理清楚了再写代码。DP 题不怕慢,怕的是没想清楚就动手 😄

最近更新