Bitmask DP — техника, у которой пугающий ярлык: до 24, состояние «подмножество», экспоненциальное по подмножествам. На самом деле это та же динамика, что и линейная — только индекс состояния пробегает не отрезок , а множество всех подмножеств. Когда из условия ясно, что порядок прохождения элементов важен, а сами элементы — фиксированный набор без повторений, и маленькое (до 22–24) — bitmask DP часто оказывается единственным разумным способом.
Эта задача — эталонный учебный кейс: ответ — число перестановок с ограничением «не попадать в запрещённые точки», и эти запреты завязаны на сумму уже сделанных шагов. То есть состояние полностью описывается набором уже сделанных шагов, без необходимости знать их порядок — потому что сумма коммутативна. Ровно та структура, на которую ложится bitmask DP с состоянием «маска использованных».
Что дано
Игрок ходит по числовой прямой. Перед игрой ему даны положительных целых чисел — длины шагов. За один ход игрок выбирает один ещё не использованный шаг и сдвигается на эту длину вправо. Каждый шаг используется ровно один раз; всего ходов — ровно .
Перед игрой на прямой отмечены «плохих» точек . После каждого хода игрок не должен оказаться ни в одной из плохих точек. Стартует игрок в точке , эта стартовая точка тоже не должна быть плохой (если , ответ — ).
Нужно найти количество различных порядков использования шагов , при которых это условие выполняется. Ответ берётся по модулю .
Ограничения
- .
- .
- .
- .
Формат ввода
n
a_1 a_2 ... a_n
k
b_1 b_2 ... b_k
Формат вывода
Одно целое число — количество допустимых перестановок по модулю .
Пример A
3
2 3 5
2
5 7
Перебор всех перестановок:
| перестановка | префиксы | прошла? |
|---|---|---|
| 2, 5, 10 | нет (5 ∈ bad) | |
| 2, 7, 10 | нет (7 ∈ bad) | |
| 3, 5, 10 | нет (5 ∈ bad) | |
| 3, 8, 10 | да | |
| 5, 7, 10 | нет (5 ∈ bad) | |
| 5, 8, 10 | нет (5 ∈ bad) |
Ответ — .
Пример B
4
1 2 3 4
2
3 7
Из 24 перестановок проходят 8 (полный список — в разделе «Проверка на примерах»). Ответ — .
Идея решения
Когда из условия в принципе непонятно, как атаковать, помогает упражнение «зафиксируй, какую информацию реально требует ограничение». Здесь ограничение работает после каждого хода: «текущая координата не равна ни одному из ». Текущая координата — это сумма уже сделанных шагов; она зависит только от множества уже использованных индексов, не от их порядка.
Это и есть ключевое наблюдение. Если у нас две частичные перестановки, использующие один и тот же набор индексов (например, против ), они приведут игрока в одну и ту же точку. Поэтому достаточно знать подмножество использованных индексов — а не конкретный порядок их использования.
Состояние DP: = число способов прийти в текущую конфигурацию, то есть число различных порядков, в которых можно использовать индексы из как первые шагов так, чтобы все частичные суммы по пути были «хорошими» (не попадали в ).
Сразу две приятные особенности:
- Состояние одномерное. Маска — это число от до ; никакого дополнительного параметра состояния (как «последний использованный элемент» в других bitmask-задачах) здесь не нужно.
- Сумма по маске — функция от маски. Точка, в которой стоит игрок после использования индексов , равна . Эту сумму можно посчитать заранее за — независимо от dp.
Состояние и переход
Состояние. — число различных порядков уложить элементы как первые ходов так, чтобы все промежуточные суммы (после 1-го, 2-го, …, -го хода) не лежали в .
База. — единственный способ ничего не сделать. Стартовая точка предполагается «хорошей» — отдельная проверка перед запуском DP покрывает случай .
Переход. Зафиксируем маску . Если , то после прохода через игрок попадёт в «плохую» точку — таких порядков нет, .
Иначе посмотрим, какой элемент пришёл последним в текущей перестановке. Это какой-то . После его удаления из маски остаётся подмножество — предыдущая конфигурация. Перебираем все как «последний» и суммируем:
Идея перехода — перебор по последнему действию. Это стандартный приём для bitmask DP в задачах, где порядок важен.
Ответ. — все элементов использованы, все промежуточные суммы хорошие.
Почему «по последнему действию», а не «по первому»
Можно было бы определить переход через первое действие: , что выглядит так же. Но смысл состояния тогда другой: — число способов разместить как последние ходов. Тогда фильтр по сумме должен проверять не только текущий префикс, но и «сумму до прихода в текущее множество», то есть полную сумму массива минус — это менее естественно. Перебор по последнему действию — более прямой.
Предподсчёт сумм по маскам
Считать в момент перехода — означает проход по битам, итого только на суммы. Это удвоит и так тесную сложность.
Лучше — рекуррент по младшему биту. Пусть — младший установленный бит (например, ). Пусть — позиция этого бита. Тогда
Это даёт за — один проход по маскам в порядке возрастания, на маску. Аналогично можно перебирать по старшему биту — выбор не влияет на корректность.
Руками на примере B
Возьмём пример: , , .
Шаг 1. Считаем для всех 16 масок. Обозначаю битами «использовал ли элемент » — бит соответствует , бит соответствует и т.д. Маска означает «использованы ».
| mask (бин) | mask | ? | |
|---|---|---|---|
| 0000 | 0 | 0 | — |
| 0001 | 1 | 1 | — |
| 0010 | 2 | 2 | — |
| 0011 | 3 | 3 | да |
| 0100 | 4 | 3 | да |
| 0101 | 5 | 4 | — |
| 0110 | 6 | 5 | — |
| 0111 | 7 | 6 | — |
| 1000 | 8 | 4 | — |
| 1001 | 9 | 5 | — |
| 1010 | 10 | 6 | — |
| 1011 | 11 | 7 | да |
| 1100 | 12 | 7 | да |
| 1101 | 13 | 8 | — |
| 1110 | 14 | 9 | — |
| 1111 | 15 | 10 | — |
Шаг 2. Считаем в порядке возрастания (это автоматически гарантирует, что все подмаски уже посчитаны).
| mask | биты | ? | переход | |
|---|---|---|---|---|
| 0 | — | база | 1 | |
| 1 | нет | 1 | ||
| 2 | нет | 1 | ||
| 3 | да | — | 0 | |
| 4 | да | — | 0 | |
| 5 | нет | 1 | ||
| 6 | нет | 1 | ||
| 7 | нет | 2 | ||
| 8 | нет | 1 | ||
| 9 | нет | 2 | ||
| 10 | нет | 2 | ||
| 11 | да | — | 0 | |
| 12 | да | — | 0 | |
| 13 | нет | 3 | ||
| 14 | нет | 3 | ||
| 15 | нет | 8 |
Ответ — . Совпадает с прямым подсчётом 8 валидных перестановок из 24 (полный список в разделе «Проверка на примерах»).
Обратите внимание на «занулённые» маски : они участвуют в переходах дальше, но всегда вносят 0. Это правильно: если на пути в любую конфигурацию мы попадём в «плохую» сумму — этот порядок отбраковывается, что и делает занулённый член.
Алгоритм целиком
прочитать n, a[1..n], k, bad[1..k]
если 0 ∈ bad:
вывести 0; выйти
MOD ← 10^9 + 7
size ← 2^n
S[0] ← 0
для mask от 1 до size - 1:
low ← mask & -mask
i ← log2(low)
S[mask] ← S[mask ⊕ low] + a[i]
dp[0] ← 1
для mask от 1 до size - 1:
если S[mask] ∈ bad:
dp[mask] ← 0
продолжить
cur ← 0
m ← mask
пока m ≠ 0:
low ← m & -m
cur ← cur + dp[mask ⊕ low]
m ← m ⊕ low
dp[mask] ← cur mod MOD
вывести dp[size - 1]
Циклы по mask и по битам внутри mask — самая горячая часть. На : внешний цикл — итераций, внутренний — в среднем . Итого порядка операций тела цикла. В C++ это 2–3 секунды на современном CF-сервере, в Python — десятки секунд, не помещается в TL без перехода на нативные расширения.
Код решения
Сразу важное замечание про языки: эта задача в Python почти не проходит даже с агрессивными оптимизациями. Чистый Python на занимает – секунд, что превышает типичный CF TL (– секунды). Python-версия ниже корректна и проходит локальные тесты до за секунду; для CF-сабмита нужен C++. Включаю обе версии для целей разбора — Python читается легче и помогает разобраться в идее.
Комментарии по реализации
- Тип для сумм. до , до — сумма до . В C++
long longобязателен;intпереполнится. В Python целые числа произвольной точности. - Тип для dp. Значения по модулю — помещаются в
int(32 бита). В C++ важно делатьcurотдельно какlong long, потому что слагаемых по дают сумму до до взятия модуля. Если хранитьcurкакint, на переполнение случится примерно на середине внутреннего цикла. - Память на . В C++:
S— МБ,dp— МБ. Итого МБ. CF обычно даёт МБ — впритык, но проходит. В Python list of int — байт на элемент, итого МБ; не помещается. Если упирается —array.array('q', ...)сокращает до 8 байт. - Извлечение младшего бита.
mask & -maskработает в обоих языках. В C++ позиция бита — через__builtin_ctzза ; в Python —(low).bit_length() - 1, тоже для интов в нужном диапазоне. - Цикл по битам через
m &= m - 1. Это идиома «снять младший установленный бит». В C++ компактнее, чем XOR черезlow. В Python обе идиомы дают одинаковую производительность. - Множество
badв Python. При выбор междуsetиlistне критичен (проверка членства за против — на 2 элементах разница нулевая). Я оставилsetдля общности; для большего масштаба разница будет.
Проверка на примерах
Пример A
, , .
| mask | биты | ? | ||
|---|---|---|---|---|
| 0 | 0 | — | 1 | |
| 1 | 2 | нет | 1 | |
| 2 | 3 | нет | 1 | |
| 3 | 5 | да | 0 | |
| 4 | 5 | да | 0 | |
| 5 | 7 | да | 0 | |
| 6 | 8 | нет | ||
| 7 | 10 | нет |
Ответ — . ✓
Пример B
Полный список 8 валидных перестановок (для контроля):
| # | перестановка | префиксы |
|---|---|---|
| 1 | 1, 4, 6, 10 | |
| 2 | 1, 4, 8, 10 | |
| 3 | 1, 5, 8, 10 | |
| 4 | 2, 5, 6, 10 | |
| 5 | 2, 5, 9, 10 | |
| 6 | 2, 6, 9, 10 | |
| 7 | 4, 5, 8, 10 | |
| 8 | 4, 6, 9, 10 |
Все 8 перестановок свободны от значений 3 и 7 во всех префиксах. Прямой подсчёт совпал с DP: . ✓
Крайние случаи
- (нет плохих точек). Все перестановок проходят. DP вернёт . На : — корректно за , шаблон не упрощается, но и не ломается.
- . Стартовая точка — плохая, ответ . Эту проверку нужно делать до DP, потому что в самом DP мы не проверяем «до первого хода»:
dp[0] = 1без учётаbad. Без явной проверки шаблон вернёт неправильный вместо . - . Финальная точка плохая, ответ . DP это поймает сам: попадает в
bad, ветка «продолжить» обнулит . - Все маски «плохие». Например, , , . Ответ — единственная маска имеет сумму 5, попадает в bad.
- , . Ответ — единственная перестановка из одного шага.
- Очень близкие к лимиту . до , до . Все «плохие» значения, бо́льшие , никогда не достигаются — формально DP их игнорирует, корректно. В коде сравнение
S[mask] == bad[j]работает на одинаковом 64-битном типе, переполнения нет.
Типичные ошибки
- Забытая проверка . Без неё
dp[0] = 1без условий, и шаблон вернёт неверный вместо для случая «старт уже плохой». curкакintв C++. На сумма значений до взятия модуля переполнитint. Накапливать обязательно вlong long.- Использование
intдля в C++. Сумма до , переполнениеintгарантировано.long longдля . - Перестановка не в той форме. Естественный неверный вариант: «маска и последний элемент». Это даёт правильный ответ, но память — на это МБ, не помещается. Корректный шаблон обходится без
i, потому что сумма по маске не зависит от порядка — нет смысла раздваивать состояние. - Проверка
badчерез линейный поиск по большому . В этой задаче , и линейная проверкаfor (int j = 0; j < k; ++j)— даже быстрее, чемunordered_set. В похожих задачах с большим нуженset/unordered_setили предварительная сортировка с бинпоиском. - Итерация по маскам в неправильном порядке. Возрастающий порядок гарантирует, что все подмаски (с меньшим числом битов и меньшим значением) уже посчитаны. Идти, например, по числу установленных битов и внутри — по значению — тоже корректно, но это лишняя сложность реализации; обычный
for mask in 1..size-1достаточен. - Переполнение при для огромных . Если хранить в
int, сравнениеS[mask] == bad[j]при даст неопределённое поведение из-за неявных приведений.long longвезде убирает риск. - «Оптимизация» с пропуском занулённых масок. Желание не считать для «плохих» масок отдельным шагом — корректное, но удалять их из массива нельзя: они нужны как нули в переходах для бо́льших масок (см. пример B, маски 3, 4, 11, 12 нужны для переходов в 7, 13, 14, 15).
Сложность
- Время. Внешний цикл по маскам — . Внутренний цикл по битам в маске — в худшем случае , в среднем . Сумма по всем маскам числа установленных битов — . Итого . На : — в C++ 2–3 секунды.
- Память. для массива и для . На — МБ в C++, не помещается в Python без
array.array. - Предподсчёт сумм. — линейно по числу масок, по операций каждая.
Альтернативное состояние (последний элемент) при той же асимптотике даёт времени, но памяти. На это уже не проходит по памяти. Поэтому для задач, где порядок внутри маски не важен для перехода, версия с одномерным состоянием — единственная разумная.
Связанные задачи
- Гамильтонов путь / цикл за . Здесь состояние двумерное — . «Последний» нужен, потому что переход — это «добавить ребро
last → next», и стоимость зависит от пары вершин. На — терпимо (). - DP по подмножествам подмножества (subset sum DP). , где
subпробегает все подмаскиmask. Тонкость — порядок перебора подмасок:sub = (sub - 1) & mask— стандартная идиома, даёт суммарную сложность . - DP по битовой маске с дополнительным числовым параметром. Когда состояние требует «маска + значение в каком-то диапазоне» — это смесь двух парадигм; на полосе CF 2300+.
- Counting/permutations через включения-исключения. Альтернатива bitmask DP для задач со счётом перестановок с ограничениями. Часто даёт лучший асимптотически результат — против — но требует более тонкого формулирования.
- Тема в этой серии. Bitmask DP — часть третьей ступени серии «Стратегия победы: DP — от очевидного шаблона к ёмкому состоянию»: переход к подмножеству-состоянию — содержательный шаг от линейного DP и от двумерного DP.
Итого
- Идея: — число допустимых порядков пройти через подмножество как первые шагов. Сумма по маске = координата игрока после ходов.
- Ключевое наблюдение: сумма не зависит от порядка → второй параметр состояния не нужен.
- Переход: перебор «последнего» использованного элемента — при , иначе .
- Предподсчёт сумм: за .
- Сложность: времени, памяти. На — 2–3 секунды в C++.
- Главные ловушки: проверка до DP,
long longдля сумм иcur, не раздваивать состояние по «последнему элементу» (лишняя память).
Источник задачи: Codeforces 327E «Axis Walking».