Bài 8.2: Bitset và các thao tác bit hiệu quả

Bài 8.2: Bitset và các thao tác bit hiệu quả
Trong thế giới lập trình, đôi khi chúng ta cần lưu trữ và xử lý một lượng lớn các giá trị boolean (true/false). Nếu sử dụng các kiểu dữ liệu thông thường như bool
hoặc int
để biểu diễn trạng thái (0 hoặc 1), mỗi giá trị này sẽ chiếm ít nhất 1 byte bộ nhớ, thậm chí là 4 byte trên một số kiến trúc. Điều này có thể trở thành một vấn đề nghiêm trọng về bộ nhớ và hiệu suất khi chúng ta làm việc với hàng triệu hoặc hàng tỷ trạng thái.
May mắn thay, máy tính hoạt động ở cấp độ bit, nơi mà một trạng thái true/false thực sự chỉ cần một bit để biểu diễn! Đây chính là lúc các thao tác bit (bitwise operations) và cấu trúc dữ liệu chuyên dụng như std::bitset
trong C++ phát huy sức mạnh, giúp chúng ta lưu trữ và xử lý dữ liệu cực kỳ hiệu quả ở cấp độ thấp nhất.
Trong bài viết này, chúng ta sẽ đi sâu vào std::bitset
của C++ Standard Library – một công cụ mạnh mẽ cho phép chúng ta làm việc với các tập hợp bit có kích thước cố định một cách tối ưu, cùng với các thao tác bit cơ bản nhưng vô cùng hiệu quả.
std::bitset
: Lợi ích của việc làm việc ở cấp độ bit
std::bitset
là một template class trong C++ được thiết kế để lưu trữ một chuỗi bit có kích thước cố định tại thời điểm biên dịch. Thay vì sử dụng 1 byte hoặc hơn cho mỗi giá trị boolean, std::bitset
đóng gói (pack) các bit lại với nhau, chỉ sử dụng đúng lượng bộ nhớ cần thiết (ví dụ: 1 byte cho 8 bit).
Tại sao lại sử dụng std::bitset
?
- Tiết kiệm bộ nhớ: Đây là lợi ích rõ ràng nhất. Để lưu trữ N giá trị boolean, bạn chỉ cần khoảng N/8 byte bộ nhớ (cộng thêm một chút overhead nhỏ), thay vì N byte hoặc 4N byte.
- Hiệu suất cao: Các thao tác trên
std::bitset
thường được triển khai bằng các chỉ thị cấp thấp của CPU, cho phép xử lý đồng thời nhiều bit (ví dụ: 64 bit cùng lúc trên hệ thống 64-bit) chỉ với một lệnh duy nhất. Điều này nhanh hơn đáng kể so với việc lặp qua từng phần tử trong mảngbool
thông thường. - Các thao tác bit tiện lợi:
std::bitset
cung cấp giao diện trực quan cho các thao tác bit phổ biến như AND, OR, XOR, NOT, dịch bit (shift), đếm số bit 1, kiểm tra bit, v.v.
Lưu ý: std::bitset
có kích thước cố định được chỉ định tại thời điểm biên dịch dưới dạng template parameter <N>
. Nếu bạn cần một tập hợp bit có kích thước động, std::vector<bool>
là một lựa chọn khác, mặc dù nó có thể không hiệu quả bằng std::bitset
cho các thao tác bitwise tổng thể và có một số đặc thù riêng (ví dụ: vector<bool>::reference
).
Khởi tạo và Truy cập std::bitset
Để sử dụng std::bitset
, bạn cần include header <bitset>
. Kích thước N
phải là một hằng số nguyên dương.
#include <bitset>
#include <iostream>
#include <string>
int main() {
// Khởi tạo một bitset có 8 bit (tất cả đều là 0 theo mặc định)
std::bitset<8> b1; // b1: 00000000
std::cout << "b1: " << b1 << std::endl;
// Khởi tạo từ một số nguyên không dấu
// Số 42 trong hệ nhị phân là 00101010
std::bitset<8> b2(42); // b2: 00101010
std::cout << "b2 (from 42): " << b2 << std::endl;
// Khởi tạo từ một chuỗi string ('0' hoặc '1')
// Ký tự đầu tiên của string tương ứng với bit có chỉ số cao nhất
std::bitset<10> b3(std::string("1011011001")); // b3: 1011011001
std::cout << "b3 (from string): " << b3 << std::endl;
// Truy cập và kiểm tra từng bit
// Lưu ý: chỉ số bit bắt đầu từ 0 ở bên phải (bit LSB - Least Significant Bit)
std::cout << "Bit thu 0 cua b2: " << b2[0] << std::endl; // b2[0] la bit ngoai cung ben phai
std::cout << "Bit thu 5 cua b2: " << b2[5] << std::endl; // b2[5] la bit 1 trong 00101010
// Sử dụng .test() để kiểm tra bit (trả về bool)
std::cout << "Kiem tra bit thu 3 cua b2: " << b2.test(3) << std::endl; // Bit thu 3 la 0
std::cout << "Kiem tra bit thu 5 cua b2: " << b2.test(5) << std::endl; // Bit thu 5 la 1
return 0;
}
Giải thích:
std::bitset<N>
khai báo một bitset cóN
bit.<8>
và<10>
là kích thước.- Khởi tạo mặc định làm cho tất cả các bit bằng 0.
- Khởi tạo từ số nguyên không dấu sẽ biểu diễn số đó dưới dạng nhị phân trong bitset.
- Khởi tạo từ string cho phép bạn thiết lập các bit theo chuỗi '0' và '1' cung cấp. Ký tự đầu tiên của string là bit có trọng số cao nhất (most significant bit - MSB).
b[i]
cho phép truy cập bit thứi
. Bit thứ 0 là bit ít quan trọng nhất (bên phải cùng).b.test(i)
là cách an toàn và rõ ràng hơn để kiểm tra trạng thái boolean của bit thứi
.
Các Thao Tác Thay Đổi Trạng Thái Bit
std::bitset
cung cấp các phương thức để thay đổi trạng thái của các bit một cách dễ dàng.
#include <bitset>
#include <iostream>
int main() {
std::bitset<8> b(42); // b: 00101010
std::cout << "Ban dau: " << b << std::endl;
// Dat bit thu 0 ve 1 (.set())
b.set(0); // b: 00101011
std::cout << "Dat bit 0: " << b << std::endl;
// Dat bit thu 3 ve 1 (.set(index, value))
b.set(3, true); // b: 00101011 (da la 1) -> 00101011
std::cout << "Dat bit 3 ve true: " << b << std::endl;
// Dat bit thu 4 ve 0 (.set(index, value))
b.set(4, false); // b: 00101011 -> 00100011
std::cout << "Dat bit 4 ve false: " << b << std::endl;
// Dat bit thu 5 ve 0 (.reset())
b.reset(5); // b: 00100011 -> 00000011
std::cout << "Reset bit 5: " << b << std::endl;
// Dao nguoc trang thai bit thu 1 (.flip())
b.flip(1); // b: 00000011 -> 00000001
std::cout << "Flip bit 1: " << b << std::endl;
// Dao nguoc tat ca cac bit (.flip())
b.flip(); // b: 00000001 -> 11111110
std::cout << "Flip tat ca: " << b << std::endl;
// Dat tat ca cac bit ve 1 (.set())
b.set(); // b: 11111110 -> 11111111
std::cout << "Set tat ca: " << b << std::endl;
// Dat tat ca cac bit ve 0 (.reset())
b.reset(); // b: 11111111 -> 00000000
std::cout << "Reset tat ca: " << b << std::endl;
return 0;
}
Giải thích:
b.set(i)
: Đặt bit thứi
thành 1.b.set(i, value)
: Đặt bit thứi
thànhvalue
(true
hoặcfalse
).b.reset(i)
: Đặt bit thứi
thành 0.b.flip(i)
: Đảo ngược trạng thái của bit thứi
(0 thành 1, 1 thành 0).b.set()
: Đặt tất cả các bit thành 1.b.reset()
: Đặt tất cả các bit thành 0.b.flip()
: Đảo ngược trạng thái của tất cả các bit.
Các Thao Tác Bitwise Logic (AND
, OR
, XOR
, NOT
)
Sức mạnh thực sự của std::bitset
nằm ở khả năng thực hiện các thao tác logic trên toàn bộ tập hợp bit một cách hiệu quả, tương tự như các phép toán bitwise trên số nguyên. Các toán tử &
(AND), |
(OR), ^
(XOR) và ~
(NOT) được nạp chồng (overload) cho bitset
.
#include <bitset>
#include <iostream>
int main() {
std::bitset<8> b1(std::string("10101100")); // b1: 10101100
std::bitset<8> b2(std::string("01101010")); // b2: 01101010
std::cout << "b1: " << b1 << std::endl;
std::cout << "b2: " << b2 << std::endl;
// AND (&&)
// b1: 10101100
// b2: 01101010
// & : 00101000
std::bitset<8> b_and = b1 & b2;
std::cout << "b1 & b2: " << b_and << std::endl;
// OR (||)
// b1: 10101100
// b2: 01101010
// | : 11101110
std::bitset<8> b_or = b1 | b2;
std::cout << "b1 | b2: " << b_or << std::endl;
// XOR (^)
// b1: 10101100
// b2: 01101010
// ^ : 11000110
std::bitset<8> b_xor = b1 ^ b2;
std::cout << "b1 ^ b2: " << b_xor << std::endl;
// NOT (~)
// b1: 10101100
// ~b1: 01010011 (dao nguoc tat ca cac bit)
std::bitset<8> b_not_b1 = ~b1;
std::cout << "~b1: " << b_not_b1 << std::endl;
return 0;
}
Giải thích:
Các toán tử bitwise (&
, |
, ^
) hoạt động trên từng cặp bit tương ứng của hai bitset cùng kích thước. Toán tử ~
(NOT) đảo ngược trạng thái của mỗi bit trong bitset. Các phép toán này thường được thực hiện rất nhanh ở cấp độ phần cứng.
Dịch Bit (Shift Operations)
Các thao tác dịch bit (<<
- dịch trái, >>
- dịch phải) cũng được hỗ trợ.
#include <bitset>
#include <iostream>
int main() {
std::bitset<8> b(std::string("10110010")); // b: 10110010
std::cout << "Ban dau: " << b << std::endl;
// Dich trai 2 vi tri (them 0 vao ben phai)
// 10110010 << 2 -> 11001000
std::bitset<8> b_left_shift = b << 2;
std::cout << "Dich trai 2: " << b_left_shift << std::endl;
// Dich phai 3 vi tri (them 0 vao ben trai)
// 10110010 >> 3 -> 00010110
std::bitset<8> b_right_shift = b >> 3;
std::cout << "Dich phai 3: " << b_right_shift << std::endl;
// Cac phep gan ket hop dich
std::bitset<8> b_mutable(std::string("00011100"));
b_mutable <<= 1; // 00011100 <<= 1 -> 00111000
std::cout << "Dich trai va gan: " << b_mutable << std::endl;
b_mutable >>= 2; // 00111000 >>= 2 -> 00001110
std::cout << "Dich phai va gan: " << b_mutable << std::endl;
return 0;
}
Giải thích:
b << N
: Dịch tất cả các bit sang tráiN
vị trí.N
bit 0 sẽ được thêm vào bên phải.b >> N
: Dịch tất cả các bit sang phảiN
vị trí.N
bit 0 sẽ được thêm vào bên trái (đây là logical shift, không phải arithmetic shift).- Các toán tử gán kết hợp
<<=
và>>=
thực hiện phép dịch và gán kết quả trở lại bitset ban đầu.
Đếm Bit và Kiểm Tra Trạng Thái Tổng Quát
std::bitset
cung cấp các phương thức tiện lợi để kiểm tra trạng thái tổng quát hoặc đếm số lượng bit 1.
#include <bitset>
#include <iostream>
int main() {
std::bitset<10> b(std::string("1011011001")); // b: 1011011001
std::cout << "Bitset: " << b << std::endl;
// Kich thuoc cua bitset
std::cout << "Kich thuoc (so bit): " << b.size() << std::endl; // Output: 10
// Dem so luong bit 1 (set bits)
std::cout << "So luong bit 1: " << b.count() << std::endl; // b co 6 bit 1
// Kiem tra xem co bat ky bit nao la 1 khong
std::cout << "Co bit nao la 1 khong? " << b.any() << std::endl; // Output: 1 (true)
// Kiem tra xem tat ca cac bit co la 0 khong
std::cout << "Tat ca co la 0 khong? " << b.none() << std::endl; // Output: 0 (false)
// Kiem tra xem tat ca cac bit co la 1 khong
std::cout << "Tat ca co la 1 khong? " << b.all() << std::endl; // Output: 0 (false)
std::bitset<5> all_zero; // 00000
std::bitset<5> all_one;
all_one.set(); // 11111
std::cout << "\nAll zero: " << all_zero << std::endl;
std::cout << "Co bit nao la 1 khong? " << all_zero.any() << std::endl; // Output: 0 (false)
std::cout << "Tat ca co la 0 khong? " << all_zero.none() << std::endl; // Output: 1 (true)
std::cout << "Tat ca co la 1 khong? " << all_zero.all() << std::endl; // Output: 0 (false)
std::cout << "\nAll one: " << all_one << std::endl;
std::cout << "Co bit nao la 1 khong? " << all_one.any() << std::endl; // Output: 1 (true)
std::cout << "Tat ca co la 0 khong? " << all_one.none() << std::endl; // Output: 0 (false)
std::cout << "Tat ca co la 1 khong? " << all_one.all() << std::endl; // Output: 1 (true)
return 0;
}
Giải thích:
b.size()
: Trả về tổng số bit trong bitset (hằng số N).b.count()
: Trả về số lượng bit có giá trị 1.b.any()
: Trả vềtrue
nếu có ít nhất một bit là 1, ngược lại trả vềfalse
.b.none()
: Trả vềtrue
nếu tất cả các bit là 0, ngược lại trả vềfalse
. Equivalent to!b.any()
.b.all()
: Trả vềtrue
nếu tất cả các bit là 1, ngược lại trả vềfalse
. Equivalent tob.count() == b.size()
.
Chuyển Đổi Giữa bitset
và các Kiểu Dữ Liệu Khác
Bạn có thể chuyển đổi std::bitset
sang chuỗi string hoặc sang các kiểu số nguyên không dấu (nếu bitset đủ nhỏ).
#include <bitset>
#include <iostream>
#include <string>
int main() {
std::bitset<10> b(std::string("1011011001")); // b: 1011011001
std::cout << "Bitset: " << b << std::endl;
// Chuyen doi sang string
std::string s = b.to_string();
std::cout << "Chuyen doi sang string: " << s << std::endl; // Output: 1011011001
// Chuyen doi sang unsigned long (neu bitset <= sizeof(unsigned long) * 8)
// 1011011001 (base 2) = 512 + 128 + 64 + 16 + 8 + 1 = 729 (base 10)
unsigned long ul = b.to_ulong();
std::cout << "Chuyen doi sang unsigned long: " << ul << std::endl; // Output: 729
// Chuyen doi sang unsigned long long (neu bitset <= sizeof(unsigned long long) * 8)
unsigned long long ull = b.to_ullong();
std::cout << "Chuyen doi sang unsigned long long: " << ull << std::endl; // Output: 729
// Can than voi kich thuoc lon hon
// std::bitset<100> large_b;
// large_b.set(99); // Bit cao nhat
// large_b.set(0); // Bit thap nhat
// // ul = large_b.to_ulong(); // LOI: Bitset qua lon so voi unsigned long
// ull = large_b.to_ullong(); // LOI: Bitset qua lon so voi unsigned long long
return 0;
}
Giải thích:
b.to_string()
: Chuyển đổi bitset thành một chuỗi các ký tự '0' và '1'. Bit có chỉ số cao nhất tương ứng với ký tự đầu tiên của chuỗi.b.to_ulong()
: Chuyển đổi bitset thành giá trị của một số nguyên không dấu kiểuunsigned long
.b.to_ullong()
: Chuyển đổi bitset thành giá trị của một số nguyên không dấu kiểuunsigned long long
.
Quan trọng: Các phương thức to_ulong()
và to_ullong()
sẽ ném ra exception std::overflow_error
nếu giá trị của bitset vượt quá khả năng biểu diễn của kiểu đích (unsigned long
hoặc unsigned long long
). Hãy chắc chắn rằng kích thước của bitset (N
) không lớn hơn số bit của kiểu đích (thường là 32 hoặc 64).
Ứng Dụng của std::bitset
và Thao Tác Bit
std::bitset
và các thao tác bit hiệu quả được sử dụng rộng rãi trong nhiều lĩnh vực:
- Các thuật toán số học và logic: Biểu diễn và thao tác trên các tập hợp bit, thường dùng trong mật mã, nén dữ liệu.
- Các bài toán trên đồ thị: Biểu diễn ma trận kề (adjacency matrix) của đồ thị thưa (sparse graph) để tiết kiệm bộ nhớ, hoặc dùng để theo dõi trạng thái duyệt.
- Các thuật toán tìm kiếm và lọc: Ví dụ, Sàng Eratosthenes (Sieve of Eratosthenes) để tìm số nguyên tố dưới một ngưỡng N có thể được tối ưu đáng kể về bộ nhớ bằng cách sử dụng
std::bitset<N+1>
để đánh dấu các số đã sàng. - Trạng thái cờ (flags): Lưu trữ nhiều cờ boolean trong một biến duy nhất (bitmask) thay vì nhiều biến
bool
. - Tối ưu bộ nhớ trong các cấu trúc dữ liệu: Sử dụng bitset để biểu diễn các mảng boolean lớn một cách hiệu quả.
Bài tập ví dụ:
Cặp hợp lệ
Với công việc là nhân viên quản lý nhà hàng, FullHouse Dev được giao nhiệm vụ kiểm tra các cặp khách hàng vào nhà hàng Ý nổi tiếng. Theo quy định đặc biệt của nhà hàng, khách chỉ được vào theo cặp và tổng tài sản của cặp đó phải là lũy thừa của 3. FullHouse Dev cần giúp nhà hàng tính toán số lượng cặp khách hàng hợp lệ có thể được phép vào nhà hàng.
Bài toán
Cho một nhóm người với tài sản của từng người. Một cặp người với tài sản \(a\) và \(b\) được coi là hợp lệ nếu \(a + b = 3^k\) với \(k\) là một số nguyên dương. Nhiệm vụ của FullHouse Dev là tìm ra số lượng cặp hợp lệ có thể được tạo ra từ nhóm người này.
INPUT FORMAT:
- Dòng đầu tiên chứa số nguyên \(N\) - số lượng người.
- Dòng thứ hai chứa một mảng \(wealth\) biểu thị tài sản của từng người.
OUTPUT FORMAT:
- In ra một số nguyên duy nhất biểu thị số lượng cặp hợp lệ.
Ràng buộc:
- \(1 \leq N \leq 10^5\)
- \(1 \leq wealth[i] \leq 10^9\)
Ví dụ
INPUT
4
1 5 2 4
OUTPUT
2
Giải thích
- Với \(N = 4\) và mảng tài sản \([1, 5, 2, 4]\), có hai cặp hợp lệ:
- Cặp người thứ nhất và thứ ba có tổng tài sản là \(1 + 2 = 3\) (là lũy thừa của 3)
- Cặp người thứ hai và thứ tư có tổng tài sản là \(5 + 4 = 9\) (là lũy thừa của 3)
Lưu ý:
- Một người có thể thuộc nhiều cặp hợp lệ khác nhau
- Cặp người X và Y được coi là giống với cặp người Y và X Tuyệt vời! Đây là hướng giải ngắn gọn cho bài toán "Cặp hợp lệ" bằng C++ mà không cung cấp code hoàn chỉnh:
Ý tưởng chính: Bài toán yêu cầu tìm cặp
(a, b)
sao choa + b
là một lũy thừa dương của 3. Có rất ít các lũy thừa của 3 mà tổng tài sản của hai người có thể đạt được (tổng tối đa khoảng 2 * 10^9, các lũy thừa của 3 nhanh chóng vượt quá giá trị này). Ta có thể tiền tính các lũy thừa của 3 này. Sau đó, với mỗi người có tài sảnw
, ta tìm những người khác có tài sảntarget - w
, trong đótarget
là một trong các lũy thừa của 3.Đếm tần suất: Để tìm nhanh những người có tài sản
target - w
và biết số lượng của họ, hãy sử dụng một cấu trúc dữ liệu nhưstd::unordered_map
(hoặcstd::map
) để lưu trữ tần suất xuất hiện của mỗi giá trị tài sản trong mảng đầu vào. Khóa của map là giá trị tài sản, và giá trị là số lần xuất hiện.Lập danh sách các mục tiêu (powers of 3): Tạo một danh sách (ví dụ:
std::vector
) chứa các lũy thừa của 3 (từ 3^1 trở đi) mà nhỏ hơn hoặc bằng tổng tài sản tối đa có thể có (khoảng 2 * 10^9).Đếm cặp:
- Duyệt qua map tần suất. Với mỗi cặp
(tài sản A, số lượng A)
trong map: - Duyệt qua danh sách các lũy thừa của 3 (các mục tiêu
T
). - Tính giá trị cần tìm
tài sản B = T - tài sản A
. - Nếu
tài sản B
tồn tại trong map tần suất:- Lấy số lượng
tài sản B
(số lượng B
). - Trường hợp 1: tài sản A == tài sản B. Đây là trường hợp cặp có hai người cùng tài sản. Số lượng cặp tạo được là
(số lượng A * (số lượng A - 1)) / 2
. Cộng vào tổng số cặp. Lưu ý: Chỉ xử lý trường hợp này một lần khitài sản A == tài sản B
. - Trường hợp 2: tài sản A < tài sản B. Đây là trường hợp cặp có hai người tài sản khác nhau. Số lượng cặp tạo được là
số lượng A * số lượng B
. Cộng vào tổng số cặp. Lưu ý: Chỉ xử lý khitài sản A < tài sản B
để tránh đếm hai lần cặp (A, B) và (B, A). - Trường hợp 3: tài sản A > tài sản B. Bỏ qua, cặp (B, A) sẽ được đếm khi ta xử lý
tài sản B
trong vòng lặp ngoài (vìtài sản B < tài sản A
).
- Lấy số lượng
- Duyệt qua map tần suất. Với mỗi cặp
Kiểu dữ liệu: Sử dụng kiểu
long long
cho tài sản, tổng tài sản và biến đếm tổng số cặp để tránh tràn số, vì tài sản có thể lên tới 10^9 và số cặp có thể rất lớn.Kết quả: In ra tổng số cặp đã đếm được.
Comments