Hash Table

Hash tables back Python's dict and set, database hash joins, caches, and symbol tables. This page explains the lookup path, the two main ways to handle collisions, why load factor and resizing keep operations O(1) on average, and the failure modes that push them to O(n).

A hash table maps keys to values via a hash function that converts each key to an array index. Average O(1) for insert, lookup, and delete; worst case O(n) when too many keys collide. Python's dict and set are hash tables.

Complexity

Average Worst Space
AccessSearchInsertionDeletion AccessSearchInsertionDeletion Worst
N/A Θ(1) Θ(1) Θ(1) N/A O(n) O(n) O(n) O(n)

How it works

Allocate an array of m buckets. To insert (key, value): compute i = hash(key) % m, place the entry in bucket i. Resolve collisions by chaining (each bucket holds a linked list) or open addressing (probe to the next free slot). When the load factor exceeds a threshold (~0.75), grow the array and rehash everything.

Python implementation

# Python dict — already a hash table.
d = {}
d["alice"] = 30
d["bob"] = 25
print(d["alice"])      # 30 — O(1) average
print("alice" in d)    # True — O(1) average
del d["bob"]


# Hand-rolled hash table with separate chaining.
class HashTable:
    def __init__(self, capacity=16):
        self._buckets = [[] for _ in range(capacity)]
        self._size = 0

    def _bucket(self, key):
        return self._buckets[hash(key) % len(self._buckets)]

    def put(self, key, val):                # O(1) average
        bucket = self._bucket(key)
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, val)
                return
        bucket.append((key, val))
        self._size += 1
        if self._size > 0.75 * len(self._buckets):
            self._resize(len(self._buckets) * 2)

    def get(self, key):                     # O(1) average
        for k, v in self._bucket(key):
            if k == key:
                return v
        raise KeyError(key)

    def _resize(self, new_cap):
        old = self._buckets
        self._buckets = [[] for _ in range(new_cap)]
        self._size = 0
        for bucket in old:
            for k, v in bucket:
                self.put(k, v)


h = HashTable()
h.put("alice", 30); h.put("bob", 25)
print(h.get("alice"))   # 30

Trade-offs

← Back to Algorithms


1. How a lookup works

Hash table lookup path key "apple" hash(key) 64-bit integer index = hash mod m bucket or slot number Bucket / probe sequence compare stored hash, then key value or KeyError

A lookup hashes the key to an integer, reduces it to a slot index (with modulo, or a bit mask when the table size is a power of two), and then inspects that slot. Because different keys can map to the same slot, the table stores enough information to confirm a match: usually the full hash and the key itself. Implementations compare the cached hash first since it is a cheap integer check, then call the key's equality method only when the hashes match.

For this to work, keys must be hashable: their hash must not change while they are in the table, and equal keys must have equal hashes. That is why Python lists cannot be dict keys while tuples of immutable values can.

2. Collision resolution

Collision resolution strategies Separate chaining each slot holds a list Open addressing probe the next free slot Java HashMap, C++ unordered_map Python dict, Rust HashMap

Separate chaining stores a linked list (or small array, or in Java 8+ a tree once a bucket gets large) at each slot. It degrades gracefully as the table fills and deletion is simple. Open addressing stores entries directly in the slot array and, on collision, probes other slots in a deterministic sequence: linear probing (next slot), quadratic probing, or double hashing. It is more cache-friendly because there are no pointers to follow, but it needs tombstones for deletion and its performance collapses as the table approaches full. CPython uses open addressing with a perturbed probe sequence and keeps a separate compact array of entries so iteration follows insertion order.

3. Load factor and resizing

The load factor is the number of stored entries divided by the number of slots. Expected probe length grows as the load factor rises, so tables resize, typically doubling, once a threshold is crossed (around 0.75 for Java's HashMap, about 2/3 for CPython's dict). Resizing rehashes every entry, an O(n) operation, but because it happens after a geometric number of inserts the amortized cost per insert stays O(1).

class HashMap:
    """Separate-chaining hash map with doubling resize."""
    def __init__(self, cap=8):
        self.buckets = [[] for _ in range(cap)]
        self.size = 0

    def _slot(self, key):
        return hash(key) % len(self.buckets)

    def put(self, key, value):
        b = self.buckets[self._slot(key)]
        for i, (k, _) in enumerate(b):
            if k == key:
                b[i] = (key, value)
                return
        b.append((key, value))
        self.size += 1
        if self.size / len(self.buckets) > 0.75:
            self._resize()

    def get(self, key):
        for k, v in self.buckets[self._slot(key)]:
            if k == key:
                return v
        raise KeyError(key)

    def _resize(self):
        old = [kv for b in self.buckets for kv in b]
        self.buckets = [[] for _ in range(2 * len(self.buckets))]
        self.size = 0
        for k, v in old:
            self.put(k, v)


m = HashMap()
for i in range(20):
    m.put(f"k{i}", i)
print(m.get("k7"), len(m.buckets))   # 7 32

4. When to use a hash table

NeedHash tableAlternative
Exact-match lookup by keyO(1) average-
Ordered iteration or range queriesNot supportedBalanced BST, B-tree, sorted array
Min/max or k-th smallestO(n)Heap or BST
Membership with tiny memory, false positives OKLargeBloom filter
Guaranteed worst-case latencyO(n) worst caseBalanced tree, O(log n)

5. Pitfalls