Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°48

HyperLogLog Cardinality Estimator

Hard ProbabilisticStreamingHashing

Count how many <i>distinct</i> items a stream contained — billions of them — using a few kilobytes, not a giant set. HyperLogLog turns "the longest run of leading zeros I saw" into a cardinality estimate. It powers COUNT(DISTINCT) in Redis, Presto and BigQuery. A provided hash keeps Python and TypeScript in sync. Hidden tests.

The problem

Implement hll_estimate(items, p): estimate the number of distinct values in items using HyperLogLog with 2^p registers. For each item, hash it (use the provided hash32); the top p bits pick a register, and the register keeps the maximum "rank" seen — the position of the leftmost 1-bit in the remaining bits (leading-zeros + 1). Combine the registers with the HLL formula and return the estimated cardinality as an integer.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement hll_estimate(items, p): build m = 2^p registers of max ranks, then apply the HLL estimator with the small-range correction. The provided hash32 gives the bits; the top p select the register, the rest give the rank.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input hll_estimate([str(i) for i in range(1000)], 8)
Output 1011
1,000 distinct values and 256 registers: about 1% off, inside the 6.5% standard error for p = 8
EXAMPLE 2
Input hll_estimate([str(i) for i in range(300)], 8)
Output 317
about 6% high: an estimate, not an exact count
EXAMPLE 3
Input hll_estimate([], 8)
Output 0
edge case: an empty stream leaves every register at 0, and linear counting returns 0
EXAMPLE 4
Input hll_estimate(['x'] * 500, 8)
Output 1
edge case: 500 copies of one value estimate 1, because duplicates never change a register
CONSTRAINTS
  • Use the provided hash32. There are m = 2^p registers, all starting at 0.
  • Rank = leading-zeros-plus-one of the non-bucket bits: if the leftmost bits are 0, the rank is higher (rare, so a high rank hints at many distinct items).
  • Estimate E = alpha · m² / Σ 2^(−register). Apply the small-range correction: if E ≤ 2.5m and some registers are still 0 (count V), use E = m · ln(m / V). Return int(E).
  • It is an estimate — expected error is a few percent, not exact. That is the trade for tiny memory.
Hints 4 ideas
HINTS — 4 IDEAS
  1. For each item: h = hash32(item). Bucket = top p bits (h >> (32 - p)). The remaining 32 - p bits are w.
  2. Rank = 1 + number of leading zeros of w within its 32 - p bits (if w is all zeros, the rank is (32 - p) + 1). Keep the max rank per register.
  3. Raw estimate: alpha * m * m / Z where Z = Σ 2^(−register[j]) and alpha = 0.7213 / (1 + 1.079/m).
  4. Small-range correction: if the estimate ≤ 2.5·m and V registers are still zero, return m * ln(m / V) instead. Truncate to an int.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach counts exactly: len(set(items)). It is correct by definition and runs in O(n) expected time, but it needs O(d) space for d distinct items, because the set stores every one of them. A billion distinct 16-byte ids take at least 16 GB before any per-entry overhead, and two machines can only combine their counts by shipping their whole sets to each other. That memory is the reason HyperLogLog exists: it answers the same question within a few percent from m small registers, and Redis’s version uses 16,384 of them in 12 KB, whether the stream holds a thousand distinct values or a billion.

The approach

A good hash makes every bit a fair coin flip, so a hash that starts with r - 1 zeros and then a 1, rank r, turns up with probability 2^-r; a rank of 10 hints at about 210 distinct values. One maximum is noisy, so HyperLogLog splits the stream into m registers by the hash’s top bits, keeps the maximum rank per register, and combines them with a harmonic mean, which stops one lucky register from dominating. Duplicates hash identically, so they change nothing.

In the reference solution, m = 1 << p and bits = 32 - p. For each item, bucket = h >> bits takes the top p bits of hash32(it), w keeps the rest, and the inner loop counts the leading zeros of w to get rank; regs[bucket] keeps the maximum. Then it sums Z = sum(2.0 ** (-r) for r in regs) and computes E = alpha * m * m / Z. When E <= 2.5 * m and V registers are still 0, linear counting, m * math.log(m / V), replaces the raw estimate, which is biased in that range.

Walk through an example

The examples stream hundreds of items, so trace a small call, hll_estimate(['a', 'b', 'a', 'c'], 4), with m = 16 registers and bits = 28.

stepinputwhat the code computesresult
1'a'hash32 = 0x1ef3dffa; top bits 0001 → bucket 1; w starts with 1 → rank 1regs[1]: 0 → 1
2'b'0xd25d3383; top bits 1101 → bucket 13; w starts 001 → rank 3regs[13]: 0 → 3
3'a'same hash, bucket 1, rank 1no change: a duplicate
4'c'0xca51fb08; top bits 1100 → bucket 12; w starts with 1 → rank 1regs[12]: 0 → 1
5—Z = 13 × 20 + 2−1 + 2−1 + 2−314.125
6—alpha = 0.7213 / (1 + 1.079 / 16) ≈ 0.6757; E = alpha × 16 × 16 / Z≈ 12.25, at most 2.5 × 16 = 40
7—V = 13 registers still 0, so E = 16 × ln(16 / 13)≈ 3.32 → returns 3

Complexity

Optimal: O(n · L) time to hash n strings of length L, plus a leading-zero scan of at most 32 - p bits per item and O(m) to combine the registers; O(m) space, fixed however many items stream past. Brute force (an exact set): O(n · L) expected time too, but O(d) space that grows with every distinct item.

Edge cases to test

  • hll_estimate([], 8) → 0. All 256 registers are 0, so the raw estimate is alpha · m, below 2.5m, and linear counting gives m · ln(m / m) = 0.
  • Duplicates: hll_estimate(["x"] * 500, 8) → 1, the same as hll_estimate(["x"], 8). Every copy has the same hash, register and rank.
  • A remainder of all zeros: when w == 0 the rank is bits + 1, the largest possible, and the leading-zero loop must not run off the end looking for a 1.
  • Too few registers: with p = 4 the standard error is about 1.04 / √16 = 26%; with p = 8 it is 6.5%, and the p = 14 that Redis uses gives 0.81%.
  • Huge cardinalities: a 32-bit hash starts colliding as the count approaches 232, which biases the estimate low. The original paper adds a large-range correction above 232/30, and HyperLogLog++ moves to a 64-bit hash instead; the reference has neither.
  • Order: hll_estimate(["a","b","c","d"], 6) and the same call on the reversed list both return 4, because each register keeps a maximum and maximum does not care about order.

Common mistakes

  • Exact counting with a set — that defeats the purpose; HLL trades a small error for O(1) memory.
  • Too few buckets, so the estimate has high variance.
  • Skipping the small-range bias correction, which HLL needs at low cardinalities.
  • Counting leading zeros over the whole 32-bit hash instead of the bits left after the bucket index. The top p bits already chose the register, so reusing them ties the rank to the bucket number.

Follow-ups an interviewer asks next

How do you merge two HyperLogLog sketches?
Take the element-wise maximum of their registers. If both used the same p and the same hash, the result is exactly the sketch you would have built from the union of the two streams, so counting distinct users across a thousand servers means shipping a thousand small register arrays rather than the users themselves. This is what Redis PFMERGE does.
Can HyperLogLog estimate the size of an intersection?
Only indirectly, through inclusion–exclusion: |A ∩ B| = |A| + |B| − |A ∪ B|, with the union taken from a merged sketch. Each term’s error is relative to the large sets, so a small intersection can come out with a huge relative error, or even negative. For overlap and similarity between sets, MinHash is the better tool.
How much memory does a production HyperLogLog use?
The standard error is about 1.04 / √m, so accuracy improves with the square root of memory. With a 64-bit hash a rank never exceeds 64, so each register fits in 6 bits: Redis stores 16,384 of them in 12 KB per key for a 0.81% standard error, and keeps small sets in a sparse encoding until they grow.
What does HyperLogLog++ change?
Google’s HyperLogLog++ uses a 64-bit hash, so the large-range correction is unnecessary. It corrects the raw estimate’s bias in the low and middle range with empirically measured values and chooses the switch to linear counting from measured thresholds. And it stores small sketches sparsely, so a set with few items costs far less than the full register array.

Where this shows up

HyperLogLog counts unique users, IPs, or search queries at massive scale in kilobytes — it is the engine behind Redis PFCOUNT and BigQuery’s APPROX_COUNT_DISTINCT, which uses the HyperLogLog++ variant. Redis spends 12 KB per key for a 0.81% standard error whether the key has seen a thousand distinct values or billions: one of the great space-for-accuracy deals in systems.

Reference solution

Reference solution — Python
import math

def hash32(s):                      # provided (xmur3 32-bit, matches TypeScript)
    h = (1779033703 ^ len(s)) & 0xffffffff
    for ch in s:
        h = ((h ^ ord(ch)) * 3432918353) & 0xffffffff
        h = ((h << 13) | (h >> 19)) & 0xffffffff
    h = ((h ^ (h >> 16)) * 2246822507) & 0xffffffff
    h = ((h ^ (h >> 13)) * 3266489909) & 0xffffffff
    return (h ^ (h >> 16)) & 0xffffffff


def hll_estimate(items, p):
    m = 1 << p
    bits = 32 - p
    regs = [0] * m
    for it in items:
        h = hash32(it)
        bucket = h >> bits              # top p bits select the register
        w = h & ((1 << bits) - 1)       # remaining bits
        rank = 1
        for b in range(bits - 1, -1, -1):
            if (w >> b) & 1:
                break
            rank += 1                    # count leading zeros
        if w == 0:
            rank = bits + 1
        if rank > regs[bucket]:
            regs[bucket] = rank          # keep the max rank per register

    Z = sum(2.0 ** (-r) for r in regs)
    alpha = 0.7213 / (1 + 1.079 / m)
    E = alpha * m * m / Z
    if E <= 2.5 * m:                     # small-range correction
        V = regs.count(0)
        if V > 0:
            E = m * math.log(m / V)
    return int(E)


print(hll_estimate([str(i) for i in range(1000)], 8))   # -> about 1000
Reference solution — TypeScript
function hash32(s: string): number {   // provided (xmur3 32-bit, matches Python)
  let h = (1779033703 ^ s.length) >>> 0;
  for (let i = 0; i < s.length; i++) {
    h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  h = Math.imul(h ^ (h >>> 16), 2246822507);
  h = Math.imul(h ^ (h >>> 13), 3266489909);
  h ^= h >>> 16;
  return h >>> 0;
}

function hllEstimate(items: string[], p: number): number {
  const m = 1 << p;
  const bits = 32 - p;
  const regs = new Array(m).fill(0);
  for (const it of items) {
    const h = hash32(it);
    const bucket = h >>> bits;           // top p bits pick the register
    const w = h & ((1 << bits) - 1);     // remaining bits
    let rank = 1;
    for (let b = bits - 1; b >= 0; b--) {
      if ((w >>> b) & 1) break;
      rank++;                            // count leading zeros
    }
    if (w === 0) rank = bits + 1;
    if (rank > regs[bucket]) regs[bucket] = rank;
  }

  let Z = 0;
  for (const r of regs) Z += Math.pow(2, -r);
  const alpha = 0.7213 / (1 + 1.079 / m);
  let E = (alpha * m * m) / Z;
  if (E <= 2.5 * m) {                     // small-range correction
    const V = regs.filter((r) => r === 0).length;
    if (V > 0) E = m * Math.log(m / V);
  }
  return Math.floor(E);
}

console.log(hllEstimate(Array.from({ length: 1000 }, (_, i) => "" + i), 8)); // -> about 1000

Questions about HyperLogLog Cardinality Estimator

How does HyperLogLog count distinct elements?

It hashes every element and looks at the bits. In a uniformly random hash, a value whose bits begin with k zeros turns up about once every 2 to the power k distinct values, so the longest run of leading zeros seen so far hints at how many distinct values went past. One such observation is noisy, so HyperLogLog splits the hash space into m registers using the first bits of the hash, records the longest run per register, and combines the registers with a harmonic mean and a correction constant. Duplicates produce the same hash, so they never change a register, and the estimate depends only on how many distinct values there were.

How accurate is HyperLogLog?

The standard error is about 1.04 divided by the square root of the number of registers, m. With p equal to 8, as in this challenge, m is 256 and the standard error is about 6.5 percent; Redis uses 16,384 registers, which gives about 0.81 percent in 12 KB per key. Most estimates land within one or two standard errors of the true count. Because the error shrinks with the square root of m, halving the error takes four times as many registers, and the memory grows with it.

Why does HyperLogLog use a harmonic mean?

Each register implies an estimate of the form 2 to the power of its value, and those estimates are skewed: a single register that happens to see a hash with many leading zeros reports a huge number. An arithmetic mean of those values would be dominated by such outliers. The harmonic mean is pulled toward the smaller values, so one lucky register barely moves it. The earlier LogLog algorithm used a geometric mean and had a standard error of about 1.30 over the square root of m; switching to the harmonic mean brought it down to about 1.04 over the square root of m.

What is the small range correction in HyperLogLog?

When the true count is small compared with the number of registers, many registers are still 0 and the raw harmonic-mean estimate is biased. In that regime HyperLogLog switches to linear counting: if V of the m registers are still 0, the estimate is m times the natural log of m divided by V, which is accurate while many registers are empty. The reference applies it when the raw estimate is at most 2.5 times m and V is above zero. In the traced example, 13 of 16 registers are empty, and linear counting turns a raw estimate of about 12 into 3, the true count.

Finished this one? 0 / 76 Challenges done
NextSkip List Insert & Search Next on the “Structures you build” ladder · rung 7 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges