Как рассчитать расстояние Левенштейна в Java?

1. Вступление

В этой статье мы опишем расстояние Левенштейна, также известное как расстояние редактирования. Описанный здесь алгоритм был разработан русским ученым Владимиром Левенштейном в 1965 году.

Мы предоставим итеративную и рекурсивную реализацию этого алгоритма на Java.

2. Каково расстояние Левенштейна?

Расстояние Левенштейна - это мера различия между двумя Strings. Математически, учитывая два Strings x и y , расстояние измеряет минимальное количество символьных правок, необходимых для преобразования x в y .

Обычно допускается редактирование трех типов:

, Вставка символа c

, Удаление символа c

, Замена символа c на c

Пример: Если x = 'shot' и y =' spot' , расстояние редактирования между ними равно 1, потому что 'shot' можно преобразовать в 'spot' , подставив ' h ' в ' p ' .

В некоторых подклассах задачи стоимость, связанная с каждым типом редактирования, может быть разной.

Например, меньше стоимость замены на символ, расположенный рядом на клавиатуре, и больше стоимость в противном случае. Для простоты мы рассмотрим все затраты в этой статье как равные.

Некоторые из приложений редактирования расстояния:

, Проверка орфографии - обнаружение орфографических ошибок в тексте и поиск

Ближайшее правильное написание в словаре , Обнаружение плагиата (см. -

IEEE Paper ) , Анализ ДНК - обнаружение сходства между двумя последовательностями

, Распознавание речи (см. -

3. Формулировка алгоритма

Давайте возьмем две Строки x и y длины m и n соответственно.

Мы можем обозначить каждую String как x[1: m] и ​​ y[1: n].

Мы знаем, что в конце преобразования обе Strings будут одинаковой длины и будут иметь совпадающие символы в каждой позиции. Итак, если мы рассмотрим первый символ каждой строки __, у нас будет три варианта:

, Замена:

  1. Определите стоимость ( D1 ) замены x[1] на y[1] .

Стоимость этого шага будет равна нулю, если оба символа одинаковы. Если не, тогда стоимость будет одна .. После шага 1.1 мы знаем, что оба Strings начинаются с одинакового

персонаж. Следовательно, общая стоимость теперь будет суммой стоимости шага 1.1 и стоимость преобразования остальной части String x[2: m] в y[2: п] , Вставка:

  1. Вставьте символ в x , чтобы соответствовать первому символу в y ,

Стоимость этого шага будет одна .. После 2.1 мы обработали один символ из y . Отсюда общее

стоимость теперь будет суммой стоимости шага 2.1 (т.е. 1) и стоимости преобразования полного x[1: m] в оставшееся y (y[2: n]) , Удаление:

  1. Удалить первый символ из x , стоимость этого шага будет

один .. После 3.1 мы обработали один символ из x , но полный y

остается быть обработанным. Общая стоимость будет равна сумме 3,1 (т. Е. 1) и стоимости преобразования оставшегося x в полное y

Следующая часть решения состоит в том, чтобы выяснить, какой вариант выбрать из этих трех. Поскольку мы не знаем, какой вариант приведет к минимальной стоимости в конце, мы должны попробовать все варианты и выбрать лучший.

4. Наивная рекурсивная реализация

Мы можем видеть, что второй шаг каждого параметра в разделе # 3 - это, в основном, та же самая проблема с расстоянием редактирования, но для подстрок исходного Strings. Это означает, что после каждой итерации мы сталкиваемся с той же проблемой, но с меньшими Strings.

Это наблюдение является ключом к формулировке рекурсивного алгоритма. Отношение повторения может быть определено как:

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

}

Мы также должны определить базовые случаи для нашего рекурсивного алгоритма, который в нашем случае, когда одна или обе Strings становятся пустыми:

, Когда обе Строки пусты, расстояние между ними равно нулю

, Когда одна из Strings пуста, то расстояние редактирования между

они - длина другой Строки, так как нам нужно столько чисел вставок/удалений, чтобы преобразовать одну в другую:

  • Пример: если одна String это «собака» , а другая String это «»

(пусто), нам нужно либо три вставки в пустую String , чтобы сделать его "dog" , либо нам нужно три удаления в "dog" , чтобы сделать его пустым. Следовательно, расстояние редактирования между ними равно 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. Подход динамического программирования

Анализируя рекурсивные вызовы, мы видим, что аргументы для подзадач являются суффиксами исходных Strings. Это означает, что могут быть только m n уникальные рекурсивные вызовы (где m и n - это суффиксы x и y ). , Следовательно, сложность оптимального решения должна быть квадратичной, O (m n) .

Давайте рассмотрим некоторые из подзадач (в соответствии с рекуррентным отношением, определенным в разделе № 4):

, Подзадачами 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]) , Подзадачами 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]) , Подзадачами 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]) . Вместо того, чтобы вычислять это три раза, как мы делаем в простой реализации, мы можем вычислить это один раз и повторно использовать результат, когда это необходимо снова.

Эта проблема имеет много пересекающихся подзадач, но если мы знаем решение подзадач, мы можем легко найти ответ на исходную проблему. Таким образом, у нас есть оба свойства, необходимые для формулирования решения динамического программирования, т.е. Overlapping подзадачи и https://en.wikipedia.org/wiki/Оптимальная подструктура.

Мы можем оптимизировать простую реализацию, введя memoization , то есть сохранить результаты подзадач в массиве и повторно использовать кэшированные результаты.

В качестве альтернативы, мы также можем реализовать это итеративно, используя табличный подход:

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. Заключение

В этой статье мы описали, что такое расстояние Левенштейна и как его можно рассчитать с помощью рекурсивного и динамического программирования.

Расстояние Левенштейна - только одна из мер сходства строк, некоторые другие метрики - https://en.wikipedia.org/wiki/Cosine sdentifity[Cosine Similarity](который использует подход на основе токенов и рассматривает строки как векторы) , https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice coefficient[Dice Coefficient]и т. д.

Как всегда, полную реализацию примеров можно найти в over GitHub