π Data Structures for Scale β Staff/Principal-Level Interview Q&A¶
12 deep-dive topics covering probabilistic data structures, spatial indexes, and ordered structures used at scale in production systems. Every code block below runs as-is (Python 3.10+, standard library only, except the H3 section which needs
pip install h3, v4 API).
Table of Contents¶
- Bloom Filter: Approximate Membership
- Cuckoo Filter: Better Bloom
- HyperLogLog: Cardinality Estimation
- Count-Min Sketch: Frequency Estimation
- MinHash: Set Similarity
- Geohash: Spatial Encoding
- S2 Geometry: Hierarchical Spatial Indexing
- H3: Hexagonal Grid System
- Quad Tree: 2D Spatial Partitioning
- R-Tree: Bounding Box Index
- Skip List: Probabilistic Ordered Structure
- Merkle Tree: Tamper-Evident Verification
1. Bloom Filter: Approximate Membership¶
Q: "We run a news aggregator serving 500M unique URLs per day. We need to avoid re-crawling the same URL twice. Design a system that tracks seen URLs with < 1GB memory. What false-positive rate can you guarantee? Walk me through the math."
What They're Really Testing: Whether you can reason about the trade-off space between memory, false-positive rate, and capacity β and whether you know which variant to use when.
30-second answer
With m bits, n items and k hashes, FP β (1 β e(βkn/m))k, minimised at k = (m/n)Β·ln 2. 1 GB = 8Γ10βΉ bits for 5Γ10βΈ URLs is 16 bits per URL β k = 11 β FP β 0.046%: about 1 in 2,200 new URLs is wrongly skipped. Bloom filters never give false negatives, so no URL is crawled twice, but a false positive means a page is never crawled, which is the real product cost. "500M per day" also means the set keeps growing: plan for time-partitioned filters (one per day, query the last N) or a scalable Bloom filter, and shard by host so each crawler node owns its filter.
Answer¶
Classic Bloom Filter Math:
n = 5Γ10βΈ URLs, m = 1 GB = 8Γ10βΉ bits β m/n = 16 bits per URL
Optimal k = (m/n)Β·ln 2 = 16 Γ 0.693 = 11.1 β 11 hash functions
p = (1 β e^(βkΒ·n/m))^k
= (1 β e^(β11/16))^11
= (1 β 0.503)^11
β 0.00046 β 0.046% false positives
Rule of thumb: bits per key = 1.44Β·logβ(1/p)
1% β 9.6 bits/key 0.1% β 14.4 bits/key
0.01% β 19.2 bits/key each 10Γ lower p costs ~4.8 more bits/key
What would 1.8 GB buy? 28.8 bits/URL, k = 20 β p β 0.0001% (1 in a million).
Whether that's worth 800 MB depends on how much a never-crawled page costs.
Diagram: a Bloom filter sets or checks k bit positions per item.
flowchart LR
I["Item"] --> H["k hash functions"]
H --> P["k bit positions in m-bit array"]
P --> Ins["Insert: set all k bits to 1"]
P --> Q{"Query: all k bits are 1?"}
Q -->|No| N["Definitely not present"]
Q -->|Yes| M["Probably present (false positives possible)"]
Production version: a scalable Bloom filter for an unbounded stream:
import hashlib
import math
class BloomFilter:
def __init__(self, capacity: int, fp_rate: float = 0.01):
self.capacity = capacity
self.m = math.ceil(-capacity * math.log(fp_rate) / math.log(2) ** 2) # bits
self.k = max(1, round(self.m / capacity * math.log(2))) # hashes
self.bits = bytearray((self.m + 7) // 8)
self.count = 0
def _positions(self, item: str):
d = hashlib.blake2b(item.encode(), digest_size=16).digest()
h1 = int.from_bytes(d[:8], "little")
h2 = int.from_bytes(d[8:], "little") | 1
for i in range(self.k): # KirschβMitzenmacher double hashing
yield (h1 + i * h2) % self.m
def add(self, item: str) -> None:
for p in self._positions(item):
self.bits[p >> 3] |= 1 << (p & 7)
self.count += 1
def might_contain(self, item: str) -> bool:
return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._positions(item))
def is_full(self) -> bool:
return self.count >= self.capacity
class ScalableBloomFilter:
"""Almeida et al. (2007): when the current filter reaches capacity, add a
filter `growth`Γ larger whose FP target is tightened by `ratio`. The
overall FP rate stays below p0 / (1 - ratio), so start at p0 = pΒ·(1 - ratio)."""
def __init__(self, initial_capacity: int, fp_rate: float = 0.01,
growth: int = 2, ratio: float = 0.9):
self.growth, self.ratio = growth, ratio
self.next_fp = fp_rate * (1 - ratio)
self.filters = [BloomFilter(initial_capacity, self.next_fp)]
def add(self, item: str) -> None:
if self.filters[-1].is_full():
self.next_fp *= self.ratio
self.filters.append(BloomFilter(self.filters[-1].capacity * self.growth,
self.next_fp))
self.filters[-1].add(item)
def might_contain(self, item: str) -> bool:
return any(f.might_contain(item) for f in reversed(self.filters))
sbf = ScalableBloomFilter(initial_capacity=10_000, fp_rate=0.01)
for i in range(200_000):
sbf.add(f"https://example.com/{i}")
assert all(sbf.might_contain(f"https://example.com/{i}") for i in range(0, 200_000, 7))
fp = sum(sbf.might_contain(f"https://other.org/{i}") for i in range(100_000)) / 100_000
print(f"filters={len(sbf.filters)} observed FP={fp:.4f} (target β€ 0.01)")
Sample output: 5 filters, observed FP β 0.3% against a 1% bound (the bound is conservative).
Production Variants and When to Use Them:
| Variant | Key Feature | Use Case |
|---|---|---|
| Classic Bloom | Simple, smallest for p β³ 3% | Per-SSTable "is the key in this file?" checks (Cassandra, HBase, RocksDB) |
| Scalable Bloom | Grows by adding tighter filters | Unknown cardinality: crawlers, dedup streams |
| Counting Bloom | 4-bit counters allow deletion | Rarely worth 4Γ memory; prefer a cuckoo filter |
| Blocked Bloom | All k bits in one 64-byte cache line | One cache miss per lookup instead of k; slightly higher FP for the same memory. RocksDB's format |
| Cuckoo / xor / Ribbon | Fingerprint-based | Deletion (cuckoo), static sets 15β30% smaller (xor, Ribbon) |
Staff-Level Trade-Offs:
- Which error is acceptable? A false positive here means "skip a new URL", so you lose content, not CPU. If that's unacceptable, use the filter only as a fast path and confirm positives against an exact store (a key-value lookup) for important domains.
- Rotate, don't grow forever: one filter per day (500M URLs β ~1 GB each at 0.05%) and check the last 30 days, or recrawl policy will want "seen in the last N days" anyway.
- Shard by host: crawler nodes partitioned by hostname each keep a small local filter; no cross-node lookups on the hot path.
- Hashing: one 128-bit hash split into h1/h2 (KirschβMitzenmacher) instead of k hashes; keyed if adversaries choose the input.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Math | Calculates m, k, p correctly from scratch; knows 1.44Β·logβ(1/p) bits/key |
| Hash independence | Names Kirsch-Mitzenmacher double hashing |
| Scaling | Scalable or time-partitioned filters for an unbounded stream |
| Product impact | Explains what a false positive costs in this system |
2. Cuckoo Filter: Better Bloom¶
Q: "Your caching layer needs to support deletion of stale entries from the probabilistic filter. A counting Bloom filter uses 4Γ memory. Design a better alternative. What's the Cuckoo filter's advantage, and what's its Achilles' heel?"
What They're Really Testing: Whether you understand the fundamental trade-off between Bloom filters (bit array + hashes) and Cuckoo filters (hash table + fingerprints). Most engineers know Bloom filters; staff engineers know when Bloom isn't enough.
30-second answer
A cuckoo filter (Fan et al., 2014) stores a short fingerprint of each item in one of two buckets (4 slots each); the alternate bucket is i β hash(fingerprint), so entries can be moved without the original key. Lookup checks 2 buckets (β€ 2 cache misses); delete just removes the fingerprint. FP β 2b/2^f, so 12-bit fingerprints give ~0.2%, and below ~3% FP it uses less space than a Bloom filter. Its weaknesses: inserts can fail once the table is ~95% full (so you must size it or resize/rebuild), deleting an item that was never inserted can remove another item's fingerprint, and the same item can only be inserted 2b times.
Answer¶
Cuckoo Filter β Core Idea (runnable):
import hashlib
import random
class CuckooFilter:
"""Fan et al. (2014). Stores an f-bit fingerprint of each item in one of two
candidate buckets of b slots. The second bucket is i1 XOR hash(fp), so it can
be computed from (bucket, fingerprint) alone during relocation.
False-positive rate β 2b / 2^f (b = 4, f = 12 β ~0.2%)."""
def __init__(self, capacity: int, fp_bits: int = 12, bucket_size: int = 4,
max_kicks: int = 500):
self.b, self.f, self.max_kicks = bucket_size, fp_bits, max_kicks
n = 1
while n * bucket_size * 0.95 < capacity: # ~95% load is achievable with b = 4
n <<= 1 # power of two so XOR stays in range
self.n = n
self.buckets: list[list[int]] = [[] for _ in range(n)]
self.victim: int | None = None # stashed fingerprint after a failed insert
def _h(self, data: bytes) -> int:
return int.from_bytes(hashlib.blake2b(data, digest_size=8).digest(), "little")
def _fp_and_index(self, item: str) -> tuple[int, int]:
h = self._h(item.encode())
fp = (h >> 32) % ((1 << self.f) - 1) + 1 # never 0 (0 often marks an empty slot)
return fp, h % self.n
def _alt(self, i: int, fp: int) -> int:
return (i ^ self._h(fp.to_bytes(4, "little"))) % self.n
def insert(self, item: str) -> bool:
if self.victim is not None:
return False # full: resize/rebuild before inserting more
fp, i1 = self._fp_and_index(item)
i2 = self._alt(i1, fp)
for i in (i1, i2):
if len(self.buckets[i]) < self.b:
self.buckets[i].append(fp)
return True
i = random.choice((i1, i2))
for _ in range(self.max_kicks): # relocate ("kick") existing fingerprints
slot = random.randrange(self.b)
fp, self.buckets[i][slot] = self.buckets[i][slot], fp
i = self._alt(i, fp)
if len(self.buckets[i]) < self.b:
self.buckets[i].append(fp)
return True
self.victim = fp # keep the evicted fingerprint, or that item gets a false negative
return False
def contains(self, item: str) -> bool:
fp, i1 = self._fp_and_index(item)
return fp in self.buckets[i1] or fp in self.buckets[self._alt(i1, fp)] or fp == self.victim
def delete(self, item: str) -> bool:
"""Only delete items you inserted: deleting a never-inserted item that
shares a fingerprint removes someone else's entry (false negative)."""
fp, i1 = self._fp_and_index(item)
for i in (i1, self._alt(i1, fp)):
if fp in self.buckets[i]:
self.buckets[i].remove(fp)
return True
return False
random.seed(3)
cf = CuckooFilter(capacity=100_000)
ok = sum(cf.insert(f"k{i}") for i in range(100_000))
assert ok == 100_000 and all(cf.contains(f"k{i}") for i in range(100_000))
fp = sum(cf.contains(f"x{i}") for i in range(100_000)) / 100_000
load = sum(map(len, cf.buckets)) / (cf.n * cf.b)
for i in range(50_000):
cf.delete(f"k{i}")
assert all(cf.contains(f"k{i}") for i in range(50_000, 100_000))
print(f"buckets={cf.n} load={load:.2f} fp={fp:.4f} (2b/2^f = {8 / 4096:.4f})")
Sample output: FP β 0.18% (formula 0.20%), all 100,000 inserts succeed at 76% load, and deletions leave the other items intact.
Diagram: cuckoo filter insert, relocating fingerprints between two candidate buckets.
flowchart TD
A["Compute fingerprint fp, bucket i1, alt i2 = i1 XOR hash(fp)"] --> B{"Free slot in i1 or i2?"}
B -->|Yes| OK["Store fp, done"]
B -->|No| K["Kick a random fingerprint out of a bucket"]
K --> S["Place fp there, move evicted one to its alternate bucket"]
S --> F{"Free slot found?"}
F -->|Yes| OK
F -->|No, kicks < max| K
F -->|No, max kicks hit| X["Insert fails: rebuild larger"]
The Achilles' Heels:
| Problem | Why | Mitigation |
|---|---|---|
| Insert failure | When both buckets are full, relocation chains grow; past ~95% load (b = 4) the kick limit is hit | Size for known capacity; on failure, rebuild into a 2Γ table (needs the original keys, or grow by stacking filters) |
| Unsafe delete | Delete matches on fingerprint, not key | Only delete keys you know were inserted (e.g. tracked in the source of truth) |
| Duplicate inserts | Each insert of the same item takes a slot in one of the same 2 buckets | Check contains first, or accept the 2b limit |
| Fingerprints vs FP rate | FP grows with bucket size: β 2b/2^f | Larger f for lower FP; semi-sorting saves ~1 bit per item |
Bloom vs Cuckoo Comparison:
| Property | Bloom Filter | Cuckoo Filter |
|---|---|---|
| Lookup | k probes, up to k cache misses (1 if blocked) | 2 buckets, β€ 2 cache misses |
| Insert | Always succeeds; FP rises as it overfills | O(1) amortized, can fail near capacity |
| Delete | No (counting variant: 4Γ memory) | Yes |
| Space per item | 1.44Β·logβ(1/p) | β (logβ(1/p) + 3)/Ξ± with load Ξ± β 0.95 (β +2 with semi-sorting) |
| Example, p = 0.1% | 14.4 bits | β 13.6 bits |
| Example, p = 5% | 6.2 bits | β 7.7 bits (Bloom smaller) |
When would you pick Cuckoo over Bloom? You need deletion; you want lower space at FP β² 3%; you want predictable lookups (2 cache lines). Pick Bloom when inserts must never fail, when the set size is unknown, or when FP can be a few percent. RedisBloom offers both (BF.*, CF.*).
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Fingerprint concept | Explains partial-key cuckoo hashing β alternative bucket via XOR |
| FP formula | β 2b/2^f; sizes f for a target FP |
| Insert failure | Understands the load-factor limit, kick threshold, and keeping the evicted victim |
| Comparison to Bloom | Knows the ~3% crossover and the unsafe-delete caveat |
3. HyperLogLog: Cardinality Estimation¶
Q: "We need to count distinct users visiting our site every hour β 10M DAU, 1B events/hour. Exact counting needs one entry per distinct user per window (80 MB of raw 64-bit IDs, several times that in a hash set). Design a system that uses < 2KB per time window and gives < 2% error. Walk me through the stochastic averaging math."
What They're Really Testing: Whether you understand the algorithm's internal mechanics β not just how to use a library.
30-second answer
Hash each user; the number of leading zeros in a hash is β₯ k with probability 2^βk, so the maximum seen estimates logβ(n). HLL splits users into m = 2^b buckets by the first b bits, keeps the max rank per bucket, and combines them with a bias-corrected harmonic mean; error β 1.04/βm. The two constraints conflict slightly: < 2% needs m β₯ 2,704 β 4,096 registers, which is 3 KB at 6 bits (2.5 KB at 5 bits); fitting under 2 KB means m = 2,048 at ~2.3%. Say so, then offer options: 4-bit registers with an exception table (Apache DataSketches' HLL_4 β 2 KB for m = 4,096), or accept 2.3%. Hourly sketches merge into daily/weekly uniques by taking register-wise max.
Answer¶
The Core Insight:
HyperLogLog exploits a simple probabilistic fact: if you hash each element uniformly, the probability that a hash has at least Οβ1 leading zeros is 2^β(Οβ1). The maximum rank observed gives a rough estimate of logβ(n).
Diagram: HyperLogLog splits the hash stream across registers and combines them.
flowchart LR
X["Element"] --> H["64-bit hash"]
H --> B["Top b bits select register j of m = 2^b"]
H --> R["Rank = leading zeros of rest + 1"]
B --> U["Register j keeps max rank"]
R --> U
U --> E["Harmonic mean over all registers gives cardinality estimate"]
The problem with a single register is high variance (Β±1 = 2Γ error). Stochastic averaging splits the stream into m = 2^b registers using the first b bits of the hash and combines them with a harmonic mean.
import hashlib
import math
class HyperLogLog:
"""Dense HLL, leading-zero form: top b bits pick the register, rank = leading
zeros of the remaining 64-b bits + 1. 6-bit registers (rank β€ 65 - b)."""
def __init__(self, b: int = 12):
self.b, self.m = b, 1 << b
self.registers = bytearray(self.m)
def _rank(self, x: int) -> tuple[int, int]:
j = x >> (64 - self.b)
rest = x & ((1 << (64 - self.b)) - 1)
return j, (64 - self.b) - rest.bit_length() + 1
def add(self, value: str) -> None:
x = int.from_bytes(hashlib.blake2b(value.encode(), digest_size=8).digest(), "big")
j, rho = self._rank(x)
if rho > self.registers[j]:
self.registers[j] = rho
def count(self) -> float:
m = self.m
alpha = 0.7213 / (1 + 1.079 / m) # m β₯ 128
e = alpha * m * m / sum(2.0 ** -r for r in self.registers)
zeros = self.registers.count(0)
if e <= 2.5 * m and zeros:
e = m * math.log(m / zeros) # linear counting for small n
return e # no large-range fix needed with 64-bit hashes
def merge(self, other: "HyperLogLog") -> None:
assert self.b == other.b, "precision must match"
self.registers = bytearray(map(max, self.registers, other.registers))
class SparseFirstHLL(HyperLogLog):
"""HLL++ idea: while few registers are set, keep {index: rank} instead of
the full array; convert to dense once the map would cost more than it saves."""
def __init__(self, b: int = 12):
super().__init__(b)
self.sparse: dict[int, int] | None = {}
self.registers = None # allocated on conversion
def add(self, value: str) -> None:
if self.sparse is None:
return super().add(value)
x = int.from_bytes(hashlib.blake2b(value.encode(), digest_size=8).digest(), "big")
j, rho = self._rank(x)
if rho > self.sparse.get(j, 0):
self.sparse[j] = rho
if len(self.sparse) > self.m // 8: # threshold is a tuning knob
self.registers = bytearray(self.m)
for j, r in self.sparse.items():
self.registers[j] = r
self.sparse = None
def count(self) -> float:
if self.sparse is not None: # few registers set β linear counting is exact-ish
return self.m * math.log(self.m / (self.m - len(self.sparse)))
return super().count()
for cls in (HyperLogLog, SparseFirstHLL):
for n in (100, 10_000, 1_000_000):
h = cls(12)
for i in range(n):
h.add(f"u{i}")
print(cls.__name__, n, f"{(h.count() - n) / n:+.2%}")
Sample output: errors of +0.2%, +3.8% and +0.4% for 100, 10,000 and 1,000,000 users with m = 4,096. The 10,000 case sits right where the estimator switches from linear counting to raw HLL (β 2.5m), a region where raw HLL is biased. That's exactly what HLL++'s empirical bias correction fixes. The full trailing-zero implementation with merge is in Interview Questions Q3.
HyperLogLog++ (Heule, Nunkesser & Hall, Google, 2013) adds:
- 64-bit hashes, so no large-range correction is needed.
- A sparse representation for small cardinalities (as above, but with compressed sorted lists and higher internal precision), which matters when you keep millions of hourly sketches that mostly see few users.
- Empirically measured bias correction around the 2.5m transition.
Error Bounds (6-bit registers):
Ο β 1.04 / βm
b = 8, m = 256: Ο β 6.5% 192 bytes
b = 10, m = 1024: Ο β 3.25% 768 bytes
b = 11, m = 2048: Ο β 2.3% 1.5 KB β fits < 2 KB
b = 12, m = 4096: Ο β 1.6% 3 KB β meets < 2% error
b = 14, m = 16384: Ο β 0.81% 12 KB (Redis)
b = 16, m = 65536: Ο β 0.41% 48 KB
Real-World Use:
| System | API | Configuration |
|---|---|---|
| Redis | PFADD / PFCOUNT / PFMERGE |
m = 16,384, 0.81% std error, β€ 12 KB (sparse encoding when small) |
| BigQuery | APPROX_COUNT_DISTINCT, HLL_COUNT.INIT/MERGE/EXTRACT |
HLL++; precision configurable (10β24) for HLL_COUNT |
| Presto / Trino | approx_distinct(x, e) |
Default max standard error 2.3% |
| Elasticsearch / OpenSearch | cardinality aggregation |
HLL++; near-exact below precision_threshold (default 3,000) |
Staff-Level Trade-Offs:
- HLL vs exact bitmaps: if user IDs are dense integers, a compressed bitmap (Roaring) is exact, supports intersections, and is often only a few bits per user. HLL wins when IDs are arbitrary strings or you need fixed tiny memory per window.
- Intersections ("users active both today and yesterday") aren't native to HLL; inclusionβexclusion on unions is very noisy for small overlaps. Use Theta sketches (DataSketches) when set operations matter.
- Mergeability is the killer feature: per-server, per-hour sketches roll up into any time range without re-reading events.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Stochastic averaging | Explains harmonic mean over registers, not arithmetic |
| Sizing honesty | Computes m from the error target and notices the 2 KB / 2% tension |
| Bias correction | Knows linear counting for small n and HLL++'s bias table; no large-range fix with 64-bit hashes |
| When not to use | Roaring bitmaps for dense integer IDs; Theta sketches for intersections |
4. Count-Min Sketch: Frequency Estimation¶
Q: "Design a system to detect the top-K most frequent search queries in real-time from 100K queries/second. We can tolerate approximate counts but need to guarantee no undercounts. How do you size the sketch? What are the error bounds?"
What They're Really Testing: Understanding of the sketch's asymmetric error guarantee β it only overcounts, never undercounts β and how d, w parameters control that.
30-second answer
d rows of w counters, one hash per row; add increments one counter per row, estimate takes the minimum. Never undercounts (insert-only); overcount †Ρ·N with probability β₯ 1 β Ξ΄ when w = βe/Ξ΅β, d = βln(1/Ξ΄)β, N = total events. Ξ΅ = 0.001, Ξ΄ = 0.001 β 2,719 Γ 7 counters β 76 KB. Keep a heap of the K best candidates for top-K, use conservative update to cut overcounting, a keyed hash against adversarial queries, and one sketch per time window (merged by addition) for "trending in the last 5 minutes".
Answer¶
Sizing:
Ξ΅ (error as a fraction of N) and Ξ΄ (probability the bound fails):
width w = βe / Ξ΅β
depth d = βln(1 / Ξ΄)β
Ξ΅ = 0.001, Ξ΄ = 0.001: w = 2,719, d = 7 β 19,033 counters Γ 4 B β 76 KB
100K queries/s β N = 6M per minute β overcount β€ 6,000 per query per minute
(with 99.9% probability). Heavy hitters are far above that; rare queries are noise.
The full runnable implementation (keyed hashing, conservative update, top-K tracker) is in Interview Questions Q5. Key pieces:
import math
def cms_dimensions(epsilon: float, delta: float) -> tuple[int, int]:
return math.ceil(math.e / epsilon), math.ceil(math.log(1 / delta))
def conservative_add(table, cols, count=1):
"""cols = [(row, col), ...] for the item. Raise each counter only to
(current estimate + count): counters already above that are left alone."""
target = min(table[r][c] for r, c in cols) + count
for r, c in cols:
table[r][c] = max(table[r][c], target)
def count_mean_min(table, cols, total, width):
"""Subtract each row's expected collision noise (total β c)/(w β 1) and take
the median. Less biased for rare items, but it CAN undercount."""
ests = sorted(c - (total - c) / (width - 1) for c in (table[r][k] for r, k in cols))
return ests[len(ests) // 2]
print(cms_dimensions(0.001, 0.001)) # (2719, 7)
The Heavy Hitters (Top-K) Pipeline at 100K QPS:
API nodes ββ(sample or full stream)βββΊ per-node CMS + top-K candidates (1 s windows)
βββ every few seconds ship sketch (76 KB) + candidates ββΊ aggregator
aggregator: sum sketches (same seed & dims) β re-estimate candidates β global top-K
"last 5 minutes" = sum of the last 300 one-second sketches (ring buffer),
or exponential decay (halve all counters periodically).
Diagram: the heavy-hitters pipeline merges per-node sketches.
flowchart LR
N1["API node 1: CMS + candidates"] --> AG["Aggregator: sum sketches"]
N2["API node 2: CMS + candidates"] --> AG
N3["API node N: CMS + candidates"] --> AG
AG --> RE["Re-estimate candidates"]
RE --> TK["Global top-K"]
- No false negatives for top-K? Only if every true heavy hitter gets a chance to enter the candidate set. With per-node candidate lists that's usually fine because heavy items are heavy everywhere; to be safe, track more candidates (e.g. 10K) than K and re-rank at the aggregator.
- Alternatives: Space-Saving (k counters, deterministic, error β€ N/k) is often simpler when you only need top-K. MisraβGries is similar and mergeable. CMS earns its place when you also need point queries for arbitrary keys ("how often was X searched?").
Error Analysis:
estimate(x) β€ true(x) + Ρ·N with probability β₯ 1 β Ξ΄
w = 2,719 β Ξ΅ = e/w β 0.001
d = 7 β Ξ΄ = e^β7 β 0.0009 (bound holds 99.9% of the time)
For N = 10M queries: overcount β€ ~10K per item.
Conservative update keeps the same guarantee and is much tighter on skewed
(Zipfian) traffic, but you can no longer decrement or subtract sketches.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Error guarantee | Explains asymmetry: never undercounts, may overcount, relative to N |
| Parameter sizing | Calculates w, d from Ξ΅, Ξ΄ mathematically |
| Conservative update | Knows the rule (raise to min + count) and its no-deletion cost |
| Heavy hitters | Candidate set + sketch, windowing, merge across nodes; knows Space-Saving |
5. MinHash: Set Similarity¶
Q: "We run a plagiarism detection system with 10M documents. For each new document, we need to find the top-10 most similar documents. Each document contains ~500 unique shingles. A naive pairwise Jaccard calculation is O(NΒ²). Design a system using MinHash. How does the signature size affect accuracy?"
What They're Really Testing: Whether you understand the connection between MinHash and Jaccard similarity β and can design the LSH indexing layer for sub-linear retrieval.
30-second answer
For a random hash h, P(min h(A) = min h(B)) = |Aβ©B| / |AβͺB| = Jaccard(A, B). A signature of k such minimums estimates J as the fraction of matching positions, with standard error β(J(1βJ)/k) β€ 0.5/βk (k = 128 β β€ 4.4%), and shrinks every document to k integers. To avoid comparing against all 10M signatures, LSH banding splits the signature into b bands of r rows and indexes each band; documents sharing any band become candidates. P(candidate) = 1 β (1 β Jr)b, an S-curve with threshold β (1/b)^(1/r). Then rank candidates by estimated (or exact) Jaccard.
Answer¶
The Core Insight:
Pick a random permutation (hash) of all shingles. The first shingle of A βͺ B
under that order is equally likely to be any element of A βͺ B; it is the
minimum of BOTH sets exactly when it lies in A β© B. Hence
P(min h(A) == min h(B)) = |A β© B| / |A βͺ B| = J(A, B)
With k independent hashes, the match rate is an unbiased estimate of J.
MinHash + LSH (runnable):
import hashlib
import random
from collections import defaultdict
PRIME = (1 << 61) - 1 # Mersenne prime for universal hashing
class MinHasher:
"""k-permutation MinHash. P(minhash_i(A) == minhash_i(B)) = Jaccard(A, B),
so the fraction of matching positions estimates J with standard error
sqrt(J(1-J)/k) β€ 0.5/βk (k = 128 β at most Β±4.4%)."""
def __init__(self, k: int = 128, seed: int = 1):
rnd = random.Random(seed)
self.k = k
self.coeffs = [(rnd.randrange(1, PRIME), rnd.randrange(PRIME)) for _ in range(k)]
def signature(self, shingles: set[str]) -> list[int]:
base = [int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "little")
for s in shingles] # hash each shingle once
return [min((a * x + b) % PRIME for x in base) for a, b in self.coeffs]
@staticmethod
def similarity(s1: list[int], s2: list[int]) -> float:
return sum(x == y for x, y in zip(s1, s2)) / len(s1)
class MinHashLSH:
"""Banding: split the k-length signature into b bands of r rows. Two docs
become candidates if ANY band matches exactly:
P(candidate | J) = 1 - (1 - J^r)^b, threshold β (1/b)^(1/r)."""
def __init__(self, k: int = 128, bands: int = 16):
assert k % bands == 0
self.bands, self.rows = bands, k // bands
self.tables = [defaultdict(list) for _ in range(bands)]
self.signatures: dict[str, list[int]] = {}
def _keys(self, sig):
for band in range(self.bands):
yield band, tuple(sig[band * self.rows:(band + 1) * self.rows])
def insert(self, doc_id: str, sig: list[int]) -> None:
self.signatures[doc_id] = sig
for band, key in self._keys(sig):
self.tables[band][key].append(doc_id)
def query(self, sig: list[int], min_similarity: float = 0.5) -> list[tuple[str, float]]:
candidates = {d for band, key in self._keys(sig) for d in self.tables[band].get(key, [])}
scored = [(d, MinHasher.similarity(sig, self.signatures[d])) for d in candidates]
return sorted([x for x in scored if x[1] >= min_similarity], key=lambda x: -x[1])
def shingles(text: str, w: int = 3) -> set[str]:
words = text.lower().split()
return {" ".join(words[i:i + w]) for i in range(len(words) - w + 1)}
mh, lsh = MinHasher(k=128), MinHashLSH(k=128, bands=16) # 16 bands Γ 8 rows, threshold β 0.71
base = " ".join(f"w{i}" for i in range(300))
docs = {
"original": base,
"near_copy": base.replace("w150", "x150").replace("w151", "x151"),
"unrelated": " ".join(f"z{i}" for i in range(300)),
}
for d, text in docs.items():
lsh.insert(d, mh.signature(shingles(text)))
q = mh.signature(shingles(docs["near_copy"]))
true_j = len(shingles(docs["original"]) & shingles(docs["near_copy"])) / len(shingles(docs["original"]) | shingles(docs["near_copy"]))
print("true J(original, near_copy) =", round(true_j, 3))
print(lsh.query(q))
Sample output: true J = 0.974 between the original and the near-copy (2 of 300 words changed); the LSH query finds both near-identical documents and not the unrelated one.
How to Tune b and r (k = b Γ r):
| Bands Γ rows | Threshold β (1/b)^(1/r) | P(candidate) at J = 0.9 / 0.7 / 0.5 / 0.3 |
|---|---|---|
| 50 Γ 4 | 0.38 | 1.00 / 1.00 / 0.96 / 0.33 |
| 40 Γ 5 | 0.48 | 1.00 / 1.00 / 0.72 / 0.09 |
| 25 Γ 8 | 0.67 | 1.00 / 0.77 / 0.09 / 0.002 |
| 20 Γ 10 | 0.74 | 1.00 / 0.44 / 0.02 / 0.0001 |
More bands β more recall (fewer missed near-duplicates) but more false candidates to verify; more rows per band β sharper cut-off. Pick the threshold slightly below the similarity you care about, then verify candidates.
System design for 10M documents:
- Signatures: 10M Γ 128 Γ 4β8 bytes β 5β10 GB; store them in a KV store or columnar file.
- LSH index: b hash tables keyed by band hash β doc IDs. Shard by band (each shard owns some bands) or by band-hash range.
- Query: compute the new doc's signature (500 shingles Γ 128 hashes), probe b buckets, verify a few hundred candidates, return top-10.
- Cost tricks: hash each shingle once and derive k values with universal hashing (as above) or one-permutation hashing with densification; drop over-full buckets (boilerplate shingles shared by everything).
Weighted and alternative schemes: Weighted MinHash / consistent weighted sampling (Ioffe, 2010) handles TF-IDF-style weights; SimHash (Charikar, 2002) estimates cosine similarity with 64-bit fingerprints and was used by Google for near-duplicate web pages (Manku et al., 2007).
Real-World Use:
| System | Application |
|---|---|
| AltaVista (Broder et al., 1997) | Original shingling + min-wise hashing for near-duplicate web pages |
| Apache Spark MLlib | MinHashLSH for approximate similarity joins |
| LLM pre-training pipelines | Fuzzy deduplication of web text (e.g. GPT-3 used Spark's MinHashLSH) |
datasketch (Python) |
MinHash, LSH, LSH Forest for practitioners |
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Core probability | Explains P(min-hash match) = Jaccard |
| Signature accuracy | Relates k to standard error β(J(1βJ)/k) |
| LSH bands | Tunes b, r for a similarity threshold and explains the S-curve |
| System design | Sizes signatures and index, verifies candidates, handles boilerplate buckets |
6. Geohash: Spatial Encoding¶
Q: "Design a system to find nearby restaurants within 500m of a user's location. We have 1M restaurants globally. We can't afford to compute Haversine distance on every query. How does Geohash enable efficient proximity search? What's the encoding scheme, and what are its failure modes?"
What They're Really Testing: Whether you understand spatial indexing fundamentals β the precision/length trade-off, edge cases at cell boundaries, and when to use Geohash vs alternatives.
30-second answer
Geohash bisects longitude and latitude alternately and interleaves the bits (a Z-order curve), then writes 5 bits per base-32 character; a longer string is a smaller cell nested inside its prefix. For a 500 m search pick the longest precision whose cells are at least 500 m on each side (precision 6, ~1.2 Γ 0.6 km at the equator), query the user's cell plus its 8 neighbours, then filter by exact distance. Failure modes: points a few metres apart across a cell edge can share no prefix at all (Z-order jumps), cell width shrinks with cos(latitude), and cells are rectangles, not circles, so you always over-fetch and post-filter.
Answer¶
Geohash Encoding β Interleaving Bits (runnable):
import math
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
def encode(lat: float, lng: float, precision: int = 9) -> str:
"""Interleave longitude/latitude bisection bits (lng first), 5 bits per char."""
lat_rng, lng_rng = [-90.0, 90.0], [-180.0, 180.0]
out, bits, ch, even = [], 0, 0, True
while len(out) < precision:
rng, val = (lng_rng, lng) if even else (lat_rng, lat)
mid = (rng[0] + rng[1]) / 2
ch <<= 1
if val >= mid:
ch |= 1
rng[0] = mid
else:
rng[1] = mid
even, bits = not even, bits + 1
if bits == 5:
out.append(BASE32[ch])
bits, ch = 0, 0
return "".join(out)
def bounds(gh: str) -> tuple[float, float, float, float]:
lat_rng, lng_rng, even = [-90.0, 90.0], [-180.0, 180.0], True
for c in gh:
v = BASE32.index(c)
for shift in range(4, -1, -1):
rng = lng_rng if even else lat_rng
mid = (rng[0] + rng[1]) / 2
if (v >> shift) & 1:
rng[0] = mid
else:
rng[1] = mid
even = not even
return lat_rng[0], lat_rng[1], lng_rng[0], lng_rng[1]
def neighbors(gh: str) -> list[str]:
"""The 8 surrounding cells: step one cell-size from the centre and re-encode."""
s, n, w, e = bounds(gh)
clat, clng, dlat, dlng = (s + n) / 2, (w + e) / 2, n - s, e - w
out = []
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dx or dy:
lat = clat + dy * dlat
if -90 <= lat <= 90:
lng = (clng + dx * dlng + 180) % 360 - 180 # wrap the antimeridian
out.append(encode(lat, lng, len(gh)))
return out
def cell_size_m(precision: int, lat: float) -> tuple[float, float]:
"""(height, width) of a cell in metres at a given latitude."""
lng_bits = (5 * precision + 1) // 2
lat_bits = 5 * precision // 2
height = 180 / 2 ** lat_bits * 111_320
width = 360 / 2 ** lng_bits * 111_320 * math.cos(math.radians(lat))
return height, width
def precision_for_radius(radius_m: float, lat: float) -> int:
"""Longest geohash whose cells are at least `radius_m` in both directions,
so the 3Γ3 block around the user is guaranteed to contain the whole circle."""
p = 1
while p < 12 and min(cell_size_m(p + 1, lat)) >= radius_m:
p += 1
return p
gh = encode(37.7749, -122.4194, 7)
print(gh, len(neighbors(gh)), [round(x) for x in cell_size_m(7, 0)], [round(x) for x in cell_size_m(7, 60)])
print("500 m at the equator β precision", precision_for_radius(500, 0),
"| at 60Β°N β", precision_for_radius(500, 60))
print("boundary:", encode(0.0001, -0.0001, 5), "vs", encode(-0.0001, 0.0001, 5))
Sample output: 9q8yyk8 (San Francisco) has 8 neighbours; a precision-7 cell is 153 Γ 153 m at the equator but 153 Γ 76 m at 60Β°N; a 500 m radius needs precision 6; and two points 30 m apart straddling the equator and the prime meridian (ebpbp vs kpbpb) share no prefix.
Cell sizes at the equator (height Γ width):
| Precision | Cell size | Precision | Cell size |
|---|---|---|---|
| 1 | 5,000 km Γ 5,000 km | 6 | 1.2 km Γ 0.61 km |
| 2 | 1,250 km Γ 625 km | 7 | 153 m Γ 153 m |
| 3 | 156 km Γ 156 km | 8 | 38 m Γ 19 m |
| 4 | 39 km Γ 19.5 km | 9 | 4.8 m Γ 4.8 m |
| 5 | 4.9 km Γ 4.9 km | 12 | ~3.7 cm Γ 1.9 cm |
(Odd precisions give square-ish cells; even ones are 2:1. Width in metres scales with cos(latitude).)
The Failure Modes:
- Boundary effect. A user near a cell edge has neighbours in the adjacent cell, so always query the 3Γ3 block. And that block only covers the circle if cell size β₯ radius: querying precision 7 (153 m cells) for a 500 m radius silently misses most of the circle.
- Z-order discontinuities. Adjacent cells across the equator, the prime meridian, or any high-level split boundary have completely different prefixes, so "shared prefix β close" holds but "close β shared prefix" doesn't. Never use a single
LIKE 'prefix%'for proximity. - Latitude distortion. Cells narrow towards the poles (precision 7 at 89Β° is ~153 m Γ 2.7 m), so choose precision per latitude and expect odd cell shapes at high latitudes.
- Rectangles vs circles. The 3Γ3 block covers up to 9 cells of area for a circle that needs far less, so expect to discard most candidates in the distance filter.
Query Pattern:
-- restaurants(geohash6 CHAR(6), lat, lng, ...) with a B-tree index on geohash6.
-- :cells = the user's precision-6 cell + 8 neighbours (computed in the app)
SELECT id, name, lat, lng
FROM restaurants
WHERE geohash6 = ANY(:cells)
-- then compute haversine(user, restaurant) in the app (or SQL) and keep < 500 m,
-- order by distance, LIMIT 50.
What production systems use: Redis GEOADD/GEOSEARCH stores a 52-bit geohash as the score of a sorted set and does the neighbour-cell search for you; Elasticsearch/OpenSearch index geo_point in BKD trees (geohash is used for aggregations/grids); PostGIS uses an R-tree (GiST) and ST_DWithin. For 1M restaurants, any of these on a single node answers a 500 m query in milliseconds; geohash in your own table is mainly useful when your store only offers B-tree indexes.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Encoding | Explains bit-interleaving of lat/lng β base32 |
| Edge cells | 9-cell query and cell size β₯ radius |
| Discontinuities | Knows nearby points can share no prefix (Z-order) |
| Distortion | Knows width shrinks with cos(latitude) |
7. S2 Geometry: Hierarchical Spatial Indexing¶
Q: "Uber needs to match riders with drivers within 500ms globally. Geohash has edge cases at cell boundaries and poles. Design a better spatial indexing system. How does Google's S2 geometry solve these problems? Walk me through the projection from the sphere to a Hilbert curve."
What They're Really Testing: Whether you understand the fundamental problems with lat/lng indexing on a sphere and how S2's design choices (cube projection + space-filling curve) fix them.
30-second answer
S2 projects the sphere onto the 6 faces of a cube, warps each face with a cheap quadratic transform so cells have similar areas (max/min area ratio β 2.1 instead of 5.2 for a plain projection), and orders the cells on each face along a Hilbert curve. A cell at level L (0β30) is a 64-bit ID: 3 face bits, 2 bits per level, and a trailing 1 bit. Hilbert order has no jumps (consecutive IDs are adjacent cells), parent/child relationships are bit operations, and S2RegionCoverer approximates any circle or polygon as a handful of cell-ID ranges, so "drivers near me" becomes a few B-tree range scans on an integer column. It still has cell edges, so you still post-filter by distance.
Answer¶
S2's Three-Step Pipeline:
1. (lat, lng) β unit vector (x, y, z) on the sphere
2. (x, y, z) β cube face (largest |coordinate|) + (u, v) β [-1, 1]Β²
3. (u, v) β (s, t) β [0, 1]Β² via the quadratic transform
β integer (i, j) on a 2^30 Γ 2^30 grid β position on that face's Hilbert curve
Cell ID (64 bits): [face: 3 bits][Hilbert position: 2 bits Γ level][1][zeros]
The trailing 1 marks the level, so a parent ID is the child ID with its low bits
reset, and "all descendants of cell C" is the contiguous range
[C.range_min, C.range_max]: one B-tree range scan.
import math
def lat_lng_to_face_uv(lat_deg: float, lng_deg: float) -> tuple[int, float, float]:
"""Steps 1β2 (simplified: real S2 also permutes axes per face so the
Hilbert curve stays continuous across face edges)."""
lat, lng = math.radians(lat_deg), math.radians(lng_deg)
x, y, z = math.cos(lat) * math.cos(lng), math.cos(lat) * math.sin(lng), math.sin(lat)
ax, ay, az = abs(x), abs(y), abs(z)
if ax >= ay and ax >= az:
return (0 if x > 0 else 3), y / ax, z / ax
if ay >= az:
return (1 if y > 0 else 4), x / ay, z / ay
return (2 if z > 0 else 5), x / az, y / az
def uv_to_st(u: float) -> float:
"""S2's quadratic transform: maps u β [-1, 1] to s β [0, 1], stretching the
face centre and compressing the edges so cell areas even out."""
return 0.5 * math.sqrt(1 + 3 * u) if u >= 0 else 1 - 0.5 * math.sqrt(1 - 3 * u)
face, u, v = lat_lng_to_face_uv(37.7749, -122.4194)
print(face, round(uv_to_st(u), 4), round(uv_to_st(v), 4))
assert uv_to_st(-1) == 0 and uv_to_st(0) == 0.5 and uv_to_st(1) == 1
Diagram: S2 maps a lat/lng to a 64-bit cell ID.
flowchart LR
L["lat, lng"] --> XYZ["Unit vector x, y, z"]
XYZ --> FUV["Cube face + u, v"]
FUV --> ST["Quadratic transform to s, t"]
ST --> IJ["Integer i, j on 2^30 grid"]
IJ --> HC["Hilbert curve position"]
HC --> ID["64-bit cell ID"]
Why a Quadratic Transform? Equal steps in u near a face's edge cover less of the sphere than near its centre. S2's documentation compares the ratio of largest to smallest cell area at a given level: linear 5.2Γ, quadratic 2.08Γ, tangent 1.41Γ. The tangent projection is most uniform but needs tan/atan; quadratic costs one square root and is close enough.
Hilbert Curve vs Z-order: both map 2D cells to 1D. In Z-order (geohash) consecutive positions can jump across the map; on a Hilbert curve consecutive positions are always adjacent cells, so a region maps to fewer, longer ID ranges. No curve keeps every pair of neighbours close (cells across a curve "fold" can be far apart in ID), which is why queries use coverings rather than a single range.
Cell sizes (average):
| Level | Area | ~Edge | Typical use |
|---|---|---|---|
| 7 | 5,200 kmΒ² | 72 km | Metro area sharding |
| 10 | 81 kmΒ² | 9 km | City districts |
| 12 | 5.1 kmΒ² | 2.3 km | Dispatch regions |
| 13 | 1.3 kmΒ² | 1.1 km | Neighbourhoods |
| 15 | 0.08 kmΒ² | 280 m | "Nearby" searches |
| 20 | 77 mΒ² | 9 m | Buildings |
| 30 | 0.74 cmΒ² | 9 mm | Leaf cells |
Areas vary by up to ~2Γ around these averages depending on position on the face.
S2RegionCoverer β The Killer Feature:
Given a region (a 500 m cap around the rider, or a delivery polygon) and
limits (min_level, max_level, max_cells), the coverer returns a small set of
cells of mixed levels whose union contains the region:
big cells fully inside, small cells along the boundary, β€ max_cells total.
Indexing: store each driver's leaf (or level-15) cell ID in an integer column.
Query: for each covering cell C: WHERE cell_id BETWEEN C.range_min AND C.range_max
then post-filter by exact distance.
Fewer cells = fewer range scans but more over-coverage; ~8β20 cells is typical.
For Uber-style matching: drivers' locations change every few seconds, so instead of a persistent index, bucket drivers in memory by cell at a fixed level (e.g. level 12β13), shard servers by cell, and on a request scan the covering cells of the search radius, rank by ETA (road network), not straight-line distance. Uber evaluated S2 and built H3 (next section) for this kind of analysis.
S2 vs Geohash:
| Property | Geohash | S2 |
|---|---|---|
| Projection | Lat/lng rectangle | Cube + quadratic transform |
| Cell shape | Rectangles, narrowing towards poles | Quadrilaterals, β€ ~2Γ area variation |
| Curve | Z-order (jumps) | Hilbert (no jumps) |
| Covering | 3Γ3 cells at one precision | Multi-level covering of any region |
| Levels | 12 characters (60 bits) | 31 levels (0β30) |
| Cell ID | String (or integer) | 64-bit integer |
Real-World Use: MongoDB 2dsphere indexes, Google BigQuery GEOGRAPHY, CockroachDB spatial indexes, Google Maps/Earth, PokΓ©mon Go's map cells.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Three transforms | Explains sphere β cube β quadratic β Hilbert pipeline |
| Quadratic justification | Knows it evens out cell areas cheaply |
| Hilbert vs Z-order | Explains fewer, longer ID ranges for the same region |
| RegionCoverer | Describes multi-level covering β B-tree range scans + post-filter |
8. H3: Hexagonal Grid System¶
Q: "Uber wants to price surge areas dynamically in real-time. Geohash and S2 use square/rectangular cells β but squares have unequal distances to neighbors (4 edge-neighbors at distance d, 4 corner-neighbors at distance dβ2). Design a hexagonal grid system that solves this. Why did Uber build H3 instead of using S2?"
What They're Really Testing: Whether you understand the constraints of using square grids for spatial problems that need uniform distance metrics β and the unique design of the hexagon-based H3 system.
30-second answer
H3 (Uber, open-sourced 2018) tiles the globe with hexagons at 16 resolutions by projecting onto an icosahedron. Every hexagon has 6 neighbours, all at the same centre-to-centre distance, so "k rings around this cell" is a natural, roughly circular neighbourhood: ideal for smoothing supply/demand, surge zones and movement analysis. Costs: 12 pentagons per resolution (placed in the oceans), and hexagons can't be split exactly into smaller hexagons, so parent/child containment is approximate (aperture 7). Use H3 for analytics and aggregation over areas; S2 for exact hierarchical containment and indexing.
Answer¶
Why Hexagons?
Square grid: edge neighbours at distance d, corner neighbours at dΒ·β2
β "neighbourhood" depends on direction; diffusion and smoothing look boxy
Hexagon grid: 6 neighbours, all at the same distance, no corner-only neighbours
β k-ring β a circle of radius k; gradients and flows look natural
H3's Hierarchical Structure:
- Project the sphere onto an icosahedron (20 triangular faces) with a gnomonic projection, oriented so all 12 vertices fall in the ocean.
- Lay a hexagonal grid on each face; 122 base cells at resolution 0 (110 hexagons + 12 pentagons).
- Each finer resolution has ~7Γ more cells (aperture 7); cells are rotated relative to the parent, so a parent's 7 children only approximately cover it.
- 64-bit index: mode, resolution, base cell, then 3 bits per resolution digit.
Average hexagon area by resolution (from h3.average_hexagon_area):
| Res | Avg area | Res | Avg area |
|---|---|---|---|
| 0 | 4,357,449 kmΒ² | 9 | 0.105 kmΒ² |
| 5 | 252.9 kmΒ² | 10 | 0.015 kmΒ² |
| 7 | 5.16 kmΒ² | 12 | 307 mΒ² |
| 8 | 0.737 kmΒ² | 15 | 0.9 mΒ² |
H3's Key Operations (h3-py v4 API):
import h3 # pip install h3 (v4 renamed most functions from v3)
center = h3.latlng_to_cell(37.7749, -122.4194, 9) # was geo_to_h3
disk = h3.grid_disk(center, 3) # was k_ring
print(len(disk)) # 37 = 1 + 6 + 12 + 18 (no pentagon nearby)
rings = [h3.grid_ring(center, k) for k in range(4)] # was hex_ring / k_ring_distances
print([len(r) for r in rings]) # [1, 6, 12, 18]
# Tiered surge: ring 0β1 β 1.5Γ, ring 2 β 1.2Γ, ring 3 β 1.0Γ
other = h3.latlng_to_cell(37.7849, -122.4094, 9)
print(h3.grid_distance(center, other)) # was h3_distance; in grid steps
zone = h3.LatLngPoly([(37.7749, -122.4194), (37.7849, -122.4194),
(37.7849, -122.4094), (37.7749, -122.4094)])
cells = h3.polygon_to_cells(zone, 9) # was polyfill (cell centres inside)
print(len(cells), len(h3.compact_cells(cells))) # was compact
print(h3.is_pentagon(center), len(h3.get_pentagons(9))) # False, 12 per resolution
Output (h3 4.x): 37, [1, 6, 12, 18], 5, 9 9, False 12. Note grid_disk returns O(kΒ²) cells (3kΒ² + 3k + 1).
Surge pricing design: aggregate ride requests and available drivers per res-8/9 cell every few seconds (stream processor keyed by cell ID), compute the demand/supply ratio, smooth it over grid_disk(cell, 1β2) so neighbouring cells don't flip-flop, and publish multipliers per cell. Riders look up their cell; drivers see a heatmap of the same cells.
The Pentagon Problem and Other Caveats:
- 12 pentagons at every resolution, all in the ocean; they have 5 neighbours, and some grid functions (e.g. ring traversal across them) return errors or different counts. Production code should handle
H3 errorcases rather than assume 6 neighbours. - Approximate hierarchy: a parent cell's children don't exactly tile it, so rolling res-9 counts up to res-7 via
cell_to_parentslightly misassigns area at the borders. Fine for analytics, not for legal boundaries. polygon_to_cellsincludes a cell if its centre is inside the polygon, so small polygons can return few or zero cells: use a finer resolution or a containment mode that fits the use case.
H3 vs S2 β When to Use Which:
| Criterion | H3 | S2 |
|---|---|---|
| Neighbour uniformity | β 6 equidistant neighbours | β edge vs corner neighbours |
| Neighbourhood queries | β
grid_disk / grid_ring |
Coverings of a cap |
| Hierarchy | β Approximate (aperture 7) | β Exact (each cell = 4 children) |
| Distortion | 12 pentagons; areas vary by ~2Γ | ~2Γ area variation |
| Levels | 16 resolutions | 31 levels |
| Typical use | Movement analytics, surge, aggregation, ML features | Indexing, exact containment, coverings |
Real-World Use: Uber (surge, dispatch analytics, forecasting); native H3 functions in Snowflake, Databricks and ClickHouse; DuckDB and PostGIS extensions (h3, h3-pg).
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Hex uniformity | Explains 6 equidistant neighbours vs 4+4 in squares |
| Icosahedron projection | Knows the 12 pentagons and why they're in the ocean |
| Approximate hierarchy | Knows children don't exactly tile parents |
| H3 vs S2 | Gives a principled trade-off (analytics/neighbourhoods vs exact indexing) |
9. Quad Tree: 2D Spatial Partitioning¶
Q: "Design a real-time collision detection system for a multiplayer game with 10K entities moving simultaneously on a 2D map. Brute-force pairwise comparison is O(NΒ²) β 50M pair checks per frame. Design a spatial partitioning structure. How does a Quad Tree compare to a uniform grid? Walk me through insertion, query, and rebalancing."
What They're Really Testing: Whether you can reason about the adaptability of Quad Trees vs fixed-grid approaches for non-uniform distributions.
30-second answer
A point quadtree recursively splits a square into 4 quadrants once a node holds more than a few points, so dense areas get small cells and empty areas stay coarse. For collisions you query each entity's neighbourhood and prune whole subtrees whose box can't intersect the query circle. For 10K moving entities the simplest robust approach is to rebuild the structure every frame (10K inserts take well under a millisecond in C++/Rust); for roughly uniform density a uniform grid / spatial hash with cell size β interaction radius is even simpler and faster. Use a quadtree when density is very uneven (crowds in a few towns); use loose quadtrees or BVHs when objects have extents.
Answer¶
Point Quadtree with Circle Queries (runnable):
class QuadTree:
"""Point quadtree over the rectangle [x0, x1) Γ [y0, y1).
A node holds up to `capacity` points, then splits into 4 quadrants."""
def __init__(self, x0, y0, x1, y1, capacity: int = 8, depth: int = 0, max_depth: int = 12):
self.box = (x0, y0, x1, y1)
self.capacity, self.depth, self.max_depth = capacity, depth, max_depth
self.points: list[tuple[float, float, int]] = [] # (x, y, entity_id)
self.children: list["QuadTree"] | None = None
def _contains(self, x, y) -> bool:
x0, y0, x1, y1 = self.box
return x0 <= x < x1 and y0 <= y < y1
def insert(self, x: float, y: float, eid: int) -> bool:
if not self._contains(x, y):
return False
if self.children is None:
if len(self.points) < self.capacity or self.depth == self.max_depth:
self.points.append((x, y, eid))
return True
self._split()
return any(c.insert(x, y, eid) for c in self.children)
def _split(self) -> None:
x0, y0, x1, y1 = self.box
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
args = (self.capacity, self.depth + 1, self.max_depth)
self.children = [QuadTree(x0, y0, mx, my, *args), QuadTree(mx, y0, x1, my, *args),
QuadTree(x0, my, mx, y1, *args), QuadTree(mx, my, x1, y1, *args)]
pts, self.points = self.points, []
for p in pts:
any(c.insert(*p) for c in self.children)
def query_circle(self, cx, cy, r, out=None) -> list[int]:
out = [] if out is None else out
x0, y0, x1, y1 = self.box
# closest point of this box to the circle centre; prune if it's outside the circle
nx, ny = min(max(cx, x0), x1), min(max(cy, y0), y1)
if (nx - cx) ** 2 + (ny - cy) ** 2 > r * r:
return out
for x, y, eid in self.points:
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r:
out.append(eid)
for c in self.children or ():
c.query_circle(cx, cy, r, out)
return out
import random
random.seed(0)
ents = {i: (random.uniform(0, 1000), random.uniform(0, 1000)) for i in range(10_000)}
qt = QuadTree(0, 0, 1000, 1000)
for i, (x, y) in ents.items(): # rebuild every frame: 10K inserts is cheap
qt.insert(x, y, i)
hits = qt.query_circle(500, 500, 20)
brute = [i for i, (x, y) in ents.items() if (x - 500) ** 2 + (y - 500) ** 2 <= 400]
assert sorted(hits) == sorted(brute)
print("neighbours within 20 units:", len(hits))
Uniform Grid vs Quad Tree:
| Uniform grid / spatial hash | Quadtree | |
|---|---|---|
| Build per frame | O(N), one hash insert per entity | O(N log N) typical |
| Neighbour query | Check the 3Γ3 cells around the entity | Descend, pruning by box |
| Uneven density | Degrades: a crowded cell holds hundreds | Adapts: crowded areas split deeper |
| Memory | Cells Γ bucket overhead (or a hash map of non-empty cells) | Nodes proportional to points |
| Moving objects | Trivial (rebuild or move between buckets) | Rebuild each frame, or remove/reinsert |
| Depth bound | n/a | max_depth stops infinite splitting when points coincide |
Rule of thumb: start with a grid whose cell size is about the interaction radius. Switch to a quadtree only if profiling shows crowded cells dominate.
Handling movement:
- Rebuild each frame (shown above): no stale positions, no deletion code, predictable cost. 10K inserts Γ 60 fps = 600K inserts/s, fine in a compiled language.
- Incremental update: remove + reinsert only entities that crossed a cell boundary; needs parent pointers and node merging when cells empty out.
- Loose quadtree: each node's bounds are enlarged (e.g. 2Γ) so objects with extents rarely need to move between nodes.
- Broad phase only: after finding candidate pairs, run exact narrow-phase collision tests on the shapes.
Worst cases: a quadtree query is typically O(log N + k), but depth depends on the data: many points at nearly the same spot force deep chains (hence max_depth), and a large query circle can visit many nodes. Grids have the analogous problem in crowded cells.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Adaptive partitioning | Explains why fixed grids struggle with uneven density, and when a grid is still the better answer |
| Range query | Prunes subtrees by boxβcircle distance |
| Movement | Rebuild per frame or incremental; knows loose quadtrees |
| Broad vs narrow phase | Spatial structure finds candidates; exact tests confirm |
10. R-Tree: Bounding Box Index¶
Q: "Design a map-based ride-sharing app that needs to find all available drivers within a user's visible map viewport (a rectangular area on screen). The world has 1M drivers. How does an R-Tree organize bounding boxes to enable fast rectangular range queries? What makes it different from a Quad Tree?"
What They're Really Testing: Understanding of R-Trees as dynamic, balanced spatial structures optimized for rectangle (not point) storage β and the trade-off between area overlap and query speed.
30-second answer
An R-tree is a balanced, B-tree-like tree in which every entry is a minimum bounding rectangle (MBR): leaves hold object MBRs, internal nodes hold the MBR of each child. A query descends only into children whose MBR overlaps the query rectangle. Unlike a quadtree, it partitions the data rather than space, stores rectangles (roads, polygons) as well as points, stays balanced, and maps to disk pages, which is why databases use it (PostGIS via GiST, SQLite, MySQL InnoDB). Its weakness is MBR overlap, which forces multi-path searches; R-tree insertion heuristics and STR bulk loading* minimise it. For 1M drivers whose positions change every few seconds, an in-memory grid/geohash bucket per region usually beats maintaining an R-tree.
Answer¶
Structure and Invariants:
Node capacity M (fan-out, e.g. 16β200 so a node fills a disk page), minimum m β€ M/2
(R*-trees use m β 0.4Β·M). All leaves at the same depth; root has β₯ 2 children unless it's a leaf.
Leaf entry: (MBR of object, object id)
Internal entry: (MBR covering the child's entries, child pointer)
Insert (Guttman, 1984): descend choosing the child whose MBR needs the LEAST
area enlargement; on overflow split the node and propagate the split and the
updated MBRs up to the root. Delete: remove, condense under-full nodes, reinsert
their orphaned entries.
STR Bulk Loading + Search (runnable):
import math
class Node:
def __init__(self, entries, leaf: bool):
self.entries = entries # leaf: [(mbr, obj_id)], internal: [(mbr, Node)]
self.leaf = leaf
self.mbr = (min(e[0][0] for e in entries), min(e[0][1] for e in entries),
max(e[0][2] for e in entries), max(e[0][3] for e in entries))
def _pack(entries, M, leaf):
"""One STR pass: sort by x-centre into β(n/M) vertical slices, sort each
slice by y-centre, cut into runs of M entries β one node per run."""
n = len(entries)
pages = math.ceil(n / M)
slices = math.ceil(math.sqrt(pages))
per_slice = slices * M
entries = sorted(entries, key=lambda e: e[0][0] + e[0][2])
nodes = []
for s in range(0, n, per_slice):
part = sorted(entries[s:s + per_slice], key=lambda e: e[0][1] + e[0][3])
nodes += [Node(part[i:i + M], leaf) for i in range(0, len(part), M)]
return nodes
def str_bulk_load(items, M: int = 16) -> Node:
"""Sort-Tile-Recursive (Leutenegger et al., 1997): nearly 100% full nodes,
little overlap, built bottom-up level by level."""
level = _pack(items, M, leaf=True)
while len(level) > 1:
level = _pack([(n.mbr, n) for n in level], M, leaf=False)
return level[0]
def overlaps(a, b) -> bool:
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
def search(node: Node, q, out=None, stats=None):
out = [] if out is None else out
if stats is not None:
stats["nodes"] += 1
for mbr, child in node.entries:
if overlaps(mbr, q):
if node.leaf:
out.append(child)
else:
search(child, q, out, stats)
return out
import random
random.seed(1)
drivers = []
for i in range(1_000_000 // 10): # 100K points keeps the demo fast
x, y = random.uniform(-180, 180), random.uniform(-85, 85)
drivers.append(((x, y, x, y), f"d{i}"))
root = str_bulk_load(drivers, M=32)
viewport = (-5.0, -5.0, 5.0, 5.0)
stats = {"nodes": 0}
found = search(root, viewport, stats=stats)
brute = [d for (b, d) in drivers if overlaps(b, viewport)]
assert sorted(found) == sorted(brute)
print(f"{len(found)} drivers in viewport, visited {stats['nodes']} nodes")
Sample output: 178 drivers found in a 10Β° Γ 10Β° box while visiting 16 of the ~3,200 nodes.
R*-tree (Beckmann et al., 1990) β why it beats Guttman's original:
- ChooseSubtree: at the level above the leaves, pick the child whose overlap with siblings grows least (not just area).
- Split: choose the split axis by minimum total margin (perimeter), then the distribution with minimum overlap.
- Forced reinsert: on the first overflow at a level, remove the ~30% of entries farthest from the node's centre and reinsert them; this often finds better homes and avoids a split.
R-Tree vs Quad Tree vs Geohash-in-B-tree:
| R-tree | Quadtree | Geohash / S2 cell in a B-tree | |
|---|---|---|---|
| Partitions | The data (MBRs) | Space (fixed quadrants) | Space (fixed cells) |
| Balanced | Always | Depends on data | B-tree is balanced |
| Stores rectangles/polygons | Natively | Awkward (straddling items) | Via coverings |
| Overlap between nodes | Yes, the main cost | None | None |
| Disk-friendly | Yes (node = page) | Not naturally | Yes (ordinary index) |
| Frequent point updates | Costly (reinsert, MBR updates) | Moderate | Cheap (update one key) |
| Best for | Static/slow-changing geometry, viewport and intersection queries | In-memory, uneven point density | Moving points, simple stores, sharding by cell |
For the 1M moving drivers: drivers report every ~4 s, i.e. ~250K updates/s. Keep them in memory bucketed by S2/H3/geohash cell (sharded by region), and answer a viewport query with the cells covering the viewport plus an exact filter. Reserve R-trees for the static layers (road segments, zones, POIs) that the map also needs.
Real-World Use:
| System | What it indexes | Variant |
|---|---|---|
| PostgreSQL / PostGIS | Geometry, geography | R-tree implemented on GiST (also SP-GiST quadtrees/k-d trees) |
| SQLite | Any rectangles | R*Tree module |
| MySQL / InnoDB | SPATIAL indexes |
R-tree |
| Oracle Spatial | Geometry | R-tree |
| Lucene / Elasticsearch / OpenSearch | geo_point, geo_shape |
BKD trees (the older quadtree/geohash prefix trees are deprecated) |
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| MBR overlap problem | Explains why overlapping MBRs cause multi-path searches |
| R*-Tree heuristics | Mentions overlap-aware choose/split and forced reinsert |
| Bulk loading | Knows STR packing for read-mostly data |
| Fit to workload | Picks cell bucketing for fast-moving points, R-trees for static geometry |
11. Skip List: Probabilistic Ordered Structure¶
Q: "Design a real-time leaderboard for a multiplayer game with 10M players. Scores update frequently (100K/sec). We need O(log N) insert, update, delete, and range queries (e.g., 'top 100 players around rank 5000'). A balanced BST works but rebalancing is complex. Design a simpler alternative. How does the Skip List achieve O(log N) with pure probability?"
What They're Really Testing: Whether you understand that the Skip List's probabilistic balancing is a simpler alternative to the deterministic balancing of red-black or AVL trees β and how it handles concurrent access.
30-second answer
A skip list is a sorted linked list plus "express lanes": each node is promoted to the next level with probability p (1/2 or 1/4), so level i holds ~NΒ·pβ± nodes and a search drops down ~log_{1/p} N levels, O(log N) expected, with no rotations. Storing a span (how many nodes each pointer skips) on every link gives rank and select-by-rank in O(log N), which is exactly what "players around rank 5000" needs. Order by (score desc, player id) so ties don't collide, and pair it with a hash map from player to score. That's precisely a Redis sorted set: in practice you'd use ZADD / ZREVRANK / ZREVRANGE and shard or approximate when one node isn't enough.
Answer¶
Indexable Skip List + Leaderboard (runnable):
import random
class _Node:
__slots__ = ("key", "next", "span")
def __init__(self, key, level: int):
self.key = key # (sort key, member) β unique even with equal scores
self.next = [None] * level # forward pointer per level
self.span = [0] * level # how many level-0 nodes each pointer jumps over
class IndexableSkipList:
"""Skip list with spans (Redis zskiplist style): insert, delete, rank and
select-by-rank all in expected O(log N). Each node is promoted to the next
level with probability p, so it carries 1/(1-p) pointers on average
(2 for p = 1/2; Redis uses p = 1/4 β 1.33)."""
MAX_LEVEL, P = 32, 0.25
def __init__(self):
self.head = _Node(None, self.MAX_LEVEL)
self.level, self.size = 1, 0
def _random_level(self) -> int:
lvl = 1
while lvl < self.MAX_LEVEL and random.random() < self.P:
lvl += 1
return lvl
def insert(self, key) -> None:
update, rank = [None] * self.MAX_LEVEL, [0] * self.MAX_LEVEL
x = self.head
for i in range(self.level - 1, -1, -1):
rank[i] = rank[i + 1] if i + 1 < self.level else 0
while x.next[i] and x.next[i].key < key:
rank[i] += x.span[i]
x = x.next[i]
update[i] = x
lvl = self._random_level()
if lvl > self.level:
for i in range(self.level, lvl):
rank[i], update[i] = 0, self.head
self.head.span[i] = self.size
self.level = lvl
node = _Node(key, lvl)
for i in range(lvl):
node.next[i], update[i].next[i] = update[i].next[i], node
node.span[i] = update[i].span[i] - (rank[0] - rank[i])
update[i].span[i] = rank[0] - rank[i] + 1
for i in range(lvl, self.level):
update[i].span[i] += 1 # pointers that now jump one more node
self.size += 1
def delete(self, key) -> bool:
update = [None] * self.MAX_LEVEL
x = self.head
for i in range(self.level - 1, -1, -1):
while x.next[i] and x.next[i].key < key:
x = x.next[i]
update[i] = x
x = x.next[0]
if x is None or x.key != key:
return False
for i in range(self.level):
if update[i].next[i] is x:
update[i].span[i] += x.span[i] - 1
update[i].next[i] = x.next[i]
else:
update[i].span[i] -= 1
while self.level > 1 and self.head.next[self.level - 1] is None:
self.level -= 1
self.size -= 1
return True
def rank(self, key) -> int:
"""1-based position of key, or 0 if absent."""
r, x = 0, self.head
for i in range(self.level - 1, -1, -1):
while x.next[i] and x.next[i].key <= key:
r += x.span[i]
x = x.next[i]
if x.key == key:
return r
return 0
def by_rank(self, r: int):
"""Node at 1-based rank r (walk forward from it for a range)."""
t, x = 0, self.head
for i in range(self.level - 1, -1, -1):
while x.next[i] and t + x.span[i] <= r:
t += x.span[i]
x = x.next[i]
if t == r:
return x
return None
class Leaderboard:
"""Highest score first; ties broken by player id. Same design as a Redis
ZSET: skip list for order + hash map for member β score."""
def __init__(self):
self.sl, self.score = IndexableSkipList(), {}
def set_score(self, player: str, score: float) -> None:
if player in self.score:
self.sl.delete((-self.score[player], player))
self.score[player] = score
self.sl.insert((-score, player))
def rank(self, player: str) -> int:
return self.sl.rank((-self.score[player], player))
def around(self, player: str, window: int) -> list[tuple[int, str, float]]:
r = self.rank(player)
start = max(1, r - window)
node, out = self.sl.by_rank(start), []
for k in range(start, min(self.sl.size, r + window) + 1):
out.append((k, node.key[1], -node.key[0]))
node = node.next[0]
return out
random.seed(42)
lb = Leaderboard()
scores = {f"p{i}": random.randint(0, 5000) for i in range(20_000)}
for p, s in scores.items():
lb.set_score(p, s)
for p in random.sample(sorted(scores), 2_000): # score updates
scores[p] = random.randint(0, 5000)
lb.set_score(p, scores[p])
expected = sorted(scores, key=lambda p: (-scores[p], p))
assert all(lb.rank(p) == i + 1 for i, p in enumerate(expected[:500]))
assert [x[1] for x in lb.around(expected[5000], 3)] == expected[4997:5004]
print("rank/around correct; size", lb.sl.size)
How the pieces work:
- Search starts at the top level, moves right while the next key is smaller, then drops a level. Expected comparisons β (1/p)Β·log_{1/p} N.
- Random level: P(level β₯ k) = p^(kβ1). Expected pointers per node = 1/(1βp): 2 for p = Β½, 1.33 for p = ΒΌ.
- Spans make rank queries cheap: summing the spans of the pointers you follow is your rank; following pointers until the sum reaches r selects by rank.
- Ties: keys are (βscore, player_id), so equal scores order deterministically and never overwrite each other.
Skip List vs Balanced BST:
| Skip list | Red-black / AVL tree | |
|---|---|---|
| Balance | Randomised, no rotations | Deterministic rotations |
| Search/insert/delete | O(log N) expected (worst case O(N), astronomically unlikely) | O(log N) worst case |
| Range scan | Walk level 0 | In-order traversal |
| Rank / select | Span counters | Subtree-size counters (order-statistic tree) |
| Memory | 1/(1βp) forward pointers + key per node (Redis also stores a backward pointer and a span per level) | 2 child pointers (+ parent) + colour/balance per node |
| Lock-free concurrency | Practical: insert = CAS on level-0 link, then index levels (Java ConcurrentSkipListMap) |
Hard: rotations touch several nodes at once |
| Cache locality | Poor (pointer chasing) | Also pointer-based; B-trees win on locality |
Why Redis uses skip lists for sorted sets (per its author): they're simpler to implement and debug than balanced trees, range operations (ZRANGE, ZRANGEBYSCORE) are a level-0 walk, memory can be tuned with p (Redis uses p = ΒΌ, max 32 levels), and span counters give ZRANK cheaply. Concurrency isn't the reason: Redis executes commands on a single thread. A ZSET is a skip list plus a hash table (member β score); small ZSETs use a compact listpack instead.
Production Design for 10M players, 100K updates/s:
- One Redis primary handles ~100K simple ops/s, so this is near one node's limit:
ZADD lb <score> <player>(O(log N)),ZREVRANK lb <player>,ZREVRANGE lb <start> <stop> WITHSCORESfor "around me" (O(log N + M)). - Memory: 10M members β ~1 GB in a ZSET (skip list + dict + strings). Fine for one node.
- Beyond one node: shard players by hash into several ZSETs and compute global rank as the sum of
ZCOUNT(score, +inf)across shards (exact, one round trip per shard), or keep an approximate rank from a score histogram (bucket counts) for "you're in the top 3%" displays. - Ties: encode a tiebreak into the score (e.g. score Γ 2^k + (max_ts β ts)) if "who got there first" should win; Redis otherwise orders equal scores lexicographically by member.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Probabilistic balancing | Explains how random levels give O(log N) expected time |
| Rank queries | Uses span counters (or knows ZSETs provide them) |
| Ties | Orders by (score, id) so equal scores don't collide |
| Production | Knows Redis ZSET internals and how to scale past one node |
12. Merkle Tree: Tamper-Evident Verification¶
Q: "Design a system to verify data consistency across 1000 database replicas without transferring full snapshots. Replicas may diverge due to network partitions. How would you use Merkle trees to reconcile differences efficiently? What's the communication cost in terms of hashes vs data?"
What They're Really Testing: Whether you understand the anti-entropy protocol β specifically, that Merkle tree comparison localizes differences to O(log N) hash exchanges instead of O(N) data transfer.
30-second answer
Each replica builds a hash tree over fixed key ranges (leaves = hash of all rows in a range). Two replicas compare roots; if they differ, they compare children and descend only where hashes differ, then stream just the differing ranges. Cost β D Γ 2Β·logβ(L) hashes for D differing leaves out of L, versus shipping everything. The catch is local work: building the tree reads and hashes all the data, so systems either build trees on demand during repair (Cassandra) or maintain them incrementally on every write (Riak AAE, Merkle B-trees). With 1,000 replicas you don't compare all pairs: each replica syncs with a few peers (or its replica set), and gossip spreads the repairs.
Answer¶
Core Concept:
A Merkle tree is a tree where each leaf is the hash of a data block and each internal node is the hash of its children, so the root commits to the entire dataset: any change to any block changes the root. The runnable tree, inclusion proofs (with leaf/node domain separation) and hash-range anti-entropy are in Interview Questions Q4. This section covers the protocol, costs and the variants used at scale.
Anti-Entropy Protocol:
Both replicas agree on the leaf layout: 2^d buckets of the key-hash (token) space.
1. Exchange root hashes. Equal β done (the common case).
2. Exchange the 2 child hashes of each differing node; recurse into differing children.
3. At the leaves, stream the rows of differing buckets (or per-row hashes, then rows).
4. Resolve each differing row by the store's rule (latest timestamp, vector clock, CRDT merge).
Choosing d: more leaves β less over-streaming per difference, more memory and
more hashes to exchange. Cassandra bounds repair-tree size (2^15 leaves per
range before 4.0, configurable since), which is why repairing a huge range can
stream far more than the changed rows.
Communication Cost Analysis:
N rows in L leaf buckets, D buckets differ:
Naive full comparison: ship all rows β O(N)
Merkle: about 2 hashes per level along each differing path β O(DΒ·log L) hashes
+ the rows in D buckets
Example: L = 2^20 buckets, D = 5, 32-byte hashes, ~1,000 rows of 1 KB per bucket
Hashes: 5 Γ 20 levels Γ 2 Γ 32 B β 6.4 KB
Data: 5 buckets Γ 1 MB = 5 MB (vs ~1 TB for everything)
The data, not the hashes, dominates; smaller buckets cut it but cost memory.
Local cost: hashing every row once per tree build, which is why trees are
built during repair (a "validation compaction" in Cassandra), throttled.
| Technique | Bandwidth | Local CPU | Notes |
|---|---|---|---|
| Full snapshot | O(N) | O(N) | Simple; for bootstrapping a new replica |
| Merkle tree | O(DΒ·log L) + D buckets | O(N) per build (or incremental) | Best when differences are rare |
| Per-row version/timestamp scan | O(N) small records | O(N) | Easy if rows carry versions |
| rsync rolling checksums | O(N / block) + changed blocks | O(N) | Files, not key-value replicas |
| Hinted handoff / read repair | O(changes) | O(1) per write/read | Fixes most divergence before anti-entropy runs |
Sparse Merkle Tree β Proving Membership AND Absence (runnable):
A Merkle tree over the whole 2Β²β΅βΆ space of key hashes, where empty subtrees have precomputed default hashes. Every key has a fixed position, so you can prove a key is absent (its leaf is the empty default), which a plain Merkle tree over a sorted list can't do without extra structure.
import hashlib
DEPTH = 256
def H(*parts: bytes) -> bytes:
return hashlib.sha256(b"".join(parts)).digest()
# DEFAULT[d] = hash of an empty subtree whose root is at depth d (leaves at depth 256)
DEFAULT = [b""] * (DEPTH + 1)
DEFAULT[DEPTH] = H(b"\x00empty")
for d in range(DEPTH - 1, -1, -1):
DEFAULT[d] = H(b"\x01", DEFAULT[d + 1], DEFAULT[d + 1])
def bits(key: bytes) -> str:
return format(int.from_bytes(hashlib.sha256(key).digest(), "big"), "0256b")
class SparseMerkleTree:
"""Merkle tree over all 2^256 key hashes. Only non-default nodes are stored
(dict keyed by path prefix), so memory is O(n Β· 256) for n keys. Empty
subtrees use precomputed DEFAULT hashes, which makes NON-membership provable:
the leaf for an absent key is the default empty leaf."""
def __init__(self):
self.nodes: dict[str, bytes] = {} # prefix β hash ("" = root)
def _get(self, prefix: str) -> bytes:
return self.nodes.get(prefix, DEFAULT[len(prefix)])
@property
def root(self) -> bytes:
return self._get("")
def update(self, key: bytes, value: bytes) -> None:
path = bits(key)
self.nodes[path] = H(b"\x00", value)
for d in range(DEPTH - 1, -1, -1): # recompute 256 ancestors
p = path[:d]
self.nodes[p] = H(b"\x01", self._get(p + "0"), self._get(p + "1"))
def prove(self, key: bytes) -> list[bytes]:
"""Sibling hashes from the leaf up to the root (256 entries; real
systems compress default siblings into a bitmap)."""
path = bits(key)
return [self._get(path[:d] + ("1" if path[d] == "0" else "0"))
for d in range(DEPTH - 1, -1, -1)]
@staticmethod
def verify(root: bytes, key: bytes, value: bytes | None, proof: list[bytes]) -> bool:
"""value=None verifies NON-membership."""
path = bits(key)
h = DEFAULT[DEPTH] if value is None else H(b"\x00", value)
for d, sib in zip(range(DEPTH - 1, -1, -1), proof):
h = H(b"\x01", h, sib) if path[d] == "0" else H(b"\x01", sib, h)
return h == root
smt = SparseMerkleTree()
for i in range(100):
smt.update(f"user:{i}".encode(), f"balance={i}".encode())
r = smt.root
assert SparseMerkleTree.verify(r, b"user:7", b"balance=7", smt.prove(b"user:7"))
assert not SparseMerkleTree.verify(r, b"user:7", b"balance=999", smt.prove(b"user:7"))
assert SparseMerkleTree.verify(r, b"user:12345", None, smt.prove(b"user:12345")) # absent
print("inclusion and non-inclusion proofs verified; stored nodes:", len(smt.nodes))
Real systems compress the 256 siblings (most are defaults) with a bitmap, or shortcut single-key subtrees (Diem/Aptos's Jellyfish Merkle Tree, a sparse Merkle radix tree). Uses: authenticated blockchain state (Diem/Aptos; Ethereum uses a related Merkle Patricia trie), key-transparency logs, verifiable key-value maps.
Keeping trees cheap under constant writes:
- Incremental updates: change one leaf β rehash its path (log L hashes). Riak's AAE keeps persistent hash trees per partition, updated on every write.
- Merkle B-trees / Prolly trees: hash each B-tree page and its children (Dolt, Noms). Updates touch one root-to-leaf path, and content-defined page boundaries make two versions' trees share unchanged subtrees, so diffing two snapshots is proportional to the changes.
- Append-only logs (Certificate Transparency, RFC 6962/9162): a dense Merkle tree over a growing list, with inclusion proofs and consistency proofs that a later tree extends an earlier one.
Production Lessons:
- Repair is an operational process: run it within the tombstone GC window (Cassandra's
gc_grace_seconds, default 10 days), or deleted data can resurrect from a replica that missed the delete. - Throttle tree builds and streaming; anti-entropy competes with foreground traffic.
- Read repair and hinted handoff fix most divergence cheaply; Merkle repair is the backstop for what they miss.
π Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Anti-entropy protocol | Fixed hash-range leaves; recursive comparison; stream differing ranges |
| Communication cost | Quantifies O(D log L) hashes and notes data volume dominates |
| Local cost | Knows building the tree is O(N) and how systems avoid rebuilding |
| Sparse variant | Knows sparse Merkle trees give non-membership proofs |
Summary: Choosing the Right Structure¶
| Problem | Best Structure | Why |
|---|---|---|
| Set membership (insert only) | Bloom filter (blocked) | Simple, never fails to insert, ~1.44Β·logβ(1/p) bits/key |
| Set membership (with deletes, p β² 3%) | Cuckoo filter | Deletion, smaller than Bloom at low FP |
| Static membership | Xor / binary-fuse / Ribbon filter | 15β30% smaller than Bloom |
| Cardinality estimation | HyperLogLog (++) | Fixed KBs per counter, mergeable, ~1.04/βm error |
| Frequency estimation | Count-Min Sketch | Never undercounts (insert-only), mergeable |
| Top-K only | Space-Saving | k counters, deterministic bound |
| Set similarity | MinHash + LSH | Jaccard estimate + sub-linear candidate search |
| Neighbourhood analytics | H3 | Uniform hex neighbours, k-rings |
| Spatial indexing on a sphere | S2 | 64-bit cell IDs, exact hierarchy, coverings β range scans |
| Simple geo in a KV/B-tree store | Geohash | String/int prefix, 3Γ3 neighbour query |
| 2D points, uneven density, in memory | Quadtree (or uniform grid) | Adaptive partitioning |
| Rectangles / polygons, on disk | R-tree (R*, STR-loaded) | Balanced, page-oriented |
| Ranked leaderboard | Skip list with spans (Redis ZSET) | O(log N) update, rank and range |
| Replica reconciliation / integrity | Merkle tree | O(D log L) comparison, inclusion proofs |