Interactive Explainer
Hash Tables, Collision by Collision
Hash tables look magical from outside: average $O(1)$ lookup, no ordering, used in every dictionary and cache. Inside, the magic is collision resolution. Insert keys into a small table and watch them collide; switch between chaining and three open-addressing strategies; watch the load factor creep up and the lookups slow down.
The pigeonhole problem
A hash table maps a key — any key — to a slot in a fixed array. The mapping is a hash function $h(\text{key}) \mod m$ for table size $m$. The whole machine depends on this mapping being approximately uniform.
It can't be uniform and injective: there are infinitely many possible keys and only $m$ slots. So two different keys will eventually hash to the same slot. The question is what you do when they do.
Two big families of answers:
- Separate chaining. Each slot owns a list. Colliding keys append to the list. Lookup walks the list. Simple to reason about; touches non-local memory.
- Open addressing. The colliding key probes for the next empty slot in the array itself. The probing strategy (linear, quadratic, random, robin hood) is the design choice. Cache-friendly; more subtle.
Below: a small 16-slot table you can insert into. Switch between strategies and watch the same insertion produce very different layouts.
Insert, watch, probe
Things to try:
- Insert "cat", "dog", "ant", "bat", "ape", "dot" with FNV-1a + linear probing. Watch the average probe length stay near 1. Now switch the hash to first-letter and re-insert — every key hashes to its first letter, so "ant", "ape" pile up on one slot.
- Switch to chaining with the bad hash and burst 10 random keys. The chains grow on a few slots; lookup becomes linear in chain length.
- Switch to robin-hood probing. Insert keys with a high load factor; watch keys that arrived early get evicted toward later slots when a key with a longer probe distance arrives.
- Delete a key from a linear-probed table. A tombstone (gray) remains — a normal "empty" would break the search chain for keys that probed past this slot.
What makes a hash function good
Two properties matter:
- Uniformity. Each slot should be equally likely. A bad hash function (like "first letter") clusters keys; a good one spreads them.
- Avalanche. One-bit changes in the input should flip about half the bits of the output. Modern hash functions (FNV-1a, MurmurHash, xxHash, CityHash, SipHash) are designed for this.
For non-cryptographic hashes — FNV-1a, Murmur — the attacker model is "no attacker, just uniform-ish inputs". Fast, good enough. For inputs an attacker controls (HTTP headers, JSON keys, network payloads), you need a keyed hash with a random secret: SipHash is the standard. Without it, an attacker who knows your hash function can synthesise keys that all map to one slot, turning your $O(1)$ table into an $O(n^2)$ DoS.
Separate chaining — the simple one
Each slot points to a linked list (or small array) of all keys hashing there. Insert: prepend to the list. Lookup: walk the list.
With a uniform hash, the expected chain length is $\alpha = n/m$ — the load factor. Lookup is $O(1 + \alpha)$ on average. Even at $\alpha = 5$ (a heavily loaded table), you're walking lists of about 5 nodes — far from $O(n)$.
What chaining gives up: cache locality. Each list node is a heap allocation; walking it is a cache miss. On modern hardware where main-memory latency is 100× L1, this is the difference between a fast hash table and a slow one.
Open addressing — the fast one
Store every key directly in the array. On collision, probe along a sequence of slots until you find empty space. The whole table fits in contiguous memory; lookups touch one cache line.
Three classic probe sequences:
- Linear probing. $h_i = (h(k) + i) \bmod m$. Cache-perfect. Suffers primary clustering: a run of occupied slots tends to grow because any key hashing inside the run probes its end. Once $\alpha \gtrsim 0.7$, performance degrades sharply.
- Quadratic probing. $h_i = (h(k) + c_1 i + c_2 i^2) \bmod m$. Breaks primary clustering but introduces secondary clustering: keys with the same initial hash follow identical probe sequences. Slightly less cache-friendly than linear.
- Double hashing. $h_i = (h_1(k) + i \, h_2(k)) \bmod m$ with a second independent hash. Avoids both clustering issues; pays a second hash computation per probe.
All open-addressing strategies break down before $\alpha = 1$ (you cannot insert into a full table — there's literally no empty slot). They typically resize when $\alpha$ crosses a threshold (0.5, 0.75, or 0.9 depending on the variant). Resize means: allocate a 2× table, rehash every key, free the old one. Amortised $O(1)$ per insert; a single slow insert when the resize fires.
Robin Hood hashing — take from the rich
Linear probing with one twist: when a new key probes past an existing one, compare their displacement (probe count). If the incumbent's displacement is shorter than the newcomer's would be at this slot, swap them — kick the incumbent out and continue probing with it. Hence the name: take from the rich (low-displacement keys) and give to the poor (high-displacement keys).
The point: it equalises probe distances. After many inserts, every key's probe distance is within a constant factor of the average. Variance shrinks dramatically. Worst-case lookup time becomes proportional to the maximum displacement, which under uniform hashing is $O(\log n)$ rather than the $O(\sqrt n)$ of plain linear probing at high load factors.
Robin-hood hashing is the default in modern
fast-hash-table libraries (Rust's HashMap via
hashbrown until recently; Boost's flat_map;
Google's absl::flat_hash_map) precisely
because it makes high load factors viable.
The cheat sheet
| strategy | avg lookup | worst lookup | memory | cache | delete | typical $\alpha_\text{max}$ |
|---|---|---|---|---|---|---|
| chaining | $O(1 + \alpha)$ | $O(n)$ (degenerate) | $m + n$ + node ptrs | poor | easy (unlink) | $\alpha$ unbounded |
| linear probing | $\frac{1}{1 - \alpha}$ | $O(\sqrt n)$ at high $\alpha$ | $m$ | excellent | tombstones | $\sim 0.7$ |
| quadratic probing | $-\frac{\ln(1-\alpha)}{\alpha}$ | $O(\log n)$ | $m$ | good | tombstones | $\sim 0.5$ |
| robin hood | $\frac{1}{1 - \alpha}$ | $O(\log n)$ | $m$ + 1 byte/slot | excellent | backshift | $\sim 0.9$ |
| cuckoo (2 tables) | $O(1)$ worst | $O(1)$ worst | $2m$ | good | easy | $\sim 0.5$ |
The advanced wing
- Cuckoo hashing. Each key has two candidate slots (via two independent hashes). On collision, evict the incumbent and re-place it at its other slot. Continue. Worst-case $O(1)$ lookup; insertions can loop if the load is too high, in which case rehash with new functions.
- Hopscotch hashing. Like linear probing but every key is guaranteed to live in a neighbourhood of $H$ slots near its home position. Lookup probes at most $H$ slots (a single cache line).
- Swiss tables (Google's
absl::flat_hash_map). 1 byte of metadata per slot stores a 7-bit hash signature plus empty/full/tombstone. Probing scans 16 metadata bytes with SIMD at a time, comparing signatures before touching the key. Order-of-magnitude speedup on real workloads. - Consistent hashing. Distributed hash tables (Cassandra, DynamoDB, every CDN) need to survive nodes joining and leaving without rehashing everything. Consistent hashing maps keys and nodes to the same ring; each key goes to its nearest-clockwise node. Adding a node moves only $O(n / N)$ keys.
- Bloom filters. When you don't need to store the keys themselves, just answer "have I seen this?". $k$ hashes + a bit array; constant memory, constant lookup, never false negatives, controllable false positives. Probabilistic relatives: count-min sketch, HyperLogLog.
- Perfect hashing. If the key set is fixed in advance (a compiler's keyword table, a language's reserved words), you can construct a hash function with zero collisions. Two-level schemes (FKS, CHD) achieve this in expected $O(n)$ construction time.
Sharp edges
- Delete in open addressing is hard. Marking a slot "empty" breaks probe chains. Tombstones say "was occupied; keep probing". Tombstones accumulate; eventually every probe scans them all. You either rehash periodically or use backshift deletion (robin-hood compatible) which slides subsequent keys into the gap.
- Iterating order is not defined. Hash
tables make no ordering promises. If you need
"iterate in insertion order" (Python's
dictsince 3.7), the implementation maintains an extra ordered list alongside the table. Don't rely on iteration order in other languages. - Resizing is amortised, not worst-case. A single insert that triggers a 2× resize costs $O(n)$. Most production systems instead use incremental rehashing (Redis): on each operation, move a few entries from the old table to the new one until the migration finishes.
- Cache lines, not slots. Modern hash table benchmarks are dominated by L1 cache hits/misses. A "slow" hash table with bad asymptotics can be 3× faster than a "fast" one with better asymptotics if its probes touch fewer cache lines. Benchmark on realistic data; never on uniform random integers.
- Equality and hash must agree. If two
objects are equal, they must hash to the same value.
Override
__hash__but not__eq__(or vice versa) and you get a silent data-loss bug that takes weeks to find.
Reading list
- Knuth, TAOCP Vol 3, §6.4. Hashing. Where most of the modern variants are first analysed.
- Pagh & Rodler, 2001 — Cuckoo Hashing.
- Celis, Larson, Munro, 1985 — Robin Hood Hashing. The original paper, before it became fashionable thirty years later.
- Aumasson & Bernstein, 2012 — SipHash: a fast short-input PRF. The default keyed hash for hash-table security.
- Maier, Sanders, Dementiev, 2019 — Concurrent Hash Tables: Fast and General(?). Survey of lock-free / wait-free hash table implementations.
- Karger et al., 1997 — Consistent Hashing and Random Trees. The foundation of distributed key-value stores.
- Google abseil / SwissTable design doc. How to use SIMD to scan metadata 16 bytes at a time — the model for modern fast hash tables.