Cairn
data structures · probabilistic frequency estimation · O(d) add/query, unbiased two-sided error

back to Probabilistic

Count Sketch

The Count-Min Sketch page ends on a guarantee that only points one way: its frequency estimate can never fall below the true count, only above. That one-sidedness is the whole trick when the question is "how big could this be, at worst" — but it also means every estimate carries the same baked-in upward lean, no matter how many independent sketches get built or averaged. Count Sketch answers the identical question, "roughly how many times has this item appeared," with the opposite trade: an estimate that lands above the truth about as often as it lands below, centered on the real answer instead of pinned above it.

The structure is nearly identical — the same d × w grid of counters, the same two hash-derived row indices Count-Min Sketch uses to spread each add across d independent rows. What's new is a second hash per row producing a random sign, +1 or −1. Adding an item multiplies its count by that row's sign before adding it to the counter — so counters aren't running tallies of "how many things landed here," they're running sums that can go negative. Querying an item recomputes the same signs, multiplies each row's raw reading back by its own sign to undo the flip, and reports the median of the d corrected readings — not the minimum.

The sign is the whole trick, and it's worth being precise about why it works. When two different items collide in the same cell, Count-Min Sketch's counter can only ever grow — the collision's damage is one-directional and permanent. A Count Sketch cell also mixes contributions from whatever else collided there, but each contributor's sign is an independent coin flip: on average, an equal number of colliding contributions push the cell up as push it down, and they partly cancel instead of stacking. The correction step (multiplying back by the queried item's own sign) recovers an estimate that's correct in expectation — the collision noise still shows up in any single query, but it's centered on zero rather than always positive, and taking the median of d independent readings suppresses whichever row's noise happened to swing hardest that time.

Try it

The same nine-word stream Count-Min Sketch's own demo uses — cat, dog, cat, bird, cat, dog, fish, cat, owl — true counts cat=4, dog=2, bird=1, fish=1, owl=1 — loaded here into a smaller d = 3, w = 6 grid (small on purpose, so collisions are common enough to see the correction actually working). Query bird or fish first: both land on 1, an exact match. Then query cat (true count 4) and watch it come back 3 — an underestimate, something Count-Min Sketch's own guarantee forbids outright. Then query owl (true count 1) and watch it read 2, an overestimate the opposite direction. Finally query lion — never added at all — and watch it report −1: a negative count for an item that was never seen, which is not a bug, just what "the noise happened to land below zero this time" looks like for an estimator with no floor.

signed count matrix (d = 3 rows, w = 6 columns) — cell text shown before sign correction
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 "bird" or "fish" (exact), then "cat" (a real underestimate) or "owl" (a real overestimate), then "lion" (never added, but reads as a negative number).

Core operations

Reference implementation

function fnv1a(s, seed) {
  let h = (0x811c9dc5 ^ seed) >>> 0;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  // same Murmur3-style finalizer xor-filter.html's own hash uses -- a raw FNV hash's low bit
  // is linear (exactly the XOR of the input bytes' own low bits), so two hashes of the same
  // string derived from the same raw hash family can share a parity that looks independent
  // but isn't. See Pitfalls for what happens without this step.
  h ^= h >>> 16;
  h = Math.imul(h, 0x85ebca6b);
  h ^= h >>> 13;
  return h >>> 0;
}

const SEED_INDEX = 0x1b873593; // seed family for bucket indices
const SEED_SIGN  = 0x9e3779b1; // a separate seed family for the +1 / -1 sign

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

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

  #indices(item) {
    const idxs = [];
    for (let i = 0; i < this.#d; i++) idxs.push(fnv1a(item, (SEED_INDEX + i) >>> 0) % this.#w);
    return idxs;
  }

  #signs(item) {
    const signs = [];
    for (let i = 0; i < this.#d; i++) {
      signs.push((fnv1a(item, (SEED_SIGN + i) >>> 0) & 1) === 0 ? 1 : -1);
    }
    return signs;
  }

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

  estimate(item) {
    const idxs = this.#indices(item);
    const signs = this.#signs(item);
    const corrected = idxs.map((idx, row) => signs[row] * this.#table[row][idx]);
    corrected.sort((a, b) => a - b);
    return corrected[(this.#d - 1) >> 1]; // d is odd -> the true middle element
  }
}

Verified in Node, self-tested before being trusted, all against a 40-of-441-item skewed stream (one heavy item added 40 times among 400 distinct one-off fillers, d = 3, w = 64), run across 5,000 independently seeded sketches so the same theoretical claim being checked — unbiasedness over the random choice of hash functions — is what's actually being measured, not just "one lucky stream." (1) Two-sided, centered error: the heavy item's estimate came in above the true count 37.3% of the time, below it 37.7%, and exactly right the rest — mean error 0.003 (essentially zero) versus a parallel Count-Min Sketch built from the identical stream and indices, which overestimated on 99.4% of the same 5,000 trials with a mean error of +4.198 and never once underestimated. Mean absolute error came out lower for Count Sketch too (1.272 vs. 4.198) on this specific skewed configuration — not a universal ranking (see Complexity below for when Count-Min Sketch's guarantee is worth more than a smaller average error), just what this stream measured. (2) The self-test that makes (1) meaningful: deliberately breaking the sign hash (Pitfall 3 below) on the same stream and trial count flips the 37/38/25 split to 100% overestimates — confirming the unbiasedness check above would actually catch a broken implementation, not just pass by construction. (3) The exact shipped demo numbers, reproduced standalone from the same 9-word stream in the same order: cat → 3 (true 4), dog → 1 (true 2), bird → 1, fish → 1 (both exact), owl → 2 (true 1), and lion → −1 despite never being added. See /tmp/csk/*.js, scratch, not committed.

Pitfalls

Taking the minimum instead of the median throws away the whole point. The instinct, having just built something that looks almost exactly like a Count-Min Sketch, is to reuse its query rule — take the smallest (or, seeing negative numbers, the most extreme) reading. Tested directly on the same skewed stream (5,000 trials): using min() over the signed, corrected readings instead of the median produced a mean error of −2.108, understating the true count on 80.5% of trials. It isn't just worse, it's biased in the opposite direction from what a reader primed by Count-Min Sketch would expect — there's no "safe side" to lean on here, because unlike Count-Min Sketch's raw counters, these readings can be negative, and the minimum of a mix of positive and negative numbers isn't a bound on anything.

Forgetting to multiply back by the item's own sign before taking the median. The raw counter at a row's index already reflects that row's sign convention baked in when the item was added; querying without re-applying it treats every row's raw value as if it directly represented the item's count. Tested on the same stream: median of the raw, uncorrected readings produced a mean error of −40.3 against a true count of 40 — essentially reporting the negative of the right answer whenever the queried item's own sign in most rows happened to be −1, since the raw cell already has that flip applied and the query never undoes it. 50.4% of trials were off by more than 10.

Deriving the sign from the index instead of an independent hash. The index and the sign are supposed to be unrelated, but it's tempting to save a hash call and reuse the bucket index's own parity as the sign — idx % 2 === 0 ? 1 : -1. This looks harmless (it's still "randomly" +1 or −1 depending on the item) but it's fatal: two items land in the same bucket exactly when they share an index, and this scheme makes the sign a direct function of the index — so any two colliding items are now guaranteed the same sign, never a mix. Collisions stop partially cancelling and start stacking every time, exactly like an unsigned counter. Measured on the same 5,000-trial stream: 100% of trials overestimated (versus the 37/38/25 split above) with a mean error of +6.196 — the two-sided guarantee doesn't degrade gracefully here, it collapses completely, because the flaw is structural rather than a matter of degree.

Where Count Sketches show up

Complexity

Time: both add and estimate are O(d), identical to Count-Min Sketch — the sort inside estimate is over the fixed, tiny d, not over any growing quantity. Space: the same fixed O(d·w) counters, sized the same way from a tolerable error and confidence budget, and subject to the same "can't grow the grid after the fact" limitation Count-Min Sketch's own Pitfalls section names — except each counter here needs a sign bit's worth of extra range (a real implementation uses a signed integer type, not an unsigned one).

Choosing between the two comes down to what "wrong" is allowed to look like: Count-Min Sketch when a hard, one-directional bound matters more than the average case (rate limiting, catching heavy hitters where "we said more than there really were" is the safe failure mode); Count Sketch when the estimate needs to be right on average, sketches need to be combined or averaged across shards without compounding a shared bias, or a squared/moment-style quantity is being estimated and a one-sided bias would distort it further. This site's guide, Choosing a Probabilistic Structure, compares both against HyperLogLog, Reservoir Sampling, and the site's other fixed-memory unbounded-stream structures side by side.