kz-maxx

Редакционное расстояние

Levenshtein Distance: DP по двум строкам, вставка, удаление, замена

Что такое редакционное расстояние?

Редакционное расстояние — это минимальное количество операций, чтобы превратить одну строку в другую.

Обычно разрешены три операции:

  • insert — вставить символ
  • delete — удалить символ
  • replace — заменить один символ на другой
Это также называется Levenshtein Distance.

Пример

Нужно превратить строку:

cat

в строку:

cut

Достаточно заменить a на u.

cat → cut
replace a на u
ответ = 1

Еще пример

Нужно превратить:

kitten

в:

sitting

Один из вариантов:

kitten → sitten
sitten → sittin
sittin → sitting

Всего 3 операции.

answer = 3

Где используется?

  • исправление ошибок в словах
  • поиск похожих строк
  • автозамена
  • сравнение текстов
  • биоинформатика
  • алгоритмы поиска
  • задачи на DP по строкам

Главная идея DP

У нас есть две строки:

string s, t;

Придумаем состояние:

dp[i][j]

dp[i][j] — минимальное количество операций, чтобы превратить первые i символов строки s в первые j символов строки t.

dp[i][j] = distance между s[0..i-1] и t[0..j-1]

Почему два индекса?

Потому что мы сравниваем две строки.

  • i — сколько символов взяли из первой строки
  • j — сколько символов взяли из второй строки
s = horse
t = ros

dp[3][2] = расстояние между "hor" и "ro"

Базовые значения

Если первая строка пустая, а во второй строке j символов, нужно вставить все j символов.

dp[0][j] = j;

Если вторая строка пустая, а в первой строке i символов, нужно удалить все i символов.

dp[i][0] = i;

Переход DP

Смотрим последние символы текущих префиксов:

s[i - 1]
t[j - 1]

Если они равны, ничего делать не надо:

dp[i][j] = dp[i - 1][j - 1];

Если они разные, выбираем минимум из трех операций:

  • удалить символ
  • вставить символ
  • заменить символ

Три операции

Удаление символа из первой строки:

dp[i - 1][j] + 1

Вставка символа в первую строку:

dp[i][j - 1] + 1

Замена символа:

dp[i - 1][j - 1] + 1

Формула целиком

if (s[i - 1] == t[j - 1]) {
    dp[i][j] = dp[i - 1][j - 1];
} else {
    dp[i][j] = min({
        dp[i - 1][j] + 1,
        dp[i][j - 1] + 1,
        dp[i - 1][j - 1] + 1
    });
}
символы равны → ничего не делаем
символы разные → берем минимум из delete, insert, replace

Задача 1: найти редакционное расстояние

Даны две строки s и t. Нужно найти минимальное количество операций, чтобы превратить s в t.

Input

horse
ros

Output

3

Полная реализация

#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

string s, t;
int dp[N][N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> s >> t;

    int n = s.size();
    int m = t.size();

    for (int i = 0; i <= n; i++) {
        dp[i][0] = i;
    }

    for (int j = 0; j <= m; j++) {
        dp[0][j] = j;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = min({
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i - 1][j - 1] + 1
                });
            }
        }
    }

    cout << dp[n][m];

    return 0;
}

Как читать dp[n][m]?

Если:

n = s.size();
m = t.size();

Тогда:

dp[n][m]

Это расстояние между всей строкой s и всей строкой t.

dp[n][m] = минимальное количество операций
чтобы превратить s в t

Пример руками

Пусть:

s = "cat"
t = "cut"

Символы:

Позиция s t Что делаем?
1 c c одинаковые
2 a u replace
3 t t одинаковые

Всего нужна 1 операция.

answer = 1

Рекурсивный метод

Сначала можно думать рекурсивно.

Пусть:

solve(i, j)

Это минимальное количество операций, чтобы превратить первые i символов s в первые j символов t.

Рекурсивные базовые случаи

Если i == 0, значит первая строка пустая. Нужно вставить j символов.

if (i == 0) {
    return j;
}

Если j == 0, значит вторая строка пустая. Нужно удалить i символов.

if (j == 0) {
    return i;
}

Рекурсивный переход

Если последние символы равны:

solve(i, j) = solve(i - 1, j - 1)

Если разные:

solve(i, j) = 1 + min(
    solve(i - 1, j),
    solve(i, j - 1),
    solve(i - 1, j - 1)
)

Рекурсия + мемоизация

int solve(int i, int j) {
    if (i == 0) {
        return j;
    }

    if (j == 0) {
        return i;
    }

    if (dp[i][j] != -1) {
        return dp[i][j];
    }

    if (s[i - 1] == t[j - 1]) {
        dp[i][j] = solve(i - 1, j - 1);
    } else {
        dp[i][j] = 1 + min({
            solve(i - 1, j),
            solve(i, j - 1),
            solve(i - 1, j - 1)
        });
    }

    return dp[i][j];
}

Полная рекурсивная реализация

#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

string s, t;
int dp[N][N];

int solve(int i, int j) {
    if (i == 0) {
        return j;
    }

    if (j == 0) {
        return i;
    }

    if (dp[i][j] != -1) {
        return dp[i][j];
    }

    if (s[i - 1] == t[j - 1]) {
        dp[i][j] = solve(i - 1, j - 1);
    } else {
        dp[i][j] = 1 + min({
            solve(i - 1, j),
            solve(i, j - 1),
            solve(i - 1, j - 1)
        });
    }

    return dp[i][j];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> s >> t;

    memset(dp, -1, sizeof(dp));

    cout << solve(s.size(), t.size());

    return 0;
}

Рекурсия → итеративный DP

Рекурсия Итеративный DP
solve(i, j) dp[i][j]
if (i == 0) return j dp[0][j] = j
if (j == 0) return i dp[i][0] = i
solve(i - 1, j) dp[i - 1][j]
solve(i, j - 1) dp[i][j - 1]
solve(i - 1, j - 1) dp[i - 1][j - 1]

Восстановление операций

Иногда нужно вывести не только число операций, но и сами операции.

После заполнения dp идем назад из:

i = n;
j = m;

Если символы равны, просто идем назад:

if (s[i - 1] == t[j - 1]) {
    i--;
    j--;
}

Как понять операцию?

Если текущее значение пришло из dp[i - 1][j] + 1, значит был delete.

if (dp[i][j] == dp[i - 1][j] + 1) {
    // delete s[i - 1]
}

Если пришло из dp[i][j - 1] + 1, значит был insert.

if (dp[i][j] == dp[i][j - 1] + 1) {
    // insert t[j - 1]
}

Если пришло из dp[i - 1][j - 1] + 1, значит был replace.

Код восстановления операций

vector<string> ops;

int i = n;
int j = m;

while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && s[i - 1] == t[j - 1]) {
        i--;
        j--;
    } else if (i > 0 && dp[i][j] == dp[i - 1][j] + 1) {
        ops.push_back("delete");
        i--;
    } else if (j > 0 && dp[i][j] == dp[i][j - 1] + 1) {
        ops.push_back("insert");
        j--;
    } else {
        ops.push_back("replace");
        i--;
        j--;
    }
}

reverse(ops.begin(), ops.end());

Полная реализация с операциями

#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

string s, t;
int dp[N][N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> s >> t;

    int n = s.size();
    int m = t.size();

    for (int i = 0; i <= n; i++) {
        dp[i][0] = i;
    }

    for (int j = 0; j <= m; j++) {
        dp[0][j] = j;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = min({
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i - 1][j - 1] + 1
                });
            }
        }
    }

    cout << dp[n][m] << '\n';

    vector<string> ops;

    int i = n;
    int j = m;

    while (i > 0 || j > 0) {
        if (i > 0 && j > 0 && s[i - 1] == t[j - 1]) {
            i--;
            j--;
        } else if (i > 0 && dp[i][j] == dp[i - 1][j] + 1) {
            ops.push_back("delete " + string(1, s[i - 1]));
            i--;
        } else if (j > 0 && dp[i][j] == dp[i][j - 1] + 1) {
            ops.push_back("insert " + string(1, t[j - 1]));
            j--;
        } else {
            ops.push_back("replace " + string(1, s[i - 1]) + " to " + string(1, t[j - 1]));
            i--;
            j--;
        }
    }

    reverse(ops.begin(), ops.end());

    for (string op : ops) {
        cout << op << '\n';
    }

    return 0;
}

Оптимизация памяти

Если нужно только расстояние, можно хранить не всю таблицу, а только две строки:

vector<int> prev(m + 1);
vector<int> cur(m + 1);

Потому что dp[i][j] зависит только от:

  • dp[i - 1][j]
  • dp[i][j - 1]
  • dp[i - 1][j - 1]

Реализация с памятью O(m)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s, t;
    cin >> s >> t;

    int n = s.size();
    int m = t.size();

    vector<int> prev(m + 1);
    vector<int> cur(m + 1);

    for (int j = 0; j <= m; j++) {
        prev[j] = j;
    }

    for (int i = 1; i <= n; i++) {
        cur[0] = i;

        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                cur[j] = prev[j - 1];
            } else {
                cur[j] = min({
                    prev[j] + 1,
                    cur[j - 1] + 1,
                    prev[j - 1] + 1
                });
            }
        }

        prev = cur;
    }

    cout << prev[m];

    return 0;
}
Если нужно восстановить операции, удобнее хранить полный dp[n][m].

Связь с LCS

Редакционное расстояние похоже на LCS, потому что обе задачи работают с двумя строками и используют состояние dp[i][j].

Задача Что ищем? Состояние
LCS самую длинную общую подпоследовательность dp[i][j]
Edit Distance минимальное число операций dp[i][j]

Типичные ошибки

  • Забыть инициализировать dp[0][j]
  • Забыть инициализировать dp[i][0]
  • Писать s[i] вместо s[i - 1]
  • Писать t[j] вместо t[j - 1]
  • Забыть +1 при операции
  • Путать insert и delete при восстановлении
  • Думать, что если символы равны, надо все равно делать +1

Асимптотика

Метод Время Память
Рекурсия + мемоизация O(nm) O(nm)
Итеративный DP O(nm) O(nm)
Оптимизация памяти O(nm) O(m)
Здесь n — длина первой строки, m — длина второй строки.

Как думать на задачах?

1. Есть две строки
2. Нужно превратить одну строку в другую
3. Состояние dp[i][j]
4. Если символы равны — ничего не делаем
5. Если разные — delete, insert, replace
6. Ответ лежит в dp[n][m]

Короткий шаблон

for (int i = 0; i <= n; i++) {
    dp[i][0] = i;
}

for (int j = 0; j <= m; j++) {
    dp[0][j] = j;
}

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
        if (s[i - 1] == t[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1];
        } else {
            dp[i][j] = min({
                dp[i - 1][j] + 1,
                dp[i][j - 1] + 1,
                dp[i - 1][j - 1] + 1
            });
        }
    }
}

cout << dp[n][m];