Shawn Ng

Shawn Ng

The Hidden Magic of Hash Tables: How Your Computer Finds Anything in an Instant

  • data-structures
  • hash-tables
  • algorithms
  • computer-science

Hash tables are arguably the most important data structure in all of computer science. They power Python dictionaries, JavaScript objects, database indexes, caches, routers, and countless other systems. Yet many developers use them daily without understanding the elegant machinery underneath. Let us fix that.

Check out the full video on my YouTube channel Divide and Quantum.

The Core Idea

Imagine you have a library with 10 million books but no catalog. Finding a specific book means checking every shelf, one by one. That is O(n) search.

Now imagine each book's title determines its exact shelf location through a formula. You plug in the title, get a shelf number, and walk straight to it. That is a hash table. The formula is the hash function, and the shelves are buckets.

A hash table combines two things:

  1. An array of fixed size (the buckets).
  2. A hash function that converts any key into an array index.
key -> hash_function(key) -> index -> bucket[index] -> value

Hash Functions: Turning Keys into Numbers

A hash function takes input of arbitrary size and produces a fixed-size integer. A good hash function has three properties:

  • Deterministic: The same key always produces the same hash.
  • Uniform: Outputs are spread evenly across the available range.
  • Fast: Computing the hash should be quick.

Here is a simple hash function for strings:

python
def simple_hash(key, size): """A basic hash function that sums character codes.""" hash_value = 0 for char in key: hash_value += ord(char) return hash_value % size

This works but has a problem: anagram strings like "listen" and "silent" produce the same hash. A better approach incorporates position:

python
def better_hash(key, size): """Hash function that accounts for character position.""" hash_value = 0 for i, char in enumerate(key): hash_value += ord(char) * (31 ** i) return hash_value % size

The multiplier 31 is a prime number, which helps distribute hashes more evenly. This is similar to how Java computes String.hashCode().

In practice, languages use sophisticated hash functions like SipHash (Python 3.4+), which is both fast and resistant to hash-flooding denial-of-service attacks.

The Collision Problem

Here is the uncomfortable truth: collisions are inevitable. If your hash table has 1,000 buckets and you insert 1,001 keys, at least two keys must share a bucket. In practice, collisions start happening much earlier. The birthday paradox tells us that with just 23 people, there is a greater than 50% chance two share a birthday. The same principle applies to hash tables.

There are two main strategies for handling collisions.

Strategy 1: Chaining

Each bucket holds a linked list (or any collection) of all key-value pairs that hash to that index.

Bucket 0: -> ("apple", 5) -> ("grape", 8)
Bucket 1: -> ("banana", 3)
Bucket 2: (empty)
Bucket 3: -> ("cherry", 12)

When you look up "grape", you hash it to bucket 0, then walk the linked list until you find the matching key. If the chains stay short, this is still effectively O(1).

Strategy 2: Open Addressing

Instead of chains, you store everything directly in the array. When a collision occurs, you probe for the next available slot.

Linear probing checks the next slot, then the next, and so on:

hash(key) -> index
if bucket[index] is occupied:
    try index + 1, index + 2, index + 3, ...

Linear probing is cache-friendly because it accesses consecutive memory locations, but it suffers from clustering, where occupied slots clump together, creating long probe sequences.

Quadratic probing spreads things out by checking index + 1, index + 4, index + 9, and so on. Double hashing uses a second hash function to determine the probe step size, which eliminates clustering almost entirely.

Python dictionaries use open addressing with a custom probing strategy that mixes bits from the full hash value to avoid clustering.

Building a Hash Table from Scratch

Let us implement a hash table with chaining to see how all the pieces fit together:

python
class HashTable: def __init__(self, initial_size=8): self.size = initial_size self.count = 0 self.buckets = [[] for _ in range(self.size)] def _hash(self, key): """Compute bucket index for a given key.""" hash_value = 0 for i, char in enumerate(str(key)): hash_value += ord(char) * (31 ** i) return hash_value % self.size def _load_factor(self): """Ratio of stored items to bucket count.""" return self.count / self.size def _resize(self): """Double the table size and rehash all entries.""" old_buckets = self.buckets self.size *= 2 self.count = 0 self.buckets = [[] for _ in range(self.size)] for bucket in old_buckets: for key, value in bucket: self.put(key, value) def put(self, key, value): """Insert or update a key-value pair.""" if self._load_factor() > 0.75: self._resize() index = self._hash(key) bucket = self.buckets[index] # Update existing key for i, (k, v) in enumerate(bucket): if k == key: bucket[i] = (key, value) return # Insert new key bucket.append((key, value)) self.count += 1 def get(self, key): """Retrieve the value for a given key.""" index = self._hash(key) bucket = self.buckets[index] for k, v in bucket: if k == key: return v raise KeyError(f"Key '{key}' not found") def delete(self, key): """Remove a key-value pair.""" index = self._hash(key) bucket = self.buckets[index] for i, (k, v) in enumerate(bucket): if k == key: bucket.pop(i) self.count -= 1 return v raise KeyError(f"Key '{key}' not found") def __repr__(self): items = [] for bucket in self.buckets: for k, v in bucket: items.append(f"{k}: {v}") return "{" + ", ".join(items) + "}"

Testing Our Hash Table

python
ht = HashTable() ht.put("name", "Shawn") ht.put("language", "Python") ht.put("topic", "Hash Tables") print(ht.get("name")) # "Shawn" print(ht.get("language")) # "Python" ht.put("name", "Updated") # Update existing key print(ht.get("name")) # "Updated" ht.delete("topic") print(ht) # {name: Updated, language: Python}

Load Factor and Resizing

The load factor is the ratio of stored items to the number of buckets. As the load factor increases, collisions become more frequent and performance degrades.

  • Load factor 0.25: Very few collisions, but wasted memory.
  • Load factor 0.75: Good balance of speed and memory. This is where most implementations trigger a resize.
  • Load factor 1.0+: Frequent collisions, degraded performance.

When resizing, you cannot simply copy buckets to a larger array. Every key must be rehashed because the bucket index depends on the array size (hash % size). Doubling the table size and rehashing all entries is an O(n) operation, but since it happens infrequently, the amortized cost of insertion remains O(1).

Why O(1) Lookups Are Not Guaranteed

Hash table lookups are O(1) on average, not in the worst case. If every key hashes to the same bucket, you end up with a single linked list and O(n) lookups. This is not just a theoretical concern. Attackers can craft inputs that intentionally cause hash collisions, turning your O(1) hash table into an O(n) list. This is called a hash-flooding attack and was a real vulnerability in web frameworks that used hash tables to store HTTP request parameters.

Modern hash functions like SipHash include randomized seeds to defend against this. Python randomizes its hash seed on every interpreter startup for exactly this reason.

Real-World Applications of Hash Tables

Hash tables are everywhere:

  • Databases: Index structures for fast row lookups.
  • Caches: Memcached and Redis are essentially distributed hash tables.
  • Compilers: Symbol tables map variable names to their types and memory locations.
  • Networking: Routing tables map IP prefixes to next-hop addresses.
  • Deduplication: Quickly detecting whether you have seen an item before.
  • Counting: Frequency analysis, histograms, and aggregations.

Key Takeaways

Hash tables achieve near-constant-time operations by converting keys into array indices through hash functions. Collisions are inevitable but manageable through chaining or open addressing. The load factor controls the trade-off between memory usage and collision frequency. Resizing keeps performance stable as the table grows.

Understanding how hash tables work under the hood does not just satisfy curiosity. It helps you make better choices about when to use them, predict their performance characteristics, and debug subtle issues when they arise.

Subscribe on YouTube