Задача о редакционном расстоянии, алгоритм Вагнера-Фишера
| Определение: |
| Расстояние Левенштейна (англ. Levenshtein distance) (также редакционное расстояние или дистанция редактирования) между двумя строками в теории информации и компьютерной лингвистике — это минимальное количество операций вставки одного символа, удаления одного символа и замены одного символа на другой, необходимых для превращения одной строки в другую. |
Свойства
Для расстояния Левенштейна справедливы следующие утверждения:
где — расстояние Левенштейна между строками и , а — длина строки .
Разные цены операций
Цены операций могут зависеть от вида операции (вставка, удаление, замена) и/или от участвующих в ней символов, отражая разную вероятность разных ошибок при вводе текста, и т. п. В общем случае:
- — цена замены символа на символ
- — цена вставки символа
- — цена удаления символа
Для решения задачи о редакционном расстоянии, необходимо найти последовательность замен, минимизирующую суммарную цену. Расстояние Левенштейна является частным случаем этой задачи при
- =
- = при
- =
- =
— пустая последовательность.
Как частный случай, так и задачу для произвольных , решает алгоритм Вагнера-Фишера, приведённый ниже. Здесь и ниже мы считаем, что все неотрицательны, и действует правило треугольника: если две последовательные операции можно заменить одной, это не ухудшает общую цену (например, заменить символ на , а потом с на не лучше, чем сразу на ).
Формула
Будем считать, что элементы строк нумеруются с первого, как принято в математике, а не нулевого.
Пусть и — две строки (длиной и соответственно) над некоторым алфавитом, тогда редакционное расстояние можно подсчитать по следующей рекуррентной формуле:
, где
,
возвращает наименьший из аргументов.
Доказательство
Рассмотрим формулу более подробно. Здесь — расстояние между префиксами строк: первыми символами строки и первыми символами строки . Очевидно, что редакционное расстояние между двумя пустыми строками равно нулю. Так же очевидно то, что чтобы получить пустую строку из строки длиной , нужно совершить операций удаления, а чтобы получить строку длиной из пустой, нужно произвести операций вставки. Осталось рассмотреть нетривиальный случай, когда обе строки непусты.
Для начала заметим, что в оптимальной последовательности операций, их можно произвольно менять местами. В самом деле, рассмотрим две последовательные операции:
- Две замены одного и того же символа — неоптимально (если мы заменили на , потом на , выгоднее было сразу заменить на ).
- Две замены разных символов можно менять местами
- Два стирания или две вставки можно менять местами
- Вставка символа с его последующим стиранием — неоптимально (можно их обе отменить)
- Стирание и вставку разных символов можно менять местами
- Вставка символа с его последующей заменой — неоптимально (излишняя замена)
- Вставка символа и замена другого символа меняются местами
- Замена символа с его последующим стиранием — неоптимально (излишняя замена)
- Стирание символа и замена другого символа меняются местами
Пускай кончается на символ , кончается на символ . Есть три варианта:
- Символ «а», на который кончается , в какой-то момент был стёрт. Сделаем это стирание первой операцией. Тогда мы стёрли символ , после чего превратили первые символов в (на что потребовалось операций), значит, всего потребовалось операций
- Символ , на который кончается , в какой-то момент был добавлен. Сделаем это добавление последней операцией. Мы превратили в первые символов , после чего добавили . Аналогично предыдущему случаю, потребовалось операций.
- Оба предыдущих утверждения неверны. Если мы добавляли символы справа от финального , то чтобы сделать последним символом , мы должны были или в какой-то момент добавить его (но тогда утверждение 2 было бы верно), либо заменить на него один из этих добавленных символов (что тоже невозможно, потому что добавление символа с его последующей заменой неоптимально). Значит, символов справа от финального мы не добавляли. Самого финального мы не стирали, поскольку утверждение 1 неверно. Значит, единственный способ изменения последнего символа — его замена. Заменять его 2 или больше раз неоптимально. Значит,
- Если , мы последний символ не меняли. Поскольку мы его также не стирали и не приписывали ничего справа от него, он не влиял на наши действия, и, значит, мы выполнили операций.
- Если , мы последний символ меняли один раз. Сделаем эту замену первой. В дальнейшем, аналогично предыдущему случаю, мы должны выполнить операций, значит, всего потребуется операций.
Алгоритм Вагнера — Фишера
Для нахождения кратчайшего расстояния необходимо вычислить матрицу , используя вышеприведённую формулу. Её можно вычислять как по строкам, так и по столбцам.
Псевдокод алгоритма, написанный при произвольных ценах замен, вставок и удалений (важно помнить, что элементы нумеруются с ):
int levensteinInstruction(String s1, String s2, int[] S2InsertCost, int[] S1DeleteCost, int[] ReplaceCost):
D[0][0] = 0
for j = 1 to N
D[0][j] = D[0][j - 1] + S2InsertCost[j] //S2InsertCost[j] - цена вставки символа S2[j]
for i = 1 to M
D[i][0] = D[i - 1][0] + S1DeleteCost[i] //S1DeleteCost[i] - цена удаления символа S1[i]
for j = 1 to N
if S1[i] != S2[j]
D[i][j] = min(D[i - 1][j] + S1DeleteCost[i],
D[i][j - 1] + S2InsertCost[j],
D[i - 1][j - 1] + ReplaceCost[i][j]) //ReplaceCost[i][j] - цена замены символа S1[i] на символ S2[j]
else
D[i][j] = D[i - 1][j - 1]
return D[M][N]
Память
Алгоритм в виде, описанном выше, требует операций и такую же память, однако, если требуется только расстояние, легко уменьшить требуемую память до . Заметим, что для вычисления нам нужно только , поэтому будет вычислять в , а в . Осталось только поменяем местами и .
int levensteinInstruction(int[] D):
for i = 0 to M
for j = 0 to N
вычислить D[1][j]
swap(D[0], D[1])
return D[0, N]
Рекурсивный алгоритм
Для того, чтобы обеспечить время при памяти , определим матрицу минимальных расстояний между суффиксами строк, то есть — расстояние между последними символами и последними символами . Очевидно, матрицу можно вычислить аналогично матрице , и так же быстро.
Теперь опишем алгоритм, считая, что — кратчайшая из двух строк.
- Если длина одной из строк (или обеих) не больше , задача тривиальна. Если нет, выполним следующие шаги.
- Разделим строку на две подстроки длиной . (Если нечётно, то длины подстрок будут и .) Обозначим подстроки и .
- Для вычислим последнюю строку матрицы для строк и , последнюю строку матрицы для строк и .
- Найдём такое, что минимально. Здесь и — матрицы из предыдущего шага, но мы используем только их последние строки. Таким образом, мы нашли разбиение на две подстроки, минимизирующее сумму расстояния левой половины до левой части и расстояния правой половины до правой части . Следовательно, левая подстрока соответствует левой половине , а правая — правой.
- Рекурсивно ищем редакционное предписание, превращающее в левую часть (то есть в подстроку )
- Рекурсивно ищем редакционное предписание, превращающее в правую часть (то есть в подстроку ).
- Объединяем оба редакционных предписания.
Псевдокод
int levensteinInstruction(String s1, String s2):
if s1.length <= 1 || s2.length <= 1
Решаем тривиально, возвращаем редакционное предписание
else
String s1l, s1r, s2l, s2r
if s2.length < s1.length
s1l = s1.substring(0, s1.length / 2) // S1-
s1r = s1.substring(s1.length / 2, s1.length) // S1+
// d, e - массивы
d = calcD(s1l, s2) // Вычисляем последнюю строку матрицы D для S1- и S2
e = calcE(s1r, s2) // Вычисляем последнюю строку матрицы E для S1+ и S2
k = 0
for i = 1 to s2.length
if d[i] + e[s2.length - i] < d[k] + e[s2.length - k]
k = i
s2l = s2.substring(0, k)
s2r = s2.substring(k, s2.length)
else
// s1 - меньшая строка
s2l = s2.substring(0, s2.length / 2) // S2-
s2r = s2.substring(s2.length / 2, s2.length) // S2+
d = calcD(s2l, s1) // Вычисляем последнюю строку матрицы D для S2- и S1
e = calcE(s2r, s1) // Вычисляем последнюю строку матрицы E для S2+ и S1
k = 0
for i = 1 to s1.length
if d[i] + e[s1.length - i] < d[k] + e[s1.length - k]
k = i
s1l = s1.substring(0, k)
s1r = s1.substring(k, s1.length)
return levensteinInstruction(s1l, s2l) + levensteinInstruction(s1r, s2r)
Время выполнения удовлетворяет (с точностью до умножения на константу) условию
- ,
Докажем:
База индукции очевидна
Пусть для всех выполнено . Тогда учитывая , , получим:
следовательно
Для вычисления последних строк матриц можно использовать два глобальных двумерных массива размера .
Т.к. мы вычисляем функцию рекурсивно, требуемый размер стека тоже следует учесть. На стек вызовов потребуется памяти, потому общая оценка использования памяти будет
Редакционное предписание
Редакционное предписание (англ. Editorial prescription) — последовательность действий, необходимых для получения из первой строки второй кратчайшим образом. Обычно действия обозначаются так: D (англ. delete) — удалить, I (англ. insert) — вставить, R (англ. replace) — заменить, M (англ. match) — совпадение.
Например, для 2-х строк «hell123» и «hello214» можно построить следующую таблицу преобразований:
| M | M | M | M | R | M | R | I |
|---|---|---|---|---|---|---|---|
| h | e | l | l | 1 | 2 | 3 | |
| h | e | l | l | o | 2 | 1 | 4 |
Источники информации
- Романовский И.В. "Дискретный анализ". Третье издание. Стр. 103 - 105