Hash Tables and Hashing

How Python's dictionaries turn arbitrary keys into near-instant lookups, and what happens when two keys collide

Introduction

Arrays gave you O(1) access in Lesson 2, but only by numeric index. A hash table breaks that requirement: it gives you O(1) average access using almost any key, a string, a tuple, a custom object. The trick is a hash function that converts a key into a number, which is then used exactly like an array index from Lesson 2. Python's dict is a hash table, and understanding how it works under the hood explains both why it's so fast and where its edge cases come from.

What is a Hash Table?

A hash table stores key-value pairs in an underlying array of buckets. To find where a key belongs, its hash is computed and reduced to a valid array index:

index = hash(key) % number_of_buckets

From Key to Bucket

"apple"hash()% capacitybucket[5]

Figure 1: Any hashable key reduces to a valid array index

Because array indexing is O(1) (Lesson 2), and computing a hash is O(1) for typical keys, a hash table lookup is O(1) on average, no matter how many entries it holds.

Hash Functions

A good hash function is deterministic (the same input always produces the same output within a single run), fast to compute, and spreads keys uniformly across buckets to minimize collisions. Python exposes this directly through the built-in hash() function:

print(hash(42))
print(hash(True), hash(1))     # bool is an int subclass, True hashes exactly like 1
print(hash(3.0), hash(3))      # equal values hash equally, even across numeric types

# Within a single run, the same string always hashes the same way...
print(hash("apple") == hash("apple"))
Expected Output:
42
1 1
3 3
True
Note: string and bytes hashes are intentionally randomized with a different seed each time the Python process starts (PEP 456), a security measure that prevents attackers from crafting keys designed to collide and degrade a server's dictionaries to O(n) lookups.

Turning a hash into a bucket index just needs the remainder after dividing by the number of buckets:

def bucket_index(key: str, capacity: int) -> int:
    return hash(key) % capacity

# hash(key) can be negative, but Python's % always follows the sign
# of the divisor, so the result is guaranteed to land in [0, capacity)
print(-17 % 8)
Expected Output:
7

Unlike some other languages, Python's % always follows the sign of the divisor, so hash(key) % capacity never needs an extra abs() to stay within bounds.

Handling Collisions

With a fixed number of buckets and an unbounded number of possible keys, two different keys will eventually hash to the same bucket, a collision. It's not a bug, it's mathematically unavoidable (the pigeonhole principle), so every hash table needs a strategy to handle it.

Separate Chaining

Each bucket holds a small collection (commonly a linked list, Lesson 3) of every key-value pair that hashed there. A collision just means appending to that bucket's list.

Open Addressing

On a collision, probe for the next open slot in the same underlying array (linear probing, quadratic probing, double hashing) instead of growing the bucket.

We'll build a hash table using separate chaining, since it maps directly onto structures you already know: an array of buckets, each one a small list.

Separate Chaining: an Array of Buckets, Each a Small List

0123(cat, 1)(act, 9)(dog, 5)

Figure 2: Two keys that hash to bucket 1 are kept as a short chain (Lesson 3), so a collision just appends a node. Buckets 0 and 2 are empty. A lookup jumps to a bucket in O(1), then scans only its short chain.

Building a Hash Table from Scratch

A minimal hash table needs put to insert or update a key, and get to retrieve one:

class HashMap:
    def __init__(self, capacity: int = 8) -> None:
        self._capacity = capacity
        self._size = 0
        self._buckets: list[list[tuple[str, int]]] = [[] for _ in range(capacity)]

    def _bucket_index(self, key: str) -> int:
        return hash(key) % self._capacity

    def put(self, key: str, value: int) -> None:
        index = self._bucket_index(key)
        bucket = self._buckets[index]
        for i, (existing_key, _) in enumerate(bucket):
            if existing_key == key:
                bucket[i] = (key, value)   # overwrite the existing entry
                return
        bucket.append((key, value))
        self._size += 1
        if self._size / self._capacity > 0.75:   # load factor exceeded
            self._resize()

    def get(self, key: str) -> int:
        index = self._bucket_index(key)
        for existing_key, value in self._buckets[index]:
            if existing_key == key:
                return value
        raise KeyError(key)

    def _resize(self) -> None:
        # Same idea as Lesson 2's dynamic array: double the capacity and
        # rehash every entry into its new bucket
        old_buckets = self._buckets
        self._capacity *= 2
        self._buckets = [[] for _ in range(self._capacity)]
        for bucket in old_buckets:
            for key, value in bucket:
                index = self._bucket_index(key)
                self._buckets[index].append((key, value))
m = HashMap(capacity=4)
m.put("apple", 1)
m.put("banana", 2)
m.put("apple", 10)     # overwrites the previous value

print(m.get("apple"))
print(m.get("banana"))

try:
    m.get("cherry")
except KeyError:
    print("KeyError: cherry not found")
Expected Output:
10
2
KeyError: cherry not found
Why put() and get() Both Walk a Bucket

Finding the right bucket is O(1), but confirming which entry in that bucket matches the key still requires comparing keys one by one. If collisions are rare and buckets stay short, that inner walk is effectively O(1) too, which is exactly what the next section's load factor is protecting.

Load Factor and Resizing

The load factor is the ratio of stored entries to bucket count. As it climbs, buckets get longer, chains get longer, and average lookup time drifts away from O(1) toward O(n). The fix is the same amortized doubling strategy from Lesson 2's dynamic array: once the load factor crosses a threshold (0.75 in our implementation), allocate a larger bucket array and rehash every entry into it.

That rehashing is O(n), but it happens rarely enough, and gets rarer as the table grows, that the amortized cost of any single put stays O(1), the exact same argument used for list.append().

How Python's Real dict Differs

Our HashMap is a faithful teaching model, but CPython's actual dict is more sophisticated. Since Python 3.6, it combines a sparse index array (open addressing, not chaining) with a separate dense array holding the actual key-value pairs in insertion order, which is also how modern dict guarantees the insertion-order iteration you may already rely on.

Only Hashable Objects Can Be Keys

Mutable objects like list and dict can't be used as dict keys, they raise TypeError: unhashable type. Allowing a mutable key would let its value change after insertion, silently invalidating the bucket it was stored in. Tuples of hashable values, strings, numbers, and frozensets are all fair game.

Bonus: The Two Sum Pattern

Given a list of numbers, find two that add up to a target. The brute-force approach checks every pair, O(n²). A hash map turns it into a single O(n) pass by remembering what you've already seen:

def two_sum(numbers: list[int], target: int) -> tuple[int, int] | None:
    seen: dict[int, int] = {}   # value -> index, built as we go

    for index, number in enumerate(numbers):
        complement = target - number
        if complement in seen:
            # O(1) average lookup instead of an O(n) inner loop
            return (seen[complement], index)
        seen[number] = index

    return None
print(two_sum([2, 7, 11, 15], 9))
print(two_sum([3, 2, 4], 6))
print(two_sum([1, 2, 3], 100))
Expected Output:
(0, 1)
(1, 2)
None

Instead of searching the rest of the list for a complement (O(n) per element, O(n²) overall), each lookup into seen is O(1) average, so the whole function runs in O(n). This "remember what you've seen in a hash map" pattern shows up constantly in interview problems and real deduplication logic alike.

Key Takeaways

  • Hash tables give O(1) average access by converting a key into an array index via hash(key) % capacity
  • Collisions are inevitable, not a bug, and are handled with separate chaining or open addressing
  • Load factor drives resizing, the same amortized doubling strategy as Lesson 2's dynamic arrays
  • Only hashable, immutable-by-convention objects can be dict keys - mutable objects would silently corrupt their own bucket
  • "Remember what you've seen in a hash map" is one of the highest-leverage patterns for turning O(n²) brute force into O(n)