О чём эта статья
«Стратегия победы» — серия про траекторию тренировки, а не про одну задачу. Берём одну технику и разбираем, как она растёт от базового шаблона до полноценного применения в составных задачах.
Динамическое программирование — отличный материал для такого разбора. На полосе CF 1200 решается линейный DP с состоянием в одно число. На 1600 — двумерный DP с парой параметров. На 2000+ — DP с экспоненциальным состоянием (битовая маска подмножества) или DP на дереве. Каркас «состояние → переход → база → порядок обхода» во всех трёх случаях один и тот же; растёт только размерность состояния.
Цель статьи — пройти три ступени с явным проговариванием что именно усложняется между ними. Это даёт ясный план тренировки: на какой полосе какие техники брать и как чувствовать, что пора переходить к следующему шагу.
Ядро техники
DP — это заполнение массива ответами на подзадачи в порядке, при котором каждое значение зависит только от уже посчитанных. Если есть отдельная статья-«тема» по линейному и двумерному DP — она в серии «Алгоритмические основы». Здесь мы фокусируемся на прогрессии состояния между ступенями.
Базовый шаблон (псевдокод)
определить состояние s и его размерность
завести массив f, заполнить нейтральным значением
проставить базу: f[начальные состояния] = …
для каждого состояния s в порядке возрастания зависимостей:
f[s] = объединение(f[s'] + переход(s' → s)) по всем s' → s
вывести f[требуемое состояние]
Сложность шаблона: .
Что мы будем усложнять по мере роста
- Ступень 1 → ступень 2. Состояние из одного параметра становится двумерным. Появляется второй индекс — обычно «оставшийся ресурс» (вес, длина, бюджет).
- Ступень 2 → ступень 3. Один из параметров переходит из «маленького числа» в подмножество (битовая маска). Размер состояния перестаёт быть полиномиальным от — он становится . Это допустимо, только когда маленькое (обычно ).
Это и есть смысл серии: не «3 разные задачи», а осознанный путь между ними с явной логикой усложнения.
Ступень 1 — CF ~1200 (базовый шаблон)
Условие: «Максимальная сумма независимых элементов»
Дан массив из положительных целых чисел . Нужно выбрать подмножество элементов так, чтобы:
- Никакие два выбранных элемента не были соседними по индексу.
- Сумма выбранных элементов была максимальной.
Вывести максимальную сумму.
Ограничения: , .
Пример. , . Лучшие выборы: или или . Максимум — .
Как применяется базовый шаблон
Состояние — индекс , описывающий «префикс массива до включительно». В хранится максимальная сумма независимых элементов в этом префиксе.
Переход: для есть два варианта.
- Не берём . Тогда лучший ответ для префикса до — тот же, что для префикса до .
- Берём . Тогда брать нельзя, лучший ответ — плюс максимум для префикса до .
Получаем рекуррентность
База: (пустой префикс), (можно либо взять, либо нет, , выгоднее взять).
Состояний , переход . Итог — по времени.
Разбор на примере
, индексация с 1. База: , .
| 2 | 3 | ||
| 3 | 3 | ||
| 4 | 10 | ||
| 5 | 13 |
Совпадает с подсчётом руками. ✓
Код решения
Сложность: по времени, по памяти (rolling array).
Что закрыли
На ступени 1 состояние — одно число, переход — два слагаемых, база — два значения. Это чистый каркас одномерного DP, в котором ничего не отвлекает.
На следующем шаге появится второй индекс, описывающий «оставшийся ресурс».
Ступень 2 — CF ~1600 (двумерное состояние с ресурсом)
Условие: «Рюкзак 0/1»
Есть предметов, каждый предмет имеет вес и стоимость . Есть рюкзак вместимости . Каждый предмет можно либо взять (один раз), либо не взять. Найти максимальную суммарную стоимость, при которой суммарный вес не превышает .
Ограничения: , , .
Пример. , , предметы: . Варианты: предметы — вес 3, стоимость 6. — 2, 4. — 4, 5. — 5, 10. — 6 (, нельзя). Максимум — .
Чем эта задача сложнее ступени 1
В «Максимальной независимой сумме» состояние — только индекс , потому что у нас не было ресурсного ограничения. В рюкзаке появляется бюджет веса . Если описывать состояние одним , то значение должно вместить «лучшая стоимость для всех возможных оставшихся весов» — это уже не число, а функция от веса.
Поэтому состояние расширяется: « предметов рассмотрено, остаточный бюджет ». Теперь — число, и переход остаётся понятным.
Что меняется в шаблоне
- Состояние: было , стало , где — суммарный вес уже взятых предметов.
- Переход: для предмета два варианта. Не брать — . Брать (если ) — .
- База: для всех — без предметов стоимость 0.
- Доказательство корректности: оптимальный выбор для первых предметов либо включает предмет , либо нет. В обоих случаях префиксная подзадача решается оптимально (принцип Беллмана).
Разбор на примере
, , предметы .
| 0 | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 (3,6) | 0 | 0 | 0 | 6 | 6 | 6 |
| 2 (2,4) | 0 | 0 | 4 | 6 | 6 | 10 |
| 3 (4,5) | 0 | 0 | 4 | 6 | 6 | 10 |
Ответ — . ✓
Например, считается так: не берём предмет 2 — ; берём — . Максимум — .
Код решения
Сложность: по времени, по памяти — после оптимизации с одним массивом и обратным проходом по .
Что закрыли
На ступени 2 состояние из одного индекса превратилось в пару «индекс + ресурс». Размер таблицы — , переход — . Появилась первая нетривиальная ловушка: порядок прохода по внутри массива. При прямом проходе ( от до ) предмет может быть учтён несколько раз — это уже неограниченный рюкзак, не 0/1.
Следующий шаг — состояние, в котором один из параметров не «ресурс» в виде числа, а подмножество уже использованных элементов.
Ступень 3 — CF ~2000+ (битовая маска подмножества)
Условие: «Минимальный гамильтонов путь»
Дан полный неориентированный граф из вершин с весами рёбер . Нужно начать с произвольной вершины и пройти через все вершин ровно по одному разу, минимизируя суммарный вес пройденных рёбер.
Ограничения: , , .
Пример. с матрицей:
0 10 15 20
10 0 35 25
15 35 0 30
20 25 30 0
Возможный маршрут имеет вес . Маршрут — . Минимальный — (проверяется перебором всех направленных маршрутов на 4-х вершинах при фиксированном направлении обхода).
Двойное гражданство. Эта задача одинаково честно попадает и в семью DP, и в семью «кратчайший путь во взвешенном графе» (Дейкстра / BFS на расширенном состоянии). Если построить граф из вершин-состояний и проводить рёбра с весом для каждого , то задача — это поиск кратчайшего пути из любой стартовой вершины , , до любой вершины . Дейкстра на даёт ответ за , что чуть хуже DP, но идейно — та же самая работа. Мы остаёмся в DP-рамке (форвард-обновление по
mask), потому что граф — DAG (множество посещённых вершин монотонно растёт), и для DAG’а DP по топологическому порядку даёт чистый без логарифмического оверхеда. Подробнее о структурной близости BFS / Дейкстры и DP — в «Что закрыли» ниже.
Чем эта задача сложнее ступени 2
В рюкзаке состояние было « предметов рассмотрено, ресурс». Каждый предмет либо берём, либо нет — порядок не важен.
В гамильтоновом пути порядок вершин критичен: нужно знать какое именно подмножество уже посещено, чтобы не зайти в вершину дважды, и из какой вершины мы вышли последней, чтобы продолжить путь оттуда.
«Какое подмножество посещено» — это вариантов. При это масок, что приемлемо. Состояние — , где — последняя посещённая вершина.
Что меняется в шаблоне
- Состояние: , — битовая маска посещённых вершин, — текущая вершина (последняя в маршруте).
- : минимальный вес маршрута, посетившего ровно множество вершин из
mask, заканчивающегося в . - Переход (forward DP): из состояния переходим в через ребро для каждой не посещённой вершины (бит в
maskравен ). Стоимость перехода — . - База: для всех — стартуем с любой одной вершины.
- Ответ: — посетили все вершины.
Сложность: состояний , на каждом — переходов. Итог — .
При это операций — около 1 секунды в C++, секунд в Python (требуется аккуратный код).
Код решения
Сложность: по времени, по памяти.
При больших (близких к 18) на Python без хитростей будет TL. В C++ та же логика проходит за секунду. Если на CF задача требует — придётся оптимизировать (например, перейти к битовой компоновке f[mask][u] в массив целых, использовать numpy или применить рассуждения о симметрии).
Что закрыли
На ступени 3 параметр перестал быть числом — он стал подмножеством. Это качественный скачок:
- Размер таблицы — экспоненциальный по ().
- Переход — линейный по (перебор следующей вершины).
- Сложность — , что разумно только при .
Битовая маска как параметр состояния — это «правый край» таблицы: дальше уже состояния не помещаются в память, и нужны другие техники (вероятностные, аппроксимации, ветвей и границ).
А ещё — про родство с BFS / Дейкстрой
Гамильтонов путь намеренно стоит в этой статье последним: на нём становится видна структурная близость DP и алгоритмов кратчайшего пути на графе, и эту близость стоит проговорить отдельно.
| Понятие в DP | Аналог в BFS / Dijkstra |
|---|---|
| Состояние | Вершина графа |
Рекуррент f[new] ← min(f[new], f[old] + cost) | Релаксация ребра «стоимость + вес → новый кандидат» |
Порядок обхода (топологический по mask) | Порядок «по возрастанию расстояния» (приоритетная очередь у Дейкстры) или «по слоям» (FIFO у BFS) |
База f[1 << s][s] = 0 | Стартовая вершина с расстоянием |
| «Не посчитано» = | «Не посещена» = |
Дейкстра — это, по сути, DP на графе с произвольным порядком обработки состояний, где порядок задаётся приоритетной очередью, потому что топологического порядка может не быть. BFS — то же самое, но для невзвешенных рёбер, где «расстояние = число шагов» и FIFO-очередь даёт корректный порядок без логарифма.
DP в нашей задаче работает чисто и без очередей именно потому, что граф состояний — это DAG: mask монотонно растёт, цикла быть не может. На DAG’е достаточно идти в порядке возрастания mask — это автоматический топологический обход. Если бы циклы были (как у Дейкстры в общем графе), пришлось бы переключаться на priority queue.
Практически это значит:
- Сильный «графщик» решает эту задачу через Дейкстру на — пишет на 10 строк длиннее, получает лишний в сложности, но не задумывается о порядке
mask. - Сильный «дп-шник» сразу видит DAG и пишет цикл по
mask— короче и чуть быстрее. - На олимпиаде — оба варианта проходят, обе классификации уважают одну и ту же задачу. Спор «это DP или граф?» бессмысленный: это и то, и другое.
То же самое работает в обратную сторону: задачи на «кратчайший путь с собиранием ключей» / «BFS по сетке с дополнительным состоянием инвентаря» (см. серию «Стратегия победы — графы») — это полноценные DP по (клетка, mask_ключей), просто решённые через FIFO-очередь, потому что веса единичные и удобнее не ломать голову над порядком обхода.
Вывод для тренировки. Если на разборе чувствуешь, что «вроде DP, но как-то странно» — попробуй переформулировать задачу как поиск кратчайшего пути на графе состояний; если структура ляжет — техника та же, просто другой словарь. И наоборот, на «графовой» задаче с маленьким состоянием стоит проверять, не ложится ли она в чистый DP по DAG’у — это часто даёт более простую реализацию.
Что нужно держать в голове при переходе между ступенями
| Ступень | CF | Состояние | Размер | Ключевое усложнение vs предыдущей |
|---|---|---|---|---|
| 1 | ~1200 | — | ||
| 2 | ~1600 | добавился ресурсный параметр | ||
| 3 | ~2000+ | один параметр — подмножество вершин |
Эта таблица — карта местности на тренировке. Видя в условии «маленькое () + комбинаторика выбора», участник должен сразу подумать о bitmask. Видя «параметр + два варианта на каждом шаге» — о двумерном DP. Видя только индекс и линейный массив — о rolling 1D.
Типичные ловушки на каждой ступени
- Ступень 1. Забыть базу или поставить её неверно (например, вместо , путая «префикс длины 0» с «первый элемент»). Симптом — ответ сдвинут.
- Ступень 2. Прямой проход по внутри предмета вместо обратного. Это превращает 0/1 рюкзак в неограниченный, ответы получаются завышенными. Запомнить намертво: обратный проход по для 0/1, прямой — для unbounded.
- Ступень 3. Включить вершину в маску дважды в переходе: в текущем состоянии уже посещена, а в коде переход не проверяет, что бит в
maskравен . Симптом — ответ занижен (маршрут «проходит через дважды» по сниженной стоимости). - Везде: перепутать порядок вложенности циклов (особенно на ступени 3 — внешний должен идти по
maskот 0 до , потому чтоf[mask | (1 << v)]зависит отf[mask]).
План тренировки
- Решить ступень 1 самостоятельно, без подсмотра кода. Цель — понять, что состояние = индекс, переход = два слагаемых.
- Решить 5 задач полосы CF 1100–1300 на тег
dp— линейный DP в разных формулировках (подсчёт способов, минимум/максимум, булевая достижимость). На этой полосе освоить мускульную память «один индекс → один цикл → одно сравнение». - Перейти к ступени 2. Решить рюкзак 0/1, потом — задачу с тегом
dp+knapsackна CF полосы 1500–1700. Если застреваем на «прямой/обратный проход» — значит ловушка не отработана, делаем ещё 2–3 задачи на 0/1. - Решить 5 задач полосы CF 1500–1700 на
dp. Здесь же — двумерные DP без рюкзака (LCS, edit distance, DP на сетке с ограничениями). - Ступень 3 — bitmask DP. Начать с TSP / гамильтонова пути / минимального покрытия подмножеств. Полоса 1900–2100. Реальные «продукционные» задачи с маской — около 5–8 разных формулировок (TSP, ассайнмент, покрытие, перестановки), их полезно прорешать все.
- Когда все три ступени крепко в руке — переходить к DP на дереве и DP с компонентами связности (это уже отдельная статья серии).
Итого
- Тема: одно семейство DP, в котором сложность растёт через расширение состояния.
- Траектория: → → , с усложнением параметра от индекса к ресурсу к подмножеству.
- Ключевое умение: не запоминать рекуррентности конкретных задач, а видеть, какое состояние нужно под текущую формулировку. Когда чувствуешь параметр сразу — задача решается за 5 минут после прочтения условия.