跳到主要內容

資料結構

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

主題 · HyperLogLog 與 Count-Min Sketch

HyperLogLog 與 Count-Min Sketch

用幾 KB 記憶體回答「有幾個不同的值」(HyperLogLog)和「這個值出現了幾次」(Count-Min Sketch):答案是估計值,但誤差可以事先算出來。

結構
暫存器數 m
300
最後加入的值「s1-u299」的雜湊:
00011101010110010110000101101010
前 6 個位元 → 第 7 號暫存器;接下來先出現 1 個 0 才有 1 → 等級 2。不比原本的 4 大,不變。
  1. 4
  2. 1
  3. 1
  4. 3
  5. 4
  6. 5
  7. 4
  8. 4
  9. 2
  10. 3
  11. 4
  12. 2
  13. 5
  14. 1
  15. 3
  16. 2
  17. 7
  18. 3
  19. 5
  20. 3
  21. 8
  22. 5
  23. 4
  24. 7
  25. 4
  26. 9
  27. 1
  28. 3
  29. 3
  30. 2
  31. 5
  32. 4
  33. 3
  34. 3
  35. 9
  36. 3
  37. 5
  38. 2
  39. 4
  40. 3
  41. 2
  42. 3
  43. 1
  44. 7
  45. 2
  46. 2
  47. 4
  48. 4
  49. 3
  50. 4
  51. 3
  52. 4
  53. 10
  54. 2
  55. 4
  56. 1
  57. 2
  58. 4
  59. 6
  60. 2
  61. 3
  62. 4
  63. 5
  64. 3
剛被更新的暫存器剛查過、沒變的暫存器還是 0

看到連續 k 個 0 是 2^k 分之一的事,所以等級 k 暗示大約有 2^k 個不同的值。把 64 個暫存器的 2^(−等級) 取調和平均再乘上常數,得到 322。典型誤差約 1.04 ÷ √m = 13.0%。

真正的不同值
300
估計
322
誤差
+7.4%
記憶體(每格 1 位元組)
64 B
亮起來的是這一步執行的程式碼
function hash32(key: string): number {
let h = 0x811c9dc5;
for (const byte of new TextEncoder().encode(key)) {
h = Math.imul(h ^ byte, 0x01000193);
}
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); // mix the bits (MurmurHash3 finaliser)
h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
return (h ^ (h >>> 16)) >>> 0;
}
class HyperLogLog {
registers: number[];
constructor(private p: number) { // m = 2^p registers
this.registers = new Array(1 << p).fill(0);
}
add(key: string): void {
const h = hash32(key);
const index = h >>> (32 - this.p);
const rest = (h << this.p) >>> 0;
const rank = rest === 0 ? 33 - this.p : Math.clz32(rest) + 1;
if (rank > this.registers[index]) this.registers[index] = rank;
}
estimate(): number {
const m = this.registers.length;
const alpha = m === 16 ? 0.673 : m === 32 ? 0.697 : m === 64 ? 0.709 : 0.7213 / (1 + 1.079 / m);
let sum = 0, empty = 0;
for (const r of this.registers) {
sum += 2 ** -r;
if (r === 0) empty++;
}
const raw = (alpha * m * m) / sum;
if (raw <= 2.5 * m && empty > 0) return m * Math.log(m / empty);
return raw;
}
}
class CountMinSketch {
rows: number[][];
constructor(private depth: number, private width: number) {
this.rows = Array.from({ length: depth }, () => new Array(width).fill(0));
}
private columns(key: string): number[] {
const h1 = hash32(key);
let h2 = h1 ^ 0x9e3779b9; // a second hash from the first
h2 ^= h2 >>> 16; h2 = Math.imul(h2, 0x85ebca6b);
h2 ^= h2 >>> 13; h2 = Math.imul(h2, 0xc2b2ae35);
h2 = ((h2 ^ (h2 >>> 16)) | 1) >>> 0;
return this.rows.map((_, i) => (h1 + i * h2) % this.width);
}
add(key: string, count = 1): void {
this.columns(key).forEach((col, row) => {
this.rows[row][col] += count;
});
}
query(key: string): number {
return Math.min(...this.columns(key).map((col, row) => this.rows[row][col]));
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 資料量大到無法存下每一個值,而答案差 1–2% 也沒關係:每日不重複訪客、每個搜尋詞被查了幾次、哪些 IP(Internet Protocol)位址流量最大。
  • 要在很多台機器上分開算再合併:兩個 HyperLogLog 逐格取最大值、兩個 Count-Min 逐格相加,結果和一起算的一樣。

和其他主題的關係

由這些組成
動態陣列
延伸閱讀
布隆過濾器

出現在這些架構裡

時間與空間複雜度(Big O)

操作平均最差
HyperLogLog 加入
一次雜湊、改一格
O(1)O(1)
HyperLogLog 估計
掃過 m 個暫存器;m 固定
O(m)O(m)
HyperLogLog 合併
逐格取最大值
O(m)O(m)
Count-Min 加入/查詢
每列一格
O(d)O(d)

空間:O(m) / O(d·w),依誤差選定,不隨資料量變大

Big O 實測:n 變大時步數怎麼長

數的是:記憶體:位元組或計數器個數,不同值的數量增加時

Big On = 1,000n = 5,000n = 25,000成長倍數:實測(理論)
HyperLogLog(m = 4,096)O(1)4,0964,0964,096×1.0 (×1.0)
Count-Min(4 × 1,024)O(1)4,0964,0964,096×1.0 (×1.0)
精確集合:存下每個值的位元組O(n)6,89038,890213,890×31 (×25)

精確的集合要記住每一個值,記憶體跟著資料長;sketch 一開始就決定好大小,換來的是有誤差的答案。

和其他做法比

m = 16m = 64m = 256m = 1,024
HyperLogLog 平均誤差(實測)8.6%13.5%3.9%1.6%
理論值 1.04 ÷ √m × √(2/π)20.7%10.4%5.2%2.6%
記憶體16 B64 B256 B1,024 B

5,000 個不同的值、4 組獨立資料的平均。理論上暫存器變成四倍、誤差減半,而且和實際有幾個值無關;實測值在理論值附近上下跳動,因為只平均了幾組。Redis 的 PFCOUNT 用 16,384 個暫存器(每格 6 位元,共 12 KB),標準誤差 0.81%,數幾十億個值也一樣。

w = 16w = 64w = 256w = 1,024
Count-Min 平均多算140.219.51.10.0
最多多算1,121993122
上限 e·N ÷ w8492125313
在上限內的值99.5%99.8%100.0%100.0%

5,000 個事件、500 種值、d = 4 列。w 從 16 加到 64,平均多算從 140.2 降到 19.5;從來不會少算。

真實世界裡的它

  • Redis 的 PFADD/PFCOUNT 是 HyperLogLog;BigQuery 的 APPROX_COUNT_DISTINCT、Presto/Trino 的 approx_distinct 用的是它的改良版 HLL++。
  • Count-Min Sketch 用在網路設備找大流量(heavy hitters)、串流系統算熱門詞,以及快取淘汰策略 TinyLFU(Caffeine 用它估計每個鍵的使用頻率)。

取捨與陷阱

  • 雜湊的品質就是一切:HyperLogLog 看的是雜湊值開頭的 0,相似的鍵(user1、user2…)如果雜湊後高位元太像,估計會大錯。這裡在 FNV-1a(Fowler–Noll–Vo)後面再加 MurmurHash3 的混合步驟,就是為了這個。
  • 值很少時 HyperLogLog 的原始公式偏差很大,要改用線性計數(數空的暫存器);試試把不同值調到 50 以下。
  • Count-Min 對罕見的值相對誤差很大:出現 1 次的值可能估成 10 次。它適合找常見的值,不適合問「這個值出現過沒有」——那是 Bloom filter 的工作。