编辑距离
删/插/改三向转移
本课摘要
编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断编辑距离的适用条件与状态边界
- 围绕“删/插/改三向转移”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
把一个词改成另一个,最少几步
给两个串 和 ,你只能用三种操作改写 :删掉其中一个字符、插入一个字符、把某个字符改成另一个。目标是把 变成 ,用的操作次数最少——这个最小次数,就叫 到 的编辑距离(Levenshtein 距离)。
先看个具体的:把 "horse" 改成 "ros"。一条可行路线是——把 h 改成 r(horse→rorse), 删掉第一个 r 后面的 o… 手工凑很容易凑不出最短。其实最优是 3 步:h→r(改)、删 r、删 e,剩下 ros。再看 "sitting"→"kitten",最优也是 3 步 (s→k、i→e、删末尾 g)。
难点在哪?此刻该删、该插还是该改,取决于两个串后面还剩什么——是个牵一发动全身的全局问题,贪心按不住。 那把所有操作序列枚举一遍?序列长度不固定、分叉又多,直接爆炸。和 LCS 一样, 这类两个串逐位对齐的问题,正是二维 DP 的主场。
状态与转移:三选一取最小
定状态。设 表示:把 的前 个字符改写成 的前 个字符, 所需的最少操作数。把「逐位对齐」当作阶段,每一步只决断末尾这一位怎么处理。
只盯住两个串的末尾字符 与 ,把 拆成三条来路:
删 :把 丢掉,问题缩成「 前 个对齐 前 个」,再计 1 步 → 。
插 :在 末尾补一个 把 这位对上,问题缩成「 前 个对齐 前 个」,计 1 步 → 。
改 / 匹配:让 与 正面相对,问题缩成「前 对齐前 」。若两字本就相同,白赚一步不花代价;否则改一次记 1 步 → 。
三条路取最小,就是转移方程:
这里 是艾弗森括号:两字不同取 1、相同取 0。还差边界——一个串为空时怎么办?
:把 前 个字符改成空串,只能一个个删,删 次;:从空串造出 前 个,只能一个个插,插 次。答案在右下角 。
本质
编辑距离把「无穷多条操作序列」压成一张 的表:每一格只问「末尾这位删、插、还是改/匹配」,三个已算好的邻格取最小。指数级的改写路径,被 个格子装下——这正是两串对齐类 DP 的通用骨架。
跟着算一遍
用 "horse"、"ros" 走几步(下标从 1 记),把方程「跑起来」:
看它一格一格填出来
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
深化:从「恒 1」到带权对齐
上面每种操作都恒记 1 步。但「编辑距离」的骨架其实更通用——只要把每种操作的代价换成任意权重,同一套三向取最小就变成了最小代价的序列对齐。这在生物信息(DNA 比对)、拼写纠错里天天用。
把方程里的三个「」换成各自的权重,转移形状一字不改:
典型如洛谷 P1279「字串距离」:删 / 插一个字符视作它与「空位」配对,记固定代价 ;把 改成 的代价是两者 ASCII 差 (相同则差为 0,自然免费)。边界也随之变成 、。普通编辑距离,不过是「删插改代价全取 1」的带权对齐特例。
换个视角:编辑距离 = 带权序列对齐
「删 / 插」= 某字符与空位配对,「改 / 匹配」= 两字符正面配对。于是求编辑距离,等价于给两个串找一套最省代价的逐位配对方案——把恒 1 的权重换成任意 ,方程原样通用。
不止要距离,还要「怎么改」:回溯操作序列
只告诉你最少几步,可很多时候我们想知道具体是哪几步——先删哪个、再改哪个。办法是从右下角回溯:站在 ,看它当初的值是从哪个邻格转移来的,就往那格走,同时记下对应的操作,一路退回 :
站在 (i, j),回头看它是从哪来的: 若 A[i] == B[j] 且 dp[i][j] == dp[i−1][j−1] → 保留,走向 (i−1, j−1) 否则若 dp[i][j] == dp[i−1][j−1] + 1 → 改 A[i]→B[j],走 (i−1, j−1) 否则若 dp[i][j] == dp[i−1][j] + 1 → 删 A[i],走 (i−1, j) 否则 → 插 B[j],走 (i, j−1) 倒着走到 (0,0),把记录翻转,就是把 A 对齐到 B 的操作序列
下面这个演示就把回溯逐步走给你看:上排是 的字符、下排是 的字符,中间的徽标标出每一位是保留 / 删 / 插 / 改。拖动步进条,看 一步步被对齐成 ——真正花代价的步数,恰好等于上面主演示算出的编辑距离。
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
回溯的两个坑
① 并列时要定一个固定优先级(这里:匹配 / 改 > 删 > 插),否则多条最优路径会让输出飘忽。② 回溯读的是转移来源而非单纯比大小——务必让判断顺序和当初填表时「谁被选中」的规则一致,否则会还原出一条并不合法的操作链。
例题
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
char a[2005], b[2005];
int f[2005][2005]; // f[i][j]:a 前 i 个字符改成 b 前 j 个的最少操作
int main()
{
cin >> (a + 1) >> (b + 1); // 下标从 1 开始存
int n = strlen(a + 1), m = strlen(b + 1);
for (int i = 0; i <= n; i++) f[i][0] = i; // 边界:全删空
for (int j = 0; j <= m; j++) f[0][j] = j; // 边界:从空串插出来
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
{
int sub = f[i - 1][j - 1] + (a[i] != b[j]); // 改/匹配:同字 +0,异字 +1
int del = f[i - 1][j] + 1; // 删掉 a[i]
int ins = f[i][j - 1] + 1; // 插入 b[j]
f[i][j] = min(sub, min(del, ins)); // ★三向取最小
}
cout << f[n][m] << endl;
return 0;
}
// TAG: 线性DP 编辑距离 Levenshtein 串对齐#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
char a[2005], b[2005];
int f[2005][2005]; // f[i][j]:a 前 i 个对齐到 b 前 j 个的最小总代价
int k; // 空位(删/插)的固定代价
int main()
{
cin >> k >> (a + 1) >> (b + 1);
int n = strlen(a + 1), m = strlen(b + 1);
for (int i = 0; i <= n; i++) f[i][0] = i * k; // 前 i 个全对空位,各计 k
for (int j = 0; j <= m; j++) f[0][j] = j * k;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
{
int sub = f[i - 1][j - 1] + abs(a[i] - b[j]); // 改:代价 = 两字符 ASCII 差
int del = f[i - 1][j] + k; // 删 a[i]:a[i] 对空位
int ins = f[i][j - 1] + k; // 插 b[j]:空位对 b[j]
f[i][j] = min(sub, min(del, ins));
}
cout << f[n][m] << endl;
return 0;
}
// TAG: 线性DP 带权编辑距离 字串距离 序列对齐
