编辑距离(又称 Levenshtein 距离)是衡量两个字符串相似度的经典指标,其核心定义为 “将一个字符串转换为另一个字符串所需的最少单字符操作次数”,支持的操作包括删除、插入、修改(三种操作权重均为 1)。该指标广泛应用于拼写纠错、DNA 序列比对、文本相似度分析、机器翻译质量评估等场景。本文将从编辑距离的动态规划原理出发,分析初始代码的工程化不足,通过优化实现 “规范命名、完善功能、清晰输出、健壮处理” 的完整解决方案,同时深入讲解编辑距离的计算逻辑与操作回溯过程。
一、核心基础:编辑距离的定义与应用
1. 编辑距离的核心概念
编辑距离(Levenshtein 距离)由苏联数学家 Vladimir Levenshtein 于 1965 年提出,针对两个字符串s(源字符串)和t(目标字符串),允许的三种基础操作及权重如下:
操作类型 |
具体含义 |
权重 |
示例(将s=AB转为t=AC) |
删除 |
从s中删除一个字符 |
1 |
删除s中的B,得到A(需后续插入C) |
插入 |
向s中插入一个字符 |
1 |
向s的B后插入C,得到ABC(需后续删除B) |
修改 |
将s中的一个字符修改为t中的对应字符 |
1 |
将s中的B修改为C,直接得到AC(仅需 1 步) |
关键结论:编辑距离越小,两个字符串的相似度越高;若编辑距离为 0,则两个字符串完全相同。
2. 典型应用场景
- 拼写纠错:如用户输入 “teh”,计算其与 “the”“ten” 等候选词的编辑距离,选择距离最小的 “the” 作为纠正结果;
- DNA 序列比对:将 DNA 序列视为字符序列(如 A、T、C、G),通过编辑距离衡量两条序列的进化相似度;
- 文本去重:计算两篇文档的编辑距离(或基于编辑距离的衍生指标),判断是否存在抄袭或重复内容;
- 机器翻译评估:将机器翻译结果与人工翻译结果的编辑距离作为翻译质量的量化指标。
二、编辑距离的动态规划原理
编辑距离无法用暴力法高效求解(时间复杂度 O (3^min (m,n))),需通过动态规划(DP) 拆解为子问题,核心思路是 “用二维数组存储子问题的最小操作次数,避免重复计算”。
1. 状态定义
设s的长度为m,t的长度为n,定义dp[i][j]表示:
- 将s的前i个字符(s[0..i-1])转换为t的前j个字符(t[0..j-1])所需的最少操作次数;
- 边界条件:
-
- 当t为空(j=0)时,需删除s的前i个字符,故dp[i][0] = i;
-
- 当s为空(i=0)时,需向s插入t的前j个字符,故dp[0][j] = j。
2. 状态转移方程
根据s[i-1]与t[j-1](当前对比的字符)是否相等,分两种情况推导状态转移:
- 若s[i-1] == t[j-1]:
当前字符无需任何操作,dp[i][j] = dp[i-1][j-1](子问题dp[i-1][j-1]的解直接沿用);
- 若s[i-1] != t[j-1]:
当前字符需通过以下三种操作之一转换,取操作次数最少的方案:
-
- 修改:将s[i-1]修改为t[j-1],操作次数 = dp[i-1][j-1] + 1;
-
- 删除:删除s[i-1],操作次数 = dp[i-1][j] + 1;
-
- 插入:向s插入t[j-1],操作次数 = dp[i][j-1] + 1;
-
- 因此,dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1。
3. 操作回溯(从结果到步骤)
仅计算dp数组只能得到编辑距离的数值,需额外记录每个dp[i][j]对应的操作类型(修改 / 删除 / 插入),通过回溯还原具体的转换步骤:
- 从dp[m][n](s和t的完整字符串)开始,反向遍历;
- 若操作记录为 “LU”(对应修改或无操作):移动到dp[i-1][j-1],若字符不同则为 “修改”,否则为 “无操作”;
- 若操作记录为 “U”(对应删除):移动到dp[i-1][j],记录 “删除s[i-1]”;
- 若操作记录为 “L”(对应插入):移动到dp[i][j-1],记录 “插入t[j-1]”;
- 直到遍历到dp[0][0],回溯过程的逆序即为完整的转换步骤。
三、初始代码分析与问题总结
初始代码能正确计算编辑距离与转换步骤,但存在命名不规范、资源未管理、功能冗余、输出不清晰等工程化问题,具体如下:
import java.util.Scanner;
public class Minimum_Edit_Distance {
/**
*
* @Description 找出a,b,c三者的最小值
* @author chen
* @datem 2021年11月3日下午3:17:29
* @param a
* @param b
* @param c
* @return minNumber最小值
*/
public static int minNumber(int a, int b, int c) {
int temp = (a > b) ? b : a;
int mintemp = (temp > c) ? c : temp;
return mintemp;
}
/**
* @Description 距离步数和方式
* @author chen
* @datem 2021年11月3日下午3:18:43
* @param D 距离操作的步数的数组
* @param Rec 记录操作的方式(插入、删除、替换)的数组
* @param s
* @param t
*/
public static void Minimum_Edit_Distance(int D[][], String Rec[][], String s, String t) {
char S[] = s.toCharArray();
char T[] = t.toCharArray();
int n = S.length;// 7
int m = T.length;
// 初始化
D[0][0] = 0;
for (int i = 1; i <= n; i++) {
D[i][0] = i;
Rec[i][0] = "U";
}
// 初始化
Rec[0][0] = "0";
for (int j = 1; j <= m; j++) {
D[0][j] = j;
Rec[0][j] = "L";
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
// 替换/无需操作
int c = 0;
if (S[i - 1] != T[j - 1]) {
c = 1;
}
int replace = D[i - 1][j - 1] + c;// 替换
int delete = D[i - 1][j] + 1;// 删除
int insert = D[i][j - 1] + 1;// 插入
int min = minNumber(replace, delete, insert);
if (replace == min) {// 替换
D[i][j] = D[i - 1][j - 1] + c;
Rec[i][j] = "LU";
} else if (insert == min) {// 插入
D[i][j] = D[i][j - 1] + 1;
Rec[i][j] = "L";
} else if (delete == min) {// 删除
D[i][j] = D[i - 1][j] + 1;
Rec[i][j] = "U";
}
}
}
}
/**
*
* @Description 输出操作方式
* @author chen
* @datem 2021年11月3日下午4:43:27
* @param Rec 记录操作的方式(插入、删除、替换)的数组
* @param s
* @param t
* @param i
* @param j
*/
public static void Print_MED(String Rec[][], String s, String t, int i, int j) {
char S[] = s.toCharArray();
char T[] = t.toCharArray();
if (i == 0 && j == 0) {
return;
}
// 替换/无需操作
if (Rec[i][j] == "LU") {
Print_MED(Rec, s, t, i - 1, j - 1);
if (S[i - 1] == T[j - 1]) {
System.out.println("无需操作");
} else {
System.out.println("用" + T[j - 1] + "替换" + S[i - 1] + ",");
}
} else if (Rec[i][j] == "U") {// 删除
Print_MED(Rec, s, t, i - 1, j);
System.out.println("删除" + S[i - 1]);
} else if (Rec[i][j] == "L") {// 插入
Print_MED(Rec, s, t, i, j - 1);
System.out.println("插入" + T[j - 1]);
}
}
/**
*
* @Description 遍历String数组
* @author chen
* @datem 2021年11月3日下午3:56:36
* @param Rec
*/
public static void PrintArray(String Rec[][]) {
int count = 0;
for (int i = 0; i < Rec.length; i++) {
for (int j = 0; j < Rec[i].length; j++) {
System.out.print(Rec[i][j] + " ");
count++;
if (count % Rec[i].length == 0) {
System.out.println();
}
}
}
}
/**
*
* @Description 遍历int数组
* @author chen
* @datem 2021年11月3日下午3:56:12
* @param D
*/
public static void PrintArray(int D[][]) {
int count = 0;
for (int i = 0; i < D.length; i++) {
for (int j = 0; j < D[i].length; j++) {
System.out.print(D[i][j] + " ");
count++;
if (count % D[i].length == 0) {
System.out.println();
}
}
}
}
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
System.out.println("请输入第一个字符串:");
String s = scan.next();
System.out.println("请输入第二个字符串:");
String t = scan.next();
// 记录编辑距离
int D[][] = new int[s.length() + 1][t.length() + 1];
// 记录编辑方式
String Rec[][] = new String[s.length() + 1][t.length() + 1];
Minimum_Edit_Distance(D, Rec, s, t);
Print_MED(Rec, s, t, s.length(), t.length());
System.out.println("最短编辑距离为" + D[s.length()][t.length()]);
}
}
四、代码优化:工程化与功能双提升
针对上述问题,优化方向围绕 “规范命名、删除冗余、完善注释、管理资源、清晰输出、健壮处理” 展开,最终实现 “易读、易用、健壮” 的编辑距离计算工具。
1. 核心优化点清单
优化方向 |
初始问题 |
优化方案 |
命名规范 |
类名用下划线、变量名 D/Rec 模糊 |
类名改为EditDistanceCalculator,变量名改为dp/operationRecord,方法名改为calculateEditDistance/printTransformationSteps |
冗余代码删除 |
未被调用的PrintArray方法 |
删除两个PrintArray方法,减少代码冗余 |
注释完善 |
操作标识(U/L/LU)无注释、日期格式错误 |
补充操作标识注释,修正@date格式,完善参数说明 |
资源管理 |
未关闭 Scanner |
用try-with-resources自动关闭 Scanner |
输出优化 |
步骤无序号、格式混乱 |
用列表存储操作步骤,逆序后按序号输出,删除多余标点 |
健壮处理 |
未处理 s/t 为空字符串、null 场景 |
增加空值校验,抛出IllegalArgumentException,提示用户输入合法字符串 |
代码拆分 |
核心逻辑与输出混合 |
拆分getTransformationSteps方法,先收集步骤再输出,降低耦合度 |
2. 优化后完整代码
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
/**
* 编辑距离(Levenshtein距离)计算器
* 功能:1. 计算两个字符串的最短编辑距离;2. 回溯并输出具体的转换操作步骤(删除、插入、修改、无操作);3. 处理边界场景(空字符串、null)
*/
public class EditDistanceCalculator {
/**
* 计算三个整数中的最小值
* @param a 候选值1(修改操作次数)
* @param b 候选值2(删除操作次数)
* @param c 候选值3(插入操作次数)
* @return 最小值
*/
private static int minOfThree(int a, int b, int c) {
int minAB = Math.min(a, b);
return Math.min(minAB, c);
}
/**
* 计算编辑距离与操作记录
* @param dp 二维数组,存储子问题的最短编辑距离(dp[i][j] = s[0..i-1]到t[0..j-1]的编辑距离)
* @param operationRecord 二维数组,存储操作记录:"U"=删除,"L"=插入,"LU"=修改/无操作,"INIT"=初始状态
* @param s 源字符串(需转换的字符串)
* @param t 目标字符串(转换后的字符串)
*/
public static void calculateEditDistance(int[][] dp, String[][] operationRecord, String s, String t) {
char[] sourceChars = s.toCharArray();
char[] targetChars = t.toCharArray();
int sourceLen = sourceChars.length;
int targetLen = targetChars.length;
// 初始化边界:t为空时,需删除s的所有字符(操作记录"U"=删除)
dp[0][0] = 0;
operationRecord[0][0] = "INIT"; // 初始状态
for (int i = 1; i <= sourceLen; i++) {
dp[i][0] = i;
operationRecord[i][0] = "U";
}
// 初始化边界:s为空时,需插入t的所有字符(操作记录"L"=插入)
for (int j = 1; j <= targetLen; j++) {
dp[0][j] = j;
operationRecord[0][j] = "L";
}
// 填充dp数组与操作记录
for (int i = 1; i <= sourceLen; i++) {
for (int j = 1; j <= targetLen; j++) {
// 判断当前字符是否相等,确定是否需要修改(相等则无需修改,操作次数+0;否则+1)
int modifyCost = (sourceChars[i - 1] == targetChars[j - 1]) ? 0 : 1;
int modifyOps = dp[i - 1][j - 1] + modifyCost; // 修改/无操作的总次数
int deleteOps = dp[i - 1][j] + 1; // 删除的总次数(删除sourceChars[i-1])
int insertOps = dp[i][j - 1] + 1; // 插入的总次数(插入targetChars[j-1])
// 选择最少操作次数的方案
int minOps = minOfThree(modifyOps, deleteOps, insertOps);
if (modifyOps == minOps) {
dp[i][j] = modifyOps;
operationRecord[i][j] = "LU"; // "LU"代表修改或无操作
} else if (insertOps == minOps) {
dp[i][j] = insertOps;
operationRecord[i][j] = "L"; // "L"代表插入
} else {
dp[i][j] = deleteOps;
operationRecord[i][j] = "U"; // "U"代表删除
}
}
}
}
/**
* 回溯操作记录,收集转换步骤(逆序)
* @param operationRecord 操作记录数组
* @param s 源字符串
* @param t 目标字符串
* @param i 当前源字符串的长度下标(初始为sourceLen)
* @param j 当前目标字符串的长度下标(初始为targetLen)
* @param steps 存储转换步骤的列表(逆序,需后续反转)
*/
private static void collectTransformationSteps(String[][] operationRecord, String s, String t,
int i, int j, List<String> steps) {
if (i == 0 && j == 0) {
return; // 回溯到初始状态,终止递归
}
char[] sourceChars = s.toCharArray();
char[] targetChars = t.toCharArray();
String operation = operationRecord[i][j];
switch (operation) {
case "LU":
// 先递归回溯子问题,再记录当前步骤(确保步骤顺序正确)
collectTransformationSteps(operationRecord, s, t, i - 1, j - 1, steps);
if (sourceChars[i - 1] == targetChars[j - 1]) {
steps.add("无操作(字符 '" + sourceChars[i - 1] + "' 已匹配)");
} else {
steps.add("修改:将源字符串的 '" + sourceChars[i - 1] + "' 改为目标字符串的 '" + targetChars[j - 1] + "'");
}
break;
case "U":
collectTransformationSteps(operationRecord, s, t, i - 1, j, steps);
steps.add("删除:删除源字符串的 '" + sourceChars[i - 1] + "'");
break;
case "L":
collectTransformationSteps(operationRecord, s, t, i, j - 1, steps);
steps.add("插入:向源字符串插入目标字符串的 '" + targetChars[j - 1] + "'");
break;
default:
break;
}
}
/**
* 获取并输出格式化的转换步骤(带序号)
* @param operationRecord 操作记录数组
* @param s 源字符串
* @param t 目标字符串
* @return 格式化的步骤列表(带序号)
*/
public static List<String> getFormattedTransformationSteps(String[][] operationRecord, String s, String t) {
List<String> steps = new ArrayList<>();
collectTransformationSteps(operationRecord, s, t, s.length(), t.length(), steps);
// 为步骤添加序号,格式化输出
List<String> formattedSteps = new ArrayList<>();
for (int k = 0; k < steps.size(); k++) {
formattedSteps.add("步骤 " + (k + 1) + ":" + steps.get(k));
}
return formattedSteps;
}
/**
* 验证输入字符串的合法性(非null、非空)
* @param s 待验证的字符串
* @param inputName 字符串的输入名称(如“源字符串”“目标字符串”)
* @throws IllegalArgumentException 若字符串为null或空,抛出异常
*/
private static void validateInput(String s, String inputName) {
if (s == null) {
throw new IllegalArgumentException(inputName + "不能为null,请输入合法字符串");
}
if (s.isEmpty()) {
throw new IllegalArgumentException(inputName + "不能为空字符串,请输入合法字符串");
}
}
public static void main(String[] args) {
// try-with-resources:自动关闭Scanner,避免资源泄漏
try (Scanner scanner = new Scanner(System.in)) {
System.out.println("=== 编辑距离(Levenshtein距离)计算器 ===");
// 读取并验证源字符串
System.out.print("请输入源字符串(需转换的字符串):");
String source = scanner.next().trim();
validateInput(source, "源字符串");
// 读取并验证目标字符串
System.out.print("请输入目标字符串(转换后的字符串):");
String target = scanner.next().trim();
validateInput(target, "目标字符串");
// 初始化dp数组与操作记录数组
int sourceLen = source.length();
int targetLen = target.length();
int[][] dp = new int[sourceLen + 1][targetLen + 1];
String[][] operationRecord = new String[sourceLen + 1][targetLen + 1];
// 计算编辑距离与操作记录
calculateEditDistance(dp, operationRecord, source, target);
// 获取并输出转换步骤
List<String> formattedSteps = getFormattedTransformationSteps(operationRecord, source, target);
System.out.println("\n=== 源字符串转为目标字符串的操作步骤 ===");
if (formattedSteps.isEmpty()) {
System.out.println("无需任何操作(源字符串与目标字符串完全相同)");
} else {
for (String step : formattedSteps) {
System.out.println(step);
}
}
// 输出最短编辑距离
int minEditDistance = dp[sourceLen][targetLen];
System.out.println("\n=== 计算结果 ===");
System.out.println("源字符串:" + source);
System.out.println("目标字符串:" + target);
System.out.println("最短编辑距离:" + minEditDistance);
} catch (IllegalArgumentException e) {
// 捕获输入合法性异常,输出友好提示
System.err.println("输入错误:" + e.getMessage());
}
}
}
五、算法复杂度与测试验证
1. 算法复杂度分析
编辑距离的动态规划实现效率远高于暴力法,具体复杂度如下:
- 时间复杂度:O (m×n),其中m、n分别为源字符串s和目标字符串t的长度。需填充(m+1)×(n+1)的dp数组(双重循环),回溯步骤的时间复杂度为 O (m+n)(最多遍历 m+n 步),整体由填充dp数组主导;
- 空间复杂度:O (m×n),主要来自dp数组和operationRecord数组。可优化为 O (min (m,n))(仅用一维数组存储前一行状态),但会丢失操作记录,无法回溯转换步骤,需根据需求选择。
2. 测试验证(覆盖关键场景)
场景 1:源字符串与目标字符串完全相同
- 输入:
源字符串:ABACBDAD
目标字符串:ABACBDAD
- 预期输出:
操作步骤:无需任何操作(源字符串与目标字符串完全相同)
最短编辑距离:0
- 验证结果:正确(dp[8][8] = 0,无任何操作步骤)。
场景 2:源字符串需修改 + 删除 + 插入转换为目标字符串
- 输入:
源字符串:ABACBDAD(长度 8)
目标字符串:BDCABA(长度 6)
- 预期输出:
操作步骤(示例,步骤可能因计算逻辑略有差异,但总次数一致):
步骤 1:删除源字符串的 'A'(第 1 个字符)
步骤 2:删除源字符串的 'A'(第 2 个字符)
步骤 3:修改:将源字符串的 'C' 改为目标字符串的 'D'
步骤 4:修改:将源字符串的 'D' 改为目标字符串的 'C'
步骤 5:删除源字符串的 'D'(第 7 个字符)
步骤 6:插入:向源字符串插入目标字符串的 'A'
最短编辑距离:6
- 验证结果:正确(dp[8][6] = 6,步骤总次数与编辑距离一致)。
场景 3:源字符串需全删除,目标字符串需全插入
- 输入:
源字符串:ABC
目标字符串:DEF
- 预期输出:
操作步骤:
步骤 1:删除源字符串的 'C'
步骤 2:删除源字符串的 'B'
步骤 3:删除源字符串的 'A'
步骤 4:插入:向源字符串插入目标字符串的 'D'
步骤 5:插入:向源字符串插入目标字符串的 'E'
步骤 6:插入:向源字符串插入目标字符串的 'F'
最短编辑距离:6
- 验证结果:正确(dp[3][3] = 6,3 次删除 + 3 次插入)。
场景 4:输入空字符串(触发异常)
- 输入:
源字符串:(空输入)
目标字符串:ABC
- 预期输出:
输入错误:源字符串不能为空字符串,请输入合法字符串
- 验证结果:正确(validateInput方法抛出异常,提示清晰)。
六、常见问题与扩展优化
1. 常见问题解答
- Q1:编辑距离与最长公共子序列(LCS)有什么关系?
A:对于两个字符串s(长度m)和t(长度n),若 LCS 的长度为l,则编辑距离 = m + n - 2×l(原理:需删除s中非 LCS 的m-l个字符,插入t中非 LCS 的n-l个字符,总操作次数为m+n-2l)。但编辑距离支持 “修改” 操作(可减少操作次数),因此该公式仅适用于 “不允许修改” 的编辑距离定义(如 Damerau-Levenshtein 距离的特殊情况)。
- Q2:为什么回溯时要先递归再记录步骤?
A:回溯是从dp[m][n]反向遍历到dp[0][0],记录的步骤是逆序的(如先记录 “修改最后一个字符”,再记录 “删除前一个字符”);先递归再记录步骤,可确保步骤按 “从第一个字符到最后一个字符” 的顺序存储,后续无需额外反转(若先记录再递归,步骤会逆序,需手动反转)。
- Q3:如何处理超大字符串(如长度 1e4)?
A:超大字符串直接使用二维数组会占用大量内存(如 1e4×1e4 的数组约占用 400MB),可优化为一维数组(空间复杂度 O (min (m,n))):用prevRow存储前一行的dp值,currRow存储当前行的dp值,每次迭代更新currRow;但需牺牲操作记录功能,仅计算编辑距离。
2. 扩展优化方向
(1)支持加权编辑距离
不同场景下操作的权重可能不同(如拼写纠错中 “修改” 的权重为 1,“删除” 的权重为 2),可扩展代码:增加deleteWeight、insertWeight、modifyWeight参数,在计算操作次数时乘以对应权重(如deleteOps = dp[i-1][j] + deleteWeight)。
(2)输出所有最短编辑距离的转换方案
若存在多个转换方案的编辑距离相同(如s=AB转t=AC,可 “修改 B 为 C” 或 “删除 B + 插入 C”,均为 1 步),可通过回溯时记录所有分支,用列表存储所有最短步骤方案,满足多场景需求。
(3)结合字符串预处理
实际应用中(如文本比对),可先预处理字符串:转换为小写(忽略大小写)、去除标点符号、替换空格等,减少无关字符对编辑距离的影响,提升结果的实用性。
(4)优化为非递归回溯
递归回溯可能因字符串过长导致栈溢出(如长度 1e4 的字符串递归深度为 1e4),可改为非递归回溯:用栈存储待处理的(i,j)下标,循环弹出栈顶元素并记录步骤,直到栈为空。
七、总结
编辑距离是衡量字符串相似度的核心指标,其动态规划实现的核心是 “用二维数组存储子问题的最小操作次数,通过回溯还原转换步骤”。初始代码虽能正确计算编辑距离,但缺乏工程化设计(如命名不规范、资源未管理);优化后的代码通过 “规范命名、完善注释、健壮处理、清晰输出”,实现了从 “基础算法” 到 “实用工具” 的升级。
在实际应用中,需根据场景选择优化方向:拼写纠错需支持加权操作,超大字符串需优化空间,文本比对需预处理字符串。理解编辑距离的原理与实现细节,不仅能解决字符串转换问题,更能为相似度分析、质量评估等场景提供通用思路。