Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa/HyperLogLog — đếm distinct xấp xỉ
23/66
Bài 23 / 66~22 phútBig data & streaming — Khi RAM không đủMiễn phí lượt xem

HyperLogLog — đếm distinct xấp xỉ

Đếm số phần tử khác nhau trên stream tỷ phần tử chỉ với vài KB, sai số ~2%. Ý tưởng leading-zero của hash và harmonic mean.

TL;DR: Đếm số phần tử khác nhau (cardinality) trên stream 1 tỷ phần tử đòi O(n) RAM nếu dùng hash set — vài GB. HyperLogLog (Flajolet et al. 2007) làm điều đó với vài kilobyte, sai số chuẩn ~1.04/sqrt(m) (~2% với m=2048 bucket). Ý tưởng: nếu hash đều, số leading zero tối đa quan sát được tương quan với log2 cardinality. Cải tiến: chia m bucket (stochastic averaging), dùng harmonic mean để triệt tiêu outlier. Redis PFADD/PFCOUNT dùng đúng thuật toán này với 12 KB, sai số 0.81%. Ứng dụng: đếm unique visitor, distinct query trong analytics, cardinality estimation cho query planner.

Analytics pipeline của bạn cần trả lời: "Hôm nay có bao nhiêu unique user truy cập?" Dữ liệu: 2 tỷ event, mỗi event có user_id. Dùng SELECT COUNT(DISTINCT user_id) — Postgres scan toàn bảng, sort hoặc hash O(n), tốn phút. Lưu hash set tất cả user_id đã thấy? 2 tỷ × 8 byte = 16 GB RAM chỉ cho bộ đếm. Bạn chỉ cần con số "khoảng bao nhiêu", sai số 1-2% chấp nhận được — nhưng phải trả lời trong vài millisecond với vài KB bộ nhớ.

HyperLogLog là câu trả lời. Đây không phải "trick" — nó có nền toán học chặt chẽ từ lý thuyết xác suất và combinatorics.

1. Trực giác — leading zero và cardinality

Quan sát cốt lõi: nếu hash function trải đều output trên [0, 2^32), và ta quan sát n giá trị hash riêng biệt, thì số leading zero tối đa quan sát được R_max thoả mãn:

E[R_max] ≈ log2(n)

Lý do: xác suất một hash có ít nhất r leading zero = 1/2^r. Trong n phần tử distinct, xác suất có ít nhất một cái đạt r leading zero ≈ 1 - (1 - 1/2^r)^n. Con số này chuyển từ ~0 sang ~1 khi n vượt 2^r. Vậy R_max quan sát được là ước lượng tự nhiên của log2(n), và 2^R_max ≈ n.

Ví dụ cụ thể: với n = 1.000.000 distinct phần tử, ta kỳ vọng thấy ít nhất một hash có 20 leading zero (vì 2^20 ≈ 1M). Nếu R_max = 20 → ước lượng n ≈ 2^20 = 1.048.576.

Vấn đề: variance rất lớn. Một phần tử duy nhất có thể tình cờ có 40 leading zero → ước lượng sai lệch gấp triệu lần. Cần kỹ thuật giảm variance.

2. Stochastic averaging — chia bucket

Thay vì dùng 1 ước lượng, chia thành m bucket bằng cách dùng b = log2(m) bit đầu của hash làm bucket index. Các bit còn lại dùng để đếm leading zero.

function HyperLogLog_add(x, M[0..m-1]):
    h <- hash(x)                        -- hash 64-bit, phân phối đều
    b <- log2(m)                        -- số bit dùng cho bucket index
    idx <- h >> (64 - b)                -- b bit đầu = bucket index
    w <- h << b                         -- bỏ b bit bucket, đẩy (64-b) bit còn lại lên đầu word
    leading <- countLeadingZeros(w) + 1 -- rank: vị trí bit 1 đầu tiên trong phần còn lại
    M[idx] <- max(M[idx], leading)      -- giữ giá trị tối đa

// Time: O(1)  Space: O(m) registers, mỗi register 5-6 bit

Mỗi bucket M[idx] giữ giá trị R_max của riêng phần tử hash vào bucket đó. Sau khi xử lý toàn stream, ta có m ước lượng độc lập.

3. Harmonic mean và hiệu chỉnh

Trung bình cộng của 2^M[i] rất nhạy với outlier (1 bucket có R_max lớn tình cờ làm tăng vọt kết quả). Harmonic mean (trung bình điều hoà) ít nhạy với outlier hơn:

function HyperLogLog_estimate(M[0..m-1]):
    Z <- 0
    for i từ 0 đến m-1:
        Z <- Z + 2^(-M[i])              -- nghịch đảo của ước lượng từng bucket

    alpha_m <- hằng số hiệu chỉnh(m)   -- xấp xỉ 0.7213 / (1 + 1.079/m)
    E <- alpha_m × m^2 / Z             -- ước lượng cardinality

    -- Hiệu chỉnh biên (small/large range correction):
    if E <= 5m/2:                       -- vùng nhỏ: có thể nhiều bucket rỗng
        V <- số bucket M[i] = 0
        if V > 0:
            E <- m × ln(m / V)          -- linear counting (chính xác hơn khi n nhỏ)
    if E > (1/30) × 2^32:              -- vùng lớn (chỉ cần với hash 32-bit)
        E <- -2^32 × ln(1 - E/2^32)

    return E
// Time: O(m)  Space: O(1) ngoài array M

Tại sao harmonic mean? Với các số 2^M[i] — một bucket tình cờ có M[i] = 40 cho 2^40 ≈ 1T. Harmonic mean dùng 2^(-M[i]) — outlier 40 đóng góp 2^(-40) ≈ 0 thay vì 10^12, không làm lệch kết quả.

📌 Large-range correction là di sản HLL gốc (32-bit)

Hiệu chỉnh vùng lớn (1/30)×2^32 thuộc HyperLogLog gốc (Flajolet 2007) dùng hash 32-bit — khi cardinality tiến gần 2^32, hash bắt đầu va chạm nên cần bù. HyperLogLog++ (Google 2013) và Redis dùng hash 64-bit, không gian đủ lớn để bỏ hẳn correction này; thay vào đó dùng bias-correction table thực nghiệm cho vùng nhỏ-trung.

flowchart LR
    ITEM(["Phần tử x"])
    HASH["hash(x)\n→ 64-bit"]
    SPLIT["Tách:\nb bit đầu → bucket idx\n64-b bit còn lại → w"]
    COUNT["leading_zeros(w) + 1\n= r"]
    UPDATE["M[idx] <- max(M[idx], r)"]
    EST["Harmonic mean\nqua m bucket\n→ ước lượng n"]

    ITEM --> HASH --> SPLIT --> COUNT --> UPDATE
    UPDATE -.->|"sau toàn stream"| EST

4. Độ chính xác — sai số chuẩn và bộ nhớ

Sai số chuẩn (standard error) của HyperLogLog:

σ = 1.04 / sqrt(m)
m (số bucket)Sai số chuẩnRAM (5 bit/register)
1626%10 byte
2566.5%160 byte
10243.25%640 byte
40961.6%2.5 KB
655360.4%40 KB

Redis chọn m=16384 (14 bit bucket) và 6-bit register → 12 KB tổng, sai số chuẩn 0.81%. Đếm được đến 2^64 phần tử distinct với sai số này.

Quan hệ thú vị: sai số tỉ lệ nghịch với sqrt(m) — muốn giảm sai số 2 lần phải tăng bộ nhớ 4 lần. Đây là trade-off cơ bản của mọi sketch algorithm.

5. HyperLogLog++ và sparse representation

Google đề xuất HyperLogLog++ (Heule et al. 2013) với hai cải tiến chính:

Sparse representation: khi cardinality nhỏ (ít phần tử), phần lớn bucket rỗng. Lưu sparse dictionary {bucket_idx: register_value} thay vì dense array → tiết kiệm memory khi n nhỏ; tự động chuyển sang dense khi fill ratio vượt ngưỡng.

Bias correction: với cardinality nhỏ, bias của estimator lớn hơn lý thuyết. HyperLogLog++ dùng empirical bias correction table thay vì công thức đơn giản.

-- Merge 2 HyperLogLog (ứng dụng distributed counting):
function merge(M_A[0..m-1], M_B[0..m-1]):
    M_merged <- []
    for i từ 0 đến m-1:
        M_merged[i] <- max(M_A[i], M_B[i])  -- union: max từng register
    return M_merged
// Time: O(m)  Space: O(m)
-- Merge đúng vì max leading zero = union của 2 tập distinct elements

Mergeability là tính chất cực kỳ quan trọng: mỗi shard xử lý 1/k stream, merge kết quả bằng max từng register — đúng như chạy trên toàn stream. Cho phép đếm distinct trên cluster mà không gửi dữ liệu thô.

6. Liên hệ các bài khác

  • Hash function: HyperLogLog hoàn toàn phụ thuộc vào hash function phân phối đều. Hash xấu (collision nhiều ở leading bit) làm sai số vượt lý thuyết. MurmurHash3 hoặc xxHash64 thường được chọn vì uniformity tốt.
  • Bloom filter: cùng họ probabilistic data structure dùng hash — Bloom filter trả lời "có tồn tại không" (membership), HyperLogLog trả lời "có bao nhiêu distinct" (cardinality). Cả hai trade exact answer lấy memory efficiency.
  • Count-Min Sketch: thêm một thành viên trong họ sketch — đếm tần suất xấp xỉ (frequency) thay vì cardinality. Ba cấu trúc (Bloom, HyperLogLog, Count-Min) giải ba bài toán khác nhau cùng paradigm: hash + compact state + probabilistic bound.
  • Reservoir sampling: cùng bài toán streaming, cách tiếp cận khác — reservoir lưu mẫu exact, HyperLogLog lưu sketch compressed. Khi cần "mẫu để analyse", dùng reservoir; khi chỉ cần cardinality, dùng HyperLogLog.
  • Case study Redis & Kafka: Redis PFADD/PFCOUNT/PFMERGE là triển khai HyperLogLog production — case study đi sâu vào config thực tế và khi nào nên dùng.

📚 Deep Dive

📚 Deep Dive — Bài báo gốc và triển khai

Bài báo gốc:

  • Flajolet, Fusy, Gandouet, Meunier (2007), "HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm", AOFA. Phân tích lý thuyết đầy đủ với bounding của harmonic mean estimator.
  • Heule, Nunkesser, Hall (2013), "HyperLogLog in Practice: Algorithmic Engineering of a State of the Art Cardinality Estimation Algorithm" (Google) — HyperLogLog++ với sparse representation và bias correction.

Redis implementation:

  • Source: src/t_hyperloglog.c trong Redis repo — 16384 bucket × 6 bit = 12 KB. PFADD O(1), PFCOUNT O(m) = O(16384) ≈ microseconds. PFMERGE merge nhiều key.
  • Sai số thực đo của Redis: 0.81% (nhỏ hơn lý thuyết 1.04/sqrt(16384) = 0.81% — khớp chính xác).

Tại sao "Hyper" + "LogLog"?

  • "LogLog" từ thuật toán tiền thân Flajolet-Martin (1985): dùng log2(log2(n)) bit lưu trữ. "Hyper" vì HyperLogLog cải thiện cơ bản qua harmonic mean + stochastic averaging.

Ứng dụng query planner:

  • PostgreSQL ước lượng cardinality cột bằng HyperLogLog-variant trong statistics (pg_statistic) để chọn plan tối ưu. Cardinality sai → plan sai → query 100x chậm.

Tóm tắt

  • Ý tưởng cốt lõi: số leading zero tối đa của hash ≈ log2(cardinality) — không cần lưu phần tử, chỉ cần 1 số đếm.
  • Stochastic averaging: chia m bucket bằng b bit đầu hash, mỗi bucket giữ leading-zero tối đa riêng → m ước lượng độc lập.
  • Harmonic mean qua m bucket triệt tiêu outlier; nhân hằng số hiệu chỉnh alpha_m.
  • Sai số chuẩn 1.04/sqrt(m) — muốn sai số 2x nhỏ hơn cần 4x bộ nhớ.
  • Redis: m=16384, 6-bit register, 12 KB total, sai số 0.81% cho đến 2^64 distinct (trần lý thuyết của không gian hash 64-bit; va chạm hash đáng kể quanh ~2^32 birthday-bound nên sai số thực tế tăng dần khi cardinality tiếp cận 2^32).
  • Mergeability: max từng register — đếm distinct trên cluster bằng cách merge sketch, không cần gửi dữ liệu thô.

Tự kiểm tra

Tự kiểm tra
Q1
Tại sao xác suất một hash ngẫu nhiên có ít nhất r leading zero bằng 1/2^r? Kết nối tính chất này với lý do R_max ≈ log2(n).

Hash function đều phân phối mỗi bit output như đồng xu công bằng (xác suất 0 hoặc 1 đều 1/2). Xác suất bit đầu = 0 là 1/2; xác suất 2 bit đầu đều 0 là (1/2)^2 = 1/4; tổng quát xác suất r bit đầu đều 0 = 1/2^r.

Kết nối với n: với n phần tử distinct, xác suất KHÔNG có phần tử nào đạt r leading zero = (1 - 1/2^r)^n. Hàm này giảm từ ~1 (khi n << 2^r) về ~0 (khi n >> 2^r) — "phase transition" tại n ≈ 2^r. Vậy R_max quan sát được gần bằng log2(n) — nhiều phần tử distinct hơn → leading zero dài hơn được quan sát.

Q2
Tại sao HyperLogLog dùng harmonic mean thay vì trung bình cộng của 2^M[i]? Cho ví dụ cụ thể cho thấy trung bình cộng bị lệch.

Giả sử m=4 bucket, cardinality thật là 1000. Ba bucket cho M[i] = 10 (ước lượng 2^10 = 1024, hợp lý), nhưng 1 bucket tình cờ có M[i] = 30 (xác suất thấp nhưng có thể — 2^30 ≈ 10^9).

Trung bình cộng: (1024 + 1024 + 1024 + 10^9) / 4 ≈ 250 triệu — sai 250.000 lần. Harmonic mean: 4 / (1/1024 + 1/1024 + 1/1024 + 1/10^9) ≈ 4 / (3/1024) ≈ 1365 — sai ~37%, chấp nhận được. Outlier 10^9 đóng góp 1/10^9 ≈ 0 vào harmonic mean, gần như không ảnh hưởng.

Q3
Sai số chuẩn của HyperLogLog là 1.04/sqrt(m). Nếu cần sai số dưới 1%, cần bao nhiêu bucket? Bộ nhớ tương ứng là bao nhiêu (6 bit/register)?

Giải: 1.04/sqrt(m) < 0.01sqrt(m) > 104m > 10816. Chọn m = 16384 (lũy thừa 2 gần nhất) → sai số = 1.04/sqrt(16384) = 1.04/128 ≈ 0.81%.

Bộ nhớ: 16384 register × 6 bit = 98304 bit = 12288 byte = 12 KB. Đây chính xác là cấu hình Redis HyperLogLog — khớp với tài liệu Redis. Đếm đến 2^64 ≈ 1.8 × 10^19 distinct với 12 KB và sai số 0.81%.

Q4
Redis có lệnh PFMERGE hợp nhất nhiều HyperLogLog key. Tại sao lấy max từng register đúng với toán học? Điều gì sẽ sai nếu dùng trung bình cộng?

M[i] của bucket i là leading-zero tối đa của tất cả phần tử hash vào bucket i. Khi merge hai tập A và B, bucket i của kết quả phải phản ánh leading-zero tối đa của A∪B vào bucket i = max(M_A[i], M_B[i]). Đây là tính chất set union — max đúng với ngữ nghĩa union.

Dùng trung bình cộng sai vì: nếu A có M_A[i] = 15 và B có M_B[i] = 5, trung bình = 10 — thấp hơn max(A,B)=15, nghĩa là ước lượng thấp hơn thực tế (undercount). Union không bao giờ có cardinality nhỏ hơn từng tập con — phải dùng max.

Q5
Trong query planner của PostgreSQL, cardinality estimation sai dẫn đến plan tồi. Cho ví dụ cụ thể: cardinality của cột bị ước tính thấp hơn thực tế 100 lần ảnh hưởng đến join plan ra sao?

Ví dụ: bảng A có 1 triệu row, cột user_id thực có 800.000 distinct value nhưng pg_statistic ước tính chỉ 8.000. Planner nghĩ selectivity của điều kiện a.user_id = b.user_id cao (ít distinct → mỗi value match nhiều row hơn) → chọn nested loop join (tốt khi 1 bên nhỏ hoặc selectivity cao).

Thực tế selectivity thấp (800K distinct → mỗi value match ~1.25 row trung bình) → nested loop O(n²) thay vì hash join O(n). Query từ vài giây → hàng giờ. Đây là lý do PostgreSQL chạy ANALYZE để cập nhật statistics dùng HyperLogLog-variant, và tại sao default_statistics_target ảnh hưởng đến query performance.

Q6
HyperLogLog và Bloom filter đều dùng hash để tiết kiệm bộ nhớ. So sánh hai cấu trúc theo: bài toán giải quyết, loại lỗi có thể xảy ra, và khi nào nên chọn cái nào?

Bloom filter: membership query — "x có trong tập S không?". Lỗi: false positive (báo có nhưng thực ra không) với xác suất có thể cấu hình; KHÔNG có false negative (báo không thì chắc chắn không). Dùng khi: cache miss avoidance, dedup check, web crawler URL seen.

HyperLogLog: cardinality query — "tập S có bao nhiêu phần tử distinct?". Lỗi: ước lượng lệch với sai số chuẩn ~1-2%; không phân biệt over/under estimate. Dùng khi: unique visitor count, distinct query analytics, query planner statistics.

Chọn theo câu hỏi cần trả lời: membership → Bloom; cardinality → HyperLogLog. Kết hợp cả hai: Bloom filter kiểm tra "đã thấy phần tử này chưa" để tránh đếm lại, HyperLogLog đếm cardinality — một số hệ thống dùng cả hai song song.

Q7
HyperLogLog có hiệu chỉnh 'small range correction' bằng linear counting khi E <= 5m/2. Tại sao cần hiệu chỉnh riêng cho vùng cardinality nhỏ?

Khi cardinality nhỏ (n << m), phần lớn bucket vẫn rỗng (M[i] = 0). Estimator chính alpha_m × m^2 / Z được thiết kế cho vùng trung bình — với nhiều bucket rỗng, harmonic mean bị kéo lệch bởi 2^(-0) = 1 từ các bucket rỗng.

Linear counting (Whang et al. 1990) chính xác hơn cho vùng nhỏ: E = m × ln(m/V) trong đó V là số bucket rỗng. Khi n nhỏ, V lớn, công thức này ước lượng chuẩn vì tương quan giữa số bucket rỗng và cardinality là tuyến tính trong log. Khi n tăng, V giảm về 0 và linear counting mất chính xác — chuyển sang estimator chính. Đây là lý do code thực tế (Redis, Java HyperLogLog library) có 3 vùng: small (linear counting), medium (main estimator), large (hash space correction).

Bài tiếp theo: Count-Min Sketch — đếm tần suất xấp xỉ

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên