Handling Collisions
Two different keys land on the same shelf — every real hash table needs a plan: chaining or probing.
The problem
Your hash table is humming. Keys go in, keys come out, all in one step. Then it happens:
hash("cat") % 8 is 3, and hash("dog") % 8 is also 3. Two different keys, one slot.
You can't just overwrite cat with dog — you'd lose a coat. And you can't refuse dog,
because a table that rejects keys is broken. This isn't a rare glitch you can ignore: with
only 8 slots and 9 keys, a clash is guaranteed. Every real hash table needs an answer to
"what happens when two keys want the same slot?"
A first attempt
The naive fix: when slot 3 is taken, just keep scanning down the table from the front until you find any empty slot, and drop the key there.
It works, but it degrades fast. Keys pile up near the front, retrieval has to re-scan those
same crowded slots, and deletions leave holes that confuse the search. Without a principled
rule, a half-full table can push lookups toward O(n) — the very cost hashing was supposed
to kill. You need a systematic way to place and later find a displaced key.
The insight
There are two clean strategies, and every hash table picks one.
Chaining — make each slot a small list. Colliding keys just join the list in that slot. Lookup hashes to the slot, then walks its short list.
Open addressing — keep one key per slot, but when a slot is full, probe for the next open one using a fixed rule (e.g. "try the next slot, then the next"). Because the rule is deterministic, lookup replays the same probe path and finds the key.
Same slot, different plans
Chaining stores collisions outside the array in per-slot lists — simple, forgiving, uses
extra pointers. Open addressing keeps everything inside one array — cache-friendly and
compact, but sensitive to how full the table gets. Both keep the average case at O(1).
How it works
Hash to a slot
Both strategies start the same way: run the key through the hash function and fold to an index. If the slot is empty, you're done — store it there.
Chaining: append to the slot's list
If the slot already holds keys, add yours to that slot's list. To look up, hash to the slot and scan its list for a matching key. Short lists mean short scans.
Open addressing: probe forward
Instead of a list, step to the next slot, then the next, until you hit an empty one (linear probing). Store the key there. To look up, replay the same steps from the hashed slot until you find the key or hit an empty slot.
Watch the load factor
Both strategies slow down as the table fills. Track the load factor — items ÷ slots. When it crosses a threshold (≈0.75 for chaining, ≈0.5 for probing), resize: allocate a bigger array and re-hash every key into it.
The two layouts, after cat, dog, and owl all hash to slot 3:
CHAINING OPEN ADDRESSING (linear probe)
slot slot
2 → (empty) 2 → (empty)
3 → [cat] → [dog] → [owl] 3 → "cat" (hashed here)
4 → (empty) 4 → "dog" (3 full, probed +1)
5 → (empty) 5 → "owl" (3,4 full, probed +2)The code
Chaining is the easier one to build and reason about — each slot is just a bucket of key/value pairs.
class HashMap:
def __init__(self, size=8):
self.buckets = [[] for _ in range(size)]
def _idx(self, key):
return hash(key) % len(self.buckets)
def put(self, key, value):
bucket = self.buckets[self._idx(key)]
for i, (k, _) in enumerate(bucket):
if k == key: # update existing
bucket[i] = (key, value)
return
bucket.append((key, value)) # new collision → append
def get(self, key):
for k, v in self.buckets[self._idx(key)]:
if k == key:
return v
return Noneclass HashMap<V> {
private buckets: [string, V][][];
constructor(size = 8) {
this.buckets = Array.from({ length: size }, () => []);
}
private idx(key: string): number {
let h = 7;
for (const ch of key) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0;
return (h >>> 0) % this.buckets.length;
}
put(key: string, value: V): void {
const bucket = this.buckets[this.idx(key)];
const hit = bucket.find(([k]) => k === key);
if (hit) hit[1] = value; // update existing
else bucket.push([key, value]); // new collision → append
}
get(key: string): V | undefined {
return this.buckets[this.idx(key)].find(([k]) => k === key)?.[1];
}
}import java.util.*;
class HashMapChained<V> {
private final List<List<Map.Entry<String, V>>> buckets;
HashMapChained(int size) {
buckets = new ArrayList<>();
for (int i = 0; i < size; i++) buckets.add(new ArrayList<>());
}
private int idx(String key) {
return Math.floorMod(key.hashCode(), buckets.size());
}
void put(String key, V value) {
var bucket = buckets.get(idx(key));
for (var e : bucket) {
if (e.getKey().equals(key)) { e.setValue(value); return; }
}
bucket.add(new AbstractMap.SimpleEntry<>(key, value));
}
V get(String key) {
for (var e : buckets.get(idx(key))) {
if (e.getKey().equals(key)) return e.getValue();
}
return null;
}
}#include <string.h>
#include <stdlib.h>
typedef struct Node { char *key; int value; struct Node *next; } Node;
#define SIZE 8
Node *buckets[SIZE];
static unsigned idx(const char *key) {
unsigned h = 7;
for (const char *p = key; *p; p++) h = h * 31u + (unsigned char)*p;
return h % SIZE;
}
void put(const char *key, int value) {
unsigned i = idx(key);
for (Node *n = buckets[i]; n; n = n->next)
if (strcmp(n->key, key) == 0) { n->value = value; return; }
Node *n = malloc(sizeof(Node)); /* new collision → prepend */
n->key = strdup(key); n->value = value; n->next = buckets[i];
buckets[i] = n;
}
int get(const char *key, int *found) {
for (Node *n = buckets[idx(key)]; n; n = n->next)
if (strcmp(n->key, key) == 0) { *found = 1; return n->value; }
*found = 0; return 0;
}#include <string>
#include <vector>
#include <list>
#include <optional>
class HashMap {
std::vector<std::list<std::pair<std::string, int>>> buckets;
std::size_t idx(const std::string &key) const {
unsigned h = 7;
for (unsigned char ch : key) h = h * 31u + ch;
return h % buckets.size();
}
public:
explicit HashMap(std::size_t size = 8) : buckets(size) {}
void put(const std::string &key, int value) {
auto &bucket = buckets[idx(key)];
for (auto &e : bucket)
if (e.first == key) { e.second = value; return; }
bucket.emplace_back(key, value); // new collision → append
}
std::optional<int> get(const std::string &key) const {
for (const auto &e : buckets[idx(key)])
if (e.first == key) return e.second;
return std::nullopt;
}
};Complexity
| Operation | Average | Worst case | Note |
|---|---|---|---|
| Insert | O(1) | O(n) | worst case if every key hits one slot/chain |
| Lookup | O(1) | O(n) | average scans a chain of length ≈ load factor |
| Delete | O(1) | O(n) | probing needs tombstones; chaining just unlinks |
Deletion is the sharp edge of probing
With open addressing you cannot simply blank a deleted slot — that empty would cut short the probe path and hide keys stored past it. You mark the slot with a tombstone (a "was here" sentinel) that lookups skip over but inserts may reuse. Tombstones accumulate and slow searches, so heavily-churned probing tables need periodic rehashing. Chaining sidesteps all of this: to delete, you just unlink the node.
When to use it
Which strategy to pick
Reach for chaining when deletions are frequent, keys are large, or you can't predict the
load — it's forgiving and simple, which is why Java's HashMap and Python's dict
historically used it. Reach for open addressing when memory and cache performance matter
and the table stays under ~50% full — no pointer overhead, and everything sits in one
contiguous array. Either way, resizing on load factor is what actually keeps you at O(1).
Practice
Recap
- Collisions are guaranteed, not exceptional — a table with more keys than slots must share, so every hash table ships a resolution strategy.
- Chaining stores each slot's collisions in a list; open addressing probes for the
next free slot inside the array. Both stay
O(1)on average. - The load factor governs everything: keep it bounded and resize on time, or your
O(1)quietly decays towardO(n).
How is this guide?
Last updated on