Битсет и битовое сжатие - Алгоритмика
Битсет и битовое сжатие

Битсет и битовое сжатие

автор Сергей Слотин
опубликовано 2019
обновлено февр. 18, 2021

Нумерованные множества можно представлять в виде булевых массивов: если элемент $x$ присутствует в множестве, то $x$-тый элемент массива будет равен единице, и нулю в противном случае.

Разные теоретико-множественные операции часто можно свести к поэлементным операциям булевыми массивами. Например:

  • Объединение множеств — побитовое ИЛИ.
  • Пересечение множеств — побитовое И.
  • Симметрическая разность — побитовый XOR (исключающее ИЛИ).

Процессор устроен так, что работает не с отдельными битами, а сразу с блоками по, например, 32 или 64 бита — эта величина называется машинным словом — и поэтому операции, затрагивающие лишь один бит, на самом деле «стоят» столько же, сколько и операции над всеми битами int или long.

Здесь появляется следующая идея оптимизации: сгруппировать элементы булева массива в блоки размера 64 и каждый такой блок представить 64-битным двоичным числом. Тогда можно применять соответствующие побитовые операцию сразу к 64 элементам и тратить на это один процессорный такт вместо 64-х.

std::bitset

Это всё несложно кодить и вручную, но в STL всё уже сделано до нас. bitset — структура, ведущая себя как большое двоичное число со всеми стандартными битовыми операциями:

const int lim = 1000;
bitset<lim> b; // создать битсет размера lim (должно быть константой)
b.set();       // заполнить единицами
b.reset();     // заполнить нулями
b.flip();      // заменить единички на нули и наоборот
b.count();     // посчитать число единичек
cout << b;     // вывести бинарную строку
cout << b[42]; // вывести 43-ий (нумерация с нуля, как у массивов) бит "справа"

Также для битсетов работает вся битовая арифметика: &, |, ^, ~, <<, >> и их варианты с [operator]=.

Примечание. Часто «асимптотику» использующих битовое сжатие алгоритмов пишут как $O(f / 64)$. Автору не нравится такая нотация, потому что численную константу формально можно сократить, и, вообще, на разных архитектурах и у разных реализаций будут разные константы. Вместо этого будем везде писать $O(f / w)$, где $w$ означает размер машинного слова.

Задача о рюкзаке

Даны $n$ предметов с положительными целыми весами $a_i$ и рюкзак размера $m$. Требуется выбрать подмножество предметов с максимальной суммой, не превышающий размер рюкзака.

Обычное решение за $O(n \cdot m)$:

bool dp[m] = {}; // так можно его заполнить нулями
dp[0] = 1;
for (int i = 0; i < n; i++)
    for (int x = m - a[i]; x >= 0; x--)
        dp[x + a[i]] |= dp[x];

…битовым сжатием разгоняется до $O(n \cdot m / w)$ так:

bitset<m> b;
b[0] = 1;
for (int i = 0; i < n; i++)
    b |= b << a[i];

Цикл длины 3

Нужно узнать, есть ли цикл длины 3 в ориентированном графе из $n$ вершин, заданном своей матрицей смежности.

Обернем матрицу смежности в массив битсетов, и тогда задача решается за $O(n^3 / w)$:

bitset<maxn> g[maxn]; // матрица смежности
for (int a = 0; a < n; a++) {
    for (int b = 0; b < n; b++) {
        if (g[a][b] && (~g[a] & g[b]).any()) {
            // цикл найден
        }
    }
}

Бенчмарк: на серверах CodeForces этот код при $n = 5000$ работает за 7 секунд.

Перемножение матриц

В задачах на подсчет числа путей часто используется факт, что матрица смежности графа, возведенная в степень $n$, имеет комбинаторный смысл: на пересечении $a$-ой строки и $b$-того столбца матрицы $G^n$ будет записано количество способов дойти из вершины $a$ в вершину $b$, используя ровно $n$ переходов.

В некоторых задачах нам не нужно знать именно число способов дойти из $a$ в $b$ — нам достаточно знания, можно ли вообще там оказаться за $n$ ходов. Тогда вместо числового умножения нам хватит битового умножения, то есть &:

typedef bitset<maxn> t;
typedef array<t, maxn> matrix;

matrix matmul(matrix a, matrix b) {
    matrix c;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            if (a[i][j])
                c[i] |= b[j];
    return c;
}

Метод Гаусса

Иногда встречаются задачи, требующие решения системы линейных уравнений. Большую часть из них на самом деле можно решить над полем $\mathbb{Z}_2$ — «по модулю 2».

Есть $n$ переключателей лампочек. Каждый активированный переключатель меняет состояние (включает или выключает) какого-то подмножества из $n$ лампочек. Известно текущее состояние всех лампочек, требуется восстановить по нему состояние переключателей.

Нас по сути просят решить следующую систему:

$$ \begin{cases} a_{11} x_1 + a_{12} x_2 + \ldots + a_{1n} x_n \equiv b_1 \pmod 2 \\\ a_{21} x_1 + a_{22} x_2 + \ldots + a_{2n} x_n \equiv b_2 \pmod 2 \\\ \ldots \\\ a_{n1} x_1 + a_{n2} x_2 + \ldots + a_{nn} x_n \equiv b_n \pmod 2 \end{cases} $$

Здесь $x$ — состояния переключателей, $b$ — состояния лампочек, а бит $a_{ij}$ отражает, влияет ли переключатель $i$ на лампочку $j$.

В таком случае можно значительно ускорить и упростить обычный метод Гаусса:

t gauss(matrix a) {
    for (int i = 0; i < n; i++) {
        int nonzero = i;
        for (int j = i+1; j < n; j++)
            if (a[j][i])
                nonzero = j;
        swap(a[nonzero], a[i]);
        for (int j = 0; j < n; j++)
            if (j != i && a[j][i])
                a[j] ^= a[i];
    }
    t x;
    for (int i = 0; i < n; i++)
        x[i] = a[i][n] ^ a[i][i];
    return x;
}

Код находит вектор $x$ из уравнения $Ax = b$ при условии, что решение существует и единственно. Для простоты кода предполагается, что вектор $b$ приписан справа к матрице $A$.

Детали реализации

На самом деле, на высоких уровнях оптимизации (g++ -O3 -march=native ...) компилятор в конечном итоге будет использовать группы не по 64, а по 256 бит, используя SIMD-инструкции. Однако, для реализации .count() самое быстрое, что можно придумать — вызывать инструкцию popcnt по очереди от каждого 64-битного числа (в GCC она доступна как встроенный интринзик __builtin_popcount), поэтому этот метод работает в несколько раз медленнее побитовых операций.

Операции с битсетами довольно просто реализовывать и с нуля: нужен только цикл с достаточно простым телом, и компилятор сделает всё сам, никакой тёмной магии при этом не требуется.