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

Count-Min Sketch — đếm tần suất xấp xỉ

Ước lượng tần suất phần tử trên stream với bộ nhớ cố định, dùng nhiều hàm băm + ma trận đếm. Luôn over-estimate; ứng dụng heavy hitters, rate limiting.

TL;DR: Bài toán: đếm tần suất từng phần tử trên stream hàng triệu sự kiện — đếm chính xác cần O(số_key) bộ nhớ, không khả thi khi key space vô hạn. Count-Min Sketch (CMS) dùng ma trận d × w ô đếm kết hợp d hàm băm độc lập: update(x) tăng một ô mỗi hàng; query(x) trả về MIN qua d hàng. Sai số một chiều: kết quả luôn ≥ giá trị thật; với xác suất cao sai số không vượt ε·N (ε ≈ e/w, xác suất vượt là e^-d). Bộ nhớ cố định O(d·w) bất kể số key — đây chính là thế mạnh cốt lõi.

Ngày 11/11 năm ngoái, Shopee xử lý hơn 87 triệu đơn hàng trong 24 giờ — tương đương ~1.000 đơn/giây. Với mỗi sự kiện add-to-cart, service cần biết sản phẩm nào đang hot để đẩy lên "trending". Nếu dùng HashMap<productId, count>, dictionary sẽ có hàng triệu key; riêng phần bộ nhớ đã vài GB. Khi dữ liệu không vừa RAM, streaming tới DB lại tạo bottleneck.

Count-Min Sketch giải quyết bài này: ước lượng tần suất với bộ nhớ vài chục KB cố định, chấp nhận over-count kiểm soát được, không bao giờ under-count.

1. Bài toán: tần suất trên stream

Cho stream vô hạn các phần tử x_1, x_2, ..., x_t. Cần trả lời frequency(x) = số lần x xuất hiện cho tới hiện tại.

Đếm chính xác: dùng hash map key → count. Bộ nhớ O(số_key_distinct). Với log truy cập của một sàn thương mại điện tử, số key distinct có thể là hàng chục triệu — hash map chiếm GB RAM.

Yêu cầu thực tế: chấp nhận sai số nhỏ, đổi lấy bộ nhớ cố định O(d·w) — và d, w do ta chọn theo ràng buộc lỗi cho trước.

2. Cấu trúc Count-Min Sketch

CMS là một ma trận số nguyên CMS[d][w] (khởi tạo toàn 0) kết hợp d hàm băm độc lập h_1, h_2, ..., h_d, mỗi hàm ánh xạ key về [0, w-1].

function CMS_init(d, w):
    -- Tạo ma trận d hàng × w cột, khởi tạo 0
    for i <- 0 to d-1:
        for j <- 0 to w-1:
            table[i][j] <- 0
    return table
// Space: O(d * w)

Tham số:

  • w (width) — số cột, kiểm soát sai số: ε ≈ e/w
  • d (depth) — số hàng, kiểm soát xác suất: δ ≈ e^(-d)
  • Ví dụ thực tế: w = 2000, d = 7 → sai số ≤ 0.14%·N, xác suất vượt < 0.1%, chỉ dùng 112 KB (nếu mỗi ô 8 byte).
flowchart LR
    X(["x = 'phone'"])
    H1["h_1(x) = 3"]
    H2["h_2(x) = 7"]
    H3["h_3(x) = 1"]
    C1["table[0][3]++"]
    C2["table[1][7]++"]
    C3["table[2][1]++"]
    X --> H1 --> C1
    X --> H2 --> C2
    X --> H3 --> C3

3. Thao tác update và query

3.1 Update — tăng đếm

function CMS_update(x):
    -- Tăng ô tương ứng trên mỗi hàng
    for i <- 0 to d-1:
        j <- h_i(x) mod w
        table[i][j] <- table[i][j] + 1
// Time: O(d)  Space: O(1) thêm

3.2 Query — ước lượng tần suất

function CMS_query(x):
    -- Lấy min qua tất cả hàng
    result <- +∞
    for i <- 0 to d-1:
        j <- h_i(x) mod w
        result <- min(result, table[i][j])
    return result
// Time: O(d)  Space: O(1)

Vì sao lấy MIN? Mỗi ô table[i][j] có thể chứa count của nhiều key bị collision tại cùng bucket. Collision chỉ làm tăng giá trị ô, không bao giờ giảm — nên ô nào cũng ≥ giá trị thật của x. Lấy MIN là cách lấy ước lượng sát nhất (tight upper bound). Với d hàm băm độc lập, xác suất tất cả d hàng đều bị collision nặng là rất nhỏ.

3.3 Trace ví dụ nhỏ — d=2, w=4

Stream: phone, laptop, phone, tablet, phone

Giả sử: h_1(phone)=0, h_1(laptop)=2, h_1(tablet)=0; h_2(phone)=1, h_2(laptop)=3, h_2(tablet)=3.

BướcPhần tửtable[0] (h_1)table[1] (h_2)
Khởi tạo[0, 0, 0, 0][0, 0, 0, 0]
1phone[1, 0, 0, 0][0, 1, 0, 0]
2laptop[1, 0, 1, 0][0, 1, 0, 1]
3phone[2, 0, 1, 0][0, 2, 0, 1]
4tablet[3, 0, 1, 0][0, 2, 0, 2]
5phone[4, 0, 1, 0][0, 3, 0, 2]

query(phone): min(table[0][0], table[1][1]) = min(4, 3) = 3. Thật là 3. ✓

query(tablet): min(table[0][0], table[1][3]) = min(4, 2) = 2. Thật là 1. Sai số +1 do collision tabletphone cùng bucket 0 trên hàng 0.

Đây đúng là tính chất sai số một chiều: kết quả ≥ giá trị thật, không bao giờ dưới.

4. Phân tích sai số

Định lý: Với CMS kích thước d × w, query(x) trả về f̂(x) thoả:

f̂(x) ≥ f(x) luôn luôn (không bao giờ under-count).

P[f̂(x) > f(x) + ε·N] ≤ δ, với ε = e/wδ = e^(-d).

N là tổng số phần tử trên stream. Khi chọn w = ⌈e/ε⌉d = ⌈ln(1/δ)⌉, ta đảm bảo sai số tương đối ≤ ε với xác suất ≥ 1 - δ.

Tham sốÝ nghĩaCông thức chọn
w (width)Kiểm soát sai số tuyệt đốiw = ⌈e/ε⌉
d (depth)Kiểm soát xác suất vượt sai sốd = ⌈ln(1/δ)⌉
Bộ nhớCố định bất kể số keyd × w × kích_thước_ô

Ví dụ thực tế: muốn sai số ≤ 0.1% tổng lượt (ε=0.001) với xác suất ≥ 99% (δ=0.01):

  • w = ⌈e/0.001⌉ = ⌈2718.28⌉ = 2719
  • d = ⌈ln(100)⌉ = ⌈4.6⌉ = 5
  • Bộ nhớ: 5 × 2719 × 8 = 108,760 byte ≈ 106 KB

So với HashMap lưu 10 triệu key (mỗi entry ~50 byte) = ~500 MB — CMS tiết kiệm hơn 4.500 lần.

5. So sánh Bloom Filter vs Count-Min Sketch

Cả hai đều là cấu trúc xác suất dùng multiple hash functions + bit/ô đếm. Điểm khác biệt then chốt:

Tiêu chíBloom FilterCount-Min Sketch
Câu hỏi trả lời"x có trong tập chưa?" (membership)"x xuất hiện bao nhiêu lần?" (frequency)
Ô lưu trữBit (0/1)Số nguyên đếm
Loại sai sốFalse positive (nói có khi không)Over-count (f̂ ≥ f thật)
Hỗ trợ deleteKhông (bit không giảm được)Chỉ với biến thể counter có dấu; delete phá vỡ bảo đảm over-estimate một chiều của CMS chuẩn
Ứng dụng chínhCache miss avoidance, spam filterFrequency estimation, heavy hitters

Cả hai đều KHÔNG bao giờ false negative: Bloom Filter không nói "không có" khi thật ra có; CMS không nói "ít hơn" khi thật ra nhiều hơn — đây là tính chất bảo toàn quan trọng cho ứng dụng thực tế.

Xem thêm về Bloom Filter tại bài 09 module tìm kiếm nhanh.

6. Ứng dụng thực tế

Heavy hitters (Top-K): chạy CMS song song với heap min-size-K. Mỗi lần update(x), nếu query(x) vượt ngưỡng → đẩy vào heap. Đây là ý tưởng cơ sở cho mini-challenge bài 06.

Rate limiting: thay vì lưu counter mỗi IP trong Redis, dùng CMS để ước lượng số request từ IP x trong cửa sổ thời gian. Over-count chỉ làm rate limit chặt hơn (false throttle thay vì false pass) — an toàn hơn cho security.

Network traffic monitoring: router cần biết flow nào chiếm băng thông lớn nhất. Với hàng triệu flow/giây, chỉ CMS mới đủ nhanh và nhỏ để chạy trên ASIC.

Database query planner: một số DB (như PostgreSQL sketch-based stats) dùng biến thể CMS để ước lượng cardinality histogram mà không scan toàn bảng.

7. Pitfall

Pitfall 1 — Nhầm CMS trả về giá trị chính xác

-- SAI: tin query() trả về đúng → dùng để billing
if CMS_query(userId) > 1000:
    charge(userId)     -- Lỗi: over-count có thể charge oan
-- DUNG: dùng CMS chỉ để lọc ứng viên, verify bằng nguồn chính xác
candidates <- items where CMS_query(x) > threshold
for x in candidates:
    exact_count <- DB.count(x)  -- verify chính xác trước khi hành động
    if exact_count > 1000: charge(x)

CMS phù hợp cho screeningmonitoring, không phải billing hay audit cần độ chính xác tuyệt đối.

Pitfall 2 — Chọn dw không dựa trên ràng buộc bài toán

-- SAI: đoán mò d=3, w=100 mà không kiểm tra ε và δ
-- ε = e/100 ≈ 2.7% — quá lớn nếu N = 10 triệu, sai số tuyệt đối ~270.000!
-- DUNG: tính ngược từ ràng buộc
epsilon <- 0.001   -- chấp nhận sai số 0.1% * N
delta   <- 0.01    -- xác suất vượt sai số ≤ 1%
w <- ceil(e / epsilon)        -- = 2719
d <- ceil(ln(1 / delta))      -- = 5

Luôn xuất phát từ εδ chấp nhận được, sau đó tính wd. Ngược lại sẽ cho kết quả sai quá ngưỡng cho phép.

Pitfall 3 — Dùng hàm băm không độc lập hoặc dùng chung seed

-- SAI: dùng cùng 1 hàm băm với d offset khác nhau
h_i(x) = hash(x) + i   -- Không độc lập: cùng collision pattern!
-- DUNG: d hàm băm thực sự độc lập (khác seed hoặc thuật toán khác nhau)
h_i(x) = murmur3(x, seed=i)  -- seed độc lập → độc lập thống kê

Các hàm băm phải pairwise independent để đảm bảo bound lý thuyết. Dùng offset đơn giản không đủ độc lập — collision patterns bị correlated, bound sai số thực tế xấu hơn lý thuyết.

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

  • Hash function — nền tảng băm: hàm băm độc lập là building block của CMS. Bài đó giải thích tính chất pairwise independence và avalanche effect — trực tiếp ảnh hưởng chất lượng CMS.
  • Bloom Filter: anh em họ của CMS — cùng dùng multiple hash + array, nhưng trả lời câu hỏi membership thay vì frequency. Đọc bài đó để thấy rõ điểm tương đồng và khác biệt kiến trúc.
  • HyperLogLog: cùng họ xác suất — HLL ước lượng cardinality (số distinct), CMS ước lượng frequency cá thể. Hai bài bổ sung nhau trong streaming analytics pipeline.
  • Mini-challenge top-K: ứng dụng CMS kết hợp min-heap để tìm heavy hitters — bài thực hành trực tiếp của bài này.
  • Amortized analysis: phân tích amortized giải thích tại sao O(d) mỗi thao tác, kết hợp với số thao tác tổng, vẫn cho throughput cao trong thực tế.

📚 Deep Dive

📚 Deep Dive — Nguồn gốc & tham khảo

Bài báo gốc:

  • Cormode & Muthukrishnan (2005) — "An Improved Data Stream Summary: The Count-Min Sketch and its Applications", Journal of Algorithms 55(1). Đây là bài báo giới thiệu CMS với đầy đủ chứng minh sai số.

Sách:

  • Mining of Massive Datasets (Leskovec, Rajaraman, Ullman), Chương 4 "Mining Data Streams" — trình bày CMS trong bối cảnh streaming algorithms, kèm so sánh với AMS sketch.
  • Probabilistic Data Structures and Algorithms (Gakhov, 2019) — chương Count-Min Sketch có ví dụ code Python và Redis.

Triển khai thực tế:

  • Redis module RedisBloom có lệnh CMS.INCRBY / CMS.QUERY — dùng được ngay trong production.
  • Apache Flink, Spark Streaming dùng CMS trong các window aggregation operator để tính approximate top-K.
  • Twitter Algebird cung cấp CMS monoid dùng trong production; xem thêm Hokusai — Sketching Streams in Real Time (Matusevych, Smola, Ahmed, UAI 2012) cho kỹ thuật CMS over time-decaying stream.

Biến thể:

  • Count-Mean-Min Sketch: thay vì MIN, dùng median để giảm bias — tốt hơn khi distribution skewed mạnh.
  • Conservative Update (CU): khi update x, chỉ tăng ô nào có giá trị nhỏ hơn query(x) + 1 — giảm over-count đáng kể với chi phí thêm 1 lần query.

Tóm tắt

  • Count-Min Sketch lưu ma trận d × w số nguyên + d hàm băm độc lập; bộ nhớ cố định O(d·w) bất kể số key.
  • update(x): tăng ô table[i][h_i(x)] cho mỗi hàng i — O(d).
  • query(x): trả về min qua d hàng — O(d). Kết quả luôn ≥ giá trị thật (không bao giờ under-count).
  • Sai số ≤ ε·N với xác suất ≥ 1 - δ; chọn w = ⌈e/ε⌉, d = ⌈ln(1/δ)⌉.
  • Phân biệt Bloom Filter (membership, bit) vs CMS (frequency, counter); cùng kiến trúc hash-matrix nhưng trả lời câu hỏi khác.
  • Dùng để screening và monitoring; khi cần chính xác tuyệt đối, verify bằng nguồn chính xác.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao query() lấy MIN qua các hàng thay vì AVG hoặc MAX? Tính chất nào của collision đảm bảo MIN là upper bound chính xác nhất?

Collision trong một hàng chỉ làm tăng giá trị ô — một ô table[i][j] chứa tổng count của tất cả key ánh xạ tới bucket j trong hàng i. Do đó mọi ô đều ≥ count thật của x. Lấy MAX sẽ chọn hàng bị collision nặng nhất — sai số lớn nhất. Lấy AVG trộn lẫn các mức collision khác nhau — không có guarantee lý thuyết rõ ràng.

MIN chọn hàng bị collision ít nhất (ước lượng sát nhất). Với d hàm băm độc lập, xác suất tất cả d hàng đều bị collision nặng (dẫn đến MIN vẫn lớn hơn thật nhiều) là (e/w)^d = e^(-d*(ln(w/e))) — rất nhỏ khi d vừa phải. Đây là lý do MIN vừa có intuition đúng vừa có bound lý thuyết.

Q2
Tại sao sai số của CMS luôn là over-count, không bao giờ under-count? Điều này ảnh hưởng thế nào đến cách thiết kế ứng dụng dùng CMS?

Cơ chế: mỗi update(x) tăng đúng 1 ô trên mỗi hàng — không bao giờ giảm ô nào. Khi query, MIN qua các ô đó luôn ≥ số lần thực tế x xuất hiện (vì ô còn chứa count của các key khác bị collision). Không có cơ chế nào làm giảm ô của x, nên under-count là bất khả.

Hệ quả thiết kế: ứng dụng nên dùng CMS cho quyết định "mềm" (identify candidates, trigger alert) chứ không phải quyết định "cứng" (billing, SLA guarantee). Pattern an toàn: CMS lọc ứng viên vượt ngưỡng → verify bằng nguồn chính xác (DB, log) trước khi hành động. Với rate limiting, over-count chỉ làm chặt hơn — thường chấp nhận được về security.

Q3
Cho ε=0.5% và δ=1%. Tính w và d cần thiết. Bộ nhớ tổng là bao nhiêu nếu mỗi ô dùng 8 byte?

ε = 0.005, δ = 0.01.

w = ⌈e / ε⌉ = ⌈2.718 / 0.005⌉ = ⌈543.6⌉ = 544 cột.

d = ⌈ln(1/δ)⌉ = ⌈ln(100)⌉ = ⌈4.605⌉ = 5 hàng.

Bộ nhớ: 5 × 544 × 8 = 21.760 byte ≈ 21 KB. Chỉ 21 KB để ước lượng tần suất với sai số ≤ 0.5%·N với xác suất 99% — bất kể stream có bao nhiêu key distinct.

Q4
Bloom Filter và Count-Min Sketch đều dùng multiple hash functions + mảng. Chỉ ra 2 điểm khác biệt kiến trúc và giải thích vì sao mỗi điểm dẫn đến câu trả lời khác nhau.

Điểm 1 — Kiểu ô lưu trữ: Bloom Filter dùng bit (0/1); CMS dùng số nguyên đếm. Bit chỉ nói "đã thấy hay chưa"; số nguyên nói "đã thấy bao nhiêu lần". Cấu trúc dữ liệu quyết định câu hỏi trả lời được — membership vs frequency.

Điểm 2 — Phép đọc: Bloom Filter dùng AND qua tất cả bit (chỉ trả về "có" nếu tất cả bit = 1); CMS dùng MIN qua các ô đếm. Với Bloom Filter, false positive = tất cả bit ngẫu nhiên đều là 1; với CMS, over-count = collision trên tất cả hàng đều xảy ra — cơ chế khác nhau dẫn đến đặc tính sai số khác nhau (false positive vs over-count).

Q5
Conservative Update (CU) cải tiến CMS bằng cách chỉ tăng ô nào có giá trị nhỏ hơn query(x)+1 thay vì tăng tất cả d ô. Giải thích tại sao kỹ thuật này giảm over-count mà không làm kết quả trở thành under-count.

Ý tưởng: trước khi update, chạy query(x) để biết ước lượng hiện tại . Chỉ tăng ô table[i][h_i(x)] nếu nó nhỏ hơn f̂ + 1. Những ô đã lớn hơn hoặc bằng f̂ + 1 là đang over-count do collision — không cần tăng thêm.

Vì sao không under-count: phép query vẫn là MIN. Sau CU update, ít nhất 1 ô trong số d ô sẽ được tăng (ô có giá trị nhỏ nhất = sẽ tăng lên f̂+1). MIN qua các ô vẫn ≥ count thật — tính chất upper bound bảo toàn. CU giảm tốc độ tăng của các ô bị collision nặng, nên MIN về lâu dài sát hơn giá trị thật.

Q6
Stream gồm 1 triệu sự kiện, 90% là một key duy nhất 'A'. CMS với w=100, d=3 sẽ cho kết quả query('A') như thế nào? Điều này dạy ta bài học gì về việc chọn w?

Với w=100, mỗi hàng có 100 ô. 'A' xuất hiện 900.000 lần; 100.000 lần còn lại trải đều qua ~100.000 key khác. Mỗi ô trung bình bị collision thêm 1000 lần. Ô của 'A' trong mỗi hàng ≈ 900.000 + 1000 = 901.000 → query(A) ≈ 901.000, sai số ~1.000/1.000.000 = 0.1%.

Tuy nhiên, với ε = e/w = 2.718/100 ≈ 2.7% và N=1.000.000, bound lý thuyết cho phép sai số tới 27.000 — lớn hơn sai số thực tế nhiều. Điều này dạy: khi distribution skewed mạnh (1 key dominant), CMS thực tế tốt hơn bound lý thuyết cho key dominant đó; nhưng bound lý thuyết vẫn là guarantee đúng. Nếu muốn bound chặt, phải tăng w — không thể rely vào "distribution sẽ skewed" vì production data thay đổi theo mùa.

Q7
Trong ứng dụng rate limiting bằng CMS: mỗi IP là một key, cửa sổ 1 phút. Giải thích tại sao over-count an toàn hơn under-count trong bài toán này, và đề xuất 1 vấn đề tiềm ẩn cần xử lý.

Over-count nghĩa là ta chặn IP sớm hơn cần thiết (false throttle). Under-count nghĩa là ta cho phép qua khi đáng lẽ phải chặn (false pass). Về security, false pass nguy hiểm hơn false throttle — một IP tấn công DDoS lách qua rate limit gây hại hơn là một IP bình thường bị chặn nhầm vài giây. CMS phù hợp vì sai số theo hướng an toàn.

Vấn đề tiềm ẩn: CMS không có cơ chế reset theo cửa sổ thời gian. Cần kết hợp với sliding window hoặc dùng 2 CMS luân phiên (swap mỗi nửa cửa sổ) để đếm trong phạm vi thời gian — nếu không, count tích luỹ mãi và mọi IP đều bị chặn sau đủ lâu. Kỹ thuật 2-CMS luân phiên là pattern chuẩn trong production rate limiting.

Bài tiếp theo: Sliding window — thống kê trên cửa sổ trượt

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