Процессор устроен так, что работает не с индивидуальными битами, а сразу с блоками по 32 или 64 бита — эта величина называется машинным словом. Как следствие, операции, затрагивающие лишь один бит, на самом деле «стоят» столько же, сколько и операции над целым int
-ом или long
-ом.
Когда требуется делать какие-то теоретико-множественные операции, их часто можно свести к большому количеству одинаковых операций над элементами булевого массива. Например:
-
Объединение множеств — побитовое ИЛИ.
-
Пересечение множеств — побитовое И.
-
Симметрическая разность — побитовый XOR.
Здесь появляется следующая идея оптимизации: сгруппировать элементы массива в блоки размера 64 и каждый такой блок считать двоичным числом. Тогда можно применить эту битовую операцию сразу к 64 элементам и потратить на это один такт вместо 64-х.
Это всё несложно кодить и вручную, но в STL это уже сделали до нас, написав структуру, ведущую себя как большое двоичное число со всеми стандартными битовыми операциями: bitset
.
Работать с ним нужно вот так:
const int lim = 1000;
bitset<lim> b; // создать битсет размера lim (должно быть константой)
b.set(); // заполнить единицами
b.reset(); // заполнить нулями
b.flip(); // заменить единички на нули и наоборот
b.count(); // посчитать число единичек
cout << b; // вывести битовую строку
Также для битсетов работает вся битовая арифметика: &, |, ^, ~, <<, >>
и их варианты с [operator]=
.
Примечание. Часто «асимптотику» использующих bitset
решений пишут как
Даны
$n$ предметов с положительными целыми весами$a_i$ и рюкзак размера$lim$ . Требуется выбрать подмножество предметов с максимальной суммой, не превышающий размер рюкзака.
Обычно его решают так:
bool dp[lim] = {}; // так можно его заполнить нулями
dp[0] = 1;
for (int i = 0; i < n; i++)
for (int x = lim - a[i]; x >= 0; x--)
dp[x + a[i]] |= dp[x];
…а с битсетом оно разгоняется так:
bitset<lim> b;
b[0] = 1;
for (int i = 0; i < n; i++)
b |= b << a[i];
Пусть нам нужно узнать, есть ли цикл длины 3 в ориентированном графе из
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 этот код при
Основное применение в олимпиадах: матрица смежности графа, возведенная в степень
В некоторых задачах нам не нужно знать именно число способов — нам хватит знания, можно ли вообще через &
:
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;
}
Иногда встречаются задачи, требующие решения системы линейных уравнений. Большую часть из них на самом деле можно решить над полем
Пример: есть
Нас по сути просят решить следующую систему:
Здесь
В таком случае можно значительно ускорить и упростить обычный метод Гаусса:
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;
}
Код находит вектор
На самом деле, реализация из STL очень медленная. Простой for
, в котором and-ят int
-ы, будет работать в несколько раз быстрее аналогичной операции битсета. Ускорение получается за счёт векторизации — компилятор перестраивает цикл, используя специальные инструкции, работающие с блоками по 128 / 256 бит, а не 64.
Нужно просто написать цикл с достаточно простым телом, и компилятор сделает всё сам. Трудности возникают только при реализации битовых сдвигов и .count()
. Последнюю проще всего реализовать через встроенную в GCC быструю функцию __builtin_popcount
, но вообще в STL для этого используется предпосчитанный массив размера 256 с числами от 0 до 8 — количеста единичных бит в записи каждого возможного значения байта.
Автор предполагает, что битсет из STL реализовывали во времена, когда векторизация и отдельная инструкция popcnt
ещё не поддерживались процессорами, поэтому всё устроено так, как устроено.