精確 vs. 近似,這不是退而求其次,而是選擇付出多少代價換取答案。

Count-Min Sketch:「這個元素出現過幾次?」

你在做即時廣告系統,需要知道每個廣告 ID 今天被點了幾次。廣告 ID 有幾百萬種,如果用 HashMap 存每個 ID 的計數器,記憶體需求隨廣告種類線性增長——幾百萬個 entry,加上 HashMap 本身的 overhead,記憶體壓力不可小覷。

更實際的問題是:你不在乎「廣告 123456 今天被點了 48,231 次」和「48,234 次」的差距,你在乎的是「哪些廣告的點擊量異常高/低」。

Count-Min Sketch 用 w × d 的二維計數器矩陣解決這個問題:

矩陣 w=8, d=3(示意):

       0   1   2   3   4   5   6   7
row 0: [0] [0] [3] [0] [0] [2] [0] [0]
row 1: [0] [2] [0] [0] [3] [0] [0] [0]
row 2: [0] [0] [0] [2] [0] [3] [0] [0]

添加 x:
  row 0: counter[0][h0(x) % 8]++
  row 1: counter[1][h1(x) % 8]++
  row 2: counter[2][h2(x) % 8]++

查詢 x 的頻率:
  return min(counter[i][hi(x) % 8]) for i in 0..2

為什麼取 min?因為不同的 key 可能雜湊到同一個格子(碰撞),導致格子的值被多個 key 共同拉高。取 min 可以找到「碰撞最少的那一行」,得到最接近真實值的估計。

Count-Min Sketch 永遠不會低估(因為只會加),但可能略微高估(因為碰撞)。

誤差保證:查詢值 ≤ 真實值 + ε × N,機率 ≥ 1 - δ
          w = ⌈e/ε⌉,d = ⌈ln(1/δ)⌉

實際用途:Cloudflare 用它做流量分析、Redis 的 Sorted Set TOP-K 用它找 Heavy Hitter(熱點 key)、Twitter 的即時熱搜底層也是類似結構。

Cuckoo Filter:Bloom Filter 的升級版,支援刪除

Bloom Filter 是個好工具,但有個致命限制:不支援刪除

Bloom Filter 把每個元素映射到位元陣列的多個 bit,刪除時無法確定哪些 bit 是「只屬於這個元素的」——貿然清除可能影響到其他元素的查詢結果。

Cuckoo Filter 改變了儲存方式:不存 bit,存元素的指紋(fingerprint),放在兩個候選桶的其中一個:

每個元素 x 有兩個候選桶:
  i1 = hash(x) % buckets
  i2 = i1 XOR hash(fingerprint(x))   ← 兩個桶互相可以從對方推導

插入:
  優先放 i1 或 i2 的空位
  若兩個桶都滿 → 把現有的指紋踢出去(Cuckoo 布穀鳥策略),
                   被踢的指紋去它的另一個候選桶

刪除:
  在 i1 或 i2 找到指紋 → 直接移除

「Cuckoo」名字來自布穀鳥——它把蛋下在別人的巢裡,把原本的蛋踢出去。插入時如果滿了,就把現有元素踢到它的備用桶,這個動作可能連鎖觸發,但通常很快收斂。

Cuckoo Filter vs Bloom Filter(同樣 1% 誤判率):

Bloom Filter: 9.6 bits/element
Cuckoo Filter: 8 bits/element(約省 17%)

Bloom Filter: 不支援刪除
Cuckoo Filter: 支援刪除

查詢速度:兩者都是 O(1),Cuckoo 的常數略小(只查兩個桶)

Cassandra 用 Bloom Filter 擋不存在的 key(避免磁碟查詢);Elasticsearch 也是類似應用。如果你的場景需要刪除(例如快取失效後要從 filter 移除 key),Cuckoo Filter 是更好的選擇。

三個結構的選擇邏輯

問題用什麼
這個 key 存在嗎(不需要刪除)Bloom Filter
這個 key 存在嗎(需要刪除)Cuckoo Filter
這個 key 出現過幾次Count-Min Sketch
總共有多少不同 keyHyperLogLog

這四個概率型結構各自解決不同的問題,不是「哪個更好」,而是「你在問哪個問題」。


概率型資料結構的本質是用精確度換空間——關鍵是你要清楚,你願意接受多少誤差,以及誤差的方向是什麼(高估 vs. 低估)。