О чём статья
Рекурсия — самая универсальная техника алгоритмики: ей решается любая задача, где ответ для целого определяется ответом для меньших частей. Цена универсальности — без аккуратной работы со структурой перебора экспоненциальная сложность убивает любую попытку уложиться в TL. Поэтому в реальной тренировке «знание рекурсии» означает не один навык, а четыре последовательных уровня: написать чистую рекурсию по дереву решений, добавить отсечения там, где это не ломает корректность, заменить повторяющиеся вызовы мемоизацией, и — где это даёт стабильный выигрыш — перевести то же самое в итеративный DP.
Эта статья — про каркас, по которому шагают все четыре уровня. Цель — чтобы при виде задачи с маленькими ограничениями вида , или «перечислить все способы», рука сразу тянулась за рекурсивным каркасом, а в случае повторных подзадач — за мемоизацией. И чтобы переход от рекурсии к DP не выглядел как «другой алгоритм», а воспринимался как та же формула, аккуратно записанная без вызова функции.
Когда применять: сигналы в условии
- «Перечислить все», «сколько способов», «существует ли набор». Прямой сигнал на перебор. Если ответ — структурный объект (подмножество, перестановка, разбиение), почти всегда стартуем с рекурсии.
- Маленькое ограничение по «структурной» оси. намекает на — перебор подмножеств. — перестановки или вложенный перебор. — meet-in-the-middle, всё ещё рекурсия в основе.
- «Сумма / произведение / комбинация» с малым диапазоном. , — сигнал, что состояние можно явно описать и закэшировать; рекурсия плавно превращается в DP.
- Условие даёт древовидную структуру выбора. «На шаге можно либо , либо » — естественное дерево решений. Если дерево «глубокое и узкое» (фиксированная глубина , на каждом уровне 2–3 варианта) — почти всегда работает или мемоизация.
- «Найти лексикографически минимальное», «вывести сам набор». Перебор удобен тем, что попутно строит сам объект; DP-таблица хранит только число, и для восстановления нужен отдельный проход.
Если сигналов нет — рекурсия, скорее всего, не нужна. Если ограничения большие (), а перебор пишется на — это явный признак, что задача требует не перебора, а структурного приёма (жадный, DP по другому индексу, графовый алгоритм).
Базовый шаблон
Идея в одну фразу
Перебор устроен как обход дерева решений в глубину: на каждом шаге фиксируем один выбор, рекурсивно решаем подзадачу для оставшихся шагов, возвращаемся и пробуем следующий вариант.
Псевдокод чистой рекурсии
функция solve(state):
если состояние терминальное:
обновить ответ или вернуть значение
return
для каждого допустимого варианта v на шаге state:
применить v к state // войти в выбор
solve(state')
откатить v из state // выйти из выбора
Базовая идея: состояние — это набор уже сделанных выборов, варианты — что можно сделать на следующем шаге. Возврат из solve гарантирует, что мы попробовали поддерево; откат необходим, потому что одни и те же структуры (массив, флажки) переиспользуются для соседних веток.
Базовый шаблон с отсечениями
функция solve(state, current_value):
если состояние терминальное:
обновить ответ
return
если current_value уже хуже лучшего ответа: // отсечение по оценке
return
если нарушен инвариант: // отсечение по инварианту
return
для каждого допустимого варианта v (в правильном порядке):
применить v
solve(state', current_value + delta)
откатить v
Отсечения — два класса:
- По оценке. Если уже текущий промежуточный результат хуже зафиксированного лучшего, дальше делать нечего. Лучший результат держим в нелокальной переменной (глобальной или ссылке).
- По симметрии / инварианту. Если выбор приводит к структурно эквивалентному поддереву (например, переставленные индексы), пропускаем. Самый известный пример — N-queens, где можно требовать упорядоченности по строкам.
Базовый шаблон с мемоизацией
cache ← пустая таблица
функция solve(state):
если state в cache:
вернуть cache[state]
если состояние терминальное:
вернуть базовый ответ
результат ← агрегация по вариантам:
для каждого допустимого варианта v:
agg(результат, f(v, solve(state')))
cache[state] ← результат
вернуть результат
Главное отличие от перебора: функция возвращает значение, а не обновляет глобальный счётчик. Это требование мемоизации — кэш связывает состояние с ответом, а не с побочным эффектом. Если структура «вернуть значение» не получается естественно, мемоизация не подойдёт.
Почему работает
Перебор корректен, потому что обход дерева решений в глубину посещает все листы — то есть все допустимые конфигурации. Отсечения корректны, если гарантированно не убивают ни одного победителя: оценка должна быть нижней (или верхней — в зависимости от направления оптимизации) границей лучшего результата в поддереве. Мемоизация корректна, если состояние полностью описывает «оставшуюся задачу» — то есть два пути с одинаковым состоянием дальше эквивалентны независимо от того, как мы туда пришли.
Сложность перебора без отсечений — экспоненциальная по числу шагов. С мемоизацией — линейная по числу различных состояний, умноженная на стоимость одного перехода. Это и есть «момент превращения рекурсии в DP»: число различных состояний становится конечным и обозримым.
Задача-иллюстрация 1: подсчёт подмножеств с заданной суммой (~CF 1300)
Условие
Дан массив из положительных целых чисел и целое число . Нужно найти количество подмножеств массива, сумма элементов которых равна ровно . Подмножество идентифицируется множеством индексов, поэтому одинаковые значения на разных позициях считаются разными.
Ограничения. , , .
Пример. , , . Ответ — : подмножества и .
Применение шаблона
Состояние — позиция (сколько элементов уже рассмотрено) и текущая сумма . На шаге два варианта: «взять » (перейти к ) или «не взять» (перейти к ). Терминальное состояние — , тогда возвращаем , если , и иначе.
Это чистый перебор без отсечений. Сложность — . При — около , на грани TL. Можно ускорить отсечением: если уже превысила , останавливаем ветку — дальше элементы только увеличивают сумму (по условию положительные).
С мемоизацией здесь сложно работать «в лоб»: может быть до , кэш по состоянию не помещается. Если бы было маленьким (), кэш по дал бы — это и есть классический «subset sum DP».
Разбор на примере
, . Дерево решений:
(0, 0)
взять 1 / \ не взять
(1,1) (1,0)
взять 2 / \ не взять ...
(2,3) (2,1)
взять 3 / \ не взять
(3,6)* (3,3)
взять 4 / \ не взять
(4,7)* (4,3) ← не 5, отбрасываем
Звёздочкой помечены случаи, где сумма превысила (отсечение по оценке). Лист встречается на двух разных путях: и . Ответ — .
Код решения
Глубина рекурсии в Python: при это безопасно даже без
setrecursionlimit. Для перестраховки выставили100. На больших (до 40 и выше с meet-in-the-middle) можно столкнуться с переполнением стека — тогда либоsetrecursionlimit(10000), либо переписывать итеративно черезbitmask.
Что поменялось
Чистый перебор + одно отсечение по оценке. Это минимальный шаг от классической рекурсии, который уже даёт ускорение на тестах, где целевое маленькое относительно суммы массива. На пограничных тестах (, близко к полусумме) отсечение почти ничего не даёт — там нужно meet-in-the-middle (разделить массив на две половины по 15 элементов, перебрать все подсуммы каждой половины и соединить через хешмап). Это уже не базовая рекурсия, а её комбинация со структурой данных.
Задача-иллюстрация 2: подсчёт способов разменять сумму монетами (~CF 1500)
Условие
Дано номиналов монет и сумма . Каждая монета доступна в неограниченном количестве. Найти количество способов набрать сумму из этих монет, где порядок монет не важен (т.е. набор и — один и тот же способ).
Ограничения. , , .
Пример. Номиналы , . Ответ — : .
Применение шаблона
Состояние — пара : рассмотрены номиналы , набрана сумма . На шаге перебираем, сколько монет -го номинала взяли — , пока . Переходим в .
Терминальное состояние: . Возвращаем , если , и иначе. Чистый перебор даёт экспоненциальную сложность — на тестах , не пройдёт.
Но! Состояние полностью описывает оставшуюся задачу. Состояний всего . Это сигнал к мемоизации.
Как пишется мемоизация
После того как чистая рекурсия написана, добавляем кэш по (i, s). Если функция вернула значение для этого состояния раньше — отдаём из кэша. Иначе считаем и кладём в кэш.
После добавления кэша сложность падает с до — последний множитель из-за того, что переход по занимает до шагов. На , , — это , всё ещё много.
Чтобы убрать лишний фактор, перебор по заменяется на «либо взять одну монету и остаться на шаге (разрешая взять ещё), либо перейти к ». Это даёт сложность — стандартная DP по монетам.
Разбор на примере
, . Считаем — число способов набрать монетами номиналов . Базовый случай: ; при .
| 0 | 1 | 2 | 3 | 4 | |
|---|---|---|---|---|---|
| 3 (после всех) | 1 | 0 | 0 | 0 | 0 |
| 2 (берём ) | 1 | 0 | 0 | 1 | 0 |
| 1 (берём ) | 1 | 0 | 1 | 1 | 2 |
| 0 (берём ) | 1 | 1 | 2 | 3 | 4 |
Ответ — . Сходится с ручным разбором.
Код решения
Что поменялось
Главное усложнение — функция возвращает значение, а не обновляет нелокальный счётчик. Это требование мемоизации. Если попытаться кэшировать функцию, которая обновляет глобальный ans, кэш не работает: «вернули из кэша» не равно «прибавили к счётчику».
Второе — переход от перебора к двум вариантам «взять ещё один » / «перейти к ». Этот трюк удваивает число вершин в дереве рекурсии, но убирает лишний фактор из каждого перехода. На итоговую сложность с мемоизацией это влияет драматически: вместо .
Переход к итеративному DP
Мемоизированная рекурсия и итеративный DP — это одна и та же формула, записанная двумя способами. У нас была формула . Запишем её итеративно, в направлении от больших к меньшим, для каждого от до :
dp[n+1][S+1] = 0
dp[n][0] = 1
для i от n - 1 до 0:
для s от 0 до S:
dp[i][s] = dp[i+1][s] // не берём i
если s - c[i] ≥ 0:
dp[i][s] += dp[i][s - c[i]] // берём ещё один c[i]
ответ = dp[0][S]
Эта версия:
- не требует кэша (хранение — двумерный массив или одномерный rolling, размер );
- избегает накладных расходов на вызовы функции (особенно важно в Python — заметное ускорение);
- не требует увеличения
setrecursionlimit.
Минус — менее «понятный» вид: не видно дерева рекурсии, и легче ошибиться с порядком обхода. На олимпиаде выбор между мемоизацией и итеративным DP — вопрос привычки и характера задачи. Для задач с порядок-зависимым состоянием (например, DP на дереве, на интервалах) мемоизация часто проще; для классического «по индексам» — итеративный DP надёжнее.
Типичные ошибки
- Забытый откат изменения в backtracking. Если на шаге «взять» меняли разделяемую структуру (массив
used[], текущую сумму на ссылке), а на возврате не восстановили — следующая ветка увидит «грязное» состояние. Симптом: правильный ответ на маленьких тестах, мусор на больших. - Мемоизация с побочными эффектами. Кэшировать функцию, которая обновляет глобальный счётчик или печатает что-то, нельзя. Кэш ловит только возврат, побочные эффекты не воспроизводятся. Если функция нужна для побочных эффектов — мемоизация не для неё.
- Состояние не описывает оставшуюся задачу. Если ответ функции зависит не только от аргументов, но и от того, как мы туда пришли (например, от множества посещённых вершин, не входящего в state), мемоизация даст неверный результат. Симптом: при чистой рекурсии всё хорошо, после добавления кэша — WA.
- Отсечение убивает корректное поддерево. Отсечение по оценке корректно только если оценка — гарантированная граница (нижняя для максимизации, верхняя для минимизации). Жадная оценка («скорее всего, не лучше») — некорректное отсечение.
- Переполнение при больших значениях суммы. В задаче 1 сумма может достигать . В C++ — обязательно
long long. В Python — нет проблем. - Глубина рекурсии в Python. При – всё в порядке. При — обязательный
sys.setrecursionlimit(...). На очень больших — итеративная переписка (стек руками или DP). - Использование
dictвместоdp[i][s]в C++. На больших таблицахunordered_map<pair<int,int>, ...>медленнее в 5–10 раз, чемvector<vector<long long>>. Если состояние плотное по ключу — массив всегда быстрее. - Кэш сбрасывается между тестами. В задачах с несколькими тест-кейсами в одном запуске —
cache.clear()в начале каждого теста. Иначе соседние тесты «загрязняют» друг друга.
Когда НЕ подходит
- Большое при экспоненциальной структуре. и нужно перебрать подмножества — рекурсия не поможет. Нужна другая идея (DP по подмножествам не помещается тоже; иногда — meet-in-the-middle, иногда — структурное наблюдение).
- Состояние не описывается компактно. Если для оценки «эквивалентны ли два пути» нужно сравнить два массива длины — мемоизация не работает. Кэш по такому ключу будет линейного размера на каждый вызов, и выигрыша нет.
- Глубокое-узкое дерево с большой стоимостью одного шага. Если каждый рекурсивный шаг — это операций (сортировка, пересчёт массива), даже мемоизация может не помочь. Иногда выручает change-tracking (хранить только изменения) или итеративный DP с rolling array.
- Задача про оптимум в непрерывном пространстве. Бинарный поиск, тернарный поиск, выпуклые оболочки — для них рекурсия не нужна. Перебор имеет смысл только в дискретных пространствах решений.
Связанные техники
- DP с явной таблицей. Естественный следующий шаг после мемоизации. Когда формула состоянного перехода устоялась, переписываем рекурсию в итеративный массив — это быстрее, меньше памяти, и проще профилировать.
- Bitmask DP. Когда состояние — подмножество, маска как индекс даёт компактный массив
dp[mask]вместоcache[set]. Применимо при . - Meet-in-the-middle. Расширение перебора подмножеств на : делим на две половины, перебираем каждую за , соединяем через сортировку или хешмап.
- Branch and bound. Развитие отсечений: помимо отсечения по оценке, удерживаем приоритетную очередь поддеревьев и выбираем самое «перспективное». Используется в задачах оптимизации, где гарантия корректности отсечений тонкая.
- DFS на графе с состояниями. Когда «дерево решений» становится графом (некоторые состояния достижимы разными путями) — это DP/мемоизация на DAG. Принципиально тот же шаблон, что и в задаче 2, но интерпретация другая.
Итого
- Техника: рекурсия по дереву решений + откат + (опционально) отсечения и мемоизация.
- Сложность шаблона: для чистой рекурсии; с мемоизацией.
- Сигналы в условии: «перечислить все / сколько способов», маленькое (–), маленькая ось «суммы / комбинации» (), древовидная структура выбора.
- Типичные ловушки: забытый откат, мемоизация с побочными эффектами, неполное состояние, ошибочные отсечения по эвристике.