STR Строки и тексты Алгоритм
Редакционное расстояние
Также: Edit distance · Levenshtein distance · расстояние Левенштейна · расстояние редактирования · алгоритм Вагнера — Фишера · Wagner–Fischer algorithm
Сколько вставок, удалений и замен букв отделяют одно слово от другого. Таблица из m × n клеток отвечает на вопрос для одной пары, а спеллер задаёт его десятки тысяч раз на каждую опечатку, и вся инженерия — в том, чтобы почти все эти вопросы не задавать: фильтр по длине, полоса вокруг диагонали, ранняя остановка, индексы.
Наберите «балконский». Спеллер ниже знает 47 585 слов «Войны и мира» и через несколько миллисекунд предлагает «болконский»: до него одна замена буквы. Чтобы это утверждать, он должен знать, что все остальные слова дальше, — то есть хоть как-то сравнить опечатку с каждым из них. Рядом — таблица, которой сравнивают два слова, под ней — выравнивание, ещё ниже — на каком расстоянии опечатка от каждого слова словаря. Попробуйте свои опечатки и другой предел правок.
Посмотрите на нижний график. Из 47 585 слов романа в двух правках от «балконского» всего пять: «болконский», «волконский» и ещё три формы фамилии Болконских. Остальные далеко, чаще всего — в девяти правках. Значит, почти вся работа спеллера уходит на то, чтобы убедиться, что слово не подходит. Полная таблица для каждого слова — это 4 017 020 клеток на одну опечатку. Спеллер считает 353 105, в одиннадцать раз меньше, и ответ при этом точный. Как он это делает, разберём после того, как поймём саму таблицу.
Идея
Редакционное расстояние, или расстояние Левенштейна, между двумя строками — наименьшее число правок, которыми одна превращается в другую. Правка — вставить букву, удалить букву или заменить одну букву другой. От «балконский» до «болконский» — одна замена, от «одьютант» до «адъютант» — две, от «kitten» до «sitting» — три.
Посмотрим, что стало с последней буквой первого слова. Её либо удалили, либо заменили последней буквой второго слова (может быть, бесплатно — если они совпадают), либо она осталась, а в конец вставили последнюю букву второго слова. В каждом случае остаётся та же задача для более коротких начал слов. Значит, расстояние между всеми началами можно записать в таблицу и заполнить её строка за строкой: каждая клетка — минимум из трёх соседних плюс цена последней правки.
Таблица и пути по ней
Пусть a длины m и b длины n. Клетка D[i][j] таблицы — расстояние между первыми i буквами a и первыми j буквами b:
$$D[i][j] = \min\bigl(D[i-1][j] + 1,\; D[i][j-1] + 1,\; D[i-1][j-1] + [a_i \ne b_j]\bigr), \qquad D[i][0] = i,\; D[0][j] = j.$$Края понятны: i букв превращаются в пустую строку i удалениями, пустая строка в j букв — j вставками. Вот таблица для «одьютант» и «адъютант» из листинга ниже:
а д ъ ю т а н т
0 1 2 3 4 5 6 7 8
о 1 1 2 3 4 5 6 7 8
д 2 2 1 2 3 4 5 6 7
ь 3 3 2 2 3 4 5 6 7
ю 4 4 3 3 2 3 4 5 6
т 5 5 4 4 3 2 3 4 5
а 6 5 5 5 4 3 2 3 4
н 7 6 6 6 5 4 3 2 3
т 8 7 7 7 6 5 4 3 2
Таблицу удобно читать как карту. Из каждой клетки можно шагнуть вниз — удалить букву первого слова, вправо — вставить букву второго, по диагонали — оставить букву, если она совпадает, или заменить. Каждый способ превратить одно слово в другое — это путь из левого верхнего угла в правый нижний, а его цена — сумма шагов. Расстояние — цена самого дешёвого пути, и на этой карте его можно было бы искать алгоритмом Дейкстры. Но граф здесь особенный: все шаги ведут вниз и вправо, поэтому клетки можно просто обойти по строкам, и к каждой клетке все её предшественницы уже готовы.
Путь, записанный столбцами, называют выравниванием: две строки друг под другом, с дефисами на месте вставленных и удалённых букв. Из угла таблицы назад к началу его восстанавливают так: у каждой клетки спрашивают, из какой соседки она получила своё значение.
Значение D[i][j], вычисленное по формуле, равно наименьшему числу правок, превращающих a[1..i] в b[1..j].
Возьмём самое дешёвое выравнивание начал a[1..i] и b[1..j] и посмотрим на его последний столбец. Он один из трёх видов: ai над пропуском, пропуск над bj или ai над bj. Если убрать этот столбец, останется выравнивание более коротких начал — соответственно a[1..i − 1] и b[1..j], a[1..i] и b[1..j − 1] или a[1..i − 1] и b[1..j − 1]. Оно обязано быть самым дешёвым для своих начал: иначе заменим его на более дешёвое и получим выравнивание дешевле исходного. Значит, цена оптимального выравнивания равна одному из трёх слагаемых формулы, а формула берёт наименьшее из них. По индукции по i + j все клетки верны.
Расстояние — настоящая метрика: оно ноль только для равных строк, симметрично (вставку и удаление можно поменять местами) и подчиняется неравенству треугольника: d(a, c) ≤ d(a, b) + d(b, c), потому что правки из a в b и из b в c вместе превращают a в c. Последнее свойство пригодится, когда будем строить индекс.
Цены правок и выравнивание
Ничто не мешает правкам стоить по-разному. Секвенаторы Illumina чаще путают буквы, чем теряют их, — для них замена дешевле пропуска; у нанопоровых наоборот. Человек на клавиатуре чаще промахивается на соседнюю клавишу — значит, замена «а» на «ф» дешевле замены «а» на «щ». Формула остаётся той же, только вместо единиц — цены. Поменяйте их ниже и посмотрите, как меняется самый дешёвый путь.
Когда замена стоит 1, выравнивание GCATGCG и GATTACA — четыре замены и цена 4. Поставьте цену замены 3: заменять стало дороже, чем удалить букву и вставить другую, и путь уходит с диагонали лестницей — три удаления и три вставки, цена 6. При замене ценой 2 и больше она вообще не нужна, и расстояние превращается в m + n − 2 · LCS, где LCS — длина наибольшей общей подпоследовательности: всё, что не вошло в общую часть, удаляют из одного слова и вставляют из другого.
Закрашенные клетки — те, через которые проходит хотя бы один самый дешёвый путь, и путей часто много. Расстояние единственно, выравниваний может быть десятки, и программа выбирает одно по правилу, в каком порядке проверять соседей. В листинге сначала диагональ, потом удаление, потом вставка; утилиты сравнения текстов выбирают по-своему, и поэтому две программы могут показать одну и ту же правку по-разному.
Код
Таблица целиком и путь назад от угла. В демо — таблица из начала статьи, выравнивания нескольких пар и выравнивание с дорогой заменой: в средней строке | — совпадение, * — замена, пробел — вставка или удаление.
Обратите внимание на «войнаимир»: ближайшее слово романа — «войнами», в двух правках. Пробелы опечаткой не считаются: «войнаимир» для спеллера — одно слово, которого нет, а не три слова без пробелов. Разбивать строку на слова — отдельная задача.
Теперь спеллер. Он читает роман с сервера, где исполняется код, собирает словарь со счётчиками и для каждой опечатки ищет слова не дальше двух правок. Колонки вывода — сколько слов прошли фильтр по длине, сколько клеток таблицы посчитано на самом деле и сколько заняли бы полные таблицы со всеми словами:
Как не сравнивать со всеми
У спеллера три приёма, и все три опираются на одно: нам не нужно точное расстояние до слов, которые дальше k. Достаточно знать, что они дальше.
- Фильтр по длине. Каждая вставка или удаление меняет длину на единицу, замена не меняет, поэтому d(a, b) ≥ |m − n|. Слова, длина которых отличается больше чем на k, можно не смотреть. Для «балконского» из 47 585 слов остаётся 25 713.
- Полоса. По той же причине D[i][j] ≥ |i − j|: клетки дальше k от главной диагонали заведомо больше k, и считать их незачем. Остаётся полоса шириной 2k + 1 — в сцене это клетки в рамках, серые числа вне её спеллер не вычисляет. Работа на одно слово падает с m · n до (2k + 1) · m.
- Ранняя остановка. Любой путь к правому нижнему углу проходит через каждую строку таблицы, а по пути цена только растёт. Поэтому, если в очередной строке все числа больше k, итог тоже больше k, и слово можно бросить. На словах, не похожих на опечатку, строки переполняются через три-четыре буквы: для «балконского» спеллер в среднем считает 14 клеток на слово вместо 84 в полной таблице.
Вместе это 353 105 клеток вместо 4 017 020 — в 11 раз меньше. В браузере сцена тратит на это несколько миллисекунд, а гистограмме снизу, которой нужны точные расстояния до всех слов, приходится считать полные таблицы. Но перебор остаётся перебором: каждое слово словаря хоть раз да тронуто. Чтобы не трогать большинство слов вовсе, нужен индекс.
- BK-дерево (Буркхард и Келлер, 1973) раскладывает словарь по расстояниям от слов-узлов. Если до узла x от запроса d, то по неравенству треугольника интересны только поддеревья слов, чьё расстояние до x лежит в [d − k, d + k], остальные отбрасываются целиком. Подробности — в статье о BK-дереве.
- Автомат Левенштейна. Для запроса и предела k строят конечный автомат, который принимает ровно строки не дальше k, и прогоняют его по бору словаря, отсекая ветки, где автомат умер. Так с версии 4.0 работает нечёткий поиск в Lucene, на котором стоят Elasticsearch и Solr; поиск стал быстрее прежнего в сто с лишним раз. Больше двух правок Lucene не допускает.
- Симметричное удаление. Заранее для каждого слова словаря выписывают все строки, получающиеся удалением до k букв, и кладут слово в хеш-таблицу под каждой. Две строки на расстоянии не больше k обязательно имеют общую такую строку, поэтому кандидаты для запроса находятся несколькими поисками в таблице. Попробуйте в задаче о словаре опечаток.
Сколько это стоит
| Что нужно | Время | Память |
|---|---|---|
| только расстояние | O(m · n) | O(min(m, n)) — две строки таблицы |
| расстояние и выравнивание | O(m · n) | O(m · n), или O(m + n) по Хиршбергу за удвоенное время |
| да или нет: не больше k? | O(k · min(m, n)) | O(k) на полосу |
Приём Хиршберга (1975) нужен, когда строки длинные, а выравнивание нужно целиком: таблицу считают от начала до средней строки и от конца до неё же, находят, где лучший путь пересекает середину, и решают две половины отдельно. Квадрат m·n клеток в памяти не держится никогда.
Замер: две версии случайной строки ДНК длины n, в каждой сотой позиции другая буква. Полная таблица против полосы шириной 17 клеток (k = 8).
Наклон полной таблицы — 2: вдвое длиннее строки, вчетверо больше клеток. Наклон полосы — 1. На строках из 800 букв полоса в Python быстрее в 62 раза, и разрыв растёт с длиной. Но полоса годится, только если заранее известен предел k. Если он неизвестен, его удваивают: 1, 2, 4, … , пока расстояние не окажется в пределах полосы, — суммарная работа всё равно O(d · n), где d — ответ. Считать только полосу и расширять её, пока ответ в неё не поместится, предложил Эско Укконен в 1985 году.
Можно ли быстрее квадрата в общем случае? Немного: метод четырёх русских (Арлазаров, Диниц, Кронрод, Фараджев) в применении Масека и Патерсона (1980) даёт O(n²/log n), а алгоритм Джина Майерса (1999) упаковывает столбец таблицы в машинное слово и обновляет 64 клетки за несколько битовых операций. Но существенно быстрее, за O(n2−ε), — вряд ли. В 2015 году Артурс Бачкурс и Пётр Индык доказали, что такой алгоритм опроверг бы сильную гипотезу об экспоненциальном времени — предположение о задаче выполнимости формул, в которое верит большинство специалистов.
Варианты
Перестановки. Фред Дамерау в 1964 году, изучая ошибки набора, насчитал больше 80 % опечаток, которые сводятся к одной вставке, одному удалению, одной замене или перестановке двух соседних букв. По Левенштейну перестановка — две правки: «кнзяь» отстоит на две и от «князь», и от «князя», и от «казнь». Включите в первой сцене «перестановка — одна правка»: в таблицу добавляется четвёртый переход, из клетки через одну по диагонали, и «князь» уходит в отрыв. Такая версия называется расстоянием оптимального выравнивания строк. Она проста, но уже не метрика: у «CA» и «AC» расстояние 1, у «AC» и «ABC» — 1, а у «CA» и «ABC» — 3, и неравенство треугольника нарушено. Полное расстояние Дамерау — Левенштейна, где переставленные буквы можно потом ещё и править, считают сложнее (Лоуренс и Вагнер, 1975).
Веса. Настоящие спеллеры ценят правки по вероятности: соседние клавиши, безударные гласные («карова»), удвоенные согласные. Формула та же, меняются цены.
Выравнивание в биологии. В последовательностях белков и ДНК замены одних букв на другие тоже имеют разную цену, а пропуск подряд нескольких букв дешевле, чем столько же разрозненных. Это алгоритм Нидлмана — Вунша (1970) для выравнивания целиком и Смита — Уотермана (1981) для поиска самого похожего куска; оба — в статье о выравнивании последовательностей. Попробуйте сами в задаче об ошибках секвенатора.
Сравнение файлов. diff сравнивает строки файлов, а не буквы, и разрешает только вставки и удаления. Это та же таблица, но Юджин Майерс в 1986 году нашёл способ обходить её за O((m + n) · d), где d — число отличий: так работает git. Подробнее — diff Майерса.
История
Расстояние придумывали несколько раз, в разных науках и для разных нужд. Владимир Левенштейн ввёл его в 1965 году в «Докладах Академии наук», когда искал коды, исправляющие не только искажения символов, но и выпадения и вставки; историю этой работы подробно рассказывает глава курса о динамическом программировании. Годом раньше Фред Дамерау из IBM опубликовал способ находить и исправлять опечатки, где перестановка соседних букв считалась одной ошибкой. Таблицу для вычисления находили независимо: Тарас Винцюк в 1968 году — для распознавания речи, Сол Нидлман и Кристиан Вунш в 1970-м — для сравнения белков, Роберт Вагнер и Майкл Фишер в 1974-м — для исправления строк, и в информатике её часто называют их именами. В 1983 году Дэвид Санкофф и Джозеф Краскал собрали эти открытия в одну книгу, «Time Warps, String Edits, and Macromolecules», и показали, что речь, тексты и молекулы сравнивают одной и той же таблицей.
Задачи
В первой задаче цены правок разные, и самое короткое выравнивание перестаёт быть самым дешёвым. Во второй таблица не нужна вовсе: нужно найти все слова словаря в одной правке от запроса быстрее, чем сравнивать его с каждым.
Ошибки секвенатора Словарь опечатокДанные: «Война и мир», Л. Н. Толстой, общественное достояние