跳到主要內容

資料結構

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

主題 · 布隆過濾器

布隆過濾器

用一排位元和幾個雜湊函數回答「一定不在」或「可能在」:用極少的記憶體,換取一點點可以控制的誤判。

64
3
  1. 00
  2. 01
  3. 12
  4. 03
  5. 04
  6. 05
  7. 16
  8. 07
  9. 08
  10. 19
  11. 010
  12. 011
  13. 012
  14. 113
  15. 114
  16. 115
  17. 116
  18. 017
  19. 018
  20. 019
  21. 020
  22. 121
  23. 022
  24. 023
  25. 024
  26. 025
  27. 026
  28. 127
  29. 028
  30. 029
  31. 030
  32. 031
  33. 032
  34. 033
  35. 134
  36. 035
  37. 036
  38. 037
  39. 038
  40. 039
  41. 040
  42. 041
  43. 042
  44. 043
  45. 044
  46. 045
  47. 046
  48. 147
  49. 048
  50. 049
  51. 150
  52. 051
  53. 052
  54. 053
  55. 054
  56. 055
  57. 156
  58. 157
  59. 058
  60. 059
  61. 060
  62. 061
  63. 162
  64. 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。

什麼時候用

  • 在做一件昂貴的事(讀磁碟、打網路)之前,先便宜地排除「一定沒有」的情況。
  • 記憶體很緊、而且偶爾誤判只是多做一點白工,不會出錯。

和其他主題的關係

由這些組成
動態陣列

時間與空間複雜度(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 On = 500n = 5,000n = 50,000成長倍數:實測(理論)
查一個字讀幾個位元O(1)777×1.0 (×1.0)
濾器大小(位元)O(n)4,80048,000480,000×100 (×100)
存字本身(位元組)O(n)3,70437,463374,450×101 (×100)

查詢永遠只讀 k 個位元,不管放了多少字。濾器和字本身都隨 n 線性成長,但濾器每個字只要 1.2 位元組左右,比字本身短得多。

和其他做法比

最佳的 k誤判率:實測誤判率:理論濾器大小
每個字 4 位元314%15%500 B
每個字 8 位元62.1%2.2%1,000 B
每個字 16 位元110.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,內容傳遞網路)用它避免把只被要過一次的檔案寫進快取。

取捨與陷阱

  • 「可能在」不等於「在」:一定要再去真正的資料確認一次(試試「找一個誤判」)。
  • 放進去的字超過當初設計的數量,誤判率會急遽上升;位元陣列不能事後加大,只能重建。
  • 不能刪除,也不能列出裡面有什麼字。

LeetCode 練習