HyperLogLog 與 Count-Min Sketch
用幾 KB 記憶體回答「有幾個不同的值」(HyperLogLog)和「這個值出現了幾次」(Count-Min Sketch):答案是估計值,但誤差可以事先算出來。
結構
暫存器數 m
300
最後加入的值「s1-u299」的雜湊:
00011101010110010110000101101010
前 6 個位元 → 第 7 號暫存器;接下來先出現 1 個 0 才有 1 → 等級 2。不比原本的 4 大,不變。
- 4
- 1
- 1
- 3
- 4
- 5
- 4
- 4
- 2
- 3
- 4
- 2
- 5
- 1
- 3
- 2
- 7
- 3
- 5
- 3
- 8
- 5
- 4
- 7
- 4
- 9
- 1
- 3
- 3
- 2
- 5
- 4
- 3
- 3
- 9
- 3
- 5
- 2
- 4
- 3
- 2
- 3
- 1
- 7
- 2
- 2
- 4
- 4
- 3
- 4
- 3
- 4
- 10
- 2
- 4
- 1
- 2
- 4
- 6
- 2
- 3
- 4
- 5
- 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 O | n = 1,000 | n = 5,000 | n = 25,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| HyperLogLog(m = 4,096) | O(1) | 4,096 | 4,096 | 4,096 | ×1.0 (×1.0) |
| Count-Min(4 × 1,024) | O(1) | 4,096 | 4,096 | 4,096 | ×1.0 (×1.0) |
| 精確集合:存下每個值的位元組 | O(n) | 6,890 | 38,890 | 213,890 | ×31 (×25) |
精確的集合要記住每一個值,記憶體跟著資料長;sketch 一開始就決定好大小,換來的是有誤差的答案。
和其他做法比
| m = 16 | m = 64 | m = 256 | m = 1,024 | |
|---|---|---|---|---|
| HyperLogLog 平均誤差(實測) | 8.6% | 13.5% | 3.9% | 1.6% |
| 理論值 1.04 ÷ √m × √(2/π) | 20.7% | 10.4% | 5.2% | 2.6% |
| 記憶體 | 16 B | 64 B | 256 B | 1,024 B |
5,000 個不同的值、4 組獨立資料的平均。理論上暫存器變成四倍、誤差減半,而且和實際有幾個值無關;實測值在理論值附近上下跳動,因為只平均了幾組。Redis 的 PFCOUNT 用 16,384 個暫存器(每格 6 位元,共 12 KB),標準誤差 0.81%,數幾十億個值也一樣。
| w = 16 | w = 64 | w = 256 | w = 1,024 | |
|---|---|---|---|---|
| Count-Min 平均多算 | 140.2 | 19.5 | 1.1 | 0.0 |
| 最多多算 | 1,121 | 993 | 12 | 2 |
| 上限 e·N ÷ w | 849 | 212 | 53 | 13 |
| 在上限內的值 | 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 的工作。