Edit Distance
Тебе даны два слова, word1 и word2. Одна правка изменяет word1 одним из трёх способов: вставить букву в любом месте, удалить букву или заменить букву на другую. Верни минимальное количество правок, необходимое, чтобы превратить word1 в word2.
Функция
- word1string
- слово, которое вы редактируете
- word2string
- слово «достигать»
- Возвращаетinteger
- минимальное количество вставок, удалений и замен, необходимых, чтобы превратить word1 в word2
Ограничения
1 ≤ word1.length ≤ 5001 ≤ word2.length ≤ 500- Оба слова содержат только строчные буквы английского алфавита.
Примеры
- Ввод
- word1 = "spot"word2 = "stop"
- Вывод
- 2
- Пояснение
- Замени p на t, а t на p:
spotстановитсяstot, затемstop. Одного изменения недостаточно, потому что слова различаются в двух местах, а вставка или удаление изменили бы длину.
- Ввод
- word1 = "garden"word2 = "ardent"
- Вывод
- 2
- Пояснение
- Удалите g, чтобы получить
arden, затем вставьте t в конец, чтобы получитьardent. Замена каждой буквы обошлась бы в 6, поскольку эти два слова различаются в каждой позиции.
- Ввод
- word1 = "rain"word2 = "shine"
- Вывод
- 3
- Пояснение
- Замените r на s, а a на h, чтобы получить
shin, затем вставьте e. Двух правок недостаточно: r и a не встречаются вshine, поэтому каждая из них требует правки, которая не делает слово длиннее, а слово всё ещё нужно увеличить на одну букву.
+21 скрытых тестов при отправке
Дополнительный вопрос
Можешь также вернуть один самый короткий список изменений, а не только указать их количество?
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Посмотрите на последнюю букву каждого слова. Если они одинаковые, нужно ли их менять? Если они различаются, какие изменения помогут сделать так, чтобы оба слова оканчивались одинаково?
Есть три варианта для разных последних букв: заменить одну на другую, удалить последнюю букву из
word1или вставить последнюю букву изword2. Каждый вариант оставляет ту же задачу для более коротких префиксов, поэтому выбери самый дешёвый и прибавь единицу.Сохраняйте ответ для каждой пары длин префиксов
(i, j)в таблице. Пустой префикс требуетiудалений илиjвставок, что заполняет первую строку и первый столбец. Заполните остальные ячейки построчно и прочитайте ответ из последней ячейки.
Решение
Правки взаимодействуют друг с другом, поэтому нельзя исправить слова позиция за позицией: garden и ardent различаются во всех шести позициях, но достаточно двух правок, если удалить g и сдвинуть всё влево. Ключевая идея — смотреть только на последнюю букву каждого слова. Либо эти две буквы уже совпадают, либо одна из трёх возможных правок делает их одинаковыми, и каждый вариант оставляет ту же задачу для более коротких префиксов. Таблица ответов размером (n+1) × (m+1) позволяет один раз решить задачу для каждой пары префиксов, а двух её строк достаточно.
Попробуй все три изменения с рекурсией
Верно, но не успевает на самых больших тестах
Идея
Пусть edits(i, j) — минимальное число изменений, необходимых, чтобы превратить суффикс word1[i:] в word2[j:]. Посмотрим на первые буквы двух суффиксов. Если они совпадают, оставим их и передвинем оба индекса: совпадающую букву никогда не нужно изменять, а любой план, в котором на неё тратится изменение, можно заменить планом, который её сохраняет, не увеличивая длину.
Если буквы различаются, какое-то изменение должно затронуть word1[i] или добавить word2[j], и есть ровно три способа. Заменить word1[i] на word2[j] и передвинуть оба индекса: edits(i+1, j+1). Удалить word1[i] и передвинуть только i: edits(i+1, j). Вставить word2[j] перед ней и передвинуть только j: edits(i, j+1). Ответ равен 1 плюс минимум из трёх вариантов. Когда в word1 заканчиваются буквы, вставляем оставшуюся часть word2, что стоит m - j; когда в word2 заканчиваются буквы, удаляем оставшуюся часть word1, что стоит n - i.
Алгоритм работает медленно, потому что при каждом несовпадении запускаются три вызова. Для двух слов из 15 букв, в которых нет общих букв, это около 6.7 × 10^10 вызовов, а в больших тестах по 500 букв в каждом слове. Однако существует всего (n+1) × (m+1) различных пар (i, j), поэтому почти каждый вызов повторяет уже сделанный ранее.
Алгоритм
- Запиши
edits(i, j)для суффиксов, начинающихся сiиj. - Если
iвыходит за конецword1, верниm - j; еслиjвыходит за конецword2, верниn - i. - Если
word1[i] == word2[j], верниedits(i+1, j+1). - В противном случае верни
1 + min(edits(i+1, j+1), edits(i+1, j), edits(i, j+1))для замены, удаления и вставки. - Ответ —
edits(0, 0).
def minDistance(word1, word2):
n, m = len(word1), len(word2)
def edits(i, j):
# Fewest edits to turn word1[i:] into word2[j:]
if i == n:
return m - j # insert the rest of word2
if j == m:
return n - i # delete the rest of word1
if word1[i] == word2[j]:
return edits(i + 1, j + 1)
return 1 + min(edits(i + 1, j + 1), # replace word1[i] with word2[j]
edits(i + 1, j), # delete word1[i]
edits(i, j + 1)) # insert word2[j]
return edits(0, 0)Заполните таблицу префиксов
Идея
Состояние. Пусть dp[i][j] — минимальное количество правок, необходимых, чтобы превратить первые i букв word1 в первые j букв word2. Индекс 0 обозначает пустой префикс.
Переходы. Сравните последние буквы двух префиксов: word1[i-1] и word2[j-1]. Если они равны, оставьте их: dp[i][j] = dp[i-1][j-1] — ячейка по диагонали сверху слева. Если нет, выполните одну правку и выберите минимум из трёх соседних ячеек. Диагональная ячейка dp[i-1][j-1] означает замену word1[i-1] на word2[j-1]. Ячейка сверху, dp[i-1][j], означает удаление word1[i-1]. Ячейка слева, dp[i][j-1], означает вставку word2[j-1] в конец.
Базовая строка и столбец. В отличие от многих задач с таблицами, здесь они не заполнены нулями. Чтобы превратить i букв в пустой префикс, нужно выполнить i удалений, поэтому dp[i][0] = i. Чтобы получить j букв из ничего, нужно выполнить j вставок, поэтому dp[0][j] = j. Каждая ячейка использует ячейку сверху, слева и по диагонали, поэтому при заполнении строка за строкой, слева направо, все нужные значения уже будут вычислены. Ответ — dp[n][m].
Вот таблица для преобразования spot в stop; столбцы соответствуют префиксам "", s, st, sto, stop. Строка "" — это [0, 1, 2, 3, 4], строка s — [1, 0, 1, 2, 3], строка sp — [2, 1, 1, 2, 2], строка spo — [3, 2, 2, 1, 2], а строка spot — [4, 3, 2, 2, 2]. Рассмотрим несколько ячеек. s и s совпадают, поэтому значение копируется из диагональной ячейки — 0. sp и st не совпадают: значения в соседних ячейках — 0 по диагонали, 1 сверху и 1 слева, поэтому значение равно 1 + 0 = 1 — одна замена. spo и sto совпадают по букве o, поэтому копируется значение 1. В последней ячейке spot и stop сравниваются буквы t и p: значения в соседних ячейках — 1, 2 и 2, поэтому ответ равен 1 + 1 = 2.
В таблице (n+1) × (m+1) ячеек, на обработку каждой из которых уходит постоянное время; для двух слов длиной 500 букв это около 2.5 × 10^5 шагов. Рекурсия с мемоизацией заполняет те же ячейки, но глубина рекурсии может достигать n + m вызовов, что превышает стандартный лимит Python в 1000.
Алгоритм
- Создай таблицу
dpиз(n+1) × (m+1)ячеек. - Установи
dp[i][0] = iдля каждогоiиdp[0][j] = jдля каждогоj. - Для
iот 1 доnиjот 1 доm, еслиword1[i-1] == word2[j-1], установиdp[i][j] = dp[i-1][j-1]. - Иначе установи
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]). - Верни
dp[n][m].
def minDistance(word1, word2):
n, m = len(word1), len(word2)
# dp[i][j]: fewest edits to turn the first i letters of word1 into the first j of word2
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i # delete all i letters
for j in range(m + 1):
dp[0][j] = j # insert all j letters
for i in range(1, n + 1):
for j in range(1, m + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i - 1][j - 1], # replace
dp[i - 1][j], # delete word1[i-1]
dp[i][j - 1]) # insert word2[j-1]
return dp[n][m]Оставьте только две строки
Идея
Строка i считывает только строку i-1 и свои ячейки слева. После завершения строки строки выше больше никогда не считываются. Храни два массива: prev для завершённой строки и cur для строки, которую ты заполняешь, и меняй их местами после каждой строки. Переходы не меняются: диагональ — это prev[j-1], сверху — prev[j], а слева — cur[j-1].
Базовый столбец не исчезает. Теперь он находится в первой ячейке каждой строки, поэтому установи cur[0] = i перед заполнением строки i. Строка 0 начинается с [0, 1, 2, ..., m] — это базовая строка.
Чтобы превратить word2 в word1, требуется столько же правок, потому что каждая вставка становится удалением, а каждое удаление — вставкой. Поэтому можно поменять слова местами и выполнять строки вдоль более короткого слова. Тогда каждая строка будет содержать min(n, m) + 1 чисел вместо таблицы размером до 251,001 ячейки, а объём работы останется O(n × m).
Алгоритм
- Если
word2длиннееword1, поменяйте их местами. - Задайте
prev = [0, 1, ..., m], гдеm— длина более короткого слова. - Для каждого
iот 1 доnзадайтеcur[0] = i, затем заполнитеcur[1..m]по тому же правилу, используя диагональ и значение сверху изprev, а значение слева — изcur. - Поменяйте
prevиcurместами. - Верните
prev[m].
def minDistance(word1, word2):
if len(word2) > len(word1):
word1, word2 = word2, word1 # the rows run along the shorter word
m = len(word2)
# prev[j]: fewest edits to turn the previous prefix of word1 into word2[:j]
prev = list(range(m + 1))
for i in range(1, len(word1) + 1):
cur = [i] + [0] * m # i letters into an empty prefix: delete them all
for j in range(1, m + 1):
if word1[i - 1] == word2[j - 1]:
cur[j] = prev[j - 1]
else:
cur[j] = 1 + min(prev[j - 1], # replace
prev[j], # delete word1[i-1]
cur[j - 1]) # insert word2[j-1]
prev = cur
return prev[m]
Ловушки и крайние случаи
Рекуррентное соотношение короткое, поэтому большинство ошибок связано с базовыми случаями или с тем, какой соседний элемент считывается.
- Заполнение строки 0 и столбца 0 нулями, как при поиске наибольшей общей подпоследовательности. Преобразование
abcв пустой префикс требует 3 удалений, а не 0, поэтомуdp[i][0]должно быть равноi, аdp[0][j]—j. - Забыть про
cur[0] = iв версии с двумя строками. Первый элемент сохраняет значение из строки двумя строками выше, и все следующие ячейки неверны. - Оплата операции редактирования при совпадении. Если для одинаковых букв использовать
dp[i][j] = 1 + min(...), преобразованиеaвaбудет стоить 1. При совпадении скопируйте диагональное значение. - Считывание левого соседа из
prevвместоcur. Левый сосед находится в текущей строке: это вставкаword2[j-1]после того, какword1[:i]уже преобразовано вword2[:j-1]. - Сравнение символов по позициям. Подсчёт позиций, в которых слова различаются, не учитывает вставки и удаления: для
gardenиardentон даёт 6, хотя ответ — 2. - Мемоизация с рекурсией для слов из 500 букв. Глубина вызовов достигает 1000, что является стандартным ограничением Python.
Частые вопросы4
Какова временная сложность расстояния редактирования?
Решение с таблицей работает за время O(n × m), где n и m — длины двух последовательностей, потому что оно заполняет по одной ячейке для каждой пары префиксов, выполняя постоянный объём работы. Для полной таблицы требуется O(n × m) памяти, а для двух строк — O(min(n, m)). Обычная рекурсия без таблицы имеет экспоненциальную сложность.
Является ли расстояние редактирования тем же, что и расстояние Левенштейна?
Да, эта версия — расстояние Левенштейна: вставка, удаление и замена стоят по одной операции. Расстояние редактирования — это общее название семейства метрик. Другие варианты допускают меньше или больше видов изменений: только вставки и удаления дают n + m - 2 × LCS, только замены при равной длине дают расстояние Хэмминга, а добавление обмена двух соседних букв даёт вариант Дамерау.
Как получить список изменений, а не только их количество?
Сохрани всю таблицу и двигайся в обратном направлении от dp[n][m]. Если буквы совпадают, переходи по диагонали без изменений. В противном случае переходи к соседней ячейке, значение которой на единицу меньше: по диагонали — замена, вверх — удаление, влево — вставка. Остановись на dp[0][0] и прочитай правки в обратном порядке. Версия с двумя строками не может сделать это самостоятельно, потому что предыдущие строки уже отброшены.
Можно ли решить задачу о расстоянии редактирования с помощью одного массива?
Да. Заполняй один массив row на месте, слева направо. Перед тем как перезаписать row[j], в нём всё ещё хранится значение из предыдущей строки, а в row[j-1] уже хранится значение из текущей строки. Теряется только диагональное значение, поэтому сохрани его в переменной: сохрани старое значение row[j] перед записью и используй его как диагональное значение для j + 1.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def minDistance(word1, word2):
# Напишите код здесьСлучай 1
Случай 2
Случай 3
Ввод
word1 = "spot" word2 = "stop"
Ожидается
2