← Вернуться в блог

Стратегия победы: DP — от очевидного шаблона к ёмкому состоянию СТРАТЕГИЯ

  • dinamicheskoe-programmirovanie
  • dp
  • trayektoriya
  • cf
  • bitmask-dp

О чём эта статья

«Стратегия победы» — серия про траекторию тренировки, а не про одну задачу. Берём одну технику и разбираем, как она растёт от базового шаблона до полноценного применения в составных задачах.

Динамическое программирование — отличный материал для такого разбора. На полосе CF 1200 решается линейный DP с состоянием в одно число. На 1600 — двумерный DP с парой параметров. На 2000+ — DP с экспоненциальным состоянием (битовая маска подмножества) или DP на дереве. Каркас «состояние → переход → база → порядок обхода» во всех трёх случаях один и тот же; растёт только размерность состояния.

Цель статьи — пройти три ступени с явным проговариванием что именно усложняется между ними. Это даёт ясный план тренировки: на какой полосе какие техники брать и как чувствовать, что пора переходить к следующему шагу.


Ядро техники

DP — это заполнение массива f[s]f[s] ответами на подзадачи в порядке, при котором каждое значение зависит только от уже посчитанных. Если есть отдельная статья-«тема» по линейному и двумерному DP — она в серии «Алгоритмические основы». Здесь мы фокусируемся на прогрессии состояния между ступенями.

Базовый шаблон (псевдокод)

определить состояние s и его размерность
завести массив f, заполнить нейтральным значением
проставить базу: f[начальные состояния] = …

для каждого состояния s в порядке возрастания зависимостей:
    f[s] = объединение(f[s'] + переход(s' → s)) по всем s' → s

вывести f[требуемое состояние]

Сложность шаблона: O(число состоянийсреднее число переходов)O(\text{число состояний} \cdot \text{среднее число переходов}).

Что мы будем усложнять по мере роста

  • Ступень 1 → ступень 2. Состояние из одного параметра становится двумерным. Появляется второй индекс — обычно «оставшийся ресурс» (вес, длина, бюджет).
  • Ступень 2 → ступень 3. Один из параметров переходит из «маленького числа» в подмножество (битовая маска). Размер состояния перестаёт быть полиномиальным от nn — он становится n2n\sim n \cdot 2^n. Это допустимо, только когда nn маленькое (обычно n20n \le 20).

Это и есть смысл серии: не «3 разные задачи», а осознанный путь между ними с явной логикой усложнения.


Ступень 1 — CF ~1200 (базовый шаблон)

Условие: «Максимальная сумма независимых элементов»

Дан массив из nn положительных целых чисел a1,a2,,ana_1, a_2, \ldots, a_n. Нужно выбрать подмножество элементов так, чтобы:

  1. Никакие два выбранных элемента не были соседними по индексу.
  2. Сумма выбранных элементов была максимальной.

Вывести максимальную сумму.

Ограничения: 1n1051 \le n \le 10^5, 1ai1041 \le a_i \le 10^4.

Пример. n=5n = 5, a=[3,2,7,10,12]a = [3, 2, 7, 10, 12]. Лучшие выборы: {a1,a3,a5}=3+7+12=22\{a_1, a_3, a_5\} = 3 + 7 + 12 = 22 или {a2,a4}=2+10=12\{a_2, a_4\} = 2 + 10 = 12 или {a3,a5}=7+12=19\{a_3, a_5\} = 7 + 12 = 19. Максимум — 2222.

Как применяется базовый шаблон

Состояние — индекс ii, описывающий «префикс массива до aia_i включительно». В f[i]f[i] хранится максимальная сумма независимых элементов в этом префиксе.

Переход: для aia_i есть два варианта.

  • Не берём aia_i. Тогда лучший ответ для префикса до ii — тот же, что для префикса до i1i - 1.
  • Берём aia_i. Тогда ai1a_{i-1} брать нельзя, лучший ответ — aia_i плюс максимум для префикса до i2i - 2.

Получаем рекуррентность

f[i]=max(f[i1], f[i2]+ai).f[i] = \max(f[i - 1],\ f[i - 2] + a_i).

База: f[0]=0f[0] = 0 (пустой префикс), f[1]=a1f[1] = a_1 (можно либо взять, либо нет, a10a_1 \ge 0, выгоднее взять).

Состояний n+1n + 1, переход O(1)O(1). Итог — O(n)O(n) по времени.

Разбор на примере

a=[3,2,7,10,12]a = [3, 2, 7, 10, 12], индексация с 1. База: f[0]=0f[0] = 0, f[1]=3f[1] = 3.

iif[i1]f[i-1]f[i2]+aif[i-2] + a_if[i]=maxf[i] = \max
230+2=20 + 2 = 2max(3,2)=3\max(3, 2) = 3
333+7=103 + 7 = 10max(3,10)=10\max(3, 10) = 10
4103+10=133 + 10 = 13max(10,13)=13\max(10, 13) = 13
51310+12=2210 + 12 = 22max(13,22)=22\max(13, 22) = \mathbf{22}

Совпадает с подсчётом руками. ✓

Код решения

Сложность: O(n)O(n) по времени, O(1)O(1) по памяти (rolling array).

Что закрыли

На ступени 1 состояние — одно число, переход — два слагаемых, база — два значения. Это чистый каркас одномерного DP, в котором ничего не отвлекает.

На следующем шаге появится второй индекс, описывающий «оставшийся ресурс».


Ступень 2 — CF ~1600 (двумерное состояние с ресурсом)

Условие: «Рюкзак 0/1»

Есть nn предметов, каждый предмет ii имеет вес wiw_i и стоимость viv_i. Есть рюкзак вместимости WW. Каждый предмет можно либо взять (один раз), либо не взять. Найти максимальную суммарную стоимость, при которой суммарный вес не превышает WW.

Ограничения: 1n10001 \le n \le 1000, 1W1051 \le W \le 10^5, 1wi,vi10001 \le w_i, v_i \le 1000.

Пример. n=3n = 3, W=5W = 5, предметы: (w,v)=(3,6),(2,4),(4,5)(w, v) = (3, 6), (2, 4), (4, 5). Варианты: предметы {1}\{1\} — вес 3, стоимость 6. {2}\{2\} — 2, 4. {3}\{3\} — 4, 5. {1,2}\{1, 2\} — 5, 10. {2,3}\{2, 3\} — 6 (>5> 5, нельзя). Максимум — 1010.

Чем эта задача сложнее ступени 1

В «Максимальной независимой сумме» состояние — только индекс ii, потому что у нас не было ресурсного ограничения. В рюкзаке появляется бюджет веса WW. Если описывать состояние одним ii, то значение f[i]f[i] должно вместить «лучшая стоимость для всех возможных оставшихся весов» — это уже не число, а функция от веса.

Поэтому состояние расширяется: «ii предметов рассмотрено, остаточный бюджет WW'». Теперь f[i][W]f[i][W'] — число, и переход остаётся понятным.

Что меняется в шаблоне

  • Состояние: было (i)(i), стало (i,w)(i, w), где w[0,W]w \in [0, W] — суммарный вес уже взятых предметов.
  • Переход: для предмета ii два варианта. Не брать — f[i][w]=f[i1][w]f[i][w] = f[i-1][w]. Брать (если wiww_i \le w) — f[i][w]=max(f[i][w],f[i1][wwi]+vi)f[i][w] = \max(f[i][w], f[i-1][w - w_i] + v_i).
  • База: f[0][w]=0f[0][w] = 0 для всех ww — без предметов стоимость 0.
  • Доказательство корректности: оптимальный выбор для первых ii предметов либо включает предмет ii, либо нет. В обоих случаях префиксная подзадача решается оптимально (принцип Беллмана).

Разбор на примере

n=3n = 3, W=5W = 5, предметы (3,6),(2,4),(4,5)(3,6), (2,4), (4,5).

i\wi \backslash w012345
0000000
1 (3,6)000666
2 (2,4)0046610
3 (4,5)0046610

Ответ — f[3][5]=10f[3][5] = 10. ✓

Например, f[2][5]f[2][5] считается так: не берём предмет 2 — f[1][5]=6f[1][5] = 6; берём — f[1][52]+4=f[1][3]+4=6+4=10f[1][5-2] + 4 = f[1][3] + 4 = 6 + 4 = 10. Максимум — 1010.

Код решения

Сложность: O(nW)O(n \cdot W) по времени, O(W)O(W) по памяти — после оптимизации с одним массивом и обратным проходом по ww.

Что закрыли

На ступени 2 состояние из одного индекса превратилось в пару «индекс + ресурс». Размер таблицы ffO(nW)O(n \cdot W), переход — O(1)O(1). Появилась первая нетривиальная ловушка: порядок прохода по ww внутри массива. При прямом проходе (ww от 00 до WW) предмет ii может быть учтён несколько раз — это уже неограниченный рюкзак, не 0/1.

Следующий шаг — состояние, в котором один из параметров не «ресурс» в виде числа, а подмножество уже использованных элементов.


Ступень 3 — CF ~2000+ (битовая маска подмножества)

Условие: «Минимальный гамильтонов путь»

Дан полный неориентированный граф из nn вершин с весами рёбер d[i][j]d[i][j]. Нужно начать с произвольной вершины и пройти через все nn вершин ровно по одному разу, минимизируя суммарный вес пройденных рёбер.

Ограничения: 1n181 \le n \le 18, 1d[i][j]1061 \le d[i][j] \le 10^6, d[i][j]=d[j][i]d[i][j] = d[j][i].

Пример. n=4n = 4 с матрицей:

0 10 15 20
10 0 35 25
15 35 0 30
20 25 30 0

Возможный маршрут 12431 \to 2 \to 4 \to 3 имеет вес 10+25+30=6510 + 25 + 30 = 65. Маршрут 13421 \to 3 \to 4 \to 215+30+25=7015 + 30 + 25 = 70. Минимальный — 6565 (проверяется перебором всех 4!/2=124!/2 = 12 направленных маршрутов на 4-х вершинах при фиксированном направлении обхода).

Двойное гражданство. Эта задача одинаково честно попадает и в семью DP, и в семью «кратчайший путь во взвешенном графе» (Дейкстра / BFS на расширенном состоянии). Если построить граф GG^* из n2nn \cdot 2^n вершин-состояний (mask,u)(\text{mask}, u) и проводить рёбра (mask,u)(mask{v},v)(\text{mask}, u) \to (\text{mask} \cup \{v\}, v) с весом d[u][v]d[u][v] для каждого vmaskv \notin \text{mask}, то задача — это поиск кратчайшего пути из любой стартовой вершины {s}\{s\}, ss, до любой вершины ((1n)1,)((1 \ll n) - 1, *). Дейкстра на GG^* даёт ответ за O((n2n)log(n2n)+n22n)O((n \cdot 2^n) \log (n \cdot 2^n) + n^2 \cdot 2^n), что чуть хуже DP, но идейно — та же самая работа. Мы остаёмся в DP-рамке (форвард-обновление по mask), потому что граф GG^* — DAG (множество посещённых вершин монотонно растёт), и для DAG’а DP по топологическому порядку даёт чистый O(n22n)O(n^2 \cdot 2^n) без логарифмического оверхеда. Подробнее о структурной близости BFS / Дейкстры и DP — в «Что закрыли» ниже.

Чем эта задача сложнее ступени 2

В рюкзаке состояние было «ii предметов рассмотрено, ww ресурс». Каждый предмет либо берём, либо нет — порядок не важен.

В гамильтоновом пути порядок вершин критичен: нужно знать какое именно подмножество уже посещено, чтобы не зайти в вершину дважды, и из какой вершины мы вышли последней, чтобы продолжить путь оттуда.

«Какое подмножество посещено» — это 2n2^n вариантов. При n18n \le 18 это 218=262144\le 2^{18} = 262\,144 масок, что приемлемо. Состояние — (маска,u)(\text{маска}, u), где uu — последняя посещённая вершина.

Что меняется в шаблоне

  • Состояние: (mask,u)(\text{mask}, u), mask[0,2n)\text{mask} \in [0, 2^n) — битовая маска посещённых вершин, u[0,n)u \in [0, n) — текущая вершина (последняя в маршруте).
  • f[mask][u]f[\text{mask}][u]: минимальный вес маршрута, посетившего ровно множество вершин из mask, заканчивающегося в uu.
  • Переход (forward DP): из состояния (mask,u)(\text{mask}, u) переходим в (mask(1v),v)(\text{mask} | (1 \ll v), v) через ребро uvu \to v для каждой не посещённой вершины vv (бит vv в mask равен 00). Стоимость перехода — d[u][v]d[u][v].
  • База: f[1s][s]=0f[1 \ll s][s] = 0 для всех s[0,n)s \in [0, n) — стартуем с любой одной вершины.
  • Ответ: minuf[(1n)1][u]\min_u f[(1 \ll n) - 1][u] — посетили все вершины.

Сложность: состояний n2nn \cdot 2^n, на каждом — O(n)O(n) переходов. Итог — O(n22n)O(n^2 \cdot 2^n).

При n=18n = 18 это 8.5107\sim 8.5 \cdot 10^7 операций — около 1 секунды в C++, 5\sim 5 секунд в Python (требуется аккуратный код).

Код решения

Сложность: O(n22n)O(n^2 \cdot 2^n) по времени, O(n2n)O(n \cdot 2^n) по памяти.

При больших nn (близких к 18) на Python без хитростей будет TL. В C++ та же логика проходит за 1\sim 1 секунду. Если на CF задача требует n20n \le 20 — придётся оптимизировать (например, перейти к битовой компоновке f[mask][u] в массив целых, использовать numpy или применить рассуждения о симметрии).

Что закрыли

На ступени 3 параметр перестал быть числом — он стал подмножеством. Это качественный скачок:

  • Размер таблицы ff — экспоненциальный по nn (O(n2n)O(n \cdot 2^n)).
  • Переход — линейный по nn (перебор следующей вершины).
  • Сложность — O(n22n)O(n^2 \cdot 2^n), что разумно только при n20n \le 20.

Битовая маска как параметр состояния — это «правый край» таблицы: дальше уже состояния не помещаются в память, и нужны другие техники (вероятностные, аппроксимации, ветвей и границ).

А ещё — про родство с BFS / Дейкстрой

Гамильтонов путь намеренно стоит в этой статье последним: на нём становится видна структурная близость DP и алгоритмов кратчайшего пути на графе, и эту близость стоит проговорить отдельно.

Понятие в DPАналог в BFS / Dijkstra
Состояние f[mask][u]f[\text{mask}][u]Вершина графа (mask,u)(\text{mask}, u)
Рекуррент f[new] ← min(f[new], f[old] + cost)Релаксация ребра «стоимость + вес → новый кандидат»
Порядок обхода (топологический по mask)Порядок «по возрастанию расстояния» (приоритетная очередь у Дейкстры) или «по слоям» (FIFO у BFS)
База f[1 << s][s] = 0Стартовая вершина с расстоянием 00
«Не посчитано» = \infty«Не посещена» = \infty

Дейкстра — это, по сути, DP на графе с произвольным порядком обработки состояний, где порядок задаётся приоритетной очередью, потому что топологического порядка может не быть. BFS — то же самое, но для невзвешенных рёбер, где «расстояние = число шагов» и FIFO-очередь даёт корректный порядок без логарифма.

DP в нашей задаче работает чисто и без очередей именно потому, что граф состояний (mask,u)(mask{v},v)(\text{mask}, u) \to (\text{mask} \cup \{v\}, v) — это DAG: mask монотонно растёт, цикла быть не может. На DAG’е достаточно идти в порядке возрастания mask — это автоматический топологический обход. Если бы циклы были (как у Дейкстры в общем графе), пришлось бы переключаться на priority queue.

Практически это значит:

  • Сильный «графщик» решает эту задачу через Дейкстру на GG^* — пишет на 10 строк длиннее, получает лишний log\log в сложности, но не задумывается о порядке mask.
  • Сильный «дп-шник» сразу видит DAG и пишет цикл по mask — короче и чуть быстрее.
  • На олимпиаде — оба варианта проходят, обе классификации уважают одну и ту же задачу. Спор «это DP или граф?» бессмысленный: это и то, и другое.

То же самое работает в обратную сторону: задачи на «кратчайший путь с собиранием ключей» / «BFS по сетке с дополнительным состоянием инвентаря» (см. серию «Стратегия победы — графы») — это полноценные DP по (клетка, mask_ключей), просто решённые через FIFO-очередь, потому что веса единичные и удобнее не ломать голову над порядком обхода.

Вывод для тренировки. Если на разборе чувствуешь, что «вроде DP, но как-то странно» — попробуй переформулировать задачу как поиск кратчайшего пути на графе состояний; если структура ляжет — техника та же, просто другой словарь. И наоборот, на «графовой» задаче с маленьким состоянием стоит проверять, не ложится ли она в чистый DP по DAG’у — это часто даёт более простую реализацию.


Что нужно держать в голове при переходе между ступенями

СтупеньCFСостояниеРазмерКлючевое усложнение vs предыдущей
1~1200iiO(n)O(n)
2~1600(i,w)(i, w)O(nW)O(n \cdot W)добавился ресурсный параметр ww
3~2000+(mask,u)(\text{mask}, u)O(n2n)O(n \cdot 2^n)один параметр — подмножество вершин

Эта таблица — карта местности на тренировке. Видя в условии «маленькое nn (20\le 20) + комбинаторика выбора», участник должен сразу подумать о bitmask. Видя «параметр W105W \le 10^5 + два варианта на каждом шаге» — о двумерном DP. Видя только индекс и линейный массив — о rolling 1D.


Типичные ловушки на каждой ступени

  • Ступень 1. Забыть базу f[0]f[0] или поставить её неверно (например, f[0]=a0f[0] = a_0 вместо 00, путая «префикс длины 0» с «первый элемент»). Симптом — ответ сдвинут.
  • Ступень 2. Прямой проход по ww внутри предмета вместо обратного. Это превращает 0/1 рюкзак в неограниченный, ответы получаются завышенными. Запомнить намертво: обратный проход по ww для 0/1, прямой — для unbounded.
  • Ступень 3. Включить вершину uu в маску дважды в переходе: в текущем состоянии uu уже посещена, а в коде переход не проверяет, что бит vv в mask равен 00. Симптом — ответ занижен (маршрут «проходит через uu дважды» по сниженной стоимости).
  • Везде: перепутать порядок вложенности циклов (особенно на ступени 3 — внешний должен идти по mask от 0 до 2n12^n - 1, потому что f[mask | (1 << v)] зависит от f[mask]).

План тренировки

  1. Решить ступень 1 самостоятельно, без подсмотра кода. Цель — понять, что состояние = индекс, переход = два слагаемых.
  2. Решить 5 задач полосы CF 1100–1300 на тег dp — линейный DP в разных формулировках (подсчёт способов, минимум/максимум, булевая достижимость). На этой полосе освоить мускульную память «один индекс → один цикл → одно сравнение».
  3. Перейти к ступени 2. Решить рюкзак 0/1, потом — задачу с тегом dp + knapsack на CF полосы 1500–1700. Если застреваем на «прямой/обратный проход» — значит ловушка не отработана, делаем ещё 2–3 задачи на 0/1.
  4. Решить 5 задач полосы CF 1500–1700 на dp. Здесь же — двумерные DP без рюкзака (LCS, edit distance, DP на сетке с ограничениями).
  5. Ступень 3 — bitmask DP. Начать с TSP / гамильтонова пути / минимального покрытия подмножеств. Полоса 1900–2100. Реальные «продукционные» задачи с маской — около 5–8 разных формулировок (TSP, ассайнмент, покрытие, перестановки), их полезно прорешать все.
  6. Когда все три ступени крепко в руке — переходить к DP на дереве и DP с компонентами связности (это уже отдельная статья серии).

Итого

  • Тема: одно семейство DP, в котором сложность растёт через расширение состояния.
  • Траектория: O(n)O(n)O(nW)O(n \cdot W)O(n2n)O(n \cdot 2^n), с усложнением параметра от индекса к ресурсу к подмножеству.
  • Ключевое умение: не запоминать рекуррентности конкретных задач, а видеть, какое состояние нужно под текущую формулировку. Когда чувствуешь параметр сразу — задача решается за 5 минут после прочтения условия.

В серии: Стратегия победы

  1. 1Стратегия победы: DP — от очевидного шаблона к ёмкому состоянию — эта статья
  2. 2Стратегия победы: графы — от BFS к многоходовым обходам
  3. 3Стратегия победы: бинпоиск по ответу — траектория 1200 → 1500 → 2000
  4. 4Стратегия победы: жадные — когда жадность ломается и как её спасти
  5. 5Стратегия победы: префиксные суммы — от 1D к 2D и подсчёту на подотрезках

Попробуй разобрать похожие задачи

В CodePal AI-партнёр подсказывает идею, а не ответ. Разбор в диалоге, код проверяется в браузере.