如何在Java中计算Levenshtein莱文斯坦(相似度)编辑距离 ?

1. 简介

在本文中,我们描述了列文施泰因距离,也称为编辑距离。这里解释的算法是由俄罗斯科学家弗拉基米尔·列文施泰因(Vladimir Levenshtein)于1965年设计的。

我们将提供此算法的迭代和递归 Java 实现。

2. 什么是列文施泰因距离?

列文施泰因距离是两个字符串之间差异的度量在数学上,给定两个字符串 x 和 y,距离测量将 x转换为y 所需的最小字符编辑次数。

通常允许三种类型的编辑:

  1. 插入字符c
  2. 删除字符c
  3. 将字符 c 替换为c'

示例:如果 x = 'shot' y = 'spot',则两者之间的编辑距离为 1,因为 'shot' 可以通过将 'h' 替换为 'p' 来转换为 'spot'

在问题的某些子类中,与每种编辑类型相关的成本可能不同。

例如,用键盘上附近的字符替换的成本更低,否则成本更高。为简单起见,在本文中,我们将所有成本视为相等。

编辑距离的一些应用是:

  1. 拼写检查器 – 检测文本中的拼写错误并找到字典中最接近的正确拼写
  2. 抄袭检测(参考 –IEEE 论文)
  3. DNA 分析 – 发现两个序列之间的相似性
  4. 语音识别(参考 –微软研究院))

3. 算法制定

让我们取两个长度分别为mn字符串 xy。我们可以将每个字符串表示为 x[1:m] 和y[1:n]。

我们知道,在转换结束时,两个字符串的长度相等,并且每个位置都有匹配的字符。因此,如果我们考虑每个字符串的第一个字符我们有三个选项:

  1. 替代:
    1. 确定用y[1] 替换x[1] 的成本 (D1)。如果两个字符相同,则此步骤的成本将为零。如果没有,那么成本将是一个
    2. 在步骤 1.1 之后,我们知道两个字符串都以相同的字符开头。因此,总成本现在将是步骤 1.1 的成本与将字符串的其余部分 x[2:m] 转换为 y[2:n] 的成本之和
  2. 插入:
    1. x中插入一个字符以匹配y 中的第一个字符,此步骤的成本将是 1
    2. 在 2.1 之后,我们处理了y 中的一个字符。因此,总成本现在将是步骤 2.1(即 1)的成本与将整个x[1:m] 转换为剩余 y(y[2:n]) 的成本之和。
  3. 删除:
    1. x 中删除第一个字符,此步骤的成本将是 1
    2. 在 3.1 之后,我们已经处理了x 中的一个字符,但完整的y仍有待处理。总成本将是 3.1(即 1)的成本与将剩余x转换为完整y 的成本之和

解决方案的下一部分是找出从这三个选项中选择哪个选项。由于我们不知道哪个选项最终会导致最低成本,因此我们必须尝试所有选项并选择最佳选项。

4. 朴素递归实现

我们可以看到,第 #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

}

我们还必须为递归算法定义基本情况,在我们的例子中,当一个或两个字符串变为空时:

  1. 当两个字符串都为空时,它们之间的距离为零
  2. 当其中一个字符串为空时,它们之间的编辑距离是另一个字符串的长度因为我们需要多次插入/删除才能将一个字符串转换为另一个字符串
    • 示例:如果一个字符串是“狗”,另一个字符串是“”(空),我们需要在空字符串中插入三个来使其成为“狗”,或者我们需要在“狗”中插入三个以使其为空。因此,它们之间的编辑距离为 3

此算法的朴素递归实现:

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) 复杂性。

在下一节中,我们将了解如何对此进行改进。

5. 动态规划方法

在分析递归调用时,我们观察到子问题的参数是原始字符串的后缀这意味着只能有m*n 个唯一的递归调用(其中mnxy 的后缀数)。因此,最优解的复杂度应该是二次的,O(m*n)。

让我们看一些子问题(根据第 #4 节中定义的递归关系):

  1. D(x[1:m], y[1:n])的子问题是D(x[2:m], y[2:n]), D(x[1:m], y[2:n])和D(x[2:m], y[1:n])
  2. D(x[1:m], y[2:n])的子问题是D(x[2:m], y[3:n]), D(x[1:m], y[3:n])和D(x[2:m], y[2:n])
  3. D(x[2:m], y[1:n])的子问题是D(x[3:m], y[2:n]), D(x[2:m], y[2:n])和D(x[3:m], y[1:n])

在所有三种情况下,其中一个子问题是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()];
}

此算法的性能明显优于递归实现。但是,它涉及大量内存消耗。

这可以通过观察我们只需要表中三个相邻单元格的值来找到当前单元格的值来进一步优化。

6. 结论

在本文中,我们描述了什么是Levenshtein距离以及如何使用递归和基于动态规划的方法计算它。

Levenshtein 距离只是字符串相似性的度量之一,其他一些指标是余弦相似性(它使用基于令牌的方法并将字符串视为向量)、骰子系数等。

与往常一样,示例的完整实现可以在GitHub上找到。

你可能感兴趣的:(java,开发语言)