Nhập môn

Phép toán bit (Bitwise): 6 mẹo thao tác bit kinh điển, code C++ và Python

Phép toán bit (bitwise operators) là các phép tính can thiệp trực tiếp vào từng chữ số nhị phân (0 và 1) của số nguyên ngay trên thanh ghi CPU. Nhờ thực hiện chỉ trong một chu kỳ lệnh phần cứng, thao tác bit giúp bạn kiểm tra tính chẵn lẻ, bật tắt trạng thái, đếm bit 1 và duyệt qua mọi tập con (Bitmask) với tốc độ nhanh vượt trội so với các phép chia lấy dư hay mảng đánh dấu thông thường.

Ý chính
  • Mọi số nguyên trong máy tính đều lưu ở dạng nhị phân: bit thứ k (đếm từ phải sang trái, bắt đầu từ 0) mang trọng số 2k.
  • Sáu toán tử bit cơ bản gồm: AND (&), OR (|), XOR (^), NOT (~), dịch trái (<<) và dịch phải (>>).
  • Biểu thức x & (x - 1) xóa đi bit 1 thấp nhất của x — dùng để kiểm tra lũy thừa của 2 trong O(1) và đếm số bit 1.
  • Trong C++, luôn đặt ngoặc đơn khi so sánh kết quả phép bit (như (a & b) == 0) và dùng 1LL << k khi dịch từ 31 bit trở lên (k >= 31) để tránh tràn số 32-bit.

Cách máy tính biểu diễn số nguyên ở hệ nhị phân

Bên trong bộ nhớ máy tính, mọi số nguyên đều được lưu bằng dãy các công tắc điện tử chỉ có hai trạng thái: tắt (0) và bật (1), gọi là các bit (binary digit). Khi viết một số không âm ở hệ nhị phân (cơ số 2), ta đánh số vị trí các bit từ phải sang trái, bắt đầu từ k = 0:

  • Bit thứ 0 (bit ngoài cùng bên phải — Least Significant Bit) có trọng số 20 = 1.
  • Bit thứ 1 có trọng số 21 = 2; bit thứ 2 có trọng số 22 = 4; tổng quát bit thứ k mang giá trị 2k.

Ví dụ với x = 13, vì 13 = 8 + 4 + 0 + 1 = 1·2³ + 1·2² + 0·2¹ + 1·2⁰, nên biểu diễn nhị phân 4 bit của 13 là 1101. Trong đó các bit ở vị trí k = 0, 2, 3 đang bật (1), còn bit ở vị trí k = 1 đang tắt (0). Quy ước đánh số từ phải sang trái này là nền tảng của mọi công thức thao tác bit cũng như thuật toán lũy thừa nhị phân.

Sáu toán tử bit (Bitwise Operators) cơ bản

Khác với toán tử logic (&&, ||, !) chỉ xét tính đúng/sai của cả con số, phép toán bit (bitwise operators) đặt hai số nguyên thẳng cột theo dạng nhị phân rồi tính toán độc lập trên từng vị trí bit. Cả C++ và Python đều dùng chung 6 toán tử sau:

1. Bảng chân trị của AND (&), OR (|), XOR (^) và NOT (~)

Bit A Bit B A & B (AND) A | B (OR) A ^ B (XOR) ~A (NOT)
0 0 0 0 0 1
0 1 0 1 1 1
1 0 0 1 1 0
1 1 1 1 0 0

Ý nghĩa trực quan của từng phép toán:

  • AND (& — Phép lọc): Chỉ bằng 1 khi cả hai bit cùng bằng 1. Dùng để giữ lại các bit ở vị trí có mặt nạ 1 và ép về 0 ở các vị trí khác. Ví dụ: 13 & 11 là 1101₂ & 1011₂ = 1001₂ = 9.
  • OR (| — Phép bật): Bằng 1 khi ít nhất một trong hai bit bằng 1. Dùng để bật bit từ 0 lên 1 mà không ảnh hưởng tới các bit khác. Ví dụ: 13 | 11 là 1101₂ | 1011₂ = 1111₂ = 15.
  • XOR (^ — Phép đảo / loại trừ): Bằng 1 khi hai bit khác nhau, bằng 0 khi hai bit giống nhau. Phép XOR có tính giao hoán, kết hợp, a ^ 0 = a và đặc biệt là tính tự triệt tiêu a ^ a = 0. Nhờ đó, nếu một mảng có mọi phần tử xuất hiện chẵn lần trừ duy nhất một số xuất hiện lẻ lần, việc XOR toàn bộ mảng sẽ tìm ra ngay số đó trong O(n) thời gian và O(1) bộ nhớ.
  • NOT (~ — Phép đảo toàn bộ bit): Lật mọi bit 0 thành 1 và 1 thành 0. Do số nguyên có dấu lưu theo mã bù 2 (two's complement), trong cả C++ và Python ta luôn có ~x = -x - 1 (ví dụ ~0 = -1, ~5 = -6).

2. Hai phép dịch bit: Dịch trái (<<) và Dịch phải (>>)

  • Dịch trái x << k (với k >= 0): Đẩy các bit của x sang trái k ô và điền k bit 0 vào bên phải. Mỗi bước dịch trái nhân đôi giá trị, nên x << k bằng x × 2k (khi không tràn số). Đặc biệt, 1 << k tạo ra số chỉ có duy nhất bit thứ k bằng 1, tức 2k.
  • Dịch phải x >> k: Đẩy các bit của x sang phải k ô, làm k bit cuối bên phải rơi mất. Với x ≥ 0, x >> k tương đương phép chia nguyên ⌊x / 2k⌋ (ví dụ 13 >> 2 biến 1101₂ thành 11₂ = 3).

Sáu mẹo thao tác bit kinh điển

Khi kết hợp 6 toán tử trên với mặt nạ (1 << k) (với k >= 0), ta điều khiển được từng bit riêng lẻ trong O(1). Hình dưới đây minh họa các thao tác trên số x = 13 (1101₂):

k = 3 (8) k = 2 (4) k = 1 (2) k = 0 (1) Số gốc x = 13 1 1 0 1 8 + 4 + 0 + 1 = 13 Bật bit 1: x | (1 << 1) 1 1 1 1 1111₂ = 15 (bật k=1) Tắt bit 2: x & ~(1 << 2) 1 0 0 1 1001₂ = 9 (tắt k=2) Xóa bit 1 cuối: x & (x - 1) 1 1 0 0 1100₂ = 12 (xóa k=0)
k=3 k=2 k=1 k=0 1 1 0 1 Gốc x = 13 1 1 1 1 Bật k=1 → 15 1 0 0 1 Tắt k=2 → 9 1 1 0 0 x&(x-1) → 12
Hình 1. Minh họa số x = 13 (1101₂) và kết quả khi bật bit 1, tắt bit 2 hoặc xóa bit 1 thấp nhất bằng x & (x - 1).
Thao tác Công thức Giải thích cơ chế Ví dụ với x = 13 (1101₂)
1. Kiểm tra bit thứ k (x >> k) & 1 Đưa bit thứ k về vị trí 0 rồi & 1 để lấy giá trị (0 hoặc 1). (13 >> 2) & 1 -> 1
2. Bật bit thứ k lên 1 x | (1 << k) Mặt nạ 1 << k (k >= 0) chỉ có bit k bằng 1; phép OR ép bit k của x lên 1. 13 | (1 << 1) -> 15 (1111₂)
3. Tắt bit thứ k về 0 x & ~(1 << k) Mặt nạ ~(1 << k) (k >= 0) toàn 1 trừ vị trí k bằng 0; phép AND xóa bit k. 13 & ~(1 << 2) -> 9 (1001₂)
4. Đảo bit thứ k x ^ (1 << k) Vì b ^ 1 = ~b, XOR với 1 << k (k >= 0) lật ngược đúng bit thứ k. 13 ^ (1 << 0) -> 12 (1100₂)
5. Xóa bit 1 thấp nhất x & (x - 1) Phép x - 1 lật bit 1 thấp nhất thành 0 và các bit bên phải thành 1; & xóa sạch phần đó. 13 & 12 = 12 (1100₂)
6. Lấy bit 1 thấp nhất x & (-x) Trong mã bù 2, -x = ~x + 1 chỉ giữ lại đúng bit 1 thấp nhất khi & với x. 12 & (-12) = 4 (0100₂)

Từ mẹo số 5 (x & (x - 1)), ta có hai ứng dụng rất phổ biến:

  1. Kiểm tra lũy thừa của 2 trong O(1): Một số nguyên dương là lũy thừa của 2 (1, 2, 4, 8, ...) khi và chỉ khi nó chỉ có duy nhất một bit 1. Do đó x > 0 && (x & (x - 1)) == 0 kiểm tra chính xác tính chất này.
  2. Đếm số lượng bit 1 (Brian Kernighan): Vòng lặp while (x > 0) { x &= (x - 1); dem++; } nhảy thẳng qua từng bit 1, chỉ mất số bước đúng bằng số bit 1 của x. Ngoài ra, bạn có thể dùng hàm gắn sẵn __builtin_popcountll(x) trong C++ hoặc x.bit_count() trong Python 3.10+.
#include <iostream>
using namespace std;

// 1. Kiểm tra bit thứ k của x (trả về 1 hoặc 0)
int kiemTraBit(long long x, int k) {
    return (x >> k) & 1LL;
}

// 2. Bật bit thứ k của x lên 1
long long batBit(long long x, int k) {
    return x | (1LL << k);
}

// 3. Tắt bit thứ k của x về 0
long long tatBit(long long x, int k) {
    return x & ~(1LL << k);
}

// 4. Đảo trạng thái bit thứ k của x (0 thành 1, 1 thành 0)
long long daoBit(long long x, int k) {
    return x ^ (1LL << k);
}

// 5. Kiểm tra x có phải lũy thừa của 2 không (2^0, 2^1, 2^2, ...)
bool laLuyThuaCua2(long long x) {
    return x > 0 && (x & (x - 1)) == 0;
}

// 6. Đếm số lượng bit 1 của x theo thuật toán Brian Kernighan
int demBit1(long long x) {
    int dem = 0;
    while (x > 0) {
        x &= (x - 1);  // xóa bit 1 thấp nhất ở mỗi bước
        dem++;
    }
    return dem;
}

// 7. Lấy giá trị của bit 1 thấp nhất (2^k nhỏ nhất chia hết x)
long long bit1ThapNhat(long long x) {
    return x & (-x);
}

int main() {
    long long x = 13;  // 13 ở hệ nhị phân là 1101 (bit 0, 2, 3 bằng 1; bit 1 bằng 0)
    cout << kiemTraBit(x, 2) << " " << kiemTraBit(x, 1) << "\n";  // 1 0
    cout << batBit(x, 1) << "\n";                                 // 15 (1111)
    cout << tatBit(x, 2) << "\n";                                 // 9  (1001)
    cout << daoBit(x, 0) << "\n";                                 // 12 (1100)
    cout << laLuyThuaCua2(16) << " " << laLuyThuaCua2(18) << "\n"; // 1 0
    cout << demBit1(x) << "\n";                                   // 3
    cout << bit1ThapNhat(12) << "\n";                             // 4  (1100 -> 0100)
    return 0;
}
Học cùng giảng viên

Chủ đề này nằm trong Lecture 02: Bit Manipulation (Thao tác Bit) của Khóa Orange. Xem đề cương

Duyệt tập con bằng kỹ thuật Bitmask

Khi tập hợp có n phần tử nhỏ (n ≤ 20), ta có thể dùng một số nguyên mask từ 0 đến 2n - 1 (tức (1 << n) - 1 với n > 0) để mã hóa một tập con: nếu bit thứ i của mask bằng 1 thì phần tử a[i] được chọn, ngược lại bằng 0 là không chọn.

Ví dụ với n = 3 phần tử [a₀, a₁, a₂], 8 giá trị mask từ 0 (000₂) đến 7 (111₂) biểu diễn trọn vẹn 8 tập con: mask = 0 là tập rỗng ∅, mask = 5 (101₂, bật bit 0 và 2) là {a₀, a₂}, còn mask = 7 (111₂) là {a₀, a₁, a₂}. Nhờ vậy, việc duyệt mọi tập con để đếm số tập con có tổng bằng S chỉ cần hai vòng lặp đơn giản thay vì viết đệ quy quay lui:

#include <iostream>
#include <vector>
using namespace std;

// Đếm số tập con của mảng a có tổng đúng bằng S bằng kỹ thuật Bitmask
int demTapConCoTong(const vector<int>& a, long long S) {
    int n = (int)a.size();
    int soTapCon = 0;

    // Duyệt mọi trạng thái mask từ 0 đến 2^n - 1
    for (int mask = 0; mask < (1 << n); mask++) {
        long long tong = 0;
        for (int i = 0; i < n; i++) {
            // Nếu bit thứ i của mask bật (bằng 1) thì chọn phần tử a[i]
            if ((mask >> i) & 1) {
                tong += a[i];
            }
        }
        if (tong == S) {
            soTapCon++;
        }
    }
    return soTapCon;
}

int main() {
    cout << demTapConCoTong({3, 5, 7, 8, 10}, 15) << "\n";  // 3 ({5,10}, {7,8}, {3,5,7})
    cout << demTapConCoTong({2, 4, 6}, 5) << "\n";          // 0
    cout << demTapConCoTong({1, 2, 3, 4}, 6) << "\n";       // 2 ({2,4}, {1,2,3})
    return 0;
}

Độ phức tạp của thuật toán là O(n · 2n) thời gian và O(1) bộ nhớ phụ, chạy tốt trong 1 giây khi n ≤ 20. Xem thêm bài nên học Python hay C++ để biết cách tối ưu tốc độ vòng lặp.

Lỗi hay gặp

Cẩn thận
  • Thứ tự ưu tiên toán tử trong C++ (== cao hơn &, ^, |). Nếu bạn viết if (a & b == 0), trình biên dịch C++ sẽ tính b == 0 trước rồi mới lấy a đem & với kết quả đó! Bắt buộc phải bọc ngoặc đơn: if ((a & b) == 0).
  • Tràn số 32-bit khi dịch 1 << k với k >= 31 trong C++. Hằng số 1 mặc định thuộc kiểu int 32-bit có dấu, nên dịch từ 31 bit trở lên sẽ gây tràn số (Undefined Behavior) dù biến nhận kết quả là long long. Khi làm việc với bit thứ 31 đến 62, luôn viết 1LL << k (k >= 31).
  • Duyệt Bitmask khi n quá lớn. Khi n = 30, 230 ≈ 109 trạng thái sẽ bị quá thời gian (TLE). Với n khoảng 30–40, hãy chia đôi mảng làm hai nửa 15–20 phần tử, duyệt Bitmask mỗi nửa rồi ghép bằng tìm kiếm nhị phân (Meet in the Middle).

Luyện ngay trên Big-O Coding

Thực hành ngay các bài toán thao tác bit và duyệt tập con Bitmask trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Toán tử & (Bitwise AND) khác gì với && (Logical AND)?

Toán tử && xét mỗi vế khác 0 hay bằng 0 để trả về true/false và có tính ngắt mạch. Ngược lại, & tính AND trên từng cặp bit của hai số nguyên: 4 && 2 bằng true (1), nhưng 4 & 2 (100₂ & 010₂) bằng 0.

Làm sao kiểm tra số chẵn lẻ bằng phép toán bit?

Dùng biểu thức (x & 1): bằng 0 là số chẵn, bằng 1 là số lẻ. Cách này kiểm tra trực tiếp bit cuối (2⁰) nên chạy nhanh và đúng với cả số nguyên âm.

Vì sao x & -x lại lấy được đúng giá trị bit 1 thấp nhất của x?

Trong mã bù 2, -x = ~x + 1. Phép đảo ~x lật bit 1 thấp nhất thành 0 và các bit 0 bên phải thành 1; khi cộng 1, phần bên phải tràn nhớ và trả đúng bit thấp nhất đó về 1, còn phần bên trái bị đảo ngược nên triệt tiêu hết khi & với x.

Muốn học bài bản cùng giảng viên?

Khóa Orange: làm chủ thuật toán nâng cao như Graph, DP tối ưu, Segment Tree, HLD để giải bài Hard. 16 buổi online, C++/Java/Python, học thử buổi 1 miễn phí.

Xem Khóa Orange

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483