All tutorials Mighty Professional
Build a Game Engine ยท Foundations

Hash Tables from Scratch

An engine looks things up by key all frame: the texture for an asset path, the compiled shader for a set of feature flags, the session for a player ID. A hash table answers each of those in one or two memory reads instead of a search, as long as it's built well. This page builds one from scratch and shows what makes it fast or slow: the hash function, what happens when two keys want the same slot, how full the table gets, how deletion works and how it resizes, with live demos of each and a look at what shipping engines use.

Time~60 min LevelJunior to mid; review for senior PrereqsYou can read C++ or pseudocode. Arrays, pointers, modular arithmetic. Big O (read the prior tutorial if it feels rusty). HardwareNone. A feel for cache lines helps in §11.
โ—‚ Build a Game Engine Phase 0 ยท Foundations Next ยท Sorting Algorithms โ–ธ
What you'll be able to do by the end

01Why hash tables

The renderer asks for the compiled shader that matches this draw's feature flags, thousands of times a frame. Keep the shaders in a sorted array and each request is a binary search; keep them in a hash table and each request goes almost straight to the right slot. The difference is small for a hundred shaders and large for a million cached entries.

A hash table works by turning the key into a number (the hash) and using that number to pick a slot in an array. A sorted array gives O(log n) lookup via binary search; a hash table gives O(1) expected lookup, insert and delete. On a table too large for the cache, a hash lookup typically costs one or two cache misses (roughly 100 ns each from DRAM), while a binary search over a million keys makes about 20 dependent probes, most of the later ones misses[23]. For lookups an engine makes many times per frame, such as the string interning table (which maps every distinct string to one canonical ID) or the shader permutation cache, that is the difference between a couple of hundred nanoseconds and a couple of microseconds per lookup.

Worked example

1,000 lookups per frame into a table of 1,000,000 keys, at about 100 ns per DRAM miss.

Binary search: log2(1,000,000) ≈ 20 probes. If about half of them miss, that's 10 × 100 ns = 1 µs per lookup, 1 ms per frame.

Hash table: 1 to 2 misses, 100 to 200 ns per lookup, 0.1 to 0.2 ms per frame. A 16.7 ms frame notices the difference.

Hash tables show up wherever you need a key-value mapping with fast access:

02A short history

Hashing is nearly as old as the stored-program computer: the first descriptions come from IBM in 1953. Two things from its history matter in practice. The core analysis (how many slots a lookup checks as the table fills) dates from Knuth in the 1960s and still holds, so the formulas in §5 are not new. And the designs engines ship today, Swiss tables and their relatives, are from 2017 on: they leave the theory alone and cut the constant factors with SIMD and cache-aware layouts.

Timeline: Luhn's 1953 memo to wyhash
1953
Luhn's IBM memorandum. Hans Peter Luhn writes an internal IBM memo describing a scheme for placing records into "buckets" addressed by a hash of the key[1]. Knuth credits it as the origin of hashing, and its use of chaining as one of the first applications of linked lists[3]. Around the same time, Gene Amdahl and colleagues building an assembler for the IBM 701 came up with open addressing with linear probing[3].
1957
Peterson's open-addressing analysis. W. Wesley Peterson publishes "Addressing for Random-Access Storage" in the IBM Journal of Research and Development[2]. Knuth calls it the first major paper on searching large files: Peterson defines open addressing in general, analyzes uniform probing, and reports empirical statistics for linear probing with various bucket sizes[3].
1963
Knuth's hashing analysis. Donald Knuth works out the expected cost of linear probing in unpublished notes. The analysis grows into Section 6.4 of The Art of Computer Programming, Volume 3 (1973)[3], which covers division and multiplication hashing and the expected probe counts for chaining, linear probing, and double hashing.
1985
Robin Hood hashing. Pedro Celis, Per-Ake Larson, and J. Ian Munro present "Robin Hood Hashing" at the 26th IEEE Symposium on Foundations of Computer Science[4]. The rule: when a new key's probe distance exceeds the incumbent's, they swap. The mean cost of a successful search is unchanged, and its variance drops sharply (§7).
1991
FNV hash. Glenn Fowler and Phong Vo propose FNV hashing in comments on the IEEE POSIX P1003.2 standard; Landon Curt Noll improves it[6]. Its FNV-1a variant becomes a common choice for short keys: one XOR and one multiply per byte.
2012
xxHash. Yann Collet publishes xxHash[7], a non-cryptographic hash function optimized for throughput on bulk data. Widely adopted in checksum and deduplication pipelines.
2017
Swiss tables. Matt Kulukundis presents "Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step" at CppCon[11]. The design uses SIMD instructions to probe 16 metadata bytes in parallel. Released as absl::flat_hash_map in Google's Abseil library. Rust's standard HashMap migrated from Robin Hood hashing to a Swiss-table clone (hashbrown) in Rust 1.36[14].
2019
wyhash. Wang Yi publishes wyhash[8], a fast 64-bit hash built on 64×64→128-bit multiplies that passes the SMHasher[9] test suite. Its README lists it as the default hash in Zig, V, and Nim, and it has been the non-AES fallback in Go's runtime since Go 1.17.

03Hash functions

An asset registry is keyed by paths like textures/rock_01 and textures/rock_02, which differ in one character. If the hash maps them to the same or neighboring slots, every lookup in that corner of the table slows down. The hash function decides how evenly keys spread out.

A maps a key of any size to a fixed-size integer. The table uses that integer, reduced to the table size, as a slot index. Two properties matter:

Worked example

64-bit FNV-1a (below) of rock_01 is 0x18F46FB128F0E366 and of rock_02 is 0x18F46EB128F0E1B3.

In a 1,024-slot table (keep the low 10 bits) they land in slots 870 and 435: different, which is what you want. But only 7 of the 64 output bits differ. A hash with full avalanche would flip about 32, which is why FNV-1a is fine for short keys in a table but weak where every output bit matters.

Integer hashing

Division method: h(k) = k mod m. Simple, but the choice of m matters. If m is a power of 2, k mod m is just the low bits of k, and sequential keys land in sequential slots (no mixing). If m is prime, the distribution is better, but prime-modulo division is slow (an integer divide).

Worked example

Keys 16, 32, 48 and 64 in a 16-slot table.

Division method with m = 16: every key is a multiple of 16, so k mod 16 is 0 for all four. One slot, four keys.

Fibonacci hashing (next paragraph), keeping the top 4 bits of k × 2,654,435,769 mod 232: slots 14, 12, 10 and 8. Four keys, four slots.

Fibonacci hashing (multiplication method): h(k) = (k * 2654435769) >> (32 - log2(m)). The magic constant is 232/φ, truncated. The multiply carries every input bit into the higher output bits, and the right shift keeps the top bits, which depend on the most input bits. The Linux kernel's hash_32() and hash_64() use this scheme.

String hashing

FNV-1a[6]: XOR each byte into the hash, then multiply by a prime. One XOR and one multiply per byte: simple and fast on short keys, though its avalanche is weak (xxHash's benchmark table flags FNV64 for poor avalanche[7]). The 64-bit variant uses offset basis 0xcbf29ce484222325 and prime 0x00000100000001B3.

xxHash[7]: XXH64 processes 32 bytes per iteration using four 64-bit accumulators with multiply-rotate-accumulate rounds. Throughput-optimized for bulk data: the project's own reference benchmark puts XXH3 at 31.5 GB/s and XXH64 at 19.4 GB/s on an i7-9700K, against 3.0 GB/s for SipHash. XXH3 takes a 64-bit seed or, separately, a custom secret (192 bytes by default, at least 136). All variants pass SMHasher.

wyhash[8]: very fast on short keys, which suits hash-table use. Uses wide multiplies (64-bit × 64-bit = 128-bit) to mix bits. Go's runtime has used a wyhash-derived function since Go 1.17 on CPUs without the AES instructions its main hash relies on.

For string interning, the hash needs to be fast on short strings, and deterministic across runs (no random seed) if hash values are baked into cooked data or used as persistent IDs. FNV-1a is a common choice. For tables keyed by untrusted input (network-facing code), use a keyed hash with a random per-process key, such as SipHash, to prevent hash flooding. Seeding a fast hash such as wyhash raises the bar, but those hashes aren't designed as keyed PRFs.

04Chaining

Two asset paths hash to the same slot. That's a collision, and with more keys than a handful it's guaranteed to happen, so every hash table needs a rule for it. The simplest rule is to let a slot hold more than one entry. Separate chaining does exactly that: each slot holds a pointer to a linked list of entries that hash to that slot. Insert adds a node to the list. Lookup walks the list comparing keys. Delete removes the node.

The expected list length is the α = n/m. Under the SUHA, an unsuccessful search examines α entries on average; a successful search examines roughly 1 + α/2[3].

Worked example

12 keys in 16 buckets: α = 12 / 16 = 0.75.

A lookup for a key that isn't there walks a whole chain, 0.75 entries on average. A lookup for a key that is there checks 1 + 0.75 / 2 = 1.375 entries on average: the key itself, plus about half of the other entries that share its chain.

Chaining tolerates load factors above 1.0 (more keys than slots). Java's HashMap defaults to a maximum α of 0.75 and doubles its bucket array when it crosses that threshold[18] (std::unordered_map defaults to 1.0), but correctness doesn't depend on staying below 1.0.

The problem is cache behavior. Each lookup dereferences a pointer to the list head, then follows next pointers through heap-allocated nodes scattered across memory. Each pointer chase is a potential DRAM miss (roughly 100 ns on modern hardware[23]). At α = 0.75, the average chain is short, but even one miss per lookup adds up at engine-frame frequency. Open addressing (next section) keeps all entries in a flat array, giving the CPU prefetcher something to work with.

Chaining still ships widely: std::unordered_map, Java's HashMap, Redis's dict and Unreal's TMap all chain. It keeps elements in place (pointers to them survive a rehash), tolerates load factors above 1, and never moves large values. Where none of that matters, open addressing is usually faster on modern hardware.

05Open addressing: linear probing

Every node in a chained table is a separate allocation, so every chain hop can be a cache miss. A shader cache on the render thread would rather keep all its entries in one flat array that the CPU can read in order. stores all entries directly in the table array. No linked lists, no per-entry allocations, no pointer chasing. When a key hashes to an occupied slot, the table probes forward through the array until it finds an empty slot (for insert) or the target key (for lookup).

Linear probing is the simplest variant: the for slot h is h, h+1, h+2, ... (mod m). Insert walks forward until it finds an empty slot. Lookup walks forward until it finds the key or an empty slot (proof that the key isn't present).

The catch is that the fuller the table, the longer the walk. At half full, most keys sit at or next to their home slot. At 90% full, a lookup for a missing key has to walk past long runs of occupied slots before it finds an empty one.

Worked example

A linear-probing table at load factor α = 0.75 (three quarters full).

Successful lookup: 0.5 × (1 + 1/(1 − 0.75)) = 0.5 × (1 + 4) = 2.5 probes on average.

Lookup for a missing key: 0.5 × (1 + 1/(1 − 0.75)2) = 0.5 × (1 + 16) = 8.5 probes. At α = 0.9 the same two formulas give 5.5 and 50.5.

Knuth's analysis[3] gives those expected probe counts under uniform hashing, the same simple-uniform-hashing assumption CLRS chapter 11 works from[25]. The formulas tell you how many slots a lookup checks at a given fullness, which is what decides when the table should grow:

KNUTH ยง6.4 Unsuccessful:   12(1 + 1(1 - α)2)    Successful:   12(1 + 11 - α)

In words: a lookup that finds its key checks about half of 1 + 1/(1 − α) slots, and one that misses checks about half of 1 + 1/(1 − α)². Both are small while the table is half empty (1.5 and 2.5 probes at α = 0.5) and blow up as α nears 1, the miss cost fastest because of the square. That's why open-addressing tables resize well before α = 1.

The failure mode is primary clustering. When several keys hash to nearby slots, their probe runs merge into a single long cluster. New keys that hash anywhere into the cluster join it, making it longer. A cluster's chance of growing is proportional to its length, so long clusters grow fastest[3]. The widget below makes this visible: as the load factor increases, the table fills with long contiguous runs of occupied slots.

Live ยท Primary clustering at increasing load
slots filled
0
longest cluster
0
avg probe (insert)
0
The row is a 64-slot linear-probing table. Set a target load factor and press fill to target; the readouts give the slots filled, the longest run and the average probes per insert. Each slot is colored by the length of the run of occupied slots it belongs to, on a fixed scale from blue (a lone key) to red (16 or more). With this widget's keys, the longest run is 11 slots at load factor 0.5 and 13 at 0.7, and at 0.9 a single run covers 44 of the 64 slots: a new key that hashes anywhere into it has to walk to its end. That is primary clustering. Quadratic probing and double hashing break up runs by jumping past neighboring slots.
Live ยท Probe sequence visualizer
probes taken
0
slot found
-
strategy
linear
The row is a half-full 32-slot table. Pick a key and a strategy and press probe: each checked slot turns red and is numbered, and the slot where the key lands turns green. Linear probing steps by 1 each time: cache-friendly, but clusters merge. Quadratic probing visits offsets 1, 3, 6, 10, ... (triangular numbers) from the home slot, jumping past nearby occupied slots. Double hashing steps by a second hash of the key, forced odd so it visits every slot of this power-of-two table; keys with the same home slot usually follow different sequences. Run the same key under each strategy to compare.

06Quadratic probing and double hashing

The clustering widget showed one run swallowing 44 of 64 slots at load factor 0.9. Linear probing's clustering problem comes from the step size: +1 every time, so any key that lands anywhere in a run extends it. The fix is to step further on each probe, so that keys starting at nearby slots fan out instead of queuing behind each other. Two mitigations:

Quadratic probing: the probe sequence is h, h+1, h+3, h+6, h+10, ... (adding 1, 2, 3, 4, ... to the previous offset, i.e., triangular numbers). This breaks primary clustering because keys that hash to different slots follow different probe paths. It introduces a weaker problem called secondary clustering: keys that hash to the same slot still follow the same sequence, but keys at different slots don't pile up.

There is a coverage guarantee: if the table size m is a power of 2, the triangular offsets 0, 1, 3, 6, ..., m(m−1)/2 are all distinct mod m, so the sequence visits every slot exactly once before repeating.

Worked example

An 8-slot table. The first eight triangular offsets are 0, 1, 3, 6, 10, 15, 21, 28.

Mod 8 they are 0, 1, 3, 6, 2, 7, 5, 4: all eight slots, each exactly once. A quadratic probe can't loop forever while an empty slot exists.

How Python's dict probes

Python's dict takes a different route. Its recurrence is perturb >>= PERTURB_SHIFT; j = (5*j) + 1 + perturb, with PERTURB_SHIFT = 5 and perturb starting as the full hash[17]. The shift feeds the high bits of the hash into the sequence a few at a time, and once perturb reaches zero, j = 5j + 1 modulo a power of two cycles through every slot.

Double hashing: the step size is a second hash function h2(key). The probe sequence is h1(key), h1(key) + h2(key), h1(key) + 2·h2(key), ... The step size must share no factor with m (with a power-of-2 table, force h2 odd) so the sequence reaches every slot. Because the step depends on the key, keys with the same home slot usually follow different sequences, which removes both primary and secondary clustering. With a good second hash the expected probe counts come close to the uniform-probing ideal: 1/(1 - α) for unsuccessful search, (1/α) ln(1/(1 - α)) for successful search. At α = 0.75 that's 4 probes for a miss and about 1.85 for a hit, against linear probing's 8.5 and 2.5.

The downside: double hashing's non-sequential probe pattern defeats the CPU prefetcher. Linear probing's sequential scan loads the next cache line automatically; double hashing jumps around the array, incurring a cache miss on most probes. In practice, linear probing with a good hash function and a moderate load factor (α ≤ 0.7) often beats double hashing on wall-clock time despite having higher probe counts, because most of its probes land on a cache line that is already loaded.

07Robin Hood hashing

In a linear-probing table a few unlucky keys can end up ten or more slots from home while most sit at home. The average lookup is fine; the worst ones are slow, and a frame budget cares about the worst ones. Robin Hood hashing[4] evens that out. It is linear probing with one added rule: during insert, if the new key's probe distance exceeds the incumbent key's probe distance at the current slot, swap them. The displaced incumbent continues probing from where the new key took over. The invariant: "steal from the rich (short probe distance), give to the poor (long probe distance)."

For successful searches, the effect is on the spread only. For a fixed key set and hash function, Robin Hood insertion occupies exactly the same slots as plain linear probing: it only changes which key sits in each of them. Total displacement is therefore identical, and so is the mean probe count for a successful search. What shrinks is the spread between the luckiest and unluckiest keys.

Worked example

The widget below inserts 12 keys into 16 slots, crowded around slots 9 to 14.

Plain linear probing: average distance from home 3.42, longest 10.

Robin Hood: same 12 occupied slots, same average 3.42, longest 6. Same total work, but the worst lookup got 4 probes shorter.

What the theory says about the spread

Celis's numerical computations suggested that the variance of the search cost stays near a small constant, about 1.883, even as the table becomes full[4]; Poblete and Viola later proved a constant bound, noting that the discipline "does not change the expected search cost" but "turns out to have a dramatic effect on its variance"[5]. Both results are for the random-probing model, in which each key's probe sequence is random. Shipping Robin Hood tables use linear probing, where the swap rule still narrows the spread but those constants don't apply.

Robin Hood is sometimes credited with about 2.13 probes per successful search in a full table. That figure belongs to binary tree hashing, a different reordering scheme with a much costlier insert, which Celis's thesis compares against Robin Hood. A constant search cost at full load needs Robin Hood insertion paired with a mean-centered search that starts near the expected displacement and works outward, rather than at the home slot[4][5]. Tables that scan forward from the home slot, which is what widely used Robin Hood implementations do, get the variance reduction but not that constant.

The other win is for lookups that miss, which can stop early. If the resident at the current slot has a smaller probe distance than the search key would have at this position, the key cannot be further along: had it been inserted, it would have taken this slot from the resident. The search stops there instead of running to an empty slot.

Rust's standard HashMap used Robin Hood hashing with backward-shift deletion from 2014 until Rust 1.36 (2019), when it was replaced by the Swiss-table-based hashbrown crate[14]. hashbrown's README reports it as around 2× faster than the implementation it replaced, with 1 byte of overhead per entry instead of 8[14].

Live ยท Robin Hood insert (steal-from-the-rich)
inserts done
0
swaps this insert
0
max probe dist
0
The row is 16 slots, each showing its key and its distance d from home; the table starts with 6 keys and inserts 6 more. Press step to advance one comparison or play to run them all. The home slot here is key mod 16, with keys chosen to crowd slots 9 to 14 (a real table would use a hash). Each slot shows its key and probe distance d. At each step the key being inserted meets one resident. Red: the resident is at least as far from home and keeps the slot. Amber: the incoming key is farther from home, so they swap and the displaced key carries on probing. Green: the key lands in an empty slot. After all six inserts (12 keys in 16 slots) the longest probe distance is 6. The same keys inserted by plain linear probing end with the same mean distance, 3.42, but a longest distance of 10.

08Swiss tables (flat hash maps)

Even a well-tuned linear-probing table compares keys one slot at a time, and for string keys each comparison can be a pointer chase. Modern CPUs have SIMD instructions that compare a whole register of bytes at once, and Swiss tables are built to use them. Google's Swiss table design[11] is the one most current high-performance hash maps build on. The core idea: store one metadata byte per slot in a separate array, and use SSE2 instructions (x86's 128-bit SIMD set) to compare 16 metadata bytes in one instruction. A lookup that would take 16 separate comparisons in a conventional table takes one _mm_cmpeq_epi8.

The H1/H2 split

The hash of a key is split into two parts:

Which 7 bits become H2 has changed between versions. The design notes and early Abseil take H2 from the low bits (H1 = hash >> 7); current Abseil takes the top 7 bits and uses the whole hash, masked by the capacity, for the starting position[13].

Each metadata byte is empty (0x80 = 0b10000000), deleted/tombstone (0xFE), or occupied (0b0xxxxxxx, where the 7 low bits are H2); a fourth value, the sentinel 0xFF, marks the end of the array for iterators. The high bit distinguishes empty/deleted from occupied, and the 7-bit H2 fingerprint filters out roughly 127/128 of non-matching keys before the table compares the full key.

The SIMD probe

A lookup works as follows:

  1. Compute H1 and H2 from the key's hash.
  2. Use H1 to find the starting group: 16 consecutive metadata bytes on SSE2, 8 on ARM and the portable fallback, starting at whatever index H1 lands on rather than at an aligned boundary.
  3. Load the 16 metadata bytes into a 128-bit SSE2 register.
  4. Broadcast H2 into all 16 lanes of a second register.
  5. Execute _mm_cmpeq_epi8: each lane that matches produces 0xFF; non-matches produce 0x00.
  6. Extract a 16-bit bitmask with _mm_movemask_epi8. Each set bit is a candidate slot.
  7. For each candidate, compare the full key. On a match, return the value.
  8. If no match, check if any metadata byte in the group was "empty." If so, the key is absent. If the group was full or contained only tombstones, advance to the next group (quadratic probing on groups) and repeat.

Worked example

The widget below uses a key's low 7 bits as its H2, to keep collisions easy to find. Key 32 is 0b0100000, so H2 = 0x20. Key 160 is 0b10100000: drop the top bit and its H2 is also 0x20.

Look up 160 while 32 is in the group: the SIMD compare reports 1 candidate, the full-key compare finds 32 ≠ 160, and the group's empty slot ends the search. One wasted comparison, which a real hash makes rare: about 1 in 128 per occupied slot.

The result: most lookups check one group of 16 slots with a couple of SIMD instructions and zero or one full-key comparison. At Abseil's maximum load factor of 7/8, a 16-slot group has two empty slots on average[13], so most probes end in the first group.

Live ยท Swiss table SIMD probe
H2 matches
0
full-key comparisons
0
result
-
Pick a lookup key and press lookup; the readouts give the H2 matches, the full-key comparisons and the result. The top row is one group's control bytes, with each slot's key underneath. One SIMD compare checks all 16 control bytes against the lookup key's H2 and produces a bitmask of candidates; only those get a full-key comparison. To keep collisions easy to find, this widget's H2 is just the key's low 7 bits: 32 is found in one comparison, 160 shares 32's H2 and shows a false positive (rare with a real hash, about 1 in 128 per occupied slot), and 37 matches nothing and stops at the group's empty slot. With a real hash, most successful lookups need exactly one full-key comparison and most misses need none.

09Deletion strategies

An entity despawns and its entry leaves the table. In an open-addressing table that's harder than it looks. You cannot simply clear a slot, because that breaks probe chains: a lookup for a key whose probe passed through the now-empty slot would stop too early and falsely report "not found."

Worked example

Linear probing. A and B both hash to slot 3, so A sits in 3 and B in 4. C hashes to 4, finds it taken, and sits in 5.

Erase B by marking slot 4 empty. Now look up C: start at 4, find it empty, stop, and report C missing, though it's sitting in slot 5.

With a tombstone in slot 4, the lookup steps past it and finds C. With backward shift, C moves back into slot 4, its home, and nothing is left behind.

Four strategies:

Abseil's Swiss table avoids some of those tombstones. Its erase writes kEmpty instead of kDeleted whenever it can prove no probe sequence could have run past the slot. It reads the control group starting at the slot and the group one width earlier, and if the run of non-empty control bytes spanning the slot is shorter than one probe window, the slot goes straight back to empty[26]. That shrinks the tombstone population without eliminating it, which is why tombstones still count against the growth budget: "Tombstones (kDeleted slots) are not included in the growth capacity, because we'd like to rehash when the table is filled with tombstones and/or full slots."[26]

Live · Three erase policies, one op stream
operations
0
tombstone
-
backward shift
-
group reclaim
-
empty slots left
- / - / -
The three rows are 32-slot tables, one per erase policy (blue live, amber tombstone, dark empty), and the chart underneath tracks each one's miss cost as the run plays; the readouts give each table's current miss cost and tombstone or move count. It plays once when the page loads and stops when the tombstone table runs out of empty slots; press reset, then play, to watch it again. Three 32-slot tables, one seeded stream of deletes and inserts, three erase policies. The plotted line is the average probe count for a search that misses, because that is the cost tombstones inflate: a failed search can only stop at an empty slot, and a tombstone is not empty. The naive tombstone table (red) has no compaction, and its miss cost climbs until op 84, when the last empty slot is consumed and every failed lookup walks all 32 slots. Backward shift (green) never creates a tombstone and sits near 2.4 probes for the whole run. Group reclaim (purple) runs Abseil's rule at a 4-slot group width instead of 16, so the run of non-empty bytes fits on screen; it holds the miss cost around 6 rather than 32, and it still leaves tombstones behind, which is why Abseil counts them against the growth budget and rehashes when they pile up.

Tombstone accumulation is a common performance bug in hand-rolled tables. A table with many inserts and deletes fills with tombstones, and lookups that miss scan more and more of them. The fix is periodic compaction (rehash everything into a fresh table, dropping tombstones) or use backward-shift deletion.

10Load factor and resizing

A table that never grows gets slower with every insert, as §5's formulas showed. A table that grows has to move every entry when it does, and on a game server that one big move can cost a frame. Both the threshold and the way the move happens matter. When the load factor exceeds a threshold, the table allocates a larger array and reinserts every key. The threshold depends on the probing strategy:

Worked example

Java's HashMap starts with 16 buckets and a 0.75 threshold: 16 × 0.75 = 12. The 13th insert pushes it past, and it doubles to 32 buckets (next threshold 24).

A linear-probing table that resizes at 0.7 with 1,024 slots grows when the entry count passes 1,024 × 0.7 = 716.8, so on the 717th insert.

Growth policies

Power-of-2 growth (double the table size): modulo reduces to a bitmask (h & (m-1)), avoiding integer division. Requires a hash function with good avalanche; a bad hash combined with power-of-2 modulo creates systematic collisions. Most open-addressing tables use this.

Prime growth (a prime near double the old size): better distribution with mediocre hash functions, but the modulo is an integer division, which costs tens of cycles on many x86 cores unless the compiler knows the divisor at compile time and can turn it into a multiply. libstdc++'s and libc++'s std::unordered_map use prime bucket counts.

Incremental rehashing (the Redis approach)

A full rehash of n keys is O(n). For a real-time system (a game server, an in-memory database), a single O(n) stall can be unacceptable. Redis[19], whose dict is a chained table, avoids it with incremental rehashing: during a resize it keeps two tables, ht[0] and ht[1]. The new table is allocated up front, but entries move over one bucket at a time as regular inserts, deletes and lookups run (plus a little on a timer). New keys go straight into ht[1]. A lookup checks ht[0] unless the key's bucket there has already been migrated, then ht[1]. The field rehashidx tracks progress; when it reaches the end, the old table is freed.

Live · Stop-the-world rehash vs incremental rehash
operations
0
worst op (touches)
0
average op (touches)
-
state
single table
The two rows of buckets are the old and new tables, with an amber cursor marking the migration point in incremental mode, and the bars below are the cost of each recent operation. Switch between the two modes and compare the worst-operation readout. Both modes run the same 120-operation mix (inserts and lookups, about half each) against a chained table that starts with 16 buckets and doubles whenever the entry count reaches the bucket count, so it grows twice, to 32 and then 64. Cost counts memory touches: one per bucket read and one per chain node visited or moved. Stop-the-world moves every entry inside the insert that triggers the resize: its worst operation makes 65 touches against a 2.4 average, and that bar is the frame drop. Incremental allocates ht[1], sets rehashidx to zero, and migrates one bucket per later operation (skipping at most ten empty ones on the way), so its worst operation makes 5 touches, at the same 2.4 average. Total work is nearly identical; only its distribution changes. While both tables exist, a lookup whose old bucket hasn't moved yet checks both tables.

Amortized O(1) and a bounded worst case are different promises, and the widget separates them. Total work over the run is close either way. What the stop-the-world table cannot offer is a ceiling on any single operation, and a frame budget constrains the worst case.

Amortized O(1) proof sketch

Each insert costs O(1) expected. A resize from n to 2n slots costs O(n) but happens only after about n/2 inserts since the previous one (the table grew from n/2 entries to n). Spread that cost over those inserts and each pays O(1) amortized, meaning averaged over the whole run of inserts.

The accounting version

Charge each insert $3 instead of $1. Spend $1 on the insert and bank $2. The n/2 inserts since the last resize have banked $n, enough to move all n entries into the new table.

Live ยท Load factor vs average probe length
linear (successful)
0
uniform (successful)
0
chaining (successful)
0
Drag the highlight to a load factor and read each scheme's average probes for a successful lookup: at the default 0.70, linear probing reads 2.17, uniform probing 1.72 and chaining 1.35. Linear probing (red) climbs steeply past alpha = 0.75: its successful-search cost grows as 1/(1 − alpha), and misses as 1/(1 − alpha)². The uniform/ideal curve (blue, the uniform-probing formula that double hashing approximates) rises more gently. Chaining (green) is a straight line: 1 + alpha/2. Robin Hood has the same expected probe count as linear probing for successful searches, with a much smaller spread. Swiss tables can run at alpha = 0.875 because one SIMD compare checks 16 slots at once, so per-slot probe counts like these overstate their cost.

11Cache behavior and memory layout

Two tables can do the same number of probes and still differ several times in speed, because a lookup's cost is set by how many cache lines it touches, and several slots can share one line. Open addressing's advantage on modern hardware comes mostly from cache lines. A typical cache line is 64 bytes. A linear-probing table with 8-byte entries fits 8 entries per cache line. A probe that walks 4 consecutive slots likely hits within the same cache line (one miss for the first, then three hits). A chaining table dereferences a pointer to a heap-allocated node, which is almost certainly on a different cache line, so every hop is a miss.

Worked example

16-byte slots (an 8-byte key and an 8-byte value), so 4 slots per 64-byte line.

A linear probe that checks 3 consecutive slots stays inside one line if it starts in the line's first or second slot, which is half the time, and touches two lines otherwise: at most 2 misses.

A chained lookup that visits 3 nodes reads the bucket array and then 3 separately allocated nodes: up to 4 misses.

Swiss tables push this further. The metadata array is separate from the key-value array: 16 metadata bytes fit in a single 16-byte chunk (one quarter of a cache line). Loading and comparing the group metadata is fast because the data is tiny and contiguous. The full-key comparison, which touches the key-value array, usually happens once per successful lookup and rarely on a miss.

Prefetching. Abseil's lookup issues a prefetch for the slots at the probe position before it loads the control bytes, so the slot-array fetch overlaps the SIMD compare instead of starting after a match. It also exposes prefetch(key), which lets code that knows its next key start both fetches early and overlap them with other work[13].

Alignment. It is tempting to assume the group is 16-byte aligned so the SSE2 load is a single aligned load. Abseil does the opposite on purpose: a group starts at whatever index H1 lands on, and the load is _mm_loadu_si128, deliberately unaligned[13]. That is also why the implementation mirrors the first Group::kWidth - 1 control bytes past the end of the array: an unaligned window starting near the end must still be able to read a full group's worth of bytes.

Group width depends on the target. Everything above describes the SSE2 path. Abseil's AArch64 group is 8 slots wide and uses vceq_u8, the 64-bit form, not vceqq_u8; the portable fallback is 8 wide too[13]. Rust's hashbrown makes the same split, taking the SSE2 path because "it allows us to scan 16 buckets at once instead of 8"[14]. Phones and the Nintendo Switch are ARM (PlayStation 5 and Xbox Series consoles are x86), so when reasoning about probe counts on target hardware, check the group width before assuming 16.

12Game engine case studies

Here's what shipping engines and libraries actually build, and why each made the choice it did. The pattern to watch for: when the keys are small integers the engine hands out itself, the fastest "hash table" is often no hash table at all.

1 · Unreal's TMap

TMap<K,V> is implemented as a TSet<TPair<K,V>> backed by a sparse array[20]. Collisions are resolved by chaining rather than probing, but the chain is intrusive. In the engine source each TSet element carries a HashNextId, the sparse-array index of the next element in its bucket, so a lookup hashes to a bucket head and then walks element indices rather than heap nodes. Removal unlinks the element from its bucket's chain and leaves a reusable gap in the sparse array, so no tombstone is needed. The hash function is GetTypeHash(), customizable per key type; the set allocator decides how many hash buckets to use[20]. Epic's documentation calls TMap the most commonly used container in Unreal after TArray[20].

2 · EASTL hash_map

Electronic Arts' EASTL[21] provides hash_map and fixed_hash_map (pre-allocated, no heap allocation during gameplay). The layout is textbook separate chaining: mpBucketArray is an array of node pointers, and every element including the first lives in a heap node holding the value, a next pointer, and optionally a cached hash code so a probe can reject a node without touching the key[22]. Nothing is inlined into the bucket slot. What suits it to fixed memory budgets is fixed_hash_map, which pre-allocates both the bucket array and a fixed node pool, so no allocation happens during gameplay. Note also that EASTL's default rehash policy uses prime bucket counts with a max load factor of 1.0, not the 0.7-to-0.875 range the flat tables in this tutorial use.

3 · Entity-to-component lookup in ECS

In a sparse-set ECS (EnTT and similar), each component type has a dense array of component data and a sparse array mapping entity IDs to indices into the dense array. The sparse array is direct-indexed (not hashed): the entity ID is the array index, giving O(1) lookup with no collision resolution. The archetype engines mostly do not hash entities either. Bevy resolves an entity through a Vec<EntityMeta> indexed directly by the entity's index; Flecs uses a paged sparse-set entity index; Unity DOTS addresses its chunk lookup by entity index. Direct indexing beats hashing here for the same reason the sparse set does: the key space is dense and allocated by you.

When entity IDs do go into a hash table (a map from entity to some sparse per-entity state, say), the hash has to suit the table. Bevy's EntityHasher is a good worked example. For hashbrown's Swiss table it leaves the entity index in the low bits, which pick the slot, so entities spawned together stay together in memory, and it multiplies by a golden-ratio constant to fill the high bits that become H2; an identity hash would leave H2 the same for every small ID, so every occupied slot in a group would match the SIMD filter. Its documentation warns that the scheme suits contiguous indices and that unusual patterns, such as indices that are all multiples of 256, need a general-purpose hash[27].

4 · String interning (FName)

Unreal's FName system hashes the string once, stores it in a global name table (a hash table of unique strings), and thereafter uses the table index as the name's identity. Comparing two FNames is an integer comparison rather than a string comparison. Creating an FName from a string costs one hash and a short probe of the name table.

5 · Shader permutation caches

A material with 12 boolean features generates up to 4096 shader permutations. The compiled-shader cache maps a permutation key (a bitmask or a small struct of flags) to a compiled pipeline state object. The key is small, so a multiplicative hash of the bitmask is enough, and a flat open-addressing table is a natural fit. The lookup sits on the render thread's per-draw path, so it has to stay cheap.

Worked example

12 flags give 212 = 4,096 possible keys, but say a level only uses 300 of them. A 512-slot linear-probing table holds them at α = 300 / 512 ≈ 0.59, under the 0.7 limit from §10.

A successful lookup then averages 0.5 × (1 + 1/(1 − 0.59)) ≈ 1.7 probes, mostly within one or two cache lines.

6 · The flat-map family and its group widths

Three widely used tables share the Swiss-table idea and disagree about the group. Abseil filters 16 slots at a time with SSE2, dropping to 8 where it is unavailable. Boost's unordered_flat_map uses groups of 15 buckets inside a 16-byte metadata word, spending the sixteenth byte on an overflow marker that "removes the need to use tombstones"[15], and pins the maximum load factor at 0.875 with no way for the user to change it[15]. Meta's F14 is named "because it filters 14 slots at once", with a one-byte tag per slot, SSE2 or NEON filtering, and a typical maximum load factor of 12/14[16]. The filtering mechanism is shared; the group width, the load-factor ceiling and the handling of deletions differ.

Implementation: open-addressing hash table

open-addressing hash table
#include <bit>
#include <cstddef>
#include <cstdint>
#include <vector>

// Minimal open-addressing hash table with linear probing.
// Tombstone deletion. Power-of-2 table size. Fibonacci hash.
struct FlatMap {
    enum SlotState : uint8_t { EMPTY, OCCUPIED, DELETED };

    struct Slot {
        SlotState state = EMPTY;
        uint64_t key;
        uint64_t value;
    };

    std::vector<Slot> slots;
    size_t count = 0;                          // live entries
    size_t tombstones = 0;                     // DELETED slots, still probe-blocking
    size_t mask;                                // table_size - 1

    explicit FlatMap(size_t capacity = 16)
        : slots(capacity), mask(capacity - 1) {}

    // Fibonacci hash: multiply by 2^64 / phi, keep the top log2(size) bits.
    size_t hash(uint64_t key) const {
        // Capacity must be a power of two (and at least 2) for `mask` to be
        // all-ones and the shift to stay below 64.
        return (key * 11400714819323198485ULL) >> (64 - std::countr_zero(mask + 1));
    }

    void insert(uint64_t key, uint64_t value) {
        // Tombstones occupy slots but are not counted in `count`, so the load
        // factor must be tested against occupied + deleted. Testing `count`
        // alone lets a table under insert/erase churn fill entirely with
        // tombstones and never grow, at which point find() never terminates.
        if ((count + tombstones) * 10 > (mask + 1) * 7) rehash();
        size_t idx = hash(key);
        // Walk the whole probe run to EMPTY looking for the key, but remember
        // the first tombstone so we can reuse it. Stopping at the first
        // tombstone would insert a duplicate when the key lives further along.
        size_t firstTombstone = SIZE_MAX;
        while (slots[idx].state != EMPTY) {
            if (slots[idx].state == OCCUPIED && slots[idx].key == key) {
                slots[idx].value = value;             // update existing
                return;
            }
            if (slots[idx].state == DELETED && firstTombstone == SIZE_MAX)
                firstTombstone = idx;
            idx = (idx + 1) & mask;                   // linear probe
        }
        if (firstTombstone != SIZE_MAX) {
            idx = firstTombstone;
            tombstones--;                             // reclaimed
        }
        slots[idx] = { OCCUPIED, key, value };
        count++;
    }

    uint64_t* find(uint64_t key) {
        size_t idx = hash(key);
        while (slots[idx].state != EMPTY) {
            if (slots[idx].state == OCCUPIED && slots[idx].key == key)
                return &slots[idx].value;
            idx = (idx + 1) & mask;
        }
        return nullptr;
    }

    void erase(uint64_t key) {
        size_t idx = hash(key);
        while (slots[idx].state != EMPTY) {
            if (slots[idx].state == OCCUPIED && slots[idx].key == key) {
                slots[idx].state = DELETED;           // tombstone
                count--;
                tombstones++;
                return;
            }
            idx = (idx + 1) & mask;
        }
    }

    void rehash() {
        size_t newSize = (mask + 1) * 2;
        std::vector<Slot> old = std::move(slots);
        slots.assign(newSize, Slot{});
        mask = newSize - 1;
        count = 0;
        tombstones = 0;   // rehashing is what actually reclaims them
        for (auto& slot : old)
            if (slot.state == OCCUPIED) insert(slot.key, slot.value);
    }
};
// Minimal open-addressing hash table with linear probing.
// Tombstone deletion. Power-of-2 table size. Fibonacci hash.
const FIB: u64 = 11400714819323198485;

enum Slot {
    Empty,
    Deleted,
    Occupied { key: u64, value: u64 },
}

struct FlatMap {
    slots: Vec<Slot>,
    count: usize,        // live entries
    tombstones: usize,   // Deleted slots, still probe-blocking
    mask: usize,
}

impl FlatMap {
    fn new(capacity: usize) -> Self {
        let slots = (0..capacity).map(|_| Slot::Empty).collect();
        FlatMap { slots, count: 0, tombstones: 0, mask: capacity - 1 }
    }

    fn hash(&self, key: u64) -> usize {
        let shift = 64 - (self.mask + 1).trailing_zeros();
        (key.wrapping_mul(FIB) >> shift) as usize
    }

    fn insert(&mut self, key: u64, value: u64) {
        // Tombstones block probes but are not in `count`, so they must be part
        // of the load test or a churning table never grows and find() spins.
        if (self.count + self.tombstones) * 10 > (self.mask + 1) * 7 {
            self.rehash();                  // resize at alpha = 0.7
        }
        let mut idx = self.hash(key);
        let mut first_tombstone: Option<usize> = None;
        // Run to Empty looking for the key. Stopping at the first tombstone
        // would insert a duplicate whenever the key sits further along.
        loop {
            match &self.slots[idx] {
                Slot::Occupied { key: k, .. } if *k == key => {
                    self.slots[idx] = Slot::Occupied { key, value };
                    return;
                }
                Slot::Empty => break,
                Slot::Deleted => {
                    if first_tombstone.is_none() { first_tombstone = Some(idx); }
                    idx = (idx + 1) & self.mask;
                }
                _ => idx = (idx + 1) & self.mask,   // linear probe
            }
        }
        if let Some(slot) = first_tombstone {
            idx = slot;
            self.tombstones -= 1;          // reclaimed
        }
        self.slots[idx] = Slot::Occupied { key, value };
        self.count += 1;
    }

    fn erase(&mut self, key: u64) {
        let mut idx = self.hash(key);
        loop {
            match &self.slots[idx] {
                Slot::Occupied { key: k, .. } if *k == key => {
                    // Tombstone, not Empty: an Empty here would cut the probe
                    // run and hide every key that probed past this slot.
                    self.slots[idx] = Slot::Deleted;
                    self.count -= 1;
                    self.tombstones += 1;
                    return;
                }
                Slot::Empty => return,
                _ => idx = (idx + 1) & self.mask,
            }
        }
    }

    fn find(&self, key: u64) -> Option<u64> {
        let mut idx = self.hash(key);
        loop {
            match &self.slots[idx] {
                Slot::Occupied { key: k, value } if *k == key => {
                    return Some(*value);
                }
                Slot::Empty => return None,
                _ => idx = (idx + 1) & self.mask,
            }
        }
    }

    fn rehash(&mut self) {
        let new_size = (self.mask + 1) * 2;
        let old = std::mem::replace(
            &mut self.slots,
            (0..new_size).map(|_| Slot::Empty).collect(),
        );
        self.mask = new_size - 1;
        self.count = 0;
        self.tombstones = 0;   // rehashing is what reclaims them
        for slot in old {
            if let Slot::Occupied { key, value } = slot {
                self.insert(key, value);
            }
        }
    }
}
What's intentionally missing

This implementation skips: iterator support, generic key and value types, heterogeneous lookup (looking up a &str in a String-keyed table), rehashing in place when tombstones rather than live entries fill the table (it always doubles), SIMD metadata (Swiss table optimization), Robin Hood displacement, prefetch hints, and thread safety. Production tables (Abseil, hashbrown, boost::unordered_flat_map) handle most of these; thread safety is left to the caller even there.

13Pitfalls

Use this as a review checklist. Each item is a hash table bug that passes ordinary tests and shows up later as a slow frame or a server under attack.

14What's next

Next in the series, Sorting Algorithms covers the other half of organizing data: putting it in order, from insertion sort to radix sort, which distributes keys into buckets much like a hash table does. Beyond this page, the hash-table variants worth knowing about:

Elastic and funnel hashing (2025)

Elastic and funnel hashing. Uniform probing costs Θ(log δ-1) amortized expected probes when the table is filled to 1 − δ. Ullman conjectured in 1972 that no greedy scheme (one where each insertion takes the first free slot in its probe sequence) can do better, and Yao proved it in 1985 in "Uniform Hashing is Optimal". Farach-Colton, Krapivin and Kuszmaul (2025) showed two things. Their elastic hashing, which is not greedy (an insertion may pass over a free slot), reaches O(1) amortized and O(log δ-1) worst-case expected probes without moving elements after insertion. Their funnel hashing, which is greedy, reaches O(log² δ-1) worst-case expected probes, disproving Yao's separate conjecture that greedy tables need about δ-1. Both bounds come with matching lower bounds[24].

The setting is narrow: insertions only, no reordering, and the cost measured in probes rather than cache misses (elastic hashing's probes are spread across the array, where linear probing's share cache lines). The paper also notes that once insertions and deletions run over an unbounded horizon, the best possible amortized expected probe complexity is δ-Ω(1), so "results such as Theorems 1 and 2 are not possible"[24]. Game-engine tables see constant inserts and deletes.

For the engine programmer, the most useful next step is to profile your game's hottest hash table. Measure the load factor, the average probe length, and the cache-miss rate. If the table is chained, benchmark a switch to open addressing. If it's open addressing with a weak hash, try a multiplicative (Fibonacci) mix. If it's already a Swiss table, the remaining wins are usually in the hash function and the key layout.

15Sources

  1. Hans Peter Luhn. Internal IBM memorandum on hash addressing, January 1953. Not publicly available; described in the IEEE Spectrum article "Hans Peter Luhn and the Birth of the Hashing Algorithm." spectrum.ieee.org. Knuth's history of hashing credits this memo as the origin of hashing and of chaining.
  2. W. Wesley Peterson. "Addressing for Random-Access Storage." IBM Journal of Research and Development 1(2):130-146, April 1957. DOI: 10.1147/rd.12.0130. ieeexplore.ieee.org/document/5392733. Per Knuth, the first major paper on searching large files: it defines open addressing, analyzes uniform probing, and gives empirical statistics for linear probing.
  3. Donald E. Knuth. The Art of Computer Programming, Volume 3: Sorting and Searching, Section 6.4 "Hashing." Addison-Wesley, 1973 (2nd edition 1998). ISBN 0-201-89685-0. gwern.net (excerpt). The reference analysis of hashing: division/multiplication methods, expected probe lengths for linear probing, double hashing, and chaining under SUHA. The linked excerpt is the section's history, the source for the Luhn, Amdahl and Peterson attributions in §2.
  4. Pedro Celis. "Robin Hood Hashing." PhD thesis, University of Waterloo, Technical Report CS-86-14, 1986. cs.uwaterloo.ca (PDF). The primary source for the Robin Hood constants in §7, including the numerically computed variance of about 1.883 and the comparison with binary tree hashing. Its constant search cost at full load relies on a new, mean-centered search algorithm rather than the forward scan production tables use.
  5. Patricio V. Poblete, Alfredo Viola. "Robin Hood Hashing really has constant average search cost and variance in full tables." AofA 2016; arXiv:1605.04031. arxiv.org (PDF). Proves, in the random-probing model, that Robin Hood's search-cost variance stays bounded by a small constant as the load factor approaches 1, and that a mean-centered search therefore runs in expected constant time. Source of the quoted "does not change the expected search cost" and "turns out to have a dramatic effect on its variance".
  6. Glenn Fowler, Landon Curt Noll, Kiem-Phong Vo. "FNV Hash." IETF Internet-Draft (informational). ietf.org/archive/id/draft-eastlake-fnv-21.html. The FNV-1a hash specification: XOR each byte then multiply by a prime. Public domain. Also the source for the 1991 origin in reviewer comments on POSIX P1003.2.
  7. Yann Collet. "xxHash: Extremely fast non-cryptographic hash algorithm." GitHub repository, 2012-present. github.com/Cyan4973/xxHash. Throughput-optimized hash using four accumulators with multiply-rotate rounds. The README's benchmark table (i7-9700K) gives the throughput figures in §3 and flags FNV64 for poor avalanche; XXH3 accepts a seed or a custom secret of at least 136 bytes.
  8. Wang Yi. "wyhash: The fastest quality hash function." GitHub repository, 2019. github.com/wangyi-fudan/wyhash. Uses wide multiply (64x64=128) for mixing. The README lists it as the default hash in Zig, V, Nim, and Go (since 1.17, as the runtime's non-AES fallback).
  9. Reini Urban et al. SMHasher (rurban fork). github.com/rurban/smhasher. The standard test battery referenced in §3: avalanche, differential and keyset tests, plus bad-seed detection, with throughput reported separately for bulk and small keys.
  10. Scott A. Crosby, Dan S. Wallach. "Denial of Service via Algorithmic Complexity Attacks." 12th USENIX Security Symposium, 2003. usenix.org (PDF). The origin of hash flooding as a practical attack: with chosen input a hash table degenerates to a linked list, and their Bro server was driven to drop "as much as 71% of its traffic" using less bandwidth than a dialup modem.
  11. Matt Kulukundis. "Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step." CppCon 2017. youtube.com/watch?v=ncHmEUmJZf4. See also: abseil.io/about/design/swisstables. The Swiss table design: SIMD metadata bytes, H1/H2 split, group probing. Released as absl::flat_hash_map.
  12. Abseil. "Swiss Tables Design Notes" and the Abseil containers guide. abseil.io. Documents the H1/H2 split (a 57-bit H1 and "a single control bit and the 7 bit H2 hash") and the 87.5% max load factor.
  13. Abseil. absl/container/internal/raw_hash_set.h and hashtable_control_bytes.h. github.com/abseil/abseil-cpp. The source for §8 and §11's implementation details: the group load is _mm_loadu_si128, deliberately unaligned; the SSE2 group is 16 wide while the AArch64 and portable groups are 8; probing advances by triangular numbers over whole groups; current code takes H2 from the top 7 bits of the hash; the 7/8 load factor leaves "an average of two empty slots per group"; and lookups prefetch the slot array, with a public prefetch(key).
  14. Amanieu d'Antras et al. "hashbrown: Rust port of Google's SwissTable hash map." GitHub repository. github.com/rust-lang/hashbrown. Adopted as the backing implementation of Rust's standard HashMap since Rust 1.36 (2019), replacing the previous Robin Hood implementation.
  15. Joaquín M López Muñoz. "Inside boost::unordered_flat_map." Bannalia, 2022. bannalia.blogspot.com. The contemporary flat map compared against Abseil in §12: groups of 15 buckets in a 16-byte metadata word, a max load factor of 0.875 the user cannot change, and an overflow byte that "removes the need to use tombstones".
  16. Nathan Bronson, Xiao Shi. "Open-sourcing F14 for faster, more memory-efficient hash tables." Meta Engineering, 2019. engineering.fb.com. Named "because it filters 14 slots at once", with SSE2 or NEON filtering and a typical max load factor of 12/14. The F14 design doc in the Folly repository describes the one-byte tags. A third group width alongside Abseil's 16-or-8 and Boost's 15.
  17. CPython. Objects/dictobject.c. github.com/python/cpython. The perturb recurrence quoted in §6: "perturb >>= PERTURB_SHIFT; j = (5*j) + 1 + perturb", with PERTURB_SHIFT = 5. The shift is what folds the high hash bits into the probe sequence.
  18. OpenJDK. java.util.HashMap. github.com/openjdk/jdk. Supports the Java figures in §4 and §10: default load factor 0.75, doubling on resize, and the spreading function h ^ (h >>> 16).
  19. Salvatore Sanfilippo et al. "Redis source: dict.c." GitHub repository. github.com/redis/redis/blob/unstable/src/dict.c. Incremental rehashing implementation: two hash tables, one-bucket-at-a-time migration during regular operations, rehashidx progress tracking, and lookups that skip the old table's already-migrated buckets.
  20. Epic Games. "TMap." Unreal Engine documentation. dev.epicgames.com/documentation/unreal-engine/map-containers. Documents TMap's TPair elements, its sparse-array base, GetTypeHash customization, set allocators that choose the bucket count, the undefined behavior of modifying a key in place, and TMap's standing as the most used container after TArray.
  21. Paul Pedriana et al. "EASTL: Electronic Arts Standard Template Library." N2271, ISO/IEC JTC1/SC22/WG21, 2007. open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2271.html. See also: github.com/electronicarts/EASTL. The design paper and open-source release of EA's game-oriented STL replacement, including hash_map and fixed_hash_map.
  22. Electronic Arts. EASTL include/EASTL/internal/hashtable.h. github.com/electronicarts/EASTL. The bucket layout cited in §12: node_type** mpBucketArray over singly-linked hash_node chains, with an optional cached hash code. No entry is inlined into a bucket slot.
  23. Peter Norvig. "Teach Yourself Programming in Ten Years," approximate timing table. norvig.com/21-days.html. Memory-hierarchy latency ballparks: L1 ~0.5 ns, L2 ~7 ns, DRAM ~100 ns. Used for the per-miss costs in §1 and §4.
  24. Martín Farach-Colton, Andrew Krapivin, William Kuszmaul. "Optimal Bounds for Open Addressing Without Reordering." arXiv:2501.02305, FOCS 2025. arxiv.org. The 2025 result discussed in §14: elastic hashing (non-greedy, O(1) amortized) and funnel hashing (greedy, O(log² δ-1) worst case), in an insert-only setting measured in probes, with a note that bounds like these are impossible once deletions run over an unbounded horizon.
  25. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms, 4th ed. MIT Press, 2022. ISBN 9780262046305. mitpress.mit.edu/9780262046305. Chapter 11 covers hash tables, chaining, open addressing, and the SUHA analysis.
  26. Abseil. absl/container/internal/raw_hash_set.h and raw_hash_set.cc. github.com/abseil/abseil-cpp. The implementation behind §9: WasNeverFull in raw_hash_set.cc decides whether an erase writes kEmpty or kDeleted by testing whether the run of non-empty control bytes spanning the slot is shorter than one group width, and the growth-capacity comment in raw_hash_set.h states why tombstones still force a rehash. The control-byte values in §8 (kEmpty = -128, kDeleted = -2, kSentinel = -1) are in hashtable_control_bytes.h.
  27. Bevy. bevy_ecs/src/entity/hash.rs. github.com/bevyengine/bevy. EntityHasher: keeps the entity index in the low bits for H1, multiplies by a golden-ratio constant to fill the high bits hashbrown uses for H2, and documents the cases (indices that are multiples of 256, mostly dead generations) where a general-purpose hash does better.

See also