О чём статья
Динамическое программирование на ранних задачах кажется туманом — «вроде бы как-то надо помнить ответы для подзадач». На самом деле за DP стоит очень простая дисциплина: описать состояние, записать переход, инициализировать базу, выбрать правильный порядок обхода. Когда эти четыре пункта формулируются явно, задача либо решается за пять минут, либо сразу видно, какого пункта не хватает.
Эта статья — про базовый каркас: одномерный (линейный) и двумерный DP. Это два самых частых формата на полосе сложности CF 1100–1700, и через них же проходят почти все более сложные техники — DP на дереве, DP с подмножествами состояний, DP с двумя указателями. Цель — довести шаблон до автоматизма, чтобы на следующих темах усложнение шло только в новых элементах состояния, а каркас уже не отвлекал.
Когда применять: сигналы в условии
DP уместен, когда задачу можно разбить на подзадачи меньшего размера, ответ для которых однозначно определяет ответ исходной. Несколько узнаваемых сигналов в тексте задачи:
- «Сколько способов / сколькими способами …». Подсчётные задачи почти всегда вписываются в DP: state — текущая позиция / параметр, переход — сумма по способам.
- «Минимальная / максимальная стоимость, при которой …». Оптимизационные DP — state описывает «что уже зафиксировано», переход — выбор очередного шага.
- «Можно ли получить … из …». Логические DP с булевым ответом: state — достижимая комбинация параметров.
- Маленький параметр, по которому идёт перебор. Если в условии есть параметр , или массив длины , или сетка , и при этом задача на оптимизацию или подсчёт — DP выходит на первое место в списке кандидатов.
- «Каждый элемент берём не больше одного раза» / «два соседа не выбираются вместе» / «сумма ». Локальное ограничение, которое в greedy ломается на простом контрпримере, а в DP корректно учитывается через состояние.
Если ничего из этого нет, DP, скорее всего, не нужен. Если есть один сигнал — стоит попробовать. Если два-три — почти точно DP, остаётся определить размерность состояния.
Базовый шаблон
Идея в одну фразу
Завести функцию (или массив) , в которой — ответ на подзадачу с состоянием . Заполнять в порядке, при котором каждое значение зависит только от уже посчитанных.
Псевдокод (линейный DP)
определить размерность состояния и его смысл
завести массив f нужной длины, заполнить «нейтральным» значением
проставить базу: f[начальные состояния] ← конкретные числа
для каждого состояния s в правильном порядке:
f[s] ← объединение(f[s'], …) по всем s' → s переходам
вывести f[требуемое состояние]
Сложность шаблона: .
Четыре вопроса перед написанием
Любой DP-шаблон сводится к ответам на четыре вопроса. Их полезно явно проговорить перед тем, как написать первую строчку кода:
- Что такое состояние? Какой набор параметров полностью описывает «текущую ситуацию» так, что ответ для неё не зависит от того, как мы в неё пришли. Это ключевой вопрос: правильно сформулированное состояние решает половину задачи.
- Что хранится в ? Число способов? Минимальная стоимость? Максимальная сумма? Булевое «достижимо»?
- Каков переход? В состояние как мы могли прийти и из каких ? Или: из в какие мы можем уйти? Один из этих двух взглядов всегда удобнее.
- Какова база? Какие состояния «начальные», и какое значение в них стоит до пересчёта.
Когда все четыре пункта проговорены — порядок обхода обычно становится очевидным: начинаем с базы, идём в направлении, при котором все «зависимости» уже посчитаны.
Почему шаблон работает
DP формально опирается на принцип оптимальности Беллмана: в оптимальном решении любая «префиксная» подзадача тоже решена оптимально. Если это не так — DP неприменим, и нужна другая техника (greedy, поток, и т.д.).
В подсчётных задачах аналог — линейность: общее число способов есть сумма способов по непересекающимся группам, на которые разбивается множество способов. Если способы пересекаются (один путь принадлежит двум группам одновременно) — наивный DP с суммой даёт двойной счёт. Это сразу красный флаг: либо сменить разбиение, либо использовать включения-исключения.
Задача-иллюстрация 1: «Лестница» (линейный DP, ~CF 1100)
Условие
Дана лестница из ступенек. Можно подниматься на 1 или 2 ступеньки за один шаг. Сколько различных способов взойти от ступеньки до ступеньки ?
Ограничения: . Ответ может быть большим — выводить по модулю .
Формат ввода: одно число .
Формат вывода: одно число — количество способов.
Пример. . Способы: , , , , . Итого способов.
Применение шаблона
Отвечаем на четыре вопроса:
- Состояние: одно число — текущая ступенька, .
- : количество способов добраться до ступеньки .
- Переход: в ступеньку можно прийти из (шаг на 1) или из (шаг на 2). Поэтому .
- База: (мы уже стоим внизу, один «пустой» способ). (один способ: один шаг на 1).
Состояний , переходов на каждое — два. Итог — .
Разбор на примере
. База: .
| переход | ||
|---|---|---|
| 2 | 2 | |
| 3 | 3 | |
| 4 | 5 |
Сходится с подсчётом руками — 5 способов. ✓
Код решения
Что поменялось
Переход — двучленный, состояние одномерное. Это самая компактная форма DP. Сложность по памяти можно ужать с до , если хранить только последние два значения (популярный приём «rolling array»):
prev, cur = 1, 1
for i in range(2, n + 1):
prev, cur = cur, (prev + cur) % MOD
print(cur)
В олимпиадных задачах с обе формы укладываются в лимит. Но привычка сразу видеть, какие предыдущие значения нужны для перехода, окупается на задачах с , где память важна.
Задача-иллюстрация 2: «Максимум суммы по пути в сетке» (двумерный DP, ~CF 1300)
Условие
Дана сетка с целыми числами . Из левой верхней клетки нужно дойти до правой нижней , перемещаясь только вправо или вниз. Найти максимальную сумму чисел на пути.
Ограничения: , .
Пример. Сетка :
1 3 1
1 5 1
4 2 1
Оптимальный путь: . Сумма = .
Применение шаблона
- Состояние: пара — текущая клетка, , .
- : максимальная сумма на пути от до .
- Переход: в клетку можно прийти сверху из или слева из . Поэтому
с поправкой на границы: если , остаётся только переход слева; если — только сверху; в значение равно . 4. База: .
Состояний , переходов — . Итог — .
Что меняется по сравнению с линейным DP
- Размерность состояния выросла на единицу. Теперь массив двумерный —
f[n][m]. - Переход — два слагаемых вместо одного. В этом нет принципиальной новизны, но добавляются проверки границ — типичный источник off-by-one и
IndexError. - Порядок обхода: зависит от и — оба меньше или равны по «лексикографически частичному порядку». Поэтому два вложенных цикла от до и от до гарантируют корректность: к моменту посчёта оба зависимых значения уже посчитаны.
Разбор на примере
, сетка как выше.
База: .
Первая строка (, переход только слева):
, .
Первый столбец (, переход только сверху):
, .
Внутренние клетки:
. . . . ✓
Код решения
Можно записать ещё компактнее, обработав границы через «отрицательную бесконечность» вне массива:
NEG_INF = float('-inf')
def get(i, j):
if i < 0 or j < 0:
return 0 if (i == -1 and j == 0) or (i == 0 and j == -1) else NEG_INF
return f[i][j]
Но в учебном коде явная обработка первой строки и первого столбца читабельнее. На олимпиадах оба стиля встречаются; выбор — вопрос привычки.
Что поменялось vs линейный DP
Состояние стало двумерным; переход — два слагаемых; порядок обхода — два вложенных цикла. Принципиально нового ничего: всё та же дисциплина «состояние → переход → база → порядок».
Запомнить полезно: для двумерного DP порядок вложенности циклов определяется тем, как зависит состояние. В этой задаче — оба цикла идут «снизу вверх» (от к / ), потому что зависит от меньших индексов. Если бы мы шли с правой нижней клетки и хотели дойти до левой верхней, циклы пришлось бы перевернуть.
Типичные ошибки
- Неверно сформулированное состояние. «Состояние = текущая клетка + что-то ещё, что я подразумеваю» — самая частая ошибка новичков. Если в формулировке проскакивает «при условии, что я перед этим сделал то-то» — это «то-то» должно быть в состоянии, иначе оно потеряется при переходе.
- Пропущенная база. Массив инициализирован нулями, и или забыт. На задачах с подсчётом «сколько способов» это даёт ноль везде; на оптимизации — некорректные сравнения с нулём.
- Неверный порядок обхода. Линейный DP — почти всегда от к , проблем нет. В двумерном — пара вложенных циклов, и порядок вложенности должен соответствовать тому, какие индексы уже посчитаны к моменту текущей итерации. Если в переходе появляется — нужен обратный обход.
- Off-by-one на границах массива. «У меня от до , и я обращаюсь к — а в ничего нет». Решение — либо явно обработать первую строку / первый столбец отдельно (как в коде выше), либо ввести «фиктивную» нулевую строку с нейтральным значением.
- Забытый модуль в подсчётных задачах. На «лестнице» при ответ — число Фибоначчи , которое уже не помещается в
int64. В Python переполнения нет, но без модуля число может стать огромным, и тесты ожидают ответ по модулю — несовпадение даёт WA. В C++ — переполнениеlong longпри больших без модуля. intвместоlong longв C++ при максимизации сумм. В сетке с максимальная сумма пути — помещается вint. Но в типовых модификациях (, ) уже нужна 64-битная арифметика.- Неинициализированная отрицательная бесконечность. В DP на минимум начальное значение должно быть «больше любого валидного». Часто пишут
INF = 1e9и начинают сравнивать с реальными суммами в — сравнение ломается. На минимуме безопаснееINF = 4 * 10^{18}(вlong long) илиfloat('inf')в Python.
Когда DP не подходит
Не каждое «оптимизировать что-то по подзадачам» — это DP. Несколько случаев, когда шаблон с виду подходит, а на деле нет:
- Greedy решает быстрее и проще. Если задача допускает «локальный оптимальный выбор», который доказывается exchange argument'ом — DP избыточен. Классика: задача о скобочных последовательностях (greedy через счётчик), задача о минимальном количестве монет в каноничной системе (greedy от старшей).
- Состояний слишком много. Если для покрытия задачи нужно состояние с двумя параметрами по — это состояний, никакой DP не уложится. Сигнал — «нужно искать другую структуру»: либо существенно более узкое состояние, либо переход к структуре данных (segment tree, Fenwick).
- Зависимости циклические. Если зависит от , где , а — от , обычный DP не работает. Это область «решаем как уравнение»: в графах — поиск кратчайших путей с отрицательными рёбрами (Bellman-Ford), на деревьях — DP с двумя проходами.
- Нелинейная агрегация. Если ответ — не сумма / минимум / максимум по предыдущим состояниям, а, скажем, медиана или -й элемент по убыванию — стандартный DP даёт неверные результаты. Это сигнал к переходу на другие техники (порядковые статистики на лету, treap, sqrt-декомпозиция).
Полезное правило большого пальца: если попытались сформулировать состояние и переход 5 минут и не выходит — верните исходную задачу под лупу. Возможно, это не DP, а что-то другое, и попытка натянуть DP-шаблон бьёт мимо.
Связанные техники
- DP с подмножествами состояний (bitmask DP) — когда параметр состояния — двоичная маска от до . Применяется, когда и нужно «помнить, какое подмножество элементов уже использовали». Естественное расширение линейного DP.
- DP на дереве — состояние индексировано вершиной дерева, переход — комбинация подответов всех детей. Каркас тот же (состояние / переход / база), но порядок обхода — DFS-ordering. Удобнее всего реализуется через обход в глубину с возвращаемыми значениями.
- Префиксные суммы — родственная техника для диапазонных запросов. Если в DP-задаче переход требует сумму по подсегменту, префиксные суммы дают эту сумму за и ускоряют DP с до .
- Бинпоиск по ответу — альтернатива, когда задача звучит как «найти минимальное / максимальное , при котором …». Часто заменяет DP, если есть монотонность.
Каждая из этих техник идёт в следующих частях серии «Алгоритмические основы» — сначала по отдельности, потом в комбинации с DP.
Итого
- Техника: заполнение массива — ответов на подзадачи — в порядке, при котором каждая зависимость уже посчитана.
- Сложность шаблона: .
- Сигналы в условии: «сколько способов», «минимальная / максимальная стоимость», «можно ли получить», маленькие параметры, локальные ограничения.
- Четыре вопроса перед написанием: что такое состояние, что хранится в , каков переход, какова база. Если на любой нет ответа — DP пока писать рано.
- Типичные ловушки: неверное состояние, забытая база, неверный порядок обхода, off-by-one на границах, потерянный модуль на подсчётных задачах, переполнение в C++ при суммах.