HyperLogLog 與基數估計

基數 (Cardinality) 指的是一個集合中不同元素的數量。例如:今天訪問網站的不同 IP 數。

傳統做法是用 HashSet 儲存所有 IP,但如果有 1000 萬個不同 IP,HashSet 需要數 GB 的記憶體。

HyperLogLog (HLL) 可以在只使用 1-2 KB 記憶體的情況下,以 2% 以下的誤差估計基數。

實作 HyperLogLog

import math
import mmh3
from bitarray import bitarray

class HyperLogLog:
    def __init__(self, b=10):
        """
        b: 精度參數(建議 10-16)
        記憶體使用量 = 2^b bytes
        b=10 → 1 KB, 誤差 ~3.25%
        b=14 → 16 KB, 誤差 ~0.81%
        """
        self.b = b
        self.m = 1 << b  # 暫存器數量
        self.registers = [0] * self.m
        self.alpha = self._get_alpha()
    
    def _get_alpha(self):
        """計算修正係數"""
        if self.m == 16:
            return 0.673
        elif self.m == 32:
            return 0.697
        elif self.m == 64:
            return 0.709
        else:
            return 0.7213 / (1 + 1.079 / self.m)
    
    def _hash(self, item):
        """計算 32-bit hash"""
        return mmh3.hash(str(item)) & 0xFFFFFFFF
    
    def _count_leading_zeros(self, x):
        """計算前導零的數量"""
        if x == 0:
            return 32
        return (x.bit_length() ^ 31) + 1
    
    def add(self, item):
        """加入元素"""
        x = self._hash(item)
        # 前 b 個 bit 決定暫存器索引
        idx = x & (self.m - 1)
        # 剩餘 bit 計算前導零
        w = x >> self.b
        self.registers[idx] = max(
            self.registers[idx],
            self._count_leading_zeros(w) + 1
        )
    
    def count(self):
        """估計基數"""
        # 調和平均數
        sum_inv = sum(2.0 ** -r for r in self.registers)
        estimate = self.alpha * self.m * self.m / sum_inv
        
        # 小範圍修正
        if estimate <= 2.5 * self.m:
            # 很多暫存器還是 0
            zeros = self.registers.count(0)
            if zeros:
                estimate = self.m * math.log(self.m / zeros)
        
        # 大範圍修正
        elif estimate > (1 << 32) / 30:
            estimate = -(1 << 32) * math.log(1 - estimate / (1 << 32))
        
        return int(estimate)

# 測試
hll = HyperLogLog(b=14)  # 16 KB
print(f"暫存器數量: {hll.m}")
print(f"記憶體使用: {hll.m} bytes ~ {hll.m/1024:.1f} KB")

# 加入 100 萬個不同的元素
for i in range(1000000):
    hll.add(f"user_{i}_session_{i*2}")

print(f"\n估計基數: {hll.count():,}")
print(f"實際基數: 1,000,000")
print(f"誤差率: {abs(hll.count() - 1000000) / 1000000:.4%}")

記憶體比較

| 演算法 | 10億項目 | 記憶體 | 精度 | |-------|---------|--------|------| | HashSet | 10億IP | 16 GB | 100% | | HyperLogLog (b=10) | 任意 | 1 KB | ~96.75% | | HyperLogLog (b=14) | 任意 | 16 KB | ~98.60% | | HyperLogLog (b=16) | 任意 | 64 KB | ~99.00% |

HLL 合併(分散式計數)

def merge_hll(hlls):
    """合併多個 HLL 為一個"""
    merged = HyperLogLog(b=hlls[0].b)
    for h in hlls:
        for i in range(merged.m):
            merged.registers[i] = max(merged.registers[i], h.registers[i])
    return merged

# 範例:統計 10 台伺服器的總不重複用戶
shard_hlls = [build_hll_from_shard(i) for i in range(10)]
total_hll = merge_hll(shard_hlls)
print(f"總不重複用戶: {total_hll.count():,}")

優勢

| 優勢 | 原因 | |------|------| | 固定記憶體 | 無論項目多少,始終是 2^b bytes | | 可合併 | 兩個 HLL 可直接合併(分散式系統專用) | | 單次掃描 | 只需一次遍歷資料 | | 支援串流 | 可在無限串流上運作 | | 可並行化 | 分割資料 → 各 shard 建立 HLL → 合併 |

使用情境

  • 網站 UV 統計:每天數億次訪問,只需 16 KB 就能即時統計
  • 資料庫 Query 最佳化:預估 SELECT DISTINCT 的結果數量以選擇最佳查詢計畫
  • 網路流量監控:統計不同 IP 的連線數,即時洞察流量模式
  • 爬蟲去重:快速判斷 URL 是否已爬過
  • 點擊流分析:統計不重複商品瀏覽數、類別、搜尋查詢

總結

  • 超低記憶體:1-64 KB 就能處理任意大小的資料
  • ~2% 誤差:對大多數使用場景可接受
  • 可合併:適合分散式 MapReduce 風格的計數
  • 基於雜湊值的前導零(丟銅板原理)
  • 廣泛使用:Redis、PostgreSQL、AWS、Google BigQuery


為什麼 HyperLogLog 能這麼省記憶體?

你在本章中實作了 HyperLogLog,但它背後的直覺你可能還不清楚。讓我用一個簡單的比喻來說明:

想像你在拋一枚公平的硬幣。

  • 第一次拋到「人頭」的機率是 1/2
  • 連續拋 2 次才出現人頭的機率是 1/4
  • 連續拋 3 次才出現人頭的機率是 1/8
  • 連續拋 k 次才出現人頭的機率是 1/2^k

HyperLogLog 的核心洞察:如果你將每個元素透過 hash 函數變成一個二進位數字,觀察它從最低位元開始連續出現多少個 0。這個「前導零的數量」就跟拋硬幣連續出現反面的次數是一樣的——機率上,出現 10 個連續零的機率約為 1/1024。

所以如果在你的資料中,你看到了最多有 20 個連續零的元素,那合理的推測是你的資料量大約在 2^20 ≈ 100 萬左右。

當然,單一測量不準,所以 HLL 將資料分到 m 個暫存器中,各自估計再取調和平均數——這就是為什麼它精確到 ~2% 的原因。


真實案例:某電商平台的 UV 統計

# 情境:雙十一活動期間,每分鐘需要統計即時不重複訪客數
# 高峰流量:每分鐘 500 萬次請求
# 預算:不能花太多錢在 Redis 記憶體上

# 方案 A:使用 Redis Set (精準統計)
# 每分鐘 UV 約 200 萬
# Redis Set 記憶體:200萬 × ~36 bytes ≈ 72 MB
# 一天的資料量:72 MB × 1440 分鐘 = 103 GB → ❌ 不可能

# 方案 B:使用 Redis HyperLogLog
# 每個 HLL 佔用 12 KB
# 一天的資料量:12 KB × 1440 = 17 MB ✅
# 誤差:~0.81%(完全可以接受)

# Redis 指令
> PFADD uv:20241111:0930 user_12345
> PFADD uv:20241111:0930 user_67890
> PFCOUNT uv:20241111:0930
(integer) 1845327  # 估計值,誤差 ~0.8%

# 合併多個時間段的 UV
> PFMERGE uv:20241111 uv:20241111:0900 uv:20241111:0910 uv:20241111:0920
> PFCOUNT uv:20241111
(integer) 8934561  # 整天的去重訪客數

這個案例展示了 16 GB vs 17 MB——將近 1000 倍的差距,而代價只是不到 1% 的誤差。在商業分析中,UV 統計本來就允許一定誤差,HLL 是最佳人選。


下章預告:Count-Min Sketch——頻率估計

Bloom Filter 回答的是元素是否存在,HyperLogLog 回答的是有多少不同元素,而下一章的 Count-Min Sketch 回答的是另一個完全不同的問題:某個元素出現了幾次?

想像你需要即時統計串流資料中最熱門的 hashtag、最常見的錯誤類型、或是某 IP 的請求次數——Count-Min Sketch 用類似 Bloom Filter 的思路,以少量誤差換取大幅的記憶體節省。

三個機率性資料結構合在一起,就構成了大數據工程師處理串流資料的完整工具箱!

會員專屬免費教學

本章節為註冊會員專屬的免費開放內容!請先登入或註冊會員,即可立即解鎖閱讀。

立即登入 / 註冊