Cairn
data structures · probabilistic frequency estimation · O(d) add/query, never undercounts

back to Probabilistic

Count-Min Sketch

The Bloom filter page answers one narrow question in fixed memory: "have I seen this before?" A Count-Min Sketch answers a different narrow question, in the same fixed memory no matter how many items go in: "roughly how many times have I seen this?" Same trade — give up exactness, keep the memory flat — for a counting problem instead of a membership one.

Instead of a single bit array, a Count-Min Sketch keeps a small d × w grid of counters — d rows, each an independent hash table of w buckets, all starting at 0. Adding an item runs it through d hash functions, one per row, and increments the counter each one points to — so a single add touches exactly one cell in every row, not one cell total. Estimating an item's count runs the same d hash functions and reads the same d cells, then reports the minimum of the d readings — not the sum, not the average.

The minimum is the whole trick, and it's worth being precise about why. Any single row can be polluted: two different items can hash to the same bucket in that row, and whichever one is queried will read the combined count of both, an overestimate. But it's far less likely that every row happens to collide the same item with something else — the rows use independent hash functions, so a bucket crowded in row 2 is, most of the time, a different, less-crowded bucket in row 0. Taking the minimum across all d rows means picking the least-polluted reading available, which gives the tightest safe upper bound the sketch can offer.

"Safe" is the operative word, and it mirrors the Bloom filter's own guarantee in the opposite direction. A Bloom filter can never wrongly say no — only wrongly say yes. A Count-Min Sketch can never undercount — only overcount. Counters only ever go up, and every add touches the same d cells a later query for that exact item will read, so those cells can never read lower than the true count on any row — meaning the minimum of all d rows can't either. The estimate is always a real lower bound on how wrong it can be: estimate(item) ≥ true_count(item), always, no matter how the collisions fall.

That one-sidedness is a deliberate trade, not a limitation to work around: it's what makes a hard "at most this many, guaranteed" bound possible at all. Count Sketch answers the identical per-item frequency question with the opposite trade — a random sign per row instead of a plain increment, a median instead of a minimum — giving up the never-undercounts guarantee for an estimate that's unbiased and centered on the truth instead of always leaning above it.

Try it

Nine words have already been added, in this order: cat, dog, cat, bird, cat, dog, fish, cat, owl — true counts cat=4, dog=2, bird=1, fish=1, owl=1 (d = 3 rows, w = 12 columns). Query dog first: all three rows read exactly 2, so the estimate matches the true count exactly. Then query cat: the three rows read 5, 6, and 5 — every row is polluted by something, but the minimum (5) is only one more than the true count of 4, not the six a single unlucky row would have reported alone. Then query lion — a word never added at all — and watch it come back reporting a count of 1, not 0. Add your own words (with an optional count greater than 1) and watch the grid fill in.

count matrix (d = 3 rows, w = 12 columns)
true counts (ground truth — not something a real sketch stores this way)
Loaded a sample stream: cat, dog, cat, bird, cat, dog, fish, cat, owl (9 adds). Try querying "dog" (exact), then "cat" (a real overestimate), then "lion" (never added, but reads as 1 anyway).

Core operations

Reference implementation

function fnv1a(s) {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

function djb2(s) {
  let h = 5381;
  for (let i = 0; i < s.length; i++) {
    h = (Math.imul(h, 33) + s.charCodeAt(i)) >>> 0;
  }
  return h >>> 0;
}

class CountMinSketch {
  #table; // d rows, each a length-w counter array
  #d;
  #w;

  constructor(d, w) {
    this.#d = d;
    this.#w = w;
    this.#table = Array.from({ length: d }, () => new Uint32Array(w));
  }

  // same Kirsch-Mitzenmacher trick as the Bloom filter: two real hashes, d derived indices
  #indices(item) {
    const s = String(item);
    const h1 = fnv1a(s);
    const h2 = djb2(s);
    const idxs = [];
    for (let i = 0; i < this.#d; i++) {
      idxs.push(((h1 + i * h2) >>> 0) % this.#w);
    }
    return idxs;
  }

  add(item, count = 1) {
    this.#indices(item).forEach((idx, row) => { this.#table[row][idx] += count; });
  }

  estimate(item) {
    return Math.min(...this.#indices(item).map((idx, row) => this.#table[row][idx]));
  }
}

Verified three ways, all in Node, self-tested before being trusted: (1) the never-undercounts invariant, across 5,000 randomized trials varying d (2-5), w (6-45), and the item stream (27,232 individual estimate checks total) — zero cases where the estimate came in below the true count. Proved this check would actually catch a broken implementation first: a deliberately sabotaged counter (one that silently drops an increment on every third add) produced 2 real undercounts on the same kind of randomized stream, confirming "zero undercounts" on the real implementation means something rather than the check being toothless. (2) An empirical error-bound measurement for the shipped demo's exact configuration (d=3, w=12): the standard Count-Min bound says estimate(item) ≤ true_count(item) + ε·N with probability ≥ 1 - e^-d, where ε = e/w and N is the total count added so far — for this configuration ε ≈ 0.227 and the failure probability is bounded by e^-3 ≈ 5.0%. Across 2,000 randomized streams (18,598 individual checks) the bound was actually exceeded only 0.57% of the time, comfortably under the 5% worst-case the theory allows (the theory is a guarantee, not a tight prediction — it's supposed to hold up with room to spare). (3) The exact shipped demo numbers, reproduced standalone from the same stream in the same order — cat's three row-readings of 5, 6, 5 (estimate 5, true 4), dog/bird/fish/owl all landing exactly on their true counts, and lion's reading of 1 despite never being added. See /tmp/cms/selftest.js and /tmp/cms/detail.js, scratch, not committed.

Pitfalls

Only ever overestimates, never underestimates — the mirror of the Bloom filter's guarantee, in the opposite direction. Every add to an item touches the exact same d cells that item's own later queries will read, and counters never decrease, so those cells can never read below what that item alone contributed. The minimum of the d readings inherits that floor. What it can't guarantee is a ceiling: other items sharing any of those cells only ever push the reading up. Same pigeonhole logic as the Bloom filter and Rabin-Karp's hash collisions before it — finitely many cells, unboundedly many possible items, so collisions are a certainty eventually, not a bug to eliminate.

Independent rows blunt collisions, they don't erase them. In the live demo, querying cat (true count 4) reads 5, 6, and 5 across the three rows — every single row is polluted by something, by a different amount each. Taking the minimum recovers 5, only one over the truth; a sketch with d = 1 (a plain hash array of counters, no rows to compare) would have had no choice but to report whichever single reading it happened to have — 6 in this case, twice as far off. More rows narrow the gap between the estimate and the truth; they don't close it to zero, and can't, without also growing w or accepting a wider error margin.

A full collision across every row is rare, but undetectable from the estimate alone. In this exact demo's configuration, lion — never added — hashes to the identical three cells, in all three rows, as fish — added once. Querying either one reads the same number, because by this configuration's hash arithmetic they are, for counting purposes, indistinguishable: the sketch has no way to tell "this cell's count came from fish" apart from "this cell's count came from lion," since it never stored which item set which counter. This is worse in one respect than the Bloom filter's false positive, which only ever answers a binary yes/no; here, two entirely different keys can silently share one one's count with the other, and nothing in the returned number reveals that it happened.

d and w have to be picked ahead of time, from how much error is tolerable (ε, setting w = ⌈e/ε⌉) and how confident the bound needs to be (δ, setting d = ⌈ln(1/δ)⌉) — both need at least a rough estimate of the eventual total count N, since the bound scales with ε·N. Same limitation the Bloom filter's own Pitfalls names for m and k: growing the grid after the fact isn't possible without re-adding every item from scratch against the new size, which needs keeping every item around somewhere, undercutting the reason to use a fixed-size sketch in the first place.

Where Count-Min Sketches show up

Complexity

Time: both add and estimate are O(d), flat — like the Bloom filter's O(k), the cost never grows with how many items are already in the sketch or how many times any of them have been added. Space: a fixed O(d·w) counters, chosen once up front from the tolerable error ε and confidence δ — independent of the number of distinct items and, unlike a hash map of counters, independent of the total count added (up to where the error bound stops being useful). Same fixed footprint the Bloom filter trades for exactness, spent on "how many" instead of "is it in."

This site's guide, Choosing a Probabilistic Structure, compares this entry against HyperLogLog, Reservoir Sampling, and the site's other fixed-memory unbounded-stream structures side by side.