Продвинутые паттерны: интервальное DP, битмаски, DP+монотоник
До этого в курсе мы решали DP, где ось состояний была “очевидной”: индекс массива (dp[i]), пара индексов (dp[i][j] для сеток и строк), сумма в рюкзаке (dp[w]), вершина дерева/графа. На LeetCode есть задачи, где DP всё ещё строится из тех же кирпичиков (состояние → переход → база → порядок), но правильная ось не так очевидна.
В этой статье разберём три продвинутых паттерна, которые регулярно встречаются в задачах уровня medium/hard:
Интервальное DP: состояние — подотрезок массива.
DP по битмаске (subset DP): состояние — множество уже выбранных элементов.
DP + монотоническая структура: переход “смотрит назад” в окно или на префикс, и его ускоряют деком/стеком.Полезные задачи на LeetCode для практики по темам статьи:
Интервальное DP: Burst Balloons, Minimum Cost to Cut a Stick
Битмаски: Can I Win, Smallest Sufficient Team
DP+монотоник: Jump Game VI, Constrained Subsequence SumИнтервальное DP
Интервальное DP — это DP, где состояние описывает отрезок массива или строки.
Обычно состояние выглядит как:
dp[l][r] — ответ для подотрезка с границами l..r (или полуинтервала).Ключевая трудность (и главный навык): понять, какое событие считать “последним шагом” внутри отрезка, чтобы разложить отрезок на независимые части.
Когда узнаётся интервальное DP
Сигналы, что это оно:
В условии есть операции “удалить элемент”, “разбить отрезок”, “поставить скобки”, “разрезать палку”.
Ответ для большого отрезка можно собрать из ответов для двух подотрезков.
Вариантов последнего шага обычно (выбираем точку разбиения k).!Как “последний шаг” внутри интервала разбивает задачу на две независимые подзадачи
Классический приём: выбирать “последний” элемент/разрез
Очень часто проще думать не “что сделать первым”, а что сделать последним внутри интервала. Тогда всё вокруг “последнего” уже независимо решено.
#### Пример: Burst Balloons
Задача Burst Balloons: есть шары с числами, при лопании шара k получаем nums[left] nums[k] nums[right], где left/right — ближайшие ещё не лопнувшие шары. Нужно максимум монет.
Главная проблема: “соседи” шара меняются со временем, поэтому прямой DP по индексу ломается.
Решение: выбрать последний лопнутый шар в интервале.
##### Подготовка массива
Добавим фиктивные шары со значением 1 по краям:
a = [1] + nums + [1]Так мы всегда можем умножать на “соседей”, и границы стабильны.
##### Состояние
Будем использовать открытый интервал:
dp[l][r] = максимальные монеты, которые можно получить, если лопать шары строго между индексами l и r (то есть l < k < r).Важно: шары l и r не лопаем в этой подзадаче — они играют роль фиксированных границ.
##### Переход
Пусть k — последний шар, который мы лопаем внутри (l, r).
Тогда:
всё внутри (l, k) уже лопнули оптимально и дали dp[l][k]
всё внутри (k, r) уже лопнули оптимально и дали dp[k][r]
в момент, когда лопаем k последним, его соседями гарантированно являются l и r, значит монеты за последний шаг: a[l] a[k] a[r]Итоговый переход:
Пояснение каждого элемента:
— лучший результат для интервала между границами l и r.
— индекс шара, который выбираем “последним” в этом интервале.
— максимум монет, если сначала оптимально “очистить” левую часть.
— максимум монет, если сначала оптимально “очистить” правую часть.
— монеты за финальный хлопок шара k, когда его соседи уже точно l и r.
— потому что мы ищем максимум.##### База
Если между l и r нет шаров (то есть r = l + 1), то:
dp[l][r] = 0Потому что лопать нечего.
##### Порядок вычисления
Нужно, чтобы dp[l][k] и dp[k][r] были посчитаны раньше dp[l][r].
Самый удобный порядок:
Перебирать длину интервала len от маленькой к большой.
Для каждой пары (l, r) такой длины перебирать k внутри.##### Реализация (bottom-up)
##### Типичные ошибки в интервальном DP
Неправильно выбран смысл интервала (закрытый/открытый), и “последний шаг” перестаёт фиксировать соседей.
Неверный порядок вычисления: интервалы должны считаться от меньших к большим.
Путают “разбиение по k” с “добавлением по k”: здесь k именно разделяет задачу на две независимые.DP по битмаске (subset DP)
Битмаска — это число, в двоичной записи которого мы кодируем, какие элементы множества уже выбраны.
Если mask — битовая маска длины n, то бит i равен 1, когда элемент i уже “внутри состояния”.Этот паттерн применим, когда:
n маленькое (обычно до 20–22, иногда до 25 при аккуратной оптимизации)
нужно учитывать “какие именно элементы уже использованы”, а не только их количествоТипичная оценка числа состояний:
всего масок И переход часто добавляет/убирает один элемент, давая множитель n, то есть .
Когда это узнаётся
Сигналы:
“каждый элемент можно использовать не более одного раза”, но важно какие именно.
“нужно покрыть набор навыков/условий минимальным набором объектов”.
“игра/выбор ходов” с небольшим n, где состояние = какие ходы уже сделаны.Пример: Can I Win (мемоизация по маске)
Задача Can I Win: есть числа 1..maxChoosableInteger, игроки по очереди выбирают ещё не выбранное число и прибавляют к сумме. Кто первым достигнет desiredTotal, тот победил. Нужно определить, выигрывает ли первый игрок при оптимальной игре.
#### Состояние
mask — какие числа уже взяты.
remain — сколько осталось набрать до desiredTotal.Замечание: remain однозначно определяется mask, если мы знаем исходный desiredTotal, но на практике часто оставляют оба параметра для ясности. На LeetCode достаточно кэшировать по mask.
Определим функцию:
win(mask, remain) = может ли текущий игрок гарантированно выиграть из этого состояния.#### Переход
Текущий игрок пробует взять любое число x, которого ещё нет в mask.
Если x >= remain, то он выигрывает сразу.
Иначе он передаёт ход сопернику в состоянии (mask | (1<<x), remain - x).
Если существует ход, после которого соперник не может выиграть, то текущий игрок выигрывает.#### База
Если есть x >= remain, это немедленный выигрыш.
Явного remain <= 0 обычно не нужно, потому что мы перехватываем выигрывающий ход на шаг раньше.#### Реализация (top-down + кэш)
#### Что важно понимать
Состояние “какие числа взяты” невозможно сжать до одного индекса, потому что важен набор, а не позиция.
Это структурно похоже на DP по графам из прошлой статьи: mask — вершина графа состояний, ребро — “взять число x”.Ещё один очень типичный формат: минимизация/оптимизация по маске
Например, в Smallest Sufficient Team можно делать:
dp[mask] = минимальная команда (список людей), которая покрывает набор навыков mask.Это уже “рюкзак по маске”: состояние — не сумма, а покрытые навыки.
DP + монотоническая структура
Иногда DP-переход выглядит так:
dp[i] зависит от максимума/минимума на диапазоне предыдущих dp[j]Примерный вид:
dp[i] = value[i] + max(dp[j]) для j в окне [i-k, i-1]Если считать максимум перебором k предыдущих значений, получится и TLE.
Трюк: поддерживать кандидатов на максимум в монотонической очереди (deque).
Пример: Jump Game VI
Задача Jump Game VI: из позиции i можно прыгнуть на i+1..i+k. Нужно максимизировать сумму значений nums по пути до конца.
#### Состояние
dp[i] = максимальная сумма, если мы пришли в i.#### Переход
В i можно прийти из любой позиции j в диапазоне [i-k, i-1].
Значит:
Пояснение частей формулы:
— лучший результат на позиции i.
— вклад текущей клетки.
— лучший результат на предыдущей позиции j, откуда можно допрыгнуть.
— выбираем, откуда прийти выгоднее.#### Как ускорить максимум: монотоническая очередь
Будем хранить в деке индексы j так, чтобы:
индексы в деке идут слева направо по времени (возрастают)
значения dp[j] в деке идут слева направо по убываниюТогда максимум на окне всегда в начале дека.
Операции на каждом i:
Удалить из начала дека индексы, которые вышли из окна (j < i-k).
Взять dp[deque[0]] как максимум.
Посчитать dp[i].
Пока хвост дека имеет dp[tail] <= dp[i], выкинуть хвост (они больше никогда не станут максимумом).
Добавить i в хвост.!Как дек поддерживает максимум dp[j на скользящем окне]
#### Реализация
Как узнавать “DP+монотоник” в задачах
Сигналы:
В переходе есть max/min по диапазону j.
Диапазон “двигается” с ростом i (скользящее окно).
В лоб получается или .Важно различать структуры:
Монотоническая очередь (deque) — когда окно скользит, и нужно выбрасывать устаревшие элементы.
Монотонический стек — когда вам нужен “предыдущий меньший/больший” элемент и однонаправленная обработка.В этой статье мы сфокусировались на деке, потому что он напрямую ускоряет DP на окне.
Сводная шпаргалка по паттернам
| Паттерн | Тип состояния | Тип перехода | Порядок вычисления | Типичные сложности |
|---|---|---|---|---|
| Интервальное DP | dp[l][r] | перебор k внутри интервала | по длине интервала | |
| Битмаска DP | dp[mask] или f(mask) | добавить/убрать элемент | часто top-down + кэш | |
| DP+монотоник | dp[i] + структура | max/min по диапазону | слева направо | или |
Итоги
Интервальное DP почти всегда строится вокруг идеи “выбрать последний шаг внутри интервала”, чтобы разложить задачу на два независимых подинтервала.
DP по битмаске кодирует множество выбранных элементов и особенно полезно при маленьком n, когда важен именно набор, а не позиция.
DP+монотоническая структура ускоряет переходы вида “взять максимум/минимум по скользящему окну” и превращает в .Эти паттерны продолжают ту же линию курса: успех в DP на LeetCode определяется не “знанием формул”, а дисциплиной формулировки состояния и честным перечислением вариантов последнего шага — после чего остаётся подобрать правильный порядок и оптимизацию.