Quiz 2
Registry Synced

14. Hash Tables — Dictionaries Under the Hood

1229 words
6 min read

Reading compass

Now · 1. The Hash Table Concept

14. Hash Tables — Dictionaries Under the Hood

What problem does this solve? Arrays and lists find elements by index in O(1) time. But what if you want to find an element by its value (not position)? A hash table maps keys to values, giving O(1) average-time lookup, insert, and delete.

1. The Hash Table Concept

Mental Model

A hash table is like a library with organized shelves. A hash function tells you exactly which shelf a book belongs on based on its title. You go directly to that shelf — no searching needed. If two books map to the same shelf (collision), you handle it with a mini-search within that shelf. (Diagram)

2. Hash Functions

Requirements

  1. Deterministic: Same key always produces the same hash
  2. Efficient: O(1) to compute
  3. Uniform distribution: Keys spread evenly across buckets

Common Hash Functions

python
# runnable
def hash_mod(key, table_size):
    """Simple modulo hash (for integer keys)."""
    return key % table_size
def hash_string(s, table_size):
    """Polynomial hash for strings."""
    h = 0
    for ch in s:
        h = (h * 31 + ord(ch)) % table_size
    return h
# Python's built-in hash
print(hash("apple"))        # Some large integer
print(hash(42))             # 42 (for small ints, hash = self)
print(hash("apple") % 10)   # Modulo to get an index

3. Collision Resolution — Chaining

Mental Model

Each bucket contains a linked list. When multiple keys hash to the same index, they all go in the same linked list. To find a key, hash to its bucket, then search the linked list. (Diagram)

Implementation

python
# runnable
class HashTableChaining:
    """Hash table with separate chaining."""
    def __init__(self, capacity=10):
        self._table = [[] for _ in range(capacity)]
        self._capacity = capacity
        self._size = 0
    def _hash(self, key):
        """Compute hash index for key."""
        return hash(key) % self._capacity
    def __setitem__(self, key, value):
        """Insert key-value pair. O(1) average."""
        idx = self._hash(key)
        bucket = self._table[idx]
        # 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._size += 1
        # Resize if load factor > 0.75
        if self._size > 0.75 * self._capacity:
            self._resize(2 * self._capacity)
    def __getitem__(self, key):
        """Get value for key. O(1) average."""
        idx = self._hash(key)
        for k, v in self._table[idx]:
            if k == key:
                return v
        raise KeyError(key)
    def __delitem__(self, key):
        """Delete key-value pair. O(1) average."""
        idx = self._hash(key)
        bucket = self._table[idx]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket.pop(i)
                self._size -= 1
                return
        raise KeyError(key)
    def _resize(self, new_capacity):
        """Resize and rehash all entries."""
        old_table = self._table
        self._table = [[] for _ in range(new_capacity)]
        self._capacity = new_capacity
        self._size = 0
        for bucket in old_table:
            for k, v in bucket:
                self[k] = v
# Test
ht = HashTableChaining(4)
ht["apple"] = "a fruit"
ht["banana"] = "yellow"
ht["cherry"] = "red"
print(ht["banana"])       # yellow
ht["banana"] = "ripe"
print(ht["banana"])       # ripe
del ht["apple"]
# print(ht["apple"])      # KeyError

4. Collision Resolution — Open Addressing (Linear Probing)

Mental Model

Instead of linked lists, when a bucket is occupied, you linearly scan forward until you find an empty bucket. The entire table is one big array.
python
# runnable
class HashTableLinearProbing:
    """Hash table with linear probing."""
    def __init__(self, capacity=10):
        self._keys = [None] * capacity
        self._values = [None] * capacity
        self._capacity = capacity
        self._size = 0
        self._DELETED = object()  # Tombstone marker
    def _hash(self, key):
        return hash(key) % self._capacity
    def __setitem__(self, key, value):
        idx = self._hash(key)
        original_idx = idx
        while self._keys[idx] is not None and self._keys[idx] is not self._DELETED:
            if self._keys[idx] == key:
                self._values[idx] = value  # Update existing
                return
            idx = (idx + 1) % self._capacity
            if idx == original_idx:
                raise Exception("Table full")
        self._keys[idx] = key
        self._values[idx] = value
        self._size += 1
        if self._size > 0.7 * self._capacity:
            self._resize(2 * self._capacity)
    def __getitem__(self, key):
        idx = self._hash(key)
        original_idx = idx
        while self._keys[idx] is not None:
            if self._keys[idx] == key:
                return self._values[idx]
            idx = (idx + 1) % self._capacity
            if idx == original_idx:
                break
        raise KeyError(key)
    def __delitem__(self, key):
        idx = self._hash(key)
        original_idx = idx
        while self._keys[idx] is not None:
            if self._keys[idx] == key:
                self._keys[idx] = self._DELETED  # Tombstone
                self._values[idx] = None
                self._size -= 1
                return
            idx = (idx + 1) % self._capacity
            if idx == original_idx:
                break
        raise KeyError(key)
    def _resize(self, new_capacity):
        old_keys, old_values = self._keys, self._values
        self._keys = [None] * new_capacity
        self._values = [None] * new_capacity
        self._capacity = new_capacity
        self._size = 0
        for k, v in zip(old_keys, old_values):
            if k is not None and k is not self._DELETED:
                self[k] = v
# Test
ht = HashTableLinearProbing(4)
ht["apple"] = "fruit"
ht["banana"] = "yellow"
ht["cherry"] = "red"
print(ht["banana"])

Linear Probing Trace

pseudo
Table size = 5. Insert: 15 (hash=0), 25 (hash=0), 35 (hash=0)
Insert 15: hash=0 → keys[0] = 15
Insert 25: hash=0 → keys[0]=15 (occupied), try keys[1] = 25
Insert 35: hash=0 → keys[0]=15, keys[1]=25, keys[2] = 35
Search 35: hash=0 → keys[0]=15≠35, keys[1]=25≠35, keys[2]=35 ✅

5. Load Factor & Performance

Load Factor αChaining (avg probes)Linear Probing (avg probes)
0.51.251.5
0.71.722.17
0.93.125.54
1.0∞ (infinite)∞ (table full)
α = n/m (number of entries / table size) Rule: Resize when α > 0.7 for linear probing, α > 0.75 for chaining.

6. Comparison

FeatureChainingLinear Probing
MemoryExtra for linked listsContiguous, cache-friendly
DeletionEasyTombstones needed
Performance degradesGracefullyQuickly (clustering)
Load factor limitAny (slower as α↑)α < 1
Cache localityPoor (scattered nodes)Excellent

Practice Questions

Q1. Insert keys [10, 22, 31, 4, 15, 28] into a hash table of size 7 using (a) chaining, (b) linear probing. Use h(k) = k % 7. Q2. What is the load factor after inserting 1000 elements into a table of size 2000? Q3. Why is it bad to use a prime number for table size in chaining? Q4. What is a tombstone in open addressing? Why is it needed? Q5. Compare the memory usage of chaining vs linear probing for n = 1000 entries. Q6. Design a hash function for student roll numbers (format: "CS2024XXX"). Q7. Why does Python's dict resizing not cause O(n) slowdown in practice? Q8. What is the worst case for a hash table? How does it degrade to O(n)?
Answers
A1. Chaining: 10→3, 22→1, 31→3, 4→4, 15→1, 28→0. Linear probing would skip occupied slots.
A2. α = 1000/2000 = 0.5.
A3. Prime table size reduces clustering with certain hash functions, but for chaining any size works. With linear probing, prime size reduces primary clustering.
A4. A tombstone marks a deleted slot in open addressing so search doesn't stop early. Without tombstones, deleting an entry would break the search chain for subsequent entries.
A5. Chaining: table (m pointers) + n nodes (each: key + value + next pointer). Linear probing: just the table (keys + values arrays). Chaining uses more memory due to node overhead.
A6. Map the letter prefix to a number and combine with the digits: h(roll) = (26²·CS_value + 26·year_value + digits) % table_size.
A7. Resizing doubles capacity and rehashes all entries. This is O(n) but happens rarely (amortized O(1)). Python's dict resizing is highly optimized in C.
A8. When all keys hash to the same bucket — O(n) for all operations. With good hash functions, this is astronomically unlikely. Join Discord Previous13. Dynamic Arrays & Amortized AnalysisNext15. Binary Trees & Traversals
Document outline

Keep your place and jump directly to a heading.

Table of Contents
System Normal // Awaiting Context

Intelligence Hub

Navigate the knowledge graph to generate context. The Hub adapts dynamically to surface backlinks, related notes, and metadata insights.