跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 位元集合與位元運算

位元集合與位元運算

一個 64 位元的整數就是 64 個開關:用位元運算一次處理 64 個布林值,集合的交集、聯集都只要一條指令。

看什麼
運算

點格子可以加入或移除。A 一開始是 3 的倍數,B 是質數。

A (43)w0 = 0x9249249249249249w1 = 0x4924924924924924
B (31)w0 = 0x28208a20a08a28acw1 = 0x800228a202088288
A & B (1)w0 = 0x0000000000000008w1 = 0x0000000000000000
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
操作寫法成本
ANDa & bO(1)
ORa | bO(1)
XORa ^ bO(1)
反轉每一位~aO(1)
左移1 << 4O(1)
右移(補符號位)-8 >> 1O(1)
右移(補 0)-8 >>> 1O(1)
前導 0 的個數Math.clz32(1)O(1)
const a = 0b0101, b = 0b0011;
a & b; // → 1
a | b; // → 7
a ^ b; // → 6
~a; // → -6
1 << 4; // → 16
40 >> 2; // → 10
-8 >> 1; // → -4
-8 >>> 1; // → 2147483644
1 << 31; // → -2147483648
1 << 32; // → 1
2 ** 32 | 0; // → 0
// Test, set and clear bit 3; isolate the lowest set bit.
(a >> 2) & 1; // → 1
a | (1 << 3); // → 13
13 & ~(1 << 0); // → 12
12 & -12; // → 4
Math.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 On = 1,000n = 10,000n = 100,000成長倍數:實測(理論)
位元集合:交集(字運算)O(n)161571,563×98 (×100)
雜湊集合:交集(查詢)O(n)919549,836×108 (×100)
位元集合:查一個值(讀幾個字)O(1)111×1.0 (×1.0)

兩種交集都隨 n 線性成長,但位元集合每一步處理 64 個值,所以常數小了好幾十倍。

和其他做法比

成員數位元集合記憶體雜湊集合記憶體排序陣列記憶體交集:字運算交集:雜湊查詢
密度 0.1%333.9 KB264 B132 B50033
密度 1%3153.9 KB2.5 KB1.2 KB500315
密度 10%3,1943.9 KB25 KB12 KB5003,173
密度 50%15,9003.9 KB124 KB62 KB50015,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。

LeetCode 練習