02 — Hash Tables, Sets & $O(n)$ Hash Joins
At a glance
After completing this lesson you can:
- Explain how CPython implements dictionaries and sets using compact hash tables and open addressing with perturbation.
- Turn $O(n \times m)$ nested loop lookups into $O(n + m)$ hash joins across large relational-style datasets.
- Diagnose hash collisions, unhashable type errors (
TypeError: unhashable type), and mutable key corruption.
Time: ~50 minutes · Prerequisites: 01 — Complexity & Dynamic Arrays
Reading Companion
- Book: Grokking Algorithms (2nd Edition) — Aditya Y. Bhargava
- Chapter 5: Hash Tables (Hash functions, Collisions, Performance).
- Book: Fluent Python (2nd Edition) — Luciano Ramalho
- Chapter 3: Dictionaries and Sets (Modern dict implementation, set operations, hashability).
Why this matters in production
Merging two datasets (such as matching 50,000 orders with 10,000 customer records) with a naive nested loop for order in orders: for user in users: if order.user_id == user.id: ... requires $500,000,000$ comparisons, locking worker CPUs for seconds or minutes. Building a hash index first (user_index = {u.id: u for u in users}) cuts this to $60,000$ operations—a $8,300\times$ speedup executed in milliseconds.
Deep dive
1. Hashability in Python
An object is hashable if:
- It implements
__hash__()returning an integer that never changes during its lifetime. - It implements
__eq__()for equality comparisons. - If
a == b, thenhash(a) == hash(b)must hold.
Mutable containers (list, dict, set) are unhashable. Tuples are hashable only if all their elements are hashable.
2. CPython's Compact Dict Representation (Raymond Hettinger design)
Since Python 3.6, dicts use a split table design:
- An indices array mapping hash slots to entries.
- An entries array storing keys, values, and precomputed hashes in insertion order.
Compact Dict Layout:
indices: [-1, 0, -1, 1, -1] <- sparse table
entries: [
(hash_a, "user_id", 101), <- dense array (compact & ordered)
(hash_b, "tenant", "acme"),
]
Hands on
python3 phase1-cs-and-architecture/01-data-structures/02-hash-tables-and-sets/examples/01_demo.py
python3 phase1-cs-and-architecture/01-data-structures/02-hash-tables-and-sets/examples/02_trap.py
AI Trap & Code Review
# BAD AI-GENERATED CODE — O(N*M) NESTED JOIN IN API HANDLER
def enrich_orders_with_customers(orders: list[dict], customers: list[dict]) -> list[dict]:
enriched = []
for order in orders:
for cust in customers: # Nested loop scan O(n * m)!
if cust["id"] == order["customer_id"]:
enriched.append({**order, "customer_name": cust["name"]})
break
return enriched
Exercises
- Rung 1 (
make_hashable_signature): Convert an arbitrary nested JSON-like dict (containing lists, strings, numbers, dicts) into a deeply immutable, hashable tuple representation. - Rung 2 (
hash_join_datasets— Fix the AI Bug): Refactor the $O(n \times m)$ nested loop join into an optimal $O(n + m)$ hash join supporting left outer joins. - Rung 3 (
detect_frequency_anomalies): Given a stream of tenant events, find all tenants whose event count exceeds a threshold $k$ in single-pass $O(n)$ time and $O(U)$ memory. - Rung 4 (
two_sum_hash_lookup): Find pair of indices whose values sum totargetin strictly single-pass $O(n)$ time. - Rung 5 (
custom_hash_set): Implement a collision-handling set data structure using a fixed-size bucket array with chaining.
Wrapping up
Can you explain?
- Why does a custom class implementing
__eq__without__hash__become unhashable in Python 3? - How does an inner hash join differ from a left outer hash join?
- What is the worst-case time complexity of a hash table lookup and when does it occur?
Further Reading & References
- CPython Source: listobject.c (Dynamic Array Implementation)
- Raymond Hettinger: Modern Dictionaries by Key-Sharing
- Alex Xu: System Design Interview — Rate Limiter Design
- Tim Roughgarden: Algorithms Illuminated Series
Show reference solution notes
Solution & Engineering Notes — 02 Hash Tables & Sets
- Why $O(n + m)$ vs $O(n \times m)$: By first hashing $m$ records into an in-memory hash map ($O(m)$), matching $n$ left records takes $n \times O(1) = O(n)$ lookups, bringing total complexity to $O(n + m)$.
- Left Outer Joins: In a left join, if a record in
lefthas no match inright, it is still emitted with its existing fields intact. - Collision handling: Python's internal dictionary resolves collisions using open addressing with perturbation:
j = (5*j + 1 + perturb) & mask.
Self-check
Run python3 -m unittest phase1-cs-and-architecture/01-data-structures/02-hash-tables-and-sets/test_exercises.py -v until green, then record it here. Saved to progress/rungs.json; commit it.
Completion edits PROGRESS.md; commit it with the lesson.