Files
eduplan-cpp-eltex/lessons/D5_algo.md
T

392 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D5, часть 1. Динамическое программирование (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Когда ДП вообще применимо
ДП применимо, если задача одновременно даёт два свойства:
- **Оптимальная подструктура** — оптимальное решение всей задачи собирается из оптимальных
решений подзадач (для LCS: если последние символы совпали, LCS всей строки — это LCS без
последних символов плюс 1; оптимум подзадачи не пересчитывается заново).
- **Перекрывающиеся подзадачи** — одни и те же подзадачи встречаются многократно при наивной
рекурсии (для чисел Фибоначчи `fib(5)` вызывает `fib(3)` дважды, `fib(2)` трижды и так
далее — без запоминания результат пересчитывается экспоненциально много раз).
Если подзадачи не пересекаются (например, обычное бинарное дерево решений без общих
поддеревьев), рекурсия остаётся рекурсией — мемоизация не ускоряет её, кэшировать нечего.
Если нет оптимальной подструктуры (жадный локальный выбор не гарантирует глобальный оптимум),
ДП даёт неверный ответ — тогда нужен либо перебор, либо доказанный жадный алгоритм.
**Факты для карточек**
- base | Два условия применимости ДП? — оптимальная подструктура + перекрывающиеся подзадачи
- core | Почему наивный рекурсивный `fib(n)` работает за экспоненциальное время? — одни и те же подзадачи (`fib(k)` для одного и того же k) пересчитываются заново много раз
- core | Что ломается, если применить ДП-переход к задаче без оптимальной подструктуры? — переход не отражает реальную зависимость оптимумов, ответ будет неверным независимо от таблицы
Почему дальше: раз задача сводится к подзадачам, нужно определить, что именно является
«состоянием» подзадачи и как один переход выражается через предыдущие.
## 2. Состояние, переход, мемоизация vs табуляция
**Состояние** — минимальный набор параметров, однозначно определяющий подзадачу (для рюкзака:
номер предмета + оставшийся вес; для LCS: позиции в обеих строках). **Переход** — формула,
выражающая ответ для состояния через ответы уже решённых состояний.
Два способа посчитать одно и то же:
- **Мемоизация (top-down)** — обычная рекурсия по формуле перехода, но перед вычислением
проверяем кэш (`unordered_map` или массив), а после вычисления кладём результат в кэш.
Считает только реально нужные состояния, но каждый вызов — это кадр стека.
- **Табуляция (bottom-up)** — заполняем таблицу итеративно от базовых случаев к целевому,
без рекурсии вообще. Требует явно определить порядок заполнения (что должно быть посчитано
раньше, чем понадобится).
```c++
// мемоизация
long long fib_memo(int n, std::vector<long long>& cache) {
if (n <= 1) return n;
if (cache[n] != -1) return cache[n];
return cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache);
}
// табуляция
long long fib_tab(int n) {
std::vector<long long> dp(n + 1);
dp[0] = 0; if (n >= 1) dp[1] = 1;
for (int i = 2; i <= n; ++i) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
```
Оба варианта — O(число состояний) по времени (каждое состояние считается ровно один раз, а
не экспоненциально много) и O(число состояний) по памяти (нужно где-то хранить ответ для
каждого состояния). У мемоизации есть дополнительный риск, которого нет у табуляции:
рекурсия на входе с большим n (например, `fib_memo(1'000'000, ...)`) кладёт по кадру на
каждый уровень — стек ограничен (типично несколько МБ), и глубокая рекурсия падает с
переполнением стека (SIGSEGV) там, где итеративная табуляция отработает без проблем.
**Ловушки**
- Забыть проверить кэш перед вычислением в мемоизации → рекурсия становится обычной наивной
→ возврат к экспоненциальному времени, видно по таймауту на больших n.
- Взять слишком глубокую рекурсию для мемоизации (n порядка 10⁵–10⁶) → переполнение стека →
падение по SIGSEGV, а не по логической ошибке.
**Факты для карточек**
- base | Сложность по времени мемоизации/табуляции? — O(число состояний)
- core | Чем мемоизация рискует, а табуляция — нет? — переполнением стека при большой глубине рекурсии
- core | Что нужно определить в ДП-задаче до написания кода? — состояние (параметры подзадачи) и переход (формула через уже решённые состояния)
Почему дальше: раз табуляция явно хранит таблицу, логично спросить, всегда ли нужна вся
таблица целиком, или память можно ужать.
## 3. Уменьшение памяти: O(n) → O(1) или O(n)
Если переход использует только последние 1–2 строки/значения таблицы, всю таблицу хранить не
нужно — достаточно «скользящего окна» из нужного числа последних значений.
Лестница (Climbing Stairs: сколько способов подняться на n ступеней шагами по 1 или 2):
переход `dp[i] = dp[i-1] + dp[i-2]` использует только два предыдущих значения — O(1) памяти.
```c++
int climb_stairs(int n) { // способов дойти до ступени n
if (n <= 2) return n;
int prev2 = 1, prev1 = 2;
for (int i = 3; i <= n; ++i) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
House Robber (нельзя грабить два соседних дома подряд, максимизировать сумму): тот же приём.
`dp[i] = max(dp[i-1], dp[i-2] + nums[i])` — не ограбить дом i (взять лучший результат без
него) или ограбить (взять лучший результат через один плюс текущий дом).
```c++
int rob(const std::vector<int>& nums) {
int prev2 = 0, prev1 = 0;
for (int x : nums) {
int curr = std::max(prev1, prev2 + x);
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
Рюкзак 0/1 ужимается не до O(1), а до O(W) (одна строка по весу вместо таблицы n×W) — переход
там зависит от целой предыдущей строки, а не от 1–2 чисел; подробно в следующем разделе.
**Факты для карточек**
- base | Сложность по памяти Climbing Stairs при развёрнутых `prev1`/`prev2`? — O(1)
- core | Почему рюкзак 0/1 нельзя ужать до O(1), только до O(W)? — переход `dp[i][w]` зависит от целой предыдущей строки по весу, а не от 1–2 соседних чисел
Почему дальше: рюкзак — задача, где переход по весу нельзя писать «вперёд» без потери
корректности; разберём, почему именно назад.
## 4. Рюкзак 0/1: почему обратный проход по весу
Задача: n предметов с весом `weight[i]` и ценностью `value[i]`, вместимость W, каждый предмет
берётся не более одного раза, максимизировать суммарную ценность.
```c++
int knapsack01(const std::vector<int>& weight, const std::vector<int>& value, int W) {
std::vector<int> dp(W + 1, 0);
for (size_t i = 0; i < weight.size(); ++i)
for (int w = W; w >= weight[i]; --w) // обратный проход!
dp[w] = std::max(dp[w], dp[w - weight[i]] + value[i]);
return dp[W]; // O(n·W) время, O(W) память
}
```
Если одномерный массив `dp[w]` переиспользуется для всех предметов подряд (без второго
измерения по номеру предмета), то при проходе весов **вперёд** (`w` от `weight[i]` до `W`)
значение `dp[w - weight[i]]`, использованное в переходе, могло уже быть обновлено этим же
предметом i на текущей итерации — то есть предмет i фактически используется дважды, и задача
незаметно превращается в рюкзак с неограниченным числом копий предмета (unbounded knapsack).
Проход **назад** гарантирует, что `dp[w - weight[i]]` берётся из состояния «до предмета i» —
старое значение ещё не тронуто текущей итерацией внешнего цикла.
**Ловушки**
- Пройти веса вперёд в одномерном 0/1-рюкзаке → предмет учитывается несколько раз → ответ
завышен относительно эталона, видно на тесте с одним дорогим предметом.
- Перепутать размер массива (`W` вместо `W+1`) → индекс `dp[W]` вне границ → UB/ASAN heap-buffer-overflow.
**Факты для карточек**
- base | Временная сложность 0/1-рюкзака с одномерным массивом? — O(n·W)
- core | Почему в одномерном 0/1-рюкзаке веса обходят от W к weight[i], а не наоборот? — иначе `dp[w-weight[i]]` уже обновлён текущим предметом в этой же итерации, предмет посчитается дважды
- deep | Как называется вариант рюкзака, в который случайно превращается 0/1-рюкзак при прямом проходе весов? — unbounded knapsack (неограниченное число копий предмета)
Почему дальше: та же ловушка с порядком циклов — не по направлению, а по тому, что снаружи, а
что внутри — по-другому проявляется в задаче про размен монет.
## 5. Монеты: порядок циклов меняет смысл ответа
Coin Change (минимальное число монет для суммы `amount`, каждая монета берётся неограниченное
число раз — это уже unbounded knapsack):
```c++
int coin_change(const std::vector<int>& coins, int amount) {
std::vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int a = 1; a <= amount; ++a)
for (int c : coins)
if (c <= a && dp[a - c] != INT_MAX)
dp[a] = std::min(dp[a], dp[a - c] + 1);
return dp[amount] == INT_MAX ? -1 : dp[amount]; // O(amount · coins.size())
}
```
Для минимума порядок циклов не влияет на корректность — минимум не зависит от того, в каком
порядке предметы разрешено переиспользовать. Но для **подсчёта числа способов** (Coin Change
II) порядок циклов меняет сам смысл ответа:
```c++
// количество КОМБИНАЦИЙ: {1,2} и {2,1} — один и тот же способ
long long change_combinations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int c : coins) // внешний цикл — монета
for (int a = c; a <= amount; ++a)
dp[a] += dp[a - c];
return dp[amount];
}
// количество ПЕРЕСТАНОВОК: {1,2} и {2,1} — разные способы
long long change_permutations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int a = 1; a <= amount; ++a) // внешний цикл — сумма
for (int c : coins)
if (c <= a) dp[a] += dp[a - c];
return dp[amount];
}
```
Монета снаружи цикла фиксирует «в каком порядке монеты рассматриваются» раз и навсегда для
всех сумм — эквивалентные по составу, но переставленные последовательности монет схлопываются
в один и тот же результат, отсюда комбинации. Сумма снаружи цикла для каждого `a` заново
перебирает все монеты как «последнюю добавленную» — одна и та же комбинация монет, добавленная
в разном порядке, считается несколько раз, отсюда перестановки.
**Ловушки**
- Перепутать порядок циклов в задаче «сколько способов» → вместо количества комбинаций
получается количество перестановок (число сильно больше ожидаемого) → расходится с
эталонным ответом на тесте с 2+ разными монетами.
**Факты для карточек**
- base | Сложность Coin Change (минимум монет) по времени? — O(amount · число_номиналов)
- core | Какой порядок циклов в Coin Change II даёт число комбинаций, а какой — перестановок? — монета снаружи/сумма внутри → комбинации; сумма снаружи/монета внутри → перестановки
Почему дальше: рюкзак и монеты — одномерные ДП по числу. Следующий класс задач — ДП по двум
строкам сразу, с двумерной таблицей.
## 6. LCS и Edit Distance: таблица (n+1)×(m+1)
**LCS** (длиннейшая общая подпоследовательность двух строк, не обязательно непрерывная):
```c++
int lcs_length(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::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
: std::max(dp[i - 1][j], dp[i][j - 1]);
return dp[n][m]; // O(n·m) время и память
}
```
Строка 0 и столбец 0 — базовый случай «одна из строк пустая», отсюда размер `(n+1)×(m+1)`, а
не `n×m`. Если последние символы совпадают, они точно входят в оптимальную LCS — переход к
`dp[i-1][j-1]+1`. Если нет — общая подпоследовательность не может использовать оба последних
символа одновременно, берём лучшее из «отбросить последний символ a» и «отбросить последний
символ b».
**Edit Distance** (минимум вставок/удалений/замен, чтобы превратить строку a в b):
```c++
int edit_distance(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1));
for (int i = 0; i <= n; ++i) dp[i][0] = i; // удалить все i символов
for (int j = 0; j <= m; ++j) dp[0][j] = j; // вставить все j символов
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 + std::min({dp[i - 1][j - 1], // замена
dp[i - 1][j], // удаление
dp[i][j - 1]}); // вставка
return dp[n][m]; // O(n·m) время и память
}
```
Тот же размер таблицы `(n+1)×(m+1)` и та же причина: строка/столбец 0 — превращение пустой
строки в префикс другой строки чисто вставками или удалениями.
**Ловушки**
- Завести таблицу `n×m` вместо `(n+1)×(m+1)` → нет места для базового случая «пустой префикс»
→ неверные значения на границе или выход за границы массива.
- В Edit Distance забыть инициализировать нулевую строку/столбец → сравнение с мусорными
значениями → неверный ответ без падения программы (тихая ошибка).
**Факты для карточек**
- base | Размер таблицы LCS/Edit Distance для строк длины n и m? — (n+1)×(m+1)
- base | Временная сложность LCS и Edit Distance? — O(n·m)
- core | Почему при несовпадении последних символов в LCS берут max(dp[i-1][j], dp[i][j-1])? — оба последних символа одновременно в общую подпоследовательность войти не могут, значит хотя бы один из них можно отбросить без потери оптимальности
Почему дальше: LCS/Edit Distance решают за O(n·m) через явную таблицу. Следующая классическая
задача — LIS — решается за O(n²) той же схемой, но улучшается до O(n log n) совсем другим
приёмом.
## 7. LIS (НВП): O(n²) и O(n log n)
Длиннейшая строго возрастающая подпоследовательность массива (не обязательно непрерывная).
Сигнатура из `tasks/05_lis`: `int lis_length(const std::vector<int>& a)`.
**Наивное O(n²)**: `dp[i]` — длина LIS, заканчивающейся ровно на элементе `a[i]`.
```c++
int lis_naive(const std::vector<int>& a) {
int n = a.size();
std::vector<int> dp(n, 1);
int best = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j)
if (a[j] < a[i]) dp[i] = std::max(dp[i], dp[j] + 1);
best = std::max(best, dp[i]);
}
return best; // O(n²)
}
```
На 100 000 элементах (ограничение задачи `05_lis`) это 10¹⁰ операций — не укладывается в 2
секунды внутреннего теста, нужен другой алгоритм.
**O(n log n) через массив «хвостов»**:
```c++
int lis_length(const std::vector<int>& a) {
std::vector<int> tails; // tails[k] = минимальный возможный последний
// элемент возрастающей подпоследовательности длины k+1
for (int x : a) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x); // x больше всех хвостов — новая длина
else *it = x; // заменить первый хвост ≥ x на x
}
return tails.size(); // O(n log n)
}
```
`tails` не хранит саму подпоследовательность — только минимально возможное значение
последнего элемента для каждой достижимой длины. `tails` всегда отсортирован по построению:
если `x` заменяет элемент на позиции `it`, новое значение `x` меньше старого (иначе
`lower_bound` нашёл бы другую позицию) и больше всех элементов слева от `it` (иначе
`lower_bound` остановился бы раньше) — порядок не нарушается ни при замене, ни при добавлении
в конец. `lower_bound` ищет первый элемент ≥ x, что даёт строго возрастающую LIS (`task.md`
требует именно строгую); для нестрогой (неубывающей) подпоследовательности нужен
`upper_bound`. Длина `tails` в конце равна длине LIS, хотя сам массив LIS обычно не является.
**Факты для карточек**
- base | Наивная сложность LIS через dp[i]? — O(n²)
- base | Сложность LIS через массив хвостов и бинарный поиск? — O(n log n)
- core | Что хранится в `tails[k]`? — минимально возможный последний элемент возрастающей подпоследовательности длины k+1
- core | Почему `tails` остаётся отсортированным после каждой замены/добавления? — новое значение всегда меньше заменяемого и больше всех элементов левее позиции, найденной `lower_bound`
- core | `lower_bound` или `upper_bound` нужен для строго возрастающей LIS? — `lower_bound`
- deep | Сколько операций у наивного O(n²) LIS на 100 000 элементах и почему это не укладывается в тест? — порядка 10¹⁰, тест роняет прогон при времени > 2 с
Почему дальше: LIS через хвосты — пример, где ДП-таблица заменяется одним отсортированным
массивом и бинарным поиском; та же идея (жертвовать явной таблицей ради log-фактора)
встречается и в других задачах на подпоследовательности.
Ссылки на задачи этого дня: `tasks/05_lis` — реализовать `lis_length` за O(n log n); наивная
O(n²) версия не пройдёт по времени на 100 000 элементах.
<details>
<summary>Проверь себя</summary>
1. Массив весов `[1, 3, 4]`, ценностей `[15, 20, 30]`, вместимость `W=4`. Какой ответ даст
0/1-рюкзак и почему это не «взять предметы 1 и 2» (вес 1+3=4)?
<details><summary>Ответ</summary>35 — рюкзак действительно может выбрать предметы с весами
1 и 3 (суммарный вес 4, ценность 15+20=35); предмет весом 4 отдельно даёт только 30, что
меньше. Ответ 35, а не 30 — если получилось 30, значит в переборе не сравнили оба варианта
заполнения веса 4.</details>
2. Почему для Coin Change II (подсчёт числа способов, не минимума) важно, какой цикл
снаружи — монета или сумма, — а для Coin Change (минимум монет) не важно?
<details><summary>Ответ</summary>Минимум не зависит от порядка, в котором монеты
рассматриваются — это просто оптимум по всем комбинациям. Подсчёт способов чувствителен к
порядку: монета снаружи фиксирует относительный порядок монет и схлопывает перестановки
одной комбинации в один результат (комбинации); сумма снаружи пересчитывает каждую монету
как потенциально «последнюю» для каждой суммы заново, из-за чего одна комбинация
монет считается один раз на каждую перестановку (перестановки).</details>
3. Для массива `[3, 1, 4, 1, 5, 9, 2, 6]` пройдите LIS через `tails` вручную. Чему равен
`tails` после обработки первых пяти элементов (`3, 1, 4, 1, 5`)?
<details><summary>Ответ</summary>`[1, 4, 5]`: 3 → `[3]`; 1 заменяет 3 → `[1]`; 4 больше
всех → `[1,4]`; 1 заменяет первый элемент ≥1 (сам 1) → `[1,4]` без изменений; 5 больше
всех → `[1,4,5]`.</details>
4. Мемоизация `fib_memo(n, cache)` вызвана с n = 200 000. Почему это скорее упадёт по
SIGSEGV, чем отработает медленно?
<details><summary>Ответ</summary>Каждый рекурсивный вызов держит кадр стека, пока не
вернётся его результат; глубина рекурсии здесь равна n, а размер стека потока ограничен
(типично несколько МБ) — при n=200 000 кадров стек переполняется раньше, чем вычисление
успевает завершиться. Табуляция (`fib_tab`) того же n отработает без проблем — там нет
рекурсии, только цикл.</details>
</details>
## Материалы
- cppreference, `std::vector` — https://en.cppreference.com/w/cpp/container/vector
- cppreference, `std::lower_bound` — https://en.cppreference.com/w/cpp/algorithm/lower_bound
- cppreference, `std::max` — https://en.cppreference.com/w/cpp/algorithm/max
- cppreference, `std::min` — https://en.cppreference.com/w/cpp/algorithm/min
- cppreference, лимиты `<climits>` (`INT_MAX`) — https://en.cppreference.com/w/cpp/types/climits