Генерация подмножеств и битовые маски
Генерация подмножеств и битовые маски
Представьте задачу: у вас есть 20 посылок разного веса, и нужно загрузить курьерский фургон так, чтобы суммарный вес составил ровно 500 килограммов. На бумаге комбинаторный ответ очевиден — число возможных комбинаций равно . Человек без компьютера потратил бы на выписывание этих вариантов годы, но для процессора миллион операций — дело нескольких миллисекунд. Вопрос лишь в том, как заставить программу перебрать все варианты без избыточных затрат памяти и без путаницы с рекурсией.
Решение кроется в фундаментальном мостике между комбинаторикой и низкоуровневой архитектурой процессоров — битовых масках.
Мостик от множества к числу
Пусть дано базовое множество из упорядоченных элементов с индексами от до :
Каждое подмножество однозначно определяется правилом: для каждого элемента мы либо включаем его в , либо нет. Это бинарный выбор:
- , если ;
- , если .
Последовательность таких нулей и единиц длины называется характеристическим вектором (или вектором принадлежности). Но последовательность из нулей и единиц — это не что иное, как двоичная запись целого неотрицательного числа.
Позиция бита (считая справа налево с нуля) соответствует индексу элемента, а значение бита указывает на его присутствие в подмножестве:
| Индекс | ||||
|---|---|---|---|---|
| Элемент | ||||
| Включение в |
Бинарная цепочка в десятичной системе счисления равна:
Ключевой инсайт: Между всеми подмножествами -элементного множества и целыми числами от до существует взаимно однозначное соответствие (биекция). Пустому множеству соответствует число , а всему множеству — число .
Целое число, представляющее подмножество через свои биты, в программировании называют битовой маской (bitmask).
Арсенал побитовых операций
Поскольку маска хранится как обычное целое число в регистре процессора, операции над множествами выполняются на аппаратном уровне за один такт. Для манипуляции битами используются базовые операторы.
1. Сдвиг влево и единичная маска элемента
Выражение 1 << i сдвигает единицу на разрядов влево, порождая двоичное число, в котором установлен только -й бит (число ). Это маска для одного элемента .
2. Проверка принадлежности элемента ()
Чтобы проверить, установлен ли -й бит в маске mask, применяют побитовое «И» (&):
is_present = (mask & (1 << i)) != 0
# Либо проверка выдвижением бита в нулевой разряд:
is_present = ((mask >> i) & 1) == 1
Если -й бит равен нулю, результат побитового умножения на 1 << i даст . Если равен единице — число .
3. Добавление элемента ()
Включение элемента выполняется через побитовое «ИЛИ» (|):
mask = mask | (1 << i)
Операция устанавливает -й бит в , оставляя остальные разряды неизменными.
4. Удаление элемента ()
Для сброса бита в используется побитовое «НЕ» (~), инвертирующее все разряды, и побитовое «И»:
mask = mask & ~(1 << i)
В выражении ~(1 << i) все биты равны единице, кроме -го. Умножение на такую маску гарантированно зануляет -й бит.
5. Симметрическая разность / переключение состояния
Побитовое исключающее «ИЛИ» (^) меняет значение бита на противоположное:
mask = mask ^ (1 << i)
6. Операции над двумя множествами
| Теоретико-множественная операция | Побитовый эквивалент | Название |
|---|---|---|
| Пересечение: | mask_A & mask_B |
Побитовое И (AND) |
| Объединение: | mask_A | mask_B |
Побитовое ИЛИ (OR) |
| Разность: | mask_A & ~mask_B |
И с инверсией |
| Симметрическая разность: | mask_A ^ mask_B |
Побитовое XOR |
Итеративная генерация всех подмножеств
Осознание биекции даёт простейший алгоритм полного перебора: достаточно запустить цикл от до и для каждого числа восстановить входящие в него элементы.
def generate_all_subsets(elements: list[str]) -> list[list[str]]:
n = len(elements)
total_subsets = 1 << n # 2^n
all_subsets = []
for mask in range(total_subsets):
current_subset = []
for i in range(n):
if (mask >> i) & 1:
current_subset.append(elements[i])
all_subsets.append(current_subset)
return all_subsets
Разберём вычислительную сложность алгоритма:
- Внешний цикл выполняется ровно раз.
- Внутренний цикл для каждой маски выполняет итераций, проверяя каждый бит.
Итоговая временная сложность: . Пространственная сложность для генерации одного подмножества: вспомогательной памяти (если элементы сразу обрабатываются, а не сохраняются в список).
Практическое ограничение: размер
В современных 64-битных системах стандартный целочисленный тип вмещает до 64 бит. Это означает, что маска может описывать множество мощностью до .
Однако сложность накладывает жесткие рамки на время:
- При : операций — выполняется за 0.05 секунды.
- При : операций — займет несколько секунд на C++ и десятки секунд на Python.
- При : операций — потребует часов работы суперкомпьютера.
Поэтому метод битовых масок на практике применим при для задач полного перебора.
Решение прикладной задачи: Subset Sum
Вернемся к задаче о загрузке фургона. Пусть дан список весов и целевая масса :
def find_exact_subset(weights: list[int], target_sum: int) -> list[int] | None:
n = len(weights)
for mask in range(1 << n):
current_sum = 0
for i in range(n):
if (mask >> i) & 1:
current_sum += weights[i]
if current_sum == target_sum:
return [weights[i] for i in range(n) if (mask >> i) & 1]
return None
weights = [45, 120, 80, 210, 75, 90]
target = 375
print(find_exact_subset(weights, target)) # [45, 120, 210] -> 45 + 120 + 210 = 375
Код не требует стека рекурсии, легко прерывается при первом найденном совпадении и не расходует лишнюю память.
Продвинутый трюк: перебор всех подмасок заданной маски
В динамическом программировании и дискретной оптимизации регулярно возникает подзадача: дана некоторая конфигурация (маска mask), и нужно перебрать не все возможные множества в мире, а только подмножества данного подмножества (подмаски).
Наивный путь — перебрать все числа от до mask и для каждого проверить условие (sub & mask) == sub. Но если mask содержит единичных бит, число ее подмасок равно , в то время как само число mask может достигать . Наивный перебор проверит множество заведомо посторонних чисел.
Существует классический битовый трюк, перебирающий строго подмаски в порядке убывания:
submask = mask
while True:
# Обработка текущей подмаски
process(submask)
if submask == 0:
break
submask = (submask - 1) & mask
Как работает шаг (submask - 1) & mask?
- Операция
submask - 1находит самый младший единичный бит вsubmask, обнуляет его, а все биты младше него превращает в единицы. - Последующее побитовое
& maskотсекает те младшие единицы, которых изначально не было в родительской маскеmask.
В результате мы мгновенно переходим к лексикографически следующей корректной подмаске, минуя все числа с посторонними битами.
Сложность перебора подмасок для всех масок
Если мы запустим перебор подмасок для каждой маски от до :
for mask in range(1 << n):
submask = mask
while True:
# шаг обработки
if submask == 0:
break
submask = (submask - 1) & mask
Сколько всего итераций выполнит внутренний цикл суммарно?
Для маски с установленными битами существует ровно подмасок. Число масок длины с ровно единичными битами равно числу сочетаний . Следовательно, суммарное число итераций:
Формула бинома Ньютона доказывает: суммарное время работы составляет , а вовсе не , как при наивном вложенном переборе всех пар. Для : действий — вполне под силу уложить в секунду процессорного времени.
Рекурсивный перебор vs Битовые маски
В алгоритмической практике подмножества генерируют двумя путями: через битовые маски или рекурсивным деревом решений (backtracking).
| Критерий | Битовые маски | Рекурсивный обход дерева |
|---|---|---|
| Реализация | Компактный итеративный цикл | Функция с рекурсивными вызовами |
| Память | вспомогательной памяти | памяти под стек вызовов |
| Накладные расходы | Минимальные (машинные инструкции) | Вызовы функций, передача параметров |
| Раннее отсечение ветвей (pruning) | Затруднено (проверяет все маски подряд) | Естественное (не спускаемся в поддерево при переполнении суммы) |
| Ограничение по размеру | (обычно до 64) | Ограничено глубиной стека (но время растет экспоненциально) |
Если пространство поиска необходимо просеивать полностью (проверить абсолютно все состояний или найти оптимальное подмножество при небольшом ) — битовые маски вне конкуренции по скорости и чистоте кода. Если же задача позволяет отсекать заведомо тупиковые ветви на ранних шагах (например, сумма уже превысила целевую), эффективнее оказывается поиск с возвратом.
Теперь у вас есть строгий математический аппарат и программный инструмент для перебора произвольных подмножеств. В следующей главе мы перейдем от выбора элементов к их упорядочиванию: разберем алгоритмы генерации перестановок и метод Нараяны.