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 будут одинаковой длины и будут иметь совпадающие символы в каждой позиции. Итак, если мы рассмотрим первый символ каждой строки __, у нас будет три варианта:
, Замена:
-
Определите стоимость ( D1 ) замены x[1] на y[1] .
Стоимость этого шага будет равна нулю, если оба символа одинаковы. Если не, тогда стоимость будет одна .. После шага 1.1 мы знаем, что оба Strings начинаются с одинакового
персонаж. Следовательно, общая стоимость теперь будет суммой стоимости шага 1.1 и стоимость преобразования остальной части String x[2: m] в y[2: п] , Вставка:
-
Вставьте символ в x , чтобы соответствовать первому символу в y ,
Стоимость этого шага будет одна .. После 2.1 мы обработали один символ из y . Отсюда общее
стоимость теперь будет суммой стоимости шага 2.1 (т.е. 1) и стоимости преобразования полного x[1: m] в оставшееся y (y[2: n]) , Удаление:
-
Удалить первый символ из 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