Run it
A working hash map with chaining, its bucket distribution printed before and after a resize, and the tombstone problem demonstrated on a small open-addressing version.
Chaining: the straightforward answer
Each bucket holds a list of key-value pairs. On collision, append.
O(1) average for all three operations, O(n) worst case when every key collides.
Two details are load-bearing:
put must check for an existing key before appending, or updating a value inserts a duplicate and get returns the stale one.
Resizing rehashes every entry, because the bucket index depends on the capacity. Copying buckets across without rehashing puts keys in the wrong places, and they become unreachable.
Why the load factor matters
The load factor is entries divided by buckets. As it rises, buckets hold longer chains and lookups degrade from O(1) towards O(n).
| Load factor | Average chain length | Behaviour |
|---|
| 0.25 | ~1.1 | Fast, memory-wasteful |
| 0.75 | ~1.4 | The usual threshold |
| 2.0 | ~2.4 | Noticeably slower |
| 10 | ~10 | Effectively a linked list |
Resizing at 0.75 is the common choice (Java's HashMap uses exactly this; CPython uses about 2/3). Doubling the capacity is O(n), and amortised across the insertions that fit in the new space it is O(1) per operation — the same argument as list append.
Choosing a power-of-two capacity allows hash & (cap - 1) instead of a modulo, which is faster. It also means only the low bits of the hash are used, so a hash function that varies mainly in its high bits clusters badly — which is why Java's HashMap mixes the high bits down before masking.
Chaining versus open addressing
Separate chaining puts colliding entries in a list per bucket. Simple, deletion is trivial, and it tolerates a high load factor. This is what to implement under time pressure.
Open addressing stores everything in the array itself and probes for the next free slot on a collision. Better cache behaviour and less pointer chasing — it is what CPython actually uses — but deletion is genuinely hard.
Why deletion is hard in open addressing
Removing an entry leaves a hole, and a probe sequence that ran through that slot to reach a later entry now stops at the hole and reports the later entry missing.
The fix is a tombstone: mark the slot deleted rather than empty, so probes continue past it but inserts may reuse it. Tombstones accumulate and eventually force a rehash even without growth. Being able to say that is usually what the question is really checking.
The load factor and the resize
Lookup is O(1) only while buckets stay short. Once entries divided by buckets passes roughly 0.75, chains lengthen and every operation drifts towards O(n).
So the table doubles and every key is rehashed, because the index is hash(key) % size and the size just changed. That single resize is O(n), and amortised over the insertions that caused it each insert is still O(1) — but any individual insert can be the expensive one.
Open addressing, the alternative
Instead of a list per bucket, store entries directly in the array and probe for another slot on collision.
| | Chaining | Open addressing |
|---|
| Storage | List per bucket | One flat array |
| Extra memory | Pointers per entry | None |
| Cache behaviour | Poorer — pointer chasing | Better — contiguous |
| Deletion | Straightforward | Needs tombstones |
| Load factor tolerance | Above 1 is workable | Must stay well below 1 |
Deletion is the awkward part. Removing an entry outright breaks probe sequences that passed through that slot — a later lookup would stop at the gap and report the key missing. So deleted slots hold a tombstone marker meaning "empty, but keep probing".
Tombstones accumulate and degrade performance until a resize clears them, which is why open-addressed tables need periodic rehashing even without growth.
CPython uses open addressing with a scattering probe sequence; Java uses chaining, converting long chains into trees above a threshold to bound the worst case.
The hash function
For an interview, hash(key) % capacity is acceptable. If asked to write one:
Three properties a hash function needs:
Deterministic. The same key must always give the same value, or entries become unreachable.
Uniform. Keys should spread across buckets. A hash returning a constant is valid and turns the table into a linked list.
Fast. It runs on every operation.
It does not need to be cryptographic — that is a different requirement, and orders of magnitude slower.
Worth mentioning: Python randomises string hashes per process to prevent deliberate-collision denial-of-service attacks, which is why hash("abc") differs between runs.
Follow-up questions
"What if keys are integers 0 to 1,000,000?" Use direct addressing — an array indexed by the key. No hashing, no collisions, O(1) guaranteed. Only viable when the key space is small and dense.
"How would you make it thread-safe?" A single lock serialises everything; striped locking — one lock per group of buckets — allows concurrency. Java's ConcurrentHashMap does this.
"What is the worst case?" O(n) when all keys collide. Java mitigates it by treeifying long chains, giving O(log n).
"How do you iterate in insertion order?" Maintain a doubly linked list of entries alongside the table — which is exactly OrderedDict and CPython's compact dict layout.
"What about memory?" Chaining costs a pointer per entry plus list overhead; open addressing wastes empty slots. Both trade memory for speed.
What it is testing
Do you name the three components? Buckets, hash function, collision strategy.
Do you handle collisions explicitly? A design without a collision answer is incomplete.
Do you resize? Without it, performance degrades to O(n) as the table fills.
Do you rehash on resize? Copying buckets without recomputing indices is the classic bug.
Do you check for an existing key in put? Otherwise updates create duplicates.
Do you know the real implementations? CPython open-addresses, Java chains and treeifies — knowing that grounds the answer in reality.
Recap in one screen
- Three components: an array of buckets, a hash function, and a collision strategy.
- Chaining stores a list per bucket; open addressing probes for another slot and needs tombstones for deletion.
- Resize when the load factor exceeds about 0.75, and rehash every entry because indices depend on the capacity.
put must update an existing key rather than appending a duplicate.- O(1) average, O(n) worst case; Java bounds the worst case by treeifying long chains.