← Параллель BSВсе параллели

Занятие 4. Параллель BS — Динамическое программирование 1

Четыре пункта ДП, строки без двух единиц подряд, наибольшая возрастающая подпоследовательность за n² и n log n с доказательством и восстановлением, наибольшая общая подпоследовательность; семинар: шахты, неубывающие массивы с суммой, пловцы.
Лекция≈ 4 ч 35 минЯзык кода: C++▶ Смотреть на YouTube
Лекции из Т-поколения

1. Что такое динамическое программирование 6:19

1.1. Идея на одной задаче и четыре обязательных пункта 6:36

6:36 Динамическое программирование (ДП) лекция вводит не определением, а на примере. Сначала задача, а метод вырастает из неё.

Задача. Дано число $N$. Нужно посчитать количество двоичных последовательностей (из нулей и единиц) длины $N$, в которых нет двух единиц подряд.

6:58 Переборное мышление подсказывает: «на каждой позиции два варианта». Но последовательностей $2^N$, и перебирать их по одной нельзя. ДП заменяет перебор вычислением: ответ собирается из ответов на меньшие задачи.

7:35 Любое решение динамикой нужно описать четырьмя пунктами. Именно их лектор просит проговаривать каждый раз, и в этом конспекте они выделены в каждой задаче:

  1. Определение — что именно хранится в ячейке массива dp. Без точного определения остальное невозможно проверить.
  2. База — значения для самых маленьких параметров, которые не выражаются через другие.
  3. Формула пересчёта (переход) — как значение ячейки получается через уже известные ячейки.
  4. Ответ — в какой ячейке (или как по нескольким ячейкам) лежит ответ на исходную задачу.

8:01 В записи звучит замечание, что порядок «база — переход» можно менять местами: часто сначала записывают формулу, смотрят, к каким ячейкам она обращается, и только затем понимают, какие из них нужно заполнить в базе.

1.2. Первая попытка: одного индекса не хватает 8:54

8:54 Естественное определение: $dp_i$ — количество подходящих последовательностей длины $i$. База: $dp_0 = 1$ (пустая последовательность одна), $dp_1 = 2$ (0 и 1). Теперь переход: хотим получить последовательность длины $i$ из последовательности длины $i-1$, дописав символ.

10:20 И тут обнаруживается проблема. Дописывать можно 0 всегда, а 1 — только если предыдущий символ 0. Но в $dp_{i-1}$ лежат вместе последовательности, заканчивающиеся и на 0, и на 1, и по одному числу мы их не различаем. Вывод лектора: значения $dp_i$ недостаточно, нужно хранить ещё последний символ.

Важное дополнение: общее правило выбора состояния. Если для перехода нужна информация, которой нет в текущем определении dp, эту информацию добавляют в состояние как ещё один параметр. Здесь это «на что заканчивается строка». Дальше в лекции этот приём встретится снова: в наибольшей возрастающей подпоследовательности «на чём заканчивается» тоже входит в состояние.

1.3. Состояние $dp_{i,c}$, база, переход и ответ 10:41

Переходы dp[i][c]: c — на что заканчивается строкадлина i − 1длина iна 0на 0на 1на 1✕ «11» нельзяставим 0dp[i][0] = dp[i−1][0] + dp[i−1][1]dp[i][1] = dp[i−1][0]
В состояние «на 0» можно прийти из любого состояния, в состояние «на 1» — только из «на 0»: две единицы подряд запрещены.
  1. Определение. 10:41 $dp_{i,c}$ — количество подходящих последовательностей длины $i$, у которых последний символ равен $c$ ($c \in \{0, 1\}$).

  2. База. 11:13 Сначала лектор хотел оставить $dp_{0,\cdot}$, но тут же заметил: последовательностей длины ноль, заканчивающихся на ноль, не существует, поэтому такую базу давать нельзя. Базу задают для длины один: $dp_{1,0} = 1$ (строка 0), $dp_{1,1} = 1$ (строка 1).

  3. Переход. 11:56 $$dp_{i,1} = dp_{i-1,0}, \qquad dp_{i,0} = dp_{i-1,0} + dp_{i-1,1}.$$ Почему так. Последовательность длины $i$, заканчивающаяся на единицу, получается из последовательности длины $i-1$ дописыванием 1; предыдущий символ обязан быть нулём, потому что две единицы подряд запрещены, поэтому берём только $dp_{i-1,0}$. А если дописываем 0, то никаких ограничений нет, и подходят обе группы: и заканчивающиеся на ноль, и на единицу.

  4. Ответ. 14:22 $dp_{N,0} + dp_{N,1}$ — количество последовательностей длины $N$, оканчивающихся на ноль, плюс оканчивающихся на единицу.

14:56 Лектор отмечает, что получилось что-то очень похожее на числа Фибоначчи. Это не совпадение: $dp_{i,1} = dp_{i-1,0}$, поэтому $dp_{i,0} = dp_{i-1,0} + dp_{i-2,0}$.

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

int main() {
    int n;
    cin >> n;
    // dp[i][c] — число строк длины i без «11», оканчивающихся на c
    vector<array<long long, 2>> dp(n + 1);
    dp[1] = {1, 1};                      // база: «0» и «1»
    for (int i = 2; i <= n; i++) {
        dp[i][0] = dp[i - 1][0] + dp[i - 1][1];   // дописываем 0 к чему угодно
        dp[i][1] = dp[i - 1][0];                  // дописываем 1 только после 0
    }
    cout << dp[n][0] + dp[n][1] << "\n";      // ответ
}
Проверка. Решение сверено с полным перебором всех $2^n$ строк для $n = 1,\dots,20$; значения для $n = 1,\dots,8$: 2, 3, 5, 8, 13, 21, 34, 55.
Собственный пример. Для $N = 3$ допустимы строки 000, 001, 010, 100, 101 — пять штук. По формуле: $dp_1 = (1,1)$; $dp_2 = (1+1,\ 1) = (2,1)$; $dp_3 = (2+1,\ 2) = (3,2)$; сумма $5$. Асимптотика: время $O(N)$, память $O(N)$ (хватило бы и двух последних слоёв, $O(1)$). Для больших $N$ ответ растёт как числа Фибоначчи и требует либо длинной арифметики, либо вывода по модулю.

1.4. Вопросы из чата 15:13

Вопросы из чата.

15:13 Почему $dp_{i,1} = dp_{i-1,0}$, а не $dp_{i-1,0} + dp_{i-1,1}$? Потому что к строке, оканчивающейся на единицу, нельзя дописать единицу.

16:32 Разве $dp_{i,0}$ не равно $dp_{i-1,1}$? Нет: два нуля подряд разрешены, поэтому к нулю дописывать ноль можно, и подходят обе группы.

17:28 Метод Ричарда Беллмана создан наподобие индукции? Лектор не берётся судить об истории. По существу схожесть есть: база, а затем переход от меньшего к большему, как в индукции.

18:06 Верно ли, что у каждой ячейки есть ровно один путь «наверх»? Ячейка $dp_{i-1,1}$ учитывается ровно один раз — в $dp_{i,0}$. А $dp_{i-1,0}$ учитывается дважды: и в $dp_{i,0}$, и в $dp_{i,1}$. Это и показывает: из нуля можно перейти куда угодно, а из единицы — только в ноль.

19:00 Лектор подчёркивает направление вычислений: ДП «вперёд» — для меньших $i$ всё уже посчитано, и мы переходим к большему $i$ через более старые значения.

2. Наибольшая возрастающая подпоследовательность (НВП) 21:46

2.1. Условие и примеры 21:50

21:50 Дан массив из $n$ чисел. Нужно найти длину самой длинной строго возрастающей подпоследовательности: набора элементов массива (в исходном порядке, возможно, с пропусками), значения которых строго возрастают.

22:17 Пример лектора: у массива, содержащего числа 3, 6, 9, 10 в таком порядке, подпоследовательность 3 6 9 10 возрастающая, как и 3 6 8; а 3 7 6 не подходит, потому что семёрка больше шестёрки. Элементы можно пропускать: из 3 7 6 10 можно взять 3 7 10.

23:35 Начинаем с решения за квадрат.

2.2. Решение за $O(n^2)$ 23:35

  1. Определение. 23:35 $dp_i$ — длина наибольшей возрастающей подпоследовательности, которая заканчивается ровно в элементе $a_i$.

    24:31 Один из участников предложил определение «заканчивается в $i$ или ранее» (то есть максимум по префиксу). Лектор возражает: тогда мы не будем знать, каким числом заканчивается подпоследовательность, а значит, не сможем понять, какие числа к ней можно дописать. Поэтому «ровно в $i$».

  2. База. 25:39 В одноиндексной нумерации $dp_1 = 1$: одиночный элемент — возрастающая подпоследовательность длины один. Лектор работает в одноиндексной нумерации: нулевого элемента в массиве нет, поэтому «ответ для $i = 0$» равен нулю. (В нуль-индексации то же самое записывается как $dp_0 = 1$.)

  3. Переход. 26:31 Первая идея участника — сравнить $a_i$ только с предыдущим $a_{i-1}$ и при $a_i > a_{i-1}$ взять $dp_{i-1} + 1$. Но это неверно: 27:21 если $a_i \le a_{i-1}$, то нельзя «обнулить» (ответ $1$): элемент мог бы продолжить более раннюю подпоследовательность. Пример из лекции: восьмёрка меньше стоящей перед ней десятки, но может продолжать подпоследовательность, заканчивающуюся на шестёрке. Правильная формула (27:56): перебираем все предыдущие $j < i$ и берём из подходящих максимум. $$dp_i = 1 + \max_{\substack{j < i \\ a_j < a_i}} dp_j,$$ а если подходящих $j$ нет, то $dp_i = 1$.

  4. Ответ. 29:12 $\max_i dp_i$ — максимум по всем $i$: заранее неизвестно, где заканчивается самая длинная подпоследовательность.

29:51 Асимптотика. Для каждого $i$ просматриваем все $j < i$, итого $O(n^2)$ по времени и $O(n)$ по памяти.

C++
// dp[i] — длина НВП, заканчивающейся ровно в a[i]
int lis_n2(const vector<int>& a) {
    int n = a.size(), ans = 0;
    vector<int> dp(n, 1);                 // база: сам элемент — подпоследовательность длины 1
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++)
            if (a[j] < a[i])               // a[i] можно дописать после a[j]
                dp[i] = max(dp[i], dp[j] + 1);
        ans = max(ans, dp[i]);             // ответ — максимум по всем i
    }
    return ans;
}
Из чата. 44:29 Почему нельзя использовать дерево отрезков? Можно: есть решение за $O(n \log n)$, где максимум значений $dp_j$ по $a_j < a_i$ считается деревом отрезков (лектор отмечает, что ДП и деревья отрезков в курсе ещё не проходили).

2.3. Решение за $O(n \log n)$ 30:37

30:40 Один из участников предложил другое решение: идти по массиву и вести вспомогательный массив, куда числа записываются в нужную позицию бинарным поиском. Лектор оформила его как ДП с нестандартным состоянием.

  1. Определение. 31:48 $d_\ell$ — наименьшее число, на которое может заканчиваться возрастающая подпоследовательность длины $\ell$, среди рассмотренных к этому моменту элементов. Размер массива $n+1$: длина может быть от $0$ до $n$.

  2. База. 32:30 $d_0 = -\infty$, остальные $d_\ell = +\infty$. Смысл: подпоследовательность длины $0$ «заканчивается на минус бесконечность» — её можно продолжить любым числом. А $+\infty$ означает, что подпоследовательности такой длины пока не существует.

33:39 Дальше обрабатываем элементы по очереди и смотрим, какие подпоследовательности каждый может продолжить. Рассмотрим массив 3 7 6 10 8 9 (восстановлен по ходу объяснения):

Массив d[len]: наименьший конец НВП длины len. Массив 3 7 6 10 8 9len = 0len = 1len = 2len = 3len = 4старт−∞∞∞∞∞+ 3−∞3∞∞∞+ 7−∞37∞∞+ 6−∞36∞∞+ 10−∞3610∞+ 8−∞368∞+ 9−∞3689ответ — число ячеек, не равных ∞: здесь 4 (3, 6, 8, 9)
Жёлтым выделена единственная ячейка, меняющаяся при добавлении числа. Сами числа 3 6 8 9 здесь оказались настоящей НВП, но в общем случае массив d не обязан быть подпоследовательностью.

37:12 Итог: $d = (-\infty,\,3,\,6,\,8,\,9)$. Возрастающая подпоследовательность длины $1$ может заканчиваться на $3$, длины $2$ — на $6$, длины $3$ — на $8$, длины $4$ — на $9$.

Ответ. 37:38 Количество значений в $d_1, d_2, \dots$, не равных $+\infty$ (то есть наибольшая длина $\ell$ с $d_\ell < +\infty$).

2.4. Как найти позицию и почему массив отсортирован 37:48

37:48 Формально: для очередного числа $x = a_i$ нужно найти позицию в $d$, куда его поставить. Участник говорит: lower_bound. Лектор спрашивает: почему можно применять бинарный поиск, то есть почему $d$ отсортирован?

38:15 Ответ из лекции. В начале $d$ возрастает: $-\infty,\ +\infty,\dots$. Пусть число $x$ встало в позицию $p$: слева от неё все значения меньше $x$ (это длины, которые $x$ может продолжить), а справа значения больше или равны $x$. Заменив $d_p$ (которое было $\ge x$) на $x$, мы сохраняем возрастание: слева значения меньше, справа не меньше. Так массив остаётся отсортированным. 39:27 В результате $x$ продолжает любую подпоследовательность из левой части, а правую продолжить не может; и выгодно заменить именно первое значение, не меньшее $x$, чтобы подпоследовательность той же длины оканчивалась на меньшее число.

Добавили число x: что происходит с d (строго возрастает)−∞d[0]2d[1]5d[2]7d[3]9d[4]12d[5]∞d[6]d < x: x им не поможетпервое d ≥ xзаменяем на x = 6дальше: x не продолжит НВПlower_bound(d.begin(), d.end(), x) — это и есть позиция замены; если все d меньше x — занимаем первую ∞
Слева все d меньше x: их конец уже лучше, помогать нечему. Справа — слишком большие, к ним x приписать нельзя. Меняется ровно одна ячейка.

40:05 Если число меньше всех. Например, встретилась двойка, а в $d_1$ лежит тройка. Двойка может продолжить только подпоследовательность длины $0$, то есть становится подпоследовательностью длины один, и тройку выгодно заменить на двойку.

C++
// d[len] — наименьшее число, на котором может заканчиваться НВП длины len
int lis_nlogn(const vector<int>& a) {
    const int INF = INT_MAX, NINF = INT_MIN;
    int n = a.size();
    vector<int> d(n + 1, INF);
    d[0] = NINF;                                     // база
    for (int x : a) {
        // первая позиция, где d[len] >= x  (строгая НВП: равные тоже заменяем)
        int len = lower_bound(d.begin(), d.end(), x) - d.begin();
        d[len] = x;                                  // меняется ровно одна ячейка
    }
    int ans = 0;
    while (ans + 1 <= n && d[ans + 1] != INF) ans++;   // сколько ячеек заполнено
    return ans;
}

46:04 Асимптотика. Для каждого из $n$ чисел делаем один бинарный поиск за $O(\log n)$: итого $O(n \log n)$ по времени и $O(n)$ по памяти.

Важное дополнение: строгая и нестрогая возрастающая. Для строго возрастающей подпоследовательности нужен lower_bound (равное число вытесняет равное). Для неубывающей нужно заменить его на upper_bound: равное число тогда продолжает подпоследовательность.

2.5. Как прийти к решению за $O(n \log n)$: вывод из двумерной динамики 50:56

50:56 В записи этот вывод рассказывают второй преподаватель в середине лекции (50:56, коротко) и ещё раз подробно после перерыва (2:21:36, с доказательствами). Ниже — объединённый рассказ. Идея: решение с массивом $d$ — это двумерная динамика, у которой лишний параметр убрали.

52:00 Шаг 1. Типовая попытка и где она ломается. Для массива чаще всего пробуют динамику «по префиксу»: $dp_i$ — ответ на первых $i$ элементах. Но зная ответ на префиксе длины $i$, ответ для $i+1$ посчитать нельзя: мы не знаем, на каком значении заканчивается самая длинная подпоследовательность, а значит, не знаем, можно ли её продолжить числом $a_{i+1}$. В решении за $n^2$ эту проблему обходили определением «заканчивается ровно в $i$». 53:02 Правило: если состояния не хватает, в него добавляют ещё один параметр.

55:38 Шаг 2. Кладём ответ в состояние. Добавим параметр $\ell$ — желаемую длину подпоследовательности — и в ячейке будем хранить то, что нужно для продолжения: наименьший возможный последний элемент. $$dp_{i,\ell} = \text{наименьший конец возрастающей подпоследовательности длины } \ell \text{ на первых } i \text{ элементах.}$$ Почему наименьший: чтобы подпоследовательность можно было продолжить числом $x$, важно только соотношение её последнего элемента с $x$; из подпоследовательностей одной длины лучшая — та, у которой конец меньше, так как ей подходит больше чисел. Ответ — наибольшее $\ell$, для которого $dp_{n,\ell} < +\infty$. 2:28:18 База: $dp_{0,0} = -\infty$, $dp_{0,\ell} = +\infty$ при $\ell > 0$.

54:15 Шаг 3. Слои почти одинаковы. Если выписать массивы $dp_{i,\cdot}$ по слоям $i = 0, 1, 2,\dots$, видно, что соседние слои различаются не больше чем в одной ячейке. Тогда не нужно хранить все слои: достаточно одного массива, в котором на каждом шаге меняется одна ячейка. Так получается массив $d$ из прошлого раздела. Остаётся доказать, что слои действительно отличаются в одной ячейке, и понять, в какой.

2.6. Доказательство: что меняется при добавлении $a_{i+1}$ 2:37:43

2:30:02 Переход от слоя $i$ к слою $i+1$ — добавление одного числа $a = a_{i+1}$. Либо мы его не используем (слой просто копируется), либо дописываем в конец какой-то подпоследовательности.

2:37:43 Лемма (возрастание слоя). Значения $dp_{i,0}, dp_{i,1}, \dots$ строго возрастают.

Доказательство (2:38:12, от противного). Пусть $\ell_1 < \ell_2$ и $x_1 = dp_{i,\ell_1} \ge x_2 = dp_{i,\ell_2}$. Возьмём подпоследовательность длины $\ell_2$, заканчивающуюся на $x_2$. Её $\ell_1$-й по счёту элемент $y$ строго меньше $x_2$ (подпоследовательность строго возрастает, а $\ell_1 < \ell_2$), то есть $y < x_2 \le x_1$. Начало этой подпоследовательности до $y$ — возрастающая подпоследовательность длины $\ell_1$ с концом $y < x_1$. Но $x_1$ по определению наименьший возможный конец длины $\ell_1$ — противоречие. Значит, $x_1 < x_2$ для любых $\ell_1 < \ell_2$. $\square$

2:43:34 Что делает число $a$. Из строгого возрастания слоя следует: найдётся позиция $p$ — первая, где $dp_{i,p} \ge a$. Разобьём слой на две части: слева значения меньше $a$, справа не меньше.

2:47:11 Вывод. Переход от слоя $i$ к слою $i+1$ меняет не более одной ячейки — первую, где $dp \ge a$ (находится бинарным поиском, это lower_bound), на значение $a$. Поэтому вместо таблицы $n \times n$ хранится один массив $d$, а каждое добавление числа стоит $O(\log n)$.

Из чата. 45:17 Почему все значения $d$ остаются корректными после добавления $a_i$? Меняется ровно одна ячейка — для той длины, которую новое число может улучшить. Остальные остаются верны: для каждой из них уже существовала подпоследовательность с таким концом, мы ничего не сломали, а только, возможно, добавили более выгодную.
Из чата. 46:33 Почему для 11 12 13 1 2 3 4 нельзя оставить 1 2 3 4 11 12 13? В $d$ хранятся не сами подпоследовательности, а концы: после трёх первых чисел $d = (-\infty, 11, 12, 13)$; единица заменяет $11$, двойка — $12$, тройка — $13$, четвёрка занимает первую бесконечность, получаем $d = (-\infty, 1, 2, 3, 4)$ и ответ $4$. 42:53 Лектор подчёркивает, что $d$ не обязано хранить саму подпоследовательность: оно хранит лишь возможные концы.
Собственный пример. На массиве 11 12 13 1 массив $d$ станет $(-\infty,\ 1,\ 12,\ 13)$, а набор $1,\ 12,\ 13$ не является подпоследовательностью массива: единица стоит после двенадцати и тринадцати. Длина ответа ($3$) при этом верна — её даёт, например, $11\ 12\ 13$. Поэтому для восстановления подпоследовательности нужен отдельный массив $pr$ (см. ниже).

2.7. Восстановление самой подпоследовательности 2:55:03

2:55:03 Массив $d$ хранит лишь концы, но саму подпоследовательность восстановить можно. В каждый момент при добавлении $a_i$ мы выбираем позицию $p$ — запомним её в массиве $pr_i = p$. Это длина самой длинной подпоследовательности, оканчивающейся ровно в $a_i$ (то же, что $dp_i$ в решении за $n^2$).

Важное дополнение: почему «ближайший слева» подходит. Среди элементов с одинаковым $pr$ значения не возрастают при движении вправо: если бы $j < i$, $pr_j = pr_i = \ell$ и $a_j < a_i$, то $a_i$ можно было бы дописать после $a_j$, и $pr_i \ge \ell + 1$. Теперь пусть мы стоим в $i$ с $pr_i = \ell$. По определению есть какой-то $j < i$ с $pr_j = \ell - 1$ и $a_j < a_i$. Среди всех $j' < i$ с $pr_{j'} = \ell - 1$ самое правое имеет наименьшее значение (значения не возрастают вправо), значит, оно тоже меньше $a_i$ и подходит. Поэтому жадно брать ближайший слева — корректно.
C++
// НВП с восстановлением ответа за O(n log n)
vector<int> lis_restore(const vector<int>& a) {
    const int INF = INT_MAX, NINF = INT_MIN;
    int n = a.size();
    vector<int> d(n + 1, INF), pr(n);
    d[0] = NINF;
    for (int i = 0; i < n; i++) {
        int len = lower_bound(d.begin(), d.end(), a[i]) - d.begin();
        d[len] = a[i];
        pr[i] = len;                   // длина НВП, оканчивающейся в a[i]
    }
    int L = *max_element(pr.begin(), pr.end());
    vector<int> res;
    int need = L;
    for (int i = n - 1; i >= 0 && need > 0; i--)
        if (pr[i] == need) {           // ближайший слева элемент нужной длины
            res.push_back(a[i]);
            need--;
        }
    reverse(res.begin(), res.end());
    return res;
}
Проверка. Обе версии (за $n^2$ и за $n \log n$ с восстановлением) сверены с полным перебором всех подпоследовательностей на 20 000 случайных массивов длины до 10: длины совпали, а восстановленная последовательность каждый раз оказывалась возрастающей подпоследовательностью исходного массива. На 3 7 6 10 8 9 ответ 3 6 8 9, на 11 12 13 1 2 3 4 — 1 2 3 4.

3. Наибольшая общая подпоследовательность (НОП) 58:51

3.1. Условие 58:51

58:51 Даны две строки $A$ и $B$. Нужно найти длину самой длинной строки, которая является подпоследовательностью (символы в исходном порядке, возможно, с пропусками) и $A$, и $B$.

59:30 Лектор поясняет на двух строках из букв A и B: общая подпоследовательность получается, если взять символы в том же порядке из обеих строк, пропуская лишние. Порядок нарушать нельзя, пропускать символы можно. Дальше строится динамика.

3.2. Определение, база, переход, ответ 1:01:13

  1. Определение. 1:00:44 Первое предложение участника — хранить длину подпоследовательности, которая «в первой строке заканчивается в позиции $i$, а во второй в $j$» — лектор не принимает. 1:01:13 Принято определение: $dp_{i,j}$ — длина наибольшей общей подпоследовательности префикса $A$ длины $i$ и префикса $B$ длины $j$ (нумерация с единицы, чтобы удобнее было обращаться к $i-1$).

  2. База. 1:02:36 $dp_{i,0} = dp_{0,j} = 0$: у пустого префикса с любой строкой нет общих символов.

  3. Переход. 1:03:30 Смотрим на последние символы префиксов $A_i$ и $B_j$.

    • Если $A_i = B_j$, этот символ можно поставить в конец общей подпоследовательности: к лучшему ответу на префиксах без этих символов добавляется единица. $$dp_{i,j} = dp_{i-1,j-1} + 1.$$
    • Если $A_i \ne B_j$, то хотя бы один из них в общей подпоследовательности не участвует, и мы выбрасываем один из двух последних символов, оставляя другой префикс целиком: $$dp_{i,j} = \max(dp_{i-1,j},\ dp_{i,j-1}).$$
  4. Ответ. 1:12:55 $dp_{n,m}$, где $n = |A|$, $m = |B|$.

dp[i][j] для A = ABCBD и B = BDCAB, ответ — 3 (BCB)BDCABABCBD000000000011011112011222011223012223зелёные клетки — совпавшие символы при обратном ходе
Собственный пример. Обратный ход начинается в правом нижнем углу: при равных символах идём по диагонали и запоминаем символ, иначе — в сторону большего соседа.
C++
// dp[i][j] — НОП префиксов a[0..i) и b[0..j)
int lcs_len(const string& a, const string& b) {
    int n = a.size(), m = b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));   // база: нули
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++) {
            if (a[i - 1] == b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    return dp[n][m];
}

1:13:21 Асимптотика. Состояний $n \cdot m$, переход за $O(1)$: время и память $O(nm)$. На вопрос, можно ли быстрее (за $O(n + m)$ или $O((n+m)\log)$), лектор отвечает отрицательно.

3.3. Почему при равных символах не нужен максимум из трёх 1:08:29

1:08:29 Участник спрашивает: при $A_i = B_j$ можно ли написать $\max$ из трёх значений — $dp_{i-1,j}$, $dp_{i,j-1}$ и $dp_{i-1,j-1}+1$? Можно, но не нужно. Лекторская версия объяснения (1:11:26): если бы выгоднее было взять $dp_{i-1,j}$, то в ответе для префикса $A_{1..i-1}$ и $B_{1..j}$ последний символ $B_j$ уже совпал с каким-то более ранним символом $A$; но его можно сопоставить с $A_i$, ничего не потеряв. При неравных символах так сделать нельзя, и приходится искать равенство глубже в префиксах.

Развёрнутое объяснение. Строго: удаление одного символа из строки уменьшает НОП не более чем на единицу, поэтому $dp_{i-1,j} \le dp_{i-1,j-1} + 1$ и $dp_{i,j-1} \le dp_{i-1,j-1} + 1$. Значит, при $A_i = B_j$ значение $dp_{i-1,j-1}+1$ не меньше двух других, и максимум из трёх всегда равен ему. В другую сторону: достижимость $dp_{i-1,j-1} + 1$ очевидна — к любой общей подпоследовательности префиксов без последних символов дописываем общий символ.

3.4. Восстановление ответа 1:14:04

1:14:04 Пусть нужно вывести не только длину, но и саму НОП. Лекция называет два подхода (1:17:03): обратный ход по таблице и массив родителей.

3.5. Обратный ход по таблице $dp$ 1:17:50

1:17:50 Таблица уже содержит всю информацию о том, откуда мы пришли в клетку. Идём из $(n, m)$ к $(0,0)$:

1:22:18 Лектор показывает на маленьком примере, как идёт обратный ход; в этом конспекте полный пример с таблицей и выделенным путём — на рисунке выше (собственный пример: строки ABCBD и BDCAB, ответ BCB).

3.6. Массив родителей 1:25:39

1:25:39 Второй способ: прямо в каждой клетке хранить пару индексов — откуда мы в неё пришли (1:26:19). Тогда обратный ход не нужно «угадывать» сравнением значений: достаточно идти по родителям и добавлять в ответ символ тогда, когда переход был по диагонали при равных символах. Платим дополнительной памятью.

C++
// НОП с восстановлением: обратный ход по таблице dp
string lcs_restore(const string& a, const string& b) {
    int n = a.size(), m = b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++)
            dp[i][j] = a[i - 1] == b[j - 1] ? dp[i - 1][j - 1] + 1
                                            : max(dp[i - 1][j], dp[i][j - 1]);
    string res;
    int i = n, j = m;
    while (i > 0 && j > 0) {
        if (a[i - 1] == b[j - 1]) {            // этот символ входит в ответ
            res += a[i - 1];
            i--; j--;
        } else if (dp[i - 1][j] >= dp[i][j - 1]) i--;   // идём туда, где больше
        else j--;
    }
    reverse(res.begin(), res.end());           // набирали с конца
    return res;
}
Проверка. Сверено с перебором всех подпоследовательностей на 20 000 случайных пар строк (длины до 8, три буквы): длина совпала, а результат каждый раз оказывался подпоследовательностью обеих строк. На ABCBD, BDCAB получается длина 3.
Важное дополнение: память. Если нужна только длина НОП, достаточно хранить две строки таблицы ($O(m)$ памяти). Для восстановления ответа полная таблица нужна (или более хитрые методы вроде алгоритма Хиршберга, который здесь не рассматривался).
Из чата. 1:27:53 При равных $A_i$ и $B_j$ можно не смотреть на $dp_{i-1,j}$ и $dp_{i,j-1}$? Да: $dp_{i-1,j-1}$ уже включает всё нужное; см. разбор выше.

4. Семинар: приёмы и задачи на ДП 3:06:53

2:49:24 После перерыва начинается семинар: второй преподаватель разбирает задачи из контеста вместе с участниками. Условия звучат устно, поэтому ниже они записаны так, как были заданы на семинаре; где формулировка осталась неполной, это оговорено.

4.1. Приём: массивы смещений dx, dy 3:06:53

3:06:53 Первый приём, который пригодится в задачах на сетке и лабиринтах. Когда из клетки можно сдвинуться в несколько сторон (север, юг, запад, восток и ещё четыре диагонали), вместо восьми однотипных if заводят два массива смещений длины $8$ и перебирают переходы циклом. 3:11:40 Асимптотика не меняется (восемь проверок остаются восемью проверками), зато код короче, и в нём меньше мест для опечаток.

C++
// 8 направлений: С, СВ, В, ЮВ, Ю, ЮЗ, З, СЗ
const int dx[8] = {-1, -1, 0, 1, 1,  1,  0, -1};   // изменение номера строки
const int dy[8] = { 0,  1, 1, 1, 0, -1, -1, -1};   // изменение номера столбца

for (int k = 0; k < 8; k++) {
    int ni = i + dx[k], nj = j + dy[k];
    if (ni < 0 || ni >= n || nj < 0 || nj >= m) continue;   // вышли за поле
    if (grid[ni][nj] == '#') continue;                       // стена
    // ... переход из (i, j) в (ni, nj)
}

4.2. Задача «Поставки еды и две шахты» 3:12:31

Условие (как оно звучало). Есть $N$ поставок еды; поставка $i$ имеет вид $a_i \in \{0, 1, 2\}$. Есть две шахты. Каждую поставку нужно отдать ровно одной из шахт. Когда шахта получает поставку, она даёт уголь: смотрим на эту поставку и две предыдущие, которые получила та же шахта. Если все три вида различны — $3$ тонны; если встречаются два вида — $2$ тонны; если все одного вида — $1$ тонна. Шахте можно отдать любое число поставок. Нужно максимизировать суммарное количество тонн (3:14:49).

3:15:38 Условие приходится повторить: шахты две; каждой шахте поставки даются в порядке прихода; сколько поставок получит каждая — неважно, важна только суммарная прибыль в тоннах. Отдельный ответ с самим распределением не требуется — достаточно числа.

  1. Определение. 3:16:56 Хочется $dp_i$ — максимум угля на первых $i$ поставках. Но этого мало: прибавку от следующей поставки определяют две последние поставки каждой шахты, а в $dp_i$ их нет. Поэтому в состояние добавляют «хеш» — число, кодирующее эти четыре вида. 3:19:41 Хранить нужно по две последние поставки (а не три), потому что новая поставка в сумме с двумя предыдущими и образует три. Итого $dp_{i,h}$ — максимум угля, когда обработаны первые $i$ поставок, а состояние шахт описывает число $h$.

  2. Размер состояния. 3:20:15 Каждая из четырёх позиций (две в первой шахте, две во второй) принимает одно из трёх значений, поэтому лектор получает $3^4 = 81$ состояние и асимптотику $O(81 \cdot N)$. 3:21:06 Лектор договаривается обозначать такие константы просто $K$: «$O(Kn)$», где $K$ — размер набора состояний.

  3. База. 3:22:24 Для удобства добавляют нулевую поставку; $dp_{0,h} = 0$ при пустых шахтах.

  4. Переход. 3:26:17 Проще считать «вперёд». Если известно $dp_{i,h}$, то поставку $a_{i+1}$ можно отдать первой шахте или второй, и состояние $h$ меняется на $h'$ (в соответствующей шахте старый «предпоследний» вид забывается, последний сдвигается, на его место встаёт новый). Прибавка угля равна числу различных видов среди новой поставки и двух предыдущих в этой шахте, и $dp_{i+1,h'}$ берётся как максимум по всем таким переходам. 3:28:21 Двух переходов именно два (какой шахте отдать), хеши у них получаются разные.

  5. Ответ. Максимум по всем $h$ из $dp_{N,h}$.

Исправление: нужна пометка «пусто». В начале у шахты ещё нет двух поставок, и это нельзя изобразить видом $0$, $1$ или $2$ — иначе пустая шахта выглядела бы как шахта, получившая поставки вида $0$, и прибавка угля считалась бы неверно. Правильно кодировать каждую позицию четырьмя значениями ($0, 1, 2$ и «пусто»), то есть $4^4 = 256$ состояний. Именно это число участник называет в конце семинара (4:31:38), и оно по-прежнему мало: $256 \cdot N \cdot 2$ переходов.
C++
// Виды 0..2, «пусто» = 3. Состояние шахты — две последние поставки (p, q), q — самая свежая.
// Состояние обеих шахт: h = ((p1*4 + q1)*4 + p2)*4 + q2  (0..255)
int gain(int p, int q, int x) {                // сколько тонн даст поставка x
    int mask = 1 << x;                         // множество различных видов
    if (q != 3) mask |= 1 << q;
    if (p != 3) mask |= 1 << p;
    return __builtin_popcount(mask);
}

int solve(const vector<int>& a) {
    vector<int> dp(256, -1), nd(256);
    dp[((3 * 4 + 3) * 4 + 3) * 4 + 3] = 0;     // обе шахты пусты
    for (int x : a) {
        fill(nd.begin(), nd.end(), -1);
        for (int h = 0; h < 256; h++) if (dp[h] >= 0) {
            int q2 = h % 4, p2 = h / 4 % 4, q1 = h / 16 % 4, p1 = h / 64;
            // поставка x уходит в первую шахту
            int h1 = ((q1 * 4 + x) * 4 + p2) * 4 + q2;
            nd[h1] = max(nd[h1], dp[h] + gain(p1, q1, x));
            // поставка x уходит во вторую шахту
            int h2 = ((p1 * 4 + q1) * 4 + q2) * 4 + x;
            nd[h2] = max(nd[h2], dp[h] + gain(p2, q2, x));
        }
        dp = nd;
    }
    return *max_element(dp.begin(), dp.end());
}
Проверка. Код сверен с полным перебором всех $2^N$ способов раздать поставки на 5 000 случайных массивов длины до 12: максимумы совпали. Асимптотика: $O(256 \cdot N)$ по времени, $O(256)$ по памяти (хранится только текущий слой).
Из чата. 4:32:38 Участник пишет решение, в котором на каждый переход создаётся std::set из трёх видов, чтобы посчитать число различных. Лектор объясняет, почему это замедляет программу: создание и удаление контейнера на каждый из десятков миллионов переходов — дорогие операции с памятью, их не окупает размер три. Лучше считать различные виды маской или несколькими сравнениями, как в функции gain выше.

4.3. Задача: сколько неубывающих массивов с заданной суммой 3:31:40

Условие (как оно звучало). Даны числа $N$ и $K$. Нужно найти количество массивов $a_1, a_2, \dots, a_m$ (длина $m$ любая) таких, что $\sum a_i = N$ и $a_1 = K$. Ответ выводится по модулю $10^9 + 7$ (3:45:25). Чтобы не углубляться в комбинаторику, лектор сразу добавляет условие, что массив не убывает (3:34:16).

3:34:45 Ход обсуждения. Сначала лектор пробует состояние «длина $i$ и последний элемент $x$»; сразу выясняется (3:37:25), что нужен ещё один параметр — сумма. Вводится $S$ — остаток суммы. Получается состояние из трёх параметров $dp_{i,x,S}$, а переход перебирает предыдущий последний элемент $x_1$ от $K$ до $x$ — слишком много (3:43:57 лектор называет такое решение «самым тупым» и ищет, как его ускорить). Дальше (3:46:00) замечено, что порядок циклов можно менять: фиксируем остаток $S$ и переходим от $x$ к $x+1$, и при этом в сумму по $x_1$ добавляется всего один новый член. Идея — получить префиксные суммы по $x_1$. 3:49:29 Но параметры $i$, $x$ и $S$ меняются одновременно, и сделать префиксные суммы напрямую не получается; обсуждение переходит к замене смысла параметра $S$ (3:51:33), но до готового решения не доходит, и семинар переходит к следующей задаче.

Собственное решение (в лекции не доведено до конца). Заметим, что длина $m$ никого не интересует, а неубывающий массив с первым элементом $K$ полностью задаётся множеством остальных элементов (с повторениями), каждый из которых $\ge K$, а сумма равна $r = N - K$. Это классическая задача о количестве способов набрать сумму $r$ «монетами» номиналов $K, K+1, \dots, r$, где каждый номинал можно брать сколько угодно раз и порядок не важен. Рассматриваем номиналы по возрастанию: $$ways_x[s] = ways_{x-1}[s] + ways_x[s - x].$$ Первое слагаемое — «монету $x$ не берём», второе — «берём хотя бы одну». Внутренний цикл по $s$ идёт от $x$ вверх, поэтому массив достаточно хранить один:
C++
const long long MOD = 1e9 + 7;

long long count_arrays(int n, int k) {
    if (k > n) return 0;
    int r = n - k;                          // сумму r нужно набрать числами >= k
    vector<long long> ways(r + 1, 0);
    ways[0] = 1;                            // пустой набор (массив из одного элемента k)
    for (int x = k; x <= r; x++)            // номинал x
        for (int s = x; s <= r; s++)
            ways[s] = (ways[s] + ways[s - x]) % MOD;
    return ways[r];
}
Время $O(N^2)$, память $O(N)$. Именно здесь «в сумму добавляется один новый член» из рассуждения лектора: при переходе от номинала $x-1$ к $x$ в $ways[s]$ добавляется единственное слагаемое $ways_x[s-x]$. Проверено полным перебором для всех $1 \le K \le N \le 25$; для $N = 10$, $K = 2$ ответ $7$.

4.4. Задача «Пловцы и дорожки» 3:56:26

Условие (как оно звучало). Есть $N$ дорожек в бассейне и $S$ пловцов; дан массив $a_1, \dots, a_N$ — сколько людей плавает на каждой дорожке, $\sum a_i = S$. Нужно переразбить пловцов по дорожкам так, чтобы на соседних дорожках количества людей отличались не более чем на единицу ($|b_i - b_{i+1}| \le 1$). Порядок пловцов в «ленте» не меняется: мы можем лишь сдвигать разметку дорожек. Каждый раз, когда границу дорожки сдвигают, «потревоженные» люди дают штраф; человек, которого потревожили дважды (две границы пересекли его), считается дважды. Нужно найти минимальный суммарный штраф. Ограничения: $N \le 400$, $S \le 1500$ (4:08:52).

3:57:49 Примеры лектора. Для массива $(3, 0, 1)$ подходит $(2, 1, 1)$: один человек перешёл на соседнюю дорожку, штраф $1$. Для $(8, 0, 2)$ подходит $(4, 3, 3)$; 4:00:12 штраф получается $5$, а не $4$, потому что одного из людей потревожили дважды — через него прошли сразу две границы.

Как считается штраф: сдвиги границ.

Штраф — сумма сдвигов границ: |P₁−Q₁| + |P₂−Q₂|было 8 0 2802стало 4 3 3433|8 − 4| = 4|8 − 7| = 1итого 4 + 1 = 5 «потревоженных» — как в примере на лекции
Каждая граница дорожки сдвигается с префиксной суммы P на Q; человек, оказавшийся между старым и новым положением границы, считается один раз за каждую такую границу.
Развёрнутое объяснение. Штраф удобно считать по границам. Границу между дорожками $i$ и $i+1$ описывает количество людей слева от неё: до переразбиения это $P_i = a_1 + \dots + a_i$, после — $Q_i = b_1 + \dots + b_i$. Граница сдвинулась на $|P_i - Q_i|$ людей, и каждый такой человек был «потревожен» именно этой границей. Человек, оказавшийся между старым и новым положением двух границ, считается дважды — как в условии. Поэтому $$\text{штраф} = \sum_{i=1}^{N-1} |P_i - Q_i|.$$ Проверка по примерам лектора: для $(8,0,2) \to (4,3,3)$: $P = (8,8)$, $Q = (4,7)$, штраф $4 + 1 = 5$. Для $(3,0,1) \to (2,1,1)$: $P = (3,3)$, $Q = (2,3)$, штраф $1$.

Как ограничить размер состояния.

4:03:15 Сколько людей может быть на одной дорожке. Допустим, на самой загруженной дорожке стоит $m$ человек. Соседние дорожки отличаются не более чем на единицу, поэтому на дорожках слева или справа от неё в лучшем случае $m-1, m-2, \dots$ человек (дальше, возможно, нули). Значит, сумма $S$ не меньше $m + (m-1) + \dots + 1 \approx m^2 / 2$, откуда $m \lesssim \sqrt{2S}$. 4:05:06 Если дорожек мало, то оценка другая: порядка $S/N + N/2$ (лектор называет её для случая «мало дорожек», а общую оценку — «корнем из $S$ плюс единица»). 4:08:08 Вывод лектора: не нужно перебирать все $S$ значений для последней дорожки, достаточно порядка $\sqrt S$.

Собственное уточнение. Точную границу легко вычислить: если максимум равен $m$, то минимальная сумма — это $\sum_{j=0}^{N-1} \max(m - j, 0)$ (максимум стоит с краю, дальше убывание на единицу). Наибольшее $m$ с такой суммой $\le S$ — и есть верхняя граница числа людей на дорожке. Для $N = 400$, $S = 1500$ это $54$, для $N = 10$, $S = 1500$ — $154$.

Состояние и переход.

4:13:37 Состояние: $dp_{i,s,l}$ — минимальный штраф, если расставлены первые $i$ дорожек, в них суммарно $s$ человек, а на последней стоит $l$ человек. 4:14:07 Переход: на следующей дорожке будет $l-1$, $l$ или $l+1$ человек — три варианта, отсюда новая сумма $s + l'$. 4:15:48 Параметр «сумма» нужен, чтобы знать, где сейчас проходит граница после первых $i$ дорожек, и посчитать, на сколько людей её пришлось сдвинуть относительно исходной.

4:17:48 Лектор считает штраф перехода через префиксные суммы исходного массива и бинарный поиск позиции границы (4:18:05), затем (4:22:12) называет наивный вариант с подсчётом «руками» стоимостью $O(N)$ на переход и предлагает заранее подсчитать штрафы сдвигов в таблице (4:24:35). Асимптотика после этого — порядка $O(N \cdot S \cdot \sqrt S)$ (4:26:49); лектор отмечает, что фактически состояний меньше из-за ограничения на число людей. 4:27:29 Подставляя оценки, получаем порядка $10^8$ операций, которые по оценке лектора укладываются в ограничение по времени.

Развёрнутое объяснение. Формула штрафа через границы делает переход мгновенным. Если после $i$ дорожек в них $s$ человек, то при выборе количества людей $l'$ на следующей дорожке штраф за границу после дорожки $i$ добавляется равным $|P_i - s|$ — без бинарного поиска и без таблиц. $$dp_{i,s',l'} = \min_{l \in \{l'-1,\ l',\ l'+1\}} dp_{i-1,\,s'-l',\,l} + |P_i - s'|.$$ Ответ: $\min_l dp_{N,S,l}$ (для $i = N$ штраф $|P_N - S| = 0$ автоматически). База: для первой дорожки $dp_{1,l,l} = |P_1 - l|$ при любом допустимом $l$.
C++
int min_penalty(const vector<int>& a) {
    const int INF = 1e9;
    int n = a.size(), S = accumulate(a.begin(), a.end(), 0);
    vector<int> P(n + 1, 0);                     // префиксные суммы исходных дорожек
    for (int i = 0; i < n; i++) P[i + 1] = P[i] + a[i];
    int cap = 0;                                 // верхняя граница людей на дорожке
    for (;; cap++) {
        long long sum = 0;
        for (int j = 0; j < n; j++) sum += max(cap + 1 - j, 0);
        if (sum > S) break;
    }
    // dp[s][l]: расставлены первые i дорожек, в них s человек, на последней l
    vector<vector<int>> dp(S + 1, vector<int>(cap + 1, INF)), nd;
    for (int l = 0; l <= min(cap, S); l++) dp[l][l] = abs(P[1] - l);
    for (int i = 2; i <= n; i++) {
        nd.assign(S + 1, vector<int>(cap + 1, INF));
        for (int s = 0; s <= S; s++)
            for (int l = 0; l <= cap; l++) {
                if (dp[s][l] >= INF) continue;
                for (int nl = max(l - 1, 0); nl <= min(l + 1, cap); nl++) {
                    int ns = s + nl;
                    if (ns > S) continue;
                    nd[ns][nl] = min(nd[ns][nl], dp[s][l] + abs(P[i] - ns));
                }
            }
        dp.swap(nd);
    }
    int best = INF;
    for (int l = 0; l <= cap; l++) best = min(best, dp[S][l]);
    return best;
}
Время $O(N \cdot S \cdot cap)$ с тремя переходами на состояние, память $O(S \cdot cap)$ за счёт двух слоёв. Проверено: на $(3,0,1)$ и $(8,0,2)$ получаются $1$ и $5$; результат совпал с перебором всех допустимых $b$ на 4 000 случайных массивов; на максимальных данных ($N = 400$, $S = 1500$) программа работает около $0{,}1$ с.
Из чата. 4:30:01 Что хранится в этом ДП? Штраф. 4:30:18 Участник предлагает хранить для каждой дорожки число людей, которые должны через неё пройти. Лектор показывает трудность: на дорожку что-то приходит, а потом от неё что-то уходит, поэтому «сколько должно перейти» не определяется независимо для каждой дорожки; удобнее считать по границам, как выше.

5. Итог занятия 4:28:13

Общее наблюдение по задачам: во всех трёх случаях исходная идея — «ответ на префиксе» — упиралась в нехватку информации в состоянии, и решение появлялось после добавления нужных параметров (последние поставки, остаток суммы, последняя дорожка и сумма). Именно так устроены и задачи лекции: НВП и строки без «11».

Сводка задач занятия.

ЗадачаСостояниеПереходСложность
Строки без «11»$dp_{i,c}$, $c$ — последний символ$dp_{i,1} = dp_{i-1,0}$; $dp_{i,0} = dp_{i-1,0} + dp_{i-1,1}$$O(N)$
НВП$dp_i$ — конец ровно в $i$ / $d_\ell$ — наименьший конец длины $\ell$максимум по $j < i$ с $a_j < a_i$ / замена первого $d \ge a$ (lower_bound)$O(n^2)$ / $O(n \log n)$
НОП$dp_{i,j}$ — НОП префиксов$dp_{i-1,j-1}+1$ при равенстве, иначе $\max$ из двух соседей$O(nm)$
Две шахты$dp_{i,h}$, $h$ — последние две поставки каждой шахты ($4^4$ значений)отдать поставку первой или второй шахте$O(256 \cdot N)$
Неубывающие массивы с суммой$ways[s]$ при допустимых «монетах» $\ge K$$ways[s] \mathrel{+}= ways[s - x]$$O(N^2)$
Пловцы$dp_{i,s,l}$$l' \in \{l-1, l, l+1\}$, штраф $|P_i - s'|$$O(N \cdot S \cdot \sqrt S)$