在本文中,我们描述了列文施泰因距离,也称为编辑距离。这里解释的算法是由俄罗斯科学家弗拉基米尔·列文施泰因(Vladimir Levenshtein)于1965年设计的。
我们将提供此算法的迭代和递归 Java 实现。
列文施泰因距离是两个字符串之间差异的度量。在数学上,给定两个字符串 x 和 y,距离测量将 x转换为y 所需的最小字符编辑次数。
通常允许三种类型的编辑:
示例:如果 x = 'shot' 和y = 'spot',则两者之间的编辑距离为 1,因为 'shot' 可以通过将 'h' 替换为 'p' 来转换为 'spot'。
在问题的某些子类中,与每种编辑类型相关的成本可能不同。
例如,用键盘上附近的字符替换的成本更低,否则成本更高。为简单起见,在本文中,我们将所有成本视为相等。
编辑距离的一些应用是:
让我们取两个长度分别为m和n的字符串 x和y。我们可以将每个字符串表示为 x[1:m] 和y[1:n]。
我们知道,在转换结束时,两个字符串的长度相等,并且每个位置都有匹配的字符。因此,如果我们考虑每个字符串的第一个字符,我们有三个选项:
解决方案的下一部分是找出从这三个选项中选择哪个选项。由于我们不知道哪个选项最终会导致最低成本,因此我们必须尝试所有选项并选择最佳选项。
我们可以看到,第 #3 节中每个选项的第二步主要是相同的编辑距离问题,但在原始字符串的子字符串上。这意味着在每次迭代之后,我们最终会遇到相同的问题,但字符串更小。
这种观察是制定递归算法的关键。递归关系可以定义为:
D(x[1:m], y[1:n])= min {
D(x[2:m], y[2:n]) + 将 x[1] 替换为 y[1] 的成本,
D(x[1:m], y[2:n]) + 1,
D(x[2:m], y[1:n]) + 1
}
我们还必须为递归算法定义基本情况,在我们的例子中,当一个或两个字符串变为空时:
此算法的朴素递归实现:
- public class EditDistanceRecursive {
- static int calculate(String x, String y) {
- if (x.isEmpty()) {
- return y.length();
- }
- if (y.isEmpty()) {
- return x.length();
- }
- int substitution = calculate(x.substring(1), y.substring(1))
- + costOfSubstitution(x.charAt(0), y.charAt(0));
- int insertion = calculate(x, y.substring(1)) + 1;
- int deletion = calculate(x.substring(1), y) + 1;
- return min(substitution, insertion, deletion);
- }
- public static int costOfSubstitution(char a, char b) {
- return a == b ? 0 : 1;
- }
- public static int min(int... numbers) {
- return Arrays.stream(numbers)
- .min().orElse(Integer.MAX_VALUE);
- }
- }
该算法具有指数级复杂性。在每一步中,我们分支为三个递归调用,构建O(3^n) 复杂性。
在下一节中,我们将了解如何对此进行改进。
在分析递归调用时,我们观察到子问题的参数是原始字符串的后缀。这意味着只能有m*n 个唯一的递归调用(其中m和n是x和y 的后缀数)。因此,最优解的复杂度应该是二次的,O(m*n)。
让我们看一些子问题(根据第 #4 节中定义的递归关系):
在所有三种情况下,其中一个子问题是D(x[2:m], y[2:n])。与其像在朴素实现中那样计算三次,我们可以计算一次,并在需要时再次重用结果。
这个问题有很多重叠的子问题,但如果我们知道子问题的解决方案,我们就可以很容易地找到原始问题的答案。因此,我们具有制定动态规划解决方案所需的两个属性,即重叠子问题和最优子结构。
我们可以通过引入记忆来优化朴素实现,即将子问题的结果存储在数组中并重用缓存的结果。
或者,我们也可以使用基于表的方法迭代实现这一点:
- static int calculate(String x, String y) {
- int[][] dp = new int[x.length() + 1][y.length() + 1];
- for (int i = 0; i <= x.length(); i++) {
- for (int j = 0; j <= y.length(); j++) {
- if (i == 0) {
- dp[i][j] = j;
- }
- else if (j == 0) {
- dp[i][j] = i;
- }
- else {
- dp[i][j] = min(dp[i - 1][j - 1]
- + costOfSubstitution(x.charAt(i - 1), y.charAt(j - 1)),
- dp[i - 1][j] + 1,
- dp[i][j - 1] + 1);
- }
- }
- }
- return dp[x.length()][y.length()];
- }
此算法的性能明显优于递归实现。但是,它涉及大量内存消耗。
这可以通过观察我们只需要表中三个相邻单元格的值来找到当前单元格的值来进一步优化。
在本文中,我们描述了什么是Levenshtein距离以及如何使用递归和基于动态规划的方法计算它。
Levenshtein 距离只是字符串相似性的度量之一,其他一些指标是余弦相似性(它使用基于令牌的方法并将字符串视为向量)、骰子系数等。
与往常一样,示例的完整实现可以在GitHub上找到。