---
title: Examples of using bit compression
description: Examples of using bit compression
tags: bit-compression bitset
author: minhnguyent546
---
###### :paperclip: tags: `bit-compression` `bitset`
# Một số ví dụ về bit-compression
## 1. Bit-compression và việc lưu một tập
Giả sử chúng ta cần thao tác trên các tập con (subsets) của một tập (set) có $n$ phần tử $\{0, 1, \ldots, n - 1\}$ với $n \le 64$. Ta sẽ dùng một dãy bit để biểu diễn tập trên. Trong đó bit $i$ là $1$ nếu phần tử $i$ có trong tập, ngược lại bit $i$ là $0$. Vì $n \le 64$ nên ta có thể dùng một số nguyên không dấu $64$ bit để biểu diễn: `unsigned long long x; // một tập có không quá 64 phần tử`.
Giả sử chúng ta có hai tập $A$ và $B$. Ta sẽ tìm cách biểu diễn [các toán tử trên tập hợp](https://en.wikipedia.org/wiki/Set_(mathematics)#Basic_operations) thông qua các toán tử trên số nguyên như sau:
| Toán tử trên tập hợp | Toán tử bitwise | |
|-------------------------------|--------------------------|---------------|
| $A \cap B$ | $A \& B$ | bitwise AND |
| $A \cup B$ | $A \| B$ | bitwise OR |
| Phần bù của tập A | $\sim A$ | bitwise NOT |
| $A \backslash B$ | $A \& \sim B$ | |
| Thêm phần tử $i$ vào tập $A$ | $A \| (1ull \ll i)$ | dịch trái bit |
| Xoá phần tử $i$ khỏi tập $A$ | $A \& \sim (1ull \ll i)$ | |
Trong đó `1ull` là giá trị đơn vị của kiểu `unsigned long long`.
Phần khó hơn đó chính là đếm số phần tử trong tập hợp, hay đếm số bit $1$ trong biểu diễn nhị phân tương ứng. Trong C++ có một hàm đặc biệt giúp ta làm việc này là `__builtin_popcount` và `__builtin_popcountll` có độ phức tạp $\mathcal{O}(\log{w})$ với $w$ là độ dài của machine word (thông thường là $32$ hoặc $64$ tuỳ vào cấu trúc phần cứng). Ngoài ra ta có thể tính thủ công đối với các tập nhỏ, ví dụ các tập có tối đa 16 hoặc 32 phần tử, khi đó ta có thể đếm số bit $1$ trong $\mathcal{O}(1)$ bằng việc tính toán trước một mảng kích thước $2^{16}$ bytes.
```cpp
char cnt[1 << 16];
void precompute() {
for (int i = 0; i < (1 << 16); ++i) {
cnt[i] = (i & 1) + cnt[i >> 1];
}
}
int bit_count_16(unsigned int x) { return cnt[x]; }
int bit_count_32(unsigned int x) { return cnt[x >> 16] + cnt[x & 65535]; }
```
Với các tập lớn hơn có thể làm như sau:
```cpp
const int N = (int) 5e7; // số phần tử tối đa của tập
const int N1 = N / 32 + 1;
unsigned int a[N1];
int get(int i) { return (a[i >> 5] >> (i & 31)) & 1; }
void set_0(int i) { a[i >> 5] &= ~(1u << (i & 31)); }
void set_1(int i) { a[i >> 5] |= 1u << (i & 31); }
int count() {
int sum = 0;
for (int i = 0; i < N1; ++i) {
sum += bit_count_32(a[i]);
}
return sum;
}
```
Trong C++, có một cấu trúc dữ liệu tương tự để thực hiện các thao tác trên là [bitset](https://cplusplus.com/reference/bitset/bitset/):
```cpp
a[3] = 1; // set bit index 3 bằng 1
a[3] = 0; // set bit index 3 bằng 0
int x = a[3];
printf("%d\n", (int) a[3]); // cần ép kiểu bitset<100>::reference thành int
a = a | b; a |= b; // hợp hai tập
a = a & b; a &= b; // giao hai tập
a = b >> 10; b = a << 10; // dịch bit sang phải và trái
a = a & ~b; // hiệu của hai tập
int c = (int) a.count(); // đếm số bit 1 trong a
a.reset(3); b.reset(); // unset bit
```
Việc truy cập và gán phần tử $i$ có độ phức tạp là $\mathcal{O}(1)$, các toán tử còn lại của `bitset<n>` có độ phức tạp $\mathcal{O}\left(\frac{n}{w}\right)$ trong đó $w$ là độ dài của machine word.
Bitset cũng giống như một mảng thông thường, do đó ta có thể duyệt qua bitset bằng pointer như sau:
```cpp
const int N = 40;
bitset<N> a;
uint8_t *ptr = (uint8_t*) &a;
ptr[0] = 10; // gán 8 bits đầu tiên bằng 00001010
ptr[1] = 132; // gán 8 bits tiếp theo bằng 10000100
cout << a << '\n'; // 00000000000000001000010000001010
```
Ở phần còn lại của bài viết là các ví dụ về việc sử dụng bitset. Để cho ngắn gọn thì các mảng mặc định sẽ được khởi tạo với giá trị 0, trừ khi được chỉ ra cụ thể.
## 2. Nhân hai ma trận boolean trong $\mathcal{O}(n^3 / \log{n})$
Nhân hai ma trận theo cách thông thường sẽ có độ phức tạp $\mathcal{O}(n^3)$:
```cpp
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
for (int k = 0; k < n; ++k) {
c[i][j] += a[i][k] * b[k][j];
}
}
}
```
Nếu ta nhân hai ma trận boolean trên trường $\mathbb{F}_2$ (các phần tử của ma trận thuộc tập $\{0, 1\}$ và các phép cộng và phép nhân giữa các phần tử được chia lấy dư cho $2$), xem thêm [Field](https://en.wikipedia.org/wiki/Field_(mathematics)#Finite_fields):
```cpp
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
for (int k = 0; k < n; ++k) {
c[i][j] ^= a[i][k] & b[k][j];
}
}
}
```
Lưu ý rằng thứ tự các vòng lặp là không quan trọng, do đó ta có thể viết:
```cpp
for (int i = 0; i < n; ++i) {
for (int k = 0; k < n; ++k) {
if (a[i][k]) {
for (int j = 0; j < n; ++j) {
c[i][j] ^= b[k][j];
}
}
}
}
```
Sử dụng bitset để giảm độ phức tạp như sau:
```cpp
const int N = 500;
bitset<N> a[N], b[N], c[N];
for (int i = 0; i < n; ++i) { // n <= N
for (int k = 0; k < n; ++k) {
if (a[i][k]) {
c[i] ^= b[k];
}
}
}
```
Với việc sử dụng bitset ta đã giảm độ phức tạp xuống còn $\mathcal{O}(n^3 / w)$ với $w$ là dộ dài của machine word (thường là $32$ hoặc $64$). Nhận xét rằng $n \le 2^w \Rightarrow \log{n} \le w$. Do đó ta có thể viết lại độ phức tạp của đoạn code trên là $\mathcal{O}(n^3 / \log{n})$.
## 3. Tìm `transitive closure` với thuật toán Floyd–Warshall trong $\mathcal{O}(n^3 / \log{n})$
**Bài toán:** transitive closure (bao đóng bắc cầu) của quan hệ hai ngôi $R$ trên $X$ là một quan hệ $S$ nhỏ nhất trên $X$ chứa $R$ và có tính bắc cầu, có nghĩa nếu $xSy$ và $ySz$ thì $xSz$.
Nếu ta xem các quan hệ như một đồ thị có hướng với các đỉnh là các phần tử trong $X$ và có cung $(u, v)$ nếu $uRv$. Bài toán trở thành tìm tất cả các cặp $(u, v)$ sao cho có đường đi từ $u$ đến $v$. Áp dụng ý tưởng của thuật toán Floyd-Warshall ta có thể viết:
```cpp
const int N = 500;
int d[N][N];
for (int k = 0; k < n; ++k) { // n <= N
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
d[i][j] |= d[i][k] & d[k][j];
}
}
}
```
Như ở phần trên ta sẽ tìm cách loại bỏ toán tử $\&$:
```cpp
for (int k = 0; k < n; ++k) {
for (int i = 0; i < n; ++i) {
if (d[i][k]) {
for (int j = 0; j < n; ++j) {
d[i][j] |= d[k][j];
}
}
}
}
```
Dùng bitset để giảm độ phức tạp ta có đoạn code như sau:
```cpp
const int N = 500;
bitset<N> d[N];
for (int k = 0; k < n; ++k) { // n <= N
for (int i = 0; i < n; ++i) {
if (d[i][k]) {
d[i] |= d[k];
}
}
}
```
Như vậy ta đã có thể tìm transitive closure trong $\mathcal{O}(n^3 / \log{n})$.
## 4. Nhân hai đa thức trong $\mathcal{O}(n^2 / \log{n})$
Giả sử ta cần nhân hai đa thức trên trường $\mathbb{F}_2$, cách nhân thông thường cho ta độ phức tạp $\mathcal{O}(n^2)$:
```cpp
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
c[i + j] ^= a[i] & b[j];
}
}
```
Loại bỏ toán tử $\&$ như sau:
```cpp
for (int i = 0; i < n; ++i) {
if (a[i]) {
for (int j = 0; j < n; ++j) {
c[i + j] ^= b[j];
}
}
}
```
Dùng bitset ta được:
```cpp
const int N = 500;
bitset<N * 2> a, b, c;
for (int i = 0; i < n; ++i) { // n <= N
if (a[i]) {
c ^= b << i;
}
}
```
Chia hai đa thức với phần dư trên trường $\mathbb{F}_2$ trong $\mathcal{O}(n^2 / \log{n})$ xin nhường lại cho bạn đọc như bài tập vận dụng.
Bây giờ ta sẽ đến với phần khó hơn: nhân hai đa thức trên $\mathbb{Z}$ trong đó các hệ số thuộc $\{0, 1\}$. Đầu tiên ta đảo ngược mảng $b$ như sau:
```cpp
for (int i = 0; i < n; ++i) {
b1[i] = b[n - i - 1];
}
```
Ta có: $(a * b)[k] = \sum\limits_{i = 0}^{n - 1}{b[k - i]a[i]} = \sum\limits_{i = 0}^{n - 1}{b_1[i + n - k - 1]a[i]}$
- Nếu $k \le n - 1:$
$(a * b)[k] =\sum\limits_{i = 0}^{n - 1}{b_1[i + \underbrace{n - k - 1}_{\ge 0}]a[i]} = a\ \&\ (b_1 \gg (n - k - 1))$
- Nếu $k \gt n - 1:$
$(a * b)[k] =\sum\limits_{i = 0}^{n - 1}{b_1[i - (\underbrace{k - n + 1}_{\gt 0})]a[i]} = a\ \&\ (b_1 \ll (k - n + 1))$
```cpp
int c[N * 2 - 1];
for (int i = 0; i < n; ++i) {
b1[i] = b[n - i - 1];
}
for (int k = 0; k < n; ++k) {
c[k] = (int) (a & (b1 >> (n - k - 1))).count();
}
for (int k = n; k < 2 * n - 1; ++k) {
c[k] = (int) (a & (b1 << (k - n + 1))).count();
}
```
*Ghi chú:* ở đây ta không cần quan tâm đến việc chỉ số tràn ra khỏi mảng do ta dùng bit shift và các bit $0$ sẽ không ảnh hưởng đến kết quả.
## 5. Bài toán cái túi trong $\mathcal{O}(nW / \log{W})$
**Bài toán:** cho một mảng $a$ có $n$ số nguyên dương và số nguyên $W$, bạn cần tìm một tập con của mảng đã cho sao cho tổng các phần tử đúng bằng $W$.
Đầu tiên ta sẽ dùng cách quy hoạch động thông thường và kiểm tra xem liệu có tồn tại tập con thoả mãn hay không.
Quy hoạch động thông thường trong $\mathcal{O}(nW)$:
```cpp
int a[N];
bool can[MAX_W + 1];
can[0] = 1;
for (int i = 0; i < n; ++i) {
for (int x = W - a[i]; x >= 0; --x) {
can[x + a[i]] = can[x + a[i]] | can[x];
}
}
cout << (can[W] ? "YES" : "NO") << '\n';
```
Sử dụng bitset để đạt được độ phức tạp tốt hơn trong $\mathcal{O}(nW / \log{W})$:
```cpp
bitset<W + 1> can;
can[0] = 1;
for (int i = 0; i < n; ++i) {
can |= can << a[i];
}
cout << (can[W] ? "YES" : "NO") << '\n';
```
Tiếp theo ta sẽ truy vết lại đáp án trong $\mathcal{O}(nW)$:
```cpp
int a[N];
bool can[MAX_W + 1];
int trace[MAX_W + 1];
can[0] = 1;
for (int i = 0; i < n; ++i) {
for (int x = W - a[i]; x >= 0; --x) {
if (can[x] && !can[x + a[i]]) {
can[x + a[i]] = 1;
trace[x + a[i]] = i;
}
}
}
if (!can[W]) {
cout << "NO" << '\n';
}
else {
cout << "YES" << '\n';
for (int x = W; x > 0; x -= a[trace[x]]) {
cout << trace[x] << ' ';
}
}
```
Bây giờ để tăng tốc thuật toán ta cần duyệt qua các giá trị $x$ mà $can[x] = 1$ và $can[x + a[i]] = 0$ một cách hiệu quả. Nếu dùng bitset thì đó chính xác là vị trí các bit $1$ trong $can \& \sim (can \ll a[i])$.
Độ phức tạp $\mathcal{O}(nW / \log{W} + Wlog{W})$:
```cpp
int a[N]
bitset<MAX_W> can, tmp;
int trace[MAX_W + 1];
can[0] = 1;
for (int i = 0; i < n; ++i) {
tmp = can & ~(can << a[i]);
uint32_t *ptr = (uint32_t*) &tmp;
for (int j = 0; j < W / 32; ++j) {
if (!ptr[j]) continue;
for (int bit = 0; bit < 32; ++bit) {
if (ptr[j] & (1 << bit)) {
can[bit + (j << 5)] = 1;
trace[bit + (j << 5)] = i;
}
}
}
}
```
Mặc dù độ phức tạp đã tốt hơn nhưng do việc tạo bitset trung gian `tmp` nên thời gian chạy sẽ chậm hơn so với các cài đặt bitset có cùng độ phức tạp.
Ta vẫn có thể cải tiến thêm phần duyệt $x$ của đoạn code "một chút" bằng các hàm có sẵn trong header bitset của C++ là `_Find_first` và `_Find_next`. Xem thêm [tại đây](https://codeforces.com/blog/entry/43718):
```cpp
int a[N]
bitset<MAX_W> can, tmp;
int trace[MAX_W + 1];
can[0] = 1;
for (int i = 0; i < n; ++i) {
tmp = can & ~(can << a[i]);
for (int j = tmp._Find_first(); j < tmp.size(); j = tmp._Find_next(j)) {
can[j] = 1;
trace[j] = i;
}
}
```
Đến đây thì đoạn code đã trông ảo ma hơn rất nhiều :)).
## 6. Gauss trong $\mathcal{O}(n^3 / \log{n})$
**Bài toán:** tính định thức của một ma trận boolean trên trường $\mathbb{F}_2$:
```cpp
const int N = 500;
bitset<N> a[N]; // ma trận cần tính định thức
int n; // n <= N;
int determinant() {
for (int i = 0; i < n; ++i) {
int k = i;
while (k < n && a[k][i] == 0) ++k;
if (k == n) return 0;
swap(a[i], a[k]);
for (int j = i + 1; j < n; ++j) {
if (a[j][i]) {
a[j] ^= a[i];
}
}
}
return 1;
}
```