Перейти к содержанию

10.3 Представление множеств

Каждое подмножество множества {0, 1, 2, ..., n-1} можно представить в виде n-битного целого числа, где единичные биты показывают, какие элементы входят в подмножество. Это очень удобный способ хранения множеств: на каждый элемент нужен всего один бит памяти, а операции над множествами превращаются в обычные побитовые операции.

Например, тип int в C++ обычно занимает 32 бита. Значит, одно значение типа int может хранить любое подмножество множества {0, 1, 2, ..., 31}.

Рассмотрим множество {1, 3, 4, 8}. Его битовая запись выглядит так:

00000000000000000000000100011010

Это число равно:

\[ 2^8 + 2^4 + 2^3 + 2^1 = 282. \]

Реализация множества

Следующий код объявляет переменную x, которая хранит подмножество множества {0, 1, 2, ..., 31}. Затем в это множество добавляются элементы 1, 3, 4 и 8, после чего выводится его размер.

int x = 0;

x |= (1 << 1);
x |= (1 << 3);
x |= (1 << 4);
x |= (1 << 8);

cout << __builtin_popcount(x) << "\n"; // 4

Функция __builtin_popcount(x) возвращает количество единичных битов в числе x, то есть фактически размер множества.

Теперь выведем все элементы, которые входят в множество:

for (int i = 0; i < 32; i++) {
    if (x & (1 << i)) cout << i << " ";
}

// вывод: 1 3 4 8

Здесь мы по очереди проверяем каждый бит. Если i-й бит установлен, значит элемент i принадлежит множеству.

Операции над множествами

Побитовые операции позволяют очень быстро выполнять стандартные операции теории множеств.

Операция Обычная запись Побитовая запись
Пересечение A ∩ B a & b
Объединение A ∪ B a | b
Дополнение Ā ~a
Разность A \ B a & (~b)

Разберём пример. Пусть

  • x = {1, 3, 4, 8}
  • y = {3, 6, 8, 9}

Тогда объединение этих множеств равно {1, 3, 4, 6, 8, 9}:

int x = (1 << 1) | (1 << 3) | (1 << 4) | (1 << 8);
int y = (1 << 3) | (1 << 6) | (1 << 8) | (1 << 9);

int z = x | y;

cout << __builtin_popcount(z) << "\n"; // 6

Для наглядности можно представить это так:

flowchart TD
    X["x = {1, 3, 4, 8}"]
    Y["y = {3, 6, 8, 9}"]
    Z["z = x ∪ y = {1, 3, 4, 6, 8, 9}"]
    X --> Z
    Y --> Z
Hold "Ctrl" to enable pan & zoom

Перебор подмножеств

Очень важное применение битовых масок — перебор всех подмножеств.

Следующий код проходит по всем подмножествам множества {0, 1, ..., n-1}:

for (int b = 0; b < (1 << n); b++) {
    // обработать подмножество b
}

Каждое число b от 0 до 2^n - 1 кодирует одно подмножество.

Если нужно перебрать только подмножества размера k, можно использовать __builtin_popcount:

for (int b = 0; b < (1 << n); b++) {
    if (__builtin_popcount(b) == k) {
        // обработать подмножество b размера k
    }
}

Также часто нужно перебрать все подмножества уже заданного множества x. Это можно сделать так:

int b = 0;
do {
    // обработать подмножество b
} while ((b = (b - x) & x));

Этот приём компактен и очень популярен в задачах на битовые маски.

Что важно запомнить

Представление множеств через биты особенно полезно, когда:

  • число элементов невелико;
  • нужно быстро выполнять операции пересечения, объединения и разности;
  • требуется перебирать все подмножества;
  • состояние в задаче удобно кодировать набором выбранных элементов.

Именно поэтому битовые маски так часто встречаются в олимпиадном программировании, особенно в переборе, динамике по подмножествам и задачах на комбинаторику.