位元集合與位元運算
一個 64 位元的整數就是 64 個開關:用位元運算一次處理 64 個布林值,集合的交集、聯集都只要一條指令。
看什麼
運算
點格子可以加入或移除。A 一開始是 3 的倍數,B 是質數。
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
AB結果
128 個可能的成員剛好裝進 2 個 64 位元的字(word),所以整個交集只做了 2 次字運算——不管 A、B 有多少成員。換成雜湊集合(hash set),要把 A 的 43 個成員一個一個拿去 B 裡查。
亮起來的是這一步執行的程式碼
class Bitset { words: bigint[]; constructor(size: number) { this.words = new Array(Math.ceil(size / 64)).fill(0n); } add(i: number): void { this.words[i >> 6] |= 1n << BigInt(i & 63); } remove(i: number): void { this.words[i >> 6] &= ~(1n << BigInt(i & 63)); } has(i: number): boolean { return ((this.words[i >> 6] >> BigInt(i & 63)) & 1n) === 1n; } combine(other: Bitset, op: "and" | "or" | "xor" | "andNot"): Bitset { const out = new Bitset(this.words.length * 64); for (let w = 0; w < this.words.length; w++) { const a = this.words[w], b = other.words[w]; if (op === "and") out.words[w] = a & b; else if (op === "or") out.words[w] = a | b; else if (op === "xor") out.words[w] = a ^ b; else out.words[w] = a & ~b; } return out; } // Kernighan: w & (w - 1) clears the lowest set bit, once per member. count(): number { let total = 0; for (let w of this.words) { while (w !== 0n) { w &= w - 1n; total++; } } return total; }} const lowestBit = (x: number) => x & -x;const clearLowest = (x: number) => x & (x - 1); // Every non-empty subset of mask's bits, largest first.function submasks(mask: number): number[] { const out: number[] = []; for (let s = mask; s > 0; s = (s - 1) & mask) out.push(s); return out;}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 值是範圍不大的整數(例如 0 到幾百萬),而且常要整組做交集、聯集:權限旗標、使用者是否看過某篇文章、搜尋引擎裡「哪些文件含有這個詞」。
- 狀態壓縮動態規劃:把「選了哪些東西」編成一個整數,n 不超過 20 左右時,2^n 個狀態可以直接當陣列索引。
和其他主題的關係
語言內建的版本
number · bigint| 操作 | 寫法 | 成本 |
|---|---|---|
| AND | a & b | O(1) |
| OR | a | b | O(1) |
| XOR | a ^ b | O(1) |
| 反轉每一位 | ~a | O(1) |
| 左移 | 1 << 4 | O(1) |
| 右移(補符號位) | -8 >> 1 | O(1) |
| 右移(補 0) | -8 >>> 1 | O(1) |
| 前導 0 的個數 | Math.clz32(1) | O(1) |
const a = 0b0101, b = 0b0011;a & b; // → 1a | b; // → 7a ^ b; // → 6~a; // → -61 << 4; // → 1640 >> 2; // → 10-8 >> 1; // → -4-8 >>> 1; // → 21474836441 << 31; // → -21474836481 << 32; // → 12 ** 32 | 0; // → 0 // Test, set and clear bit 3; isolate the lowest set bit.(a >> 2) & 1; // → 1a | (1 << 3); // → 1313 & ~(1 << 0); // → 1212 & -12; // → 4Math.clz32(1); // → 31(13).toString(2); // → "1101"parseInt("1101", 2); // → 13 (1n << 40n) | 1n; // → 1099511627777n每個 → 後面的結果都是實際執行這段程式碼驗證過的。
- 位元運算會先把
number截成 32 位元有號整數:1 << 31是負數,2 ** 32 | 0是 0,而且位移量只取最低 5 位,所以1 << 32是 1。超過 32 位的遮罩用bigint。 >>補符號位(負數保持負數),>>>補 0 並把結果當成無號數。- 沒有內建的「數有幾個 1」(popcount);常見寫法是
while (x) { x &= x - 1; count++; },每次清掉最低的一個 1。 ~x等於-x - 1(二補數),所以~5是-6,不是「把 0101 變成 1010」。
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 加入、移除、查詢一個值 一個位移、一個 AND 或 OR | O(1) | O(1) |
| 交集、聯集、差集 U 是可能值的範圍,不是成員數 | O(U / 64) | O(U / 64) |
| 數成員(Kernighan) k 是成員數;用 popcount 指令就是 O(U / 64) | O(U / 64 + k) | O(U / 64 + k) |
| 列舉一個遮罩的子集合 k 是遮罩裡 1 的個數 | O(2^k) | O(2^k) |
空間:O(U / 64) words,跟成員數無關,只看值的範圍 U
Big O 實測:n 變大時步數怎麼長
數的是:值的範圍 n、密度 10% 時,兩個集合取交集的工作量
| Big O | n = 1,000 | n = 10,000 | n = 100,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 位元集合:交集(字運算) | O(n) | 16 | 157 | 1,563 | ×98 (×100) |
| 雜湊集合:交集(查詢) | O(n) | 91 | 954 | 9,836 | ×108 (×100) |
| 位元集合:查一個值(讀幾個字) | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
兩種交集都隨 n 線性成長,但位元集合每一步處理 64 個值,所以常數小了好幾十倍。
和其他做法比
| 成員數 | 位元集合記憶體 | 雜湊集合記憶體 | 排序陣列記憶體 | 交集:字運算 | 交集:雜湊查詢 | |
|---|---|---|---|---|---|---|
| 密度 0.1% | 33 | 3.9 KB | 264 B | 132 B | 500 | 33 |
| 密度 1% | 315 | 3.9 KB | 2.5 KB | 1.2 KB | 500 | 315 |
| 密度 10% | 3,194 | 3.9 KB | 25 KB | 12 KB | 500 | 3,173 |
| 密度 50% | 15,900 | 3.9 KB | 124 KB | 62 KB | 500 | 15,900 |
兩個隨機集合,可能的值是 0 到 31,999。位元集合永遠是每個可能值 1 位元;雜湊集合用一個簡化的模型:32 位元整數、開放定址、最多半滿,每個成員約 8 位元組(各語言內建的雜湊集合通常更大);排序陣列每個成員 4 位元組。成員很稀疏時位元集合反而浪費;密度超過幾個百分點後,它的記憶體和交集工作量都最小,而且一次字運算還是處理器最快的指令之一。
真實世界裡的它
- Unix 的檔案權限 rwx、許多 API(Application Programming Interface,應用程式介面)的選項旗標,都是在一個整數裡用位元表示。
- 資料庫的點陣圖索引(bitmap index)和 Roaring Bitmap,用位元集合加速多條件篩選。
- 布隆過濾器(Bloom filter)底層就是一個位元陣列。
取捨與陷阱
- 位移超過字長:Java 的 1 << 32 是 1 而不是 0(只看位移量的低 5 位),C 則是未定義行為;要 64 位元請寫 1L << i,且 i 先 & 63。
- 運算子優先順序:C、C++、JavaScript 裡 == 比 & 先算,x & 1 == 1 其實是 x & (1 == 1);Java 直接編譯失敗;Python 則剛好相反。不管哪種語言,一律加括號。
- 值的範圍很大但很稀疏時(例如使用者 ID 到幾十億,但只存幾千個),位元集合會浪費大量記憶體,改用雜湊集合或 Roaring Bitmap。