← Back to Tutorials Chapter 5

Hash Tables

Hash tables provide lightning-fast key-value lookups with O(1) average time complexity. They are the engine behind dictionaries, maps, and associative arrays in almost every programming language.

Hash Table Concept

A hash table stores data in key-value pairs. A hash function takes a key and computes an index (a hash code) that determines where the value is stored in an internal array. When you look up a key, the same hash function is used to find its location directly.

Hash Functions

A good hash function should be:

def simple_hash(key, table_size):
    # Simple hash for string keys
    total = 0
    for ch in str(key):
        total += ord(ch)
    return total % table_size
Real-world hash functions: Python uses SipHash for string keys; Java uses a polynomial hash; cryptographic hashes like SHA-256 are used for security.

Collision Resolution

Collisions happen when two different keys hash to the same index. Here are the main strategies:

1. Chaining (Separate Chaining)

Each bucket stores a linked list (or another collection) of key-value pairs. When a collision occurs, the new pair is appended to the list at that index.

class HashTableChaining:
    def __init__(self, capacity=10):
        self.capacity = capacity
        self.table = [[] for _ in range(capacity)]

    def _hash(self, key):
        return hash(key) % self.capacity

    def put(self, key, value):
        idx = self._hash(key)
        for i, (k, v) in enumerate(self.table[idx]):
            if k == key:
                self.table[idx][i] = (key, value)
                return
        self.table[idx].append((key, value))

    def get(self, key):
        idx = self._hash(key)
        for k, v in self.table[idx]:
            if k == key:
                return v
        return None

    def remove(self, key):
        idx = self._hash(key)
        self.table[idx] = [(k, v) for k, v in self.table[idx] if k != key]

2. Open Addressing

When a collision occurs, probe for the next empty slot. Variants include:

class HashTableLinearProbing:
    def __init__(self, capacity=10):
        self.capacity = capacity
        self.keys = [None] * capacity
        self.values = [None] * capacity

    def _hash(self, key):
        return hash(key) % self.capacity

    def put(self, key, value):
        idx = self._hash(key)
        while self.keys[idx] is not None:
            if self.keys[idx] == key:
                self.values[idx] = value
                return
            idx = (idx + 1) % self.capacity
        self.keys[idx] = key
        self.values[idx] = value

    def get(self, key):
        idx = self._hash(key)
        while self.keys[idx] is not None:
            if self.keys[idx] == key:
                return self.values[idx]
            idx = (idx + 1) % self.capacity
        return None
Hash table with chaining

Load Factor and Resizing

The load factor is the ratio of stored entries to table capacity. When it exceeds a threshold (typically 0.75), the table is resized (usually doubled in capacity) and all entries are rehashed. This keeps operations fast at the cost of occasional O(n) resizing.

Hash Set vs Hash Map

Key insight: Hash tables give you O(1) average performance for insert, delete, and lookup. This makes them the go-to choice whenever you need to associate values with unique keys or quickly check membership.

Applications

Exercise: Given an array of integers, find two numbers that add up to a target sum. Implement a solution using a hash map that runs in O(n) time. For example, two_sum([2, 7, 11, 15], 9) should return [0, 1].