Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
HyperLogLog Cardinality Estimator
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.
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.
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.
Examples and constraints 4 examples
hll_estimate([str(i) for i in range(1000)], 8)1011hll_estimate([str(i) for i in range(300)], 8)317hll_estimate([], 8)0hll_estimate(['x'] * 500, 8)1- Use the provided
hash32. There arem = 2^pregisters, 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: ifE ≤ 2.5mand some registers are still 0 (count V), useE = m · ln(m / V). Returnint(E). - It is an estimate — expected error is a few percent, not exact. That is the trade for tiny memory.
Hints 4 ideas
- For each item:
h = hash32(item). Bucket = top p bits (h >> (32 - p)). The remaining32 - pbits arew. - Rank = 1 + number of leading zeros of
wwithin its32 - pbits (ifwis all zeros, the rank is(32 - p) + 1). Keep the max rank per register. - Raw estimate:
alpha * m * m / ZwhereZ = Σ 2^(−register[j])andalpha = 0.7213 / (1 + 1.079/m). - 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.
| step | input | what the code computes | result |
|---|---|---|---|
| 1 | 'a' | hash32 = 0x1ef3dffa; top bits 0001 → bucket 1; w starts with 1 → rank 1 | regs[1]: 0 → 1 |
| 2 | 'b' | 0xd25d3383; top bits 1101 → bucket 13; w starts 001 → rank 3 | regs[13]: 0 → 3 |
| 3 | 'a' | same hash, bucket 1, rank 1 | no change: a duplicate |
| 4 | 'c' | 0xca51fb08; top bits 1100 → bucket 12; w starts with 1 → rank 1 | regs[12]: 0 → 1 |
| 5 | — | Z = 13 × 20 + 2−1 + 2−1 + 2−3 | 14.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 isalpha · m, below2.5m, and linear counting givesm · ln(m / m) = 0.- Duplicates:
hll_estimate(["x"] * 500, 8)→1, the same ashll_estimate(["x"], 8). Every copy has the same hash, register and rank. - A remainder of all zeros: when
w == 0the rank isbits + 1, the largest possible, and the leading-zero loop must not run off the end looking for a 1. - Too few registers: with
p = 4the standard error is about1.04 / √16= 26%; withp = 8it is 6.5%, and thep = 14that 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
pbits 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
pand 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 RedisPFMERGEdoes. - 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 1000Reference 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 1000Questions 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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- LRU CacheThe eviction policy behind every size-limited cache: when you run out of room, throw out whatever was used least recently. The trick is O(1) get and put — an ordered hash map (Python dict / JS Map) gives you exactly that. Solve it in Python or TypeScript.Read →
- Streaming MedianLatency dashboards do this every second: maintain the median of a stream without re-sorting per event. The classic two-heap trick — a max-heap for the low half, a min-heap for the high half, the median always at the boundary. Solve it in Python or TypeScript.Read →
- Token Bucket Rate LimiterThe algorithm inside most production rate limiters — and it never runs a timer. Refill the bucket lazily from the time elapsed since the last request, cap at capacity, spend one token or reject. Two numbers of state per client, exactly like the Redis version. Solve it in Python or TypeScript.Read →
- Sliding-Window Rate LimiterAllow at most N requests per rolling window — the rate limiter that guards real APIs. A sliding log of accepted timestamps gives exact limits without fixed-window bursts. Decide accept/reject for a stream. Solve it in Python or TypeScript, with hidden tests.Read →
- LFU CacheThe cache that evicts what you use least often — and, on ties, least recently. Harder than LRU: track frequency and recency together, still O(1) per op. Replay get/put operations. Solve it in Python or TypeScript, with hidden tests.Read →
- Bloom FilterA tiny bit-array that answers "have I seen this?" in a fraction of a set’s memory — with occasional false positives but never a false negative. Build one with double hashing. Provided hashes keep both languages in sync. Hidden tests.Read →