布隆過濾器
用一排位元和幾個雜湊函數回答「一定不在」或「可能在」:用極少的記憶體,換取一點點可以控制的誤判。
64
3
- 00
- 01
- 12
- 03
- 04
- 05
- 16
- 07
- 08
- 19
- 010
- 011
- 012
- 113
- 114
- 115
- 116
- 017
- 018
- 019
- 020
- 121
- 022
- 023
- 024
- 025
- 026
- 127
- 028
- 029
- 030
- 031
- 032
- 033
- 134
- 035
- 036
- 037
- 038
- 039
- 040
- 041
- 042
- 043
- 044
- 045
- 046
- 147
- 048
- 049
- 150
- 051
- 052
- 053
- 054
- 055
- 156
- 157
- 058
- 059
- 060
- 061
- 162
- 163
這次新設成 1本來就是 1查詢時是 1查詢時是 0:一定不在
已加入的字(濾器本身並沒有存它們)
- apple
- banana
- cherry
- grape
- lemon
- mango
每個字用兩個雜湊值算出 3 個位置,加入時把那幾格設成 1;查詢時只要有一格是 0,就「一定不在」。全部是 1 只能說「可能在」——那幾格也可能是別的字設的。
加入幾個字
6
設成 1 的位元
16 / 64
誤判率:理論
1.5%
誤判率:實測
1.4%
亮起來的是這一步執行的程式碼
function fnv1a(key: string): number { let h = 0x811c9dc5; for (const byte of new TextEncoder().encode(key)) { h ^= byte; h = Math.imul(h, 0x01000193); } return h >>> 0;} class BloomFilter { bits: Uint8Array; constructor(private m: number, private k: number) { this.bits = new Uint8Array(m); } positions(key: string): number[] { const h1 = fnv1a(key); const h2 = (fnv1a("#" + key) | 1) >>> 0; const out: number[] = []; for (let i = 0; i < this.k; i++) { out.push((h1 + i * h2) % this.m); } return out; } add(key: string): void { for (const p of this.positions(key)) { this.bits[p] = 1; } } mightContain(key: string): boolean { for (const p of this.positions(key)) { if (this.bits[p] === 0) return false; } return true; }}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 在做一件昂貴的事(讀磁碟、打網路)之前,先便宜地排除「一定沒有」的情況。
- 記憶體很緊、而且偶爾誤判只是多做一點白工,不會出錯。
和其他主題的關係
- 由這些組成
- 動態陣列
- 被這些用到
- 系統設計 · LSM tree
- 延伸閱讀
- 雜湊表(dict/set)
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 加入 k 個雜湊位置,和已經存了多少無關 | O(k) | O(k) |
| 查詢 不在的字通常看到第一、二格就停 | O(k) | O(k) |
| 刪除 做不到:一個 1 可能是好幾個字共用的(Counting Bloom filter 改存計數才能刪) | — | — |
空間:O(n),約每個字 1.44·log₂(1/p) 位元:1% 誤判約 9.6 位元,和字本身多長無關
Big O 實測:n 變大時步數怎麼長
數的是:位元或位元組(濾器設成約 1% 誤判:每字 9.6 位元、k = 7)
| Big O | n = 500 | n = 5,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 查一個字讀幾個位元 | O(1) | 7 | 7 | 7 | ×1.0 (×1.0) |
| 濾器大小(位元) | O(n) | 4,800 | 48,000 | 480,000 | ×100 (×100) |
| 存字本身(位元組) | O(n) | 3,704 | 37,463 | 374,450 | ×101 (×100) |
查詢永遠只讀 k 個位元,不管放了多少字。濾器和字本身都隨 n 線性成長,但濾器每個字只要 1.2 位元組左右,比字本身短得多。
和其他做法比
| 最佳的 k | 誤判率:實測 | 誤判率:理論 | 濾器大小 | |
|---|---|---|---|---|
| 每個字 4 位元 | 3 | 14% | 15% | 500 B |
| 每個字 8 位元 | 6 | 2.1% | 2.2% | 1,000 B |
| 每個字 16 位元 | 11 | 0.06% | 0.05% | 2,000 B |
1,000 個隨機字,再查 20,000 個從沒加入過的字,數有幾個被說成「可能在」。光是把這 1,000 個字本身存起來就要 7,450 位元組(還沒算雜湊表的額外開銷),濾器只要它的一小部分;代價是那一點誤判,而且永遠不會漏判。
真實世界裡的它
- LSM tree(Log-Structured Merge tree)資料庫(RocksDB、Cassandra、HBase)每個 SSTable(Sorted String Table)檔案都附一個布隆過濾器,查詢時跳過不可能有那個鍵的檔案。
- 瀏覽器曾用它檢查網址是不是已知的惡意網站:本機先過濾,「可能是」才去問伺服器。
- CDN(Content Delivery Network,內容傳遞網路)用它避免把只被要過一次的檔案寫進快取。
取捨與陷阱
- 「可能在」不等於「在」:一定要再去真正的資料確認一次(試試「找一個誤判」)。
- 放進去的字超過當初設計的數量,誤判率會急遽上升;位元陣列不能事後加大,只能重建。
- 不能刪除,也不能列出裡面有什麼字。