Search Tech Journey

Find topics, journeys and posts

6-month learning plan24 / 130
back to blog
pythonbeginner 50m read

S024 · Hashmaps & Sets — Hash Functions, Collisions

The single data structure that turns O(n) loops into O(1) lookups — how it actually works under the hood, why it can go pathologically slow, and the interview patterns you must own.

🧩DSAM03 · Data Structures & Algorithms· Session 024 of 130 90 min

🎯 Understand hashmaps deeply enough to reason about collisions, load factor, hash-flooding attacks, and Python dict internals — and to reach for a hashmap before a nested loop, always.

Why this session exists

If you learn one data structure well, make it the hashmap. It's the single most common speed-up in real code: every O(n) → O(1) lookup, every deduplication, every count-by-key, every join in a database, every route dispatch in a web server, every cache — all hashmaps under different names. This session is the "how it actually works" version — including the two ways it can fall apart at scale.

You will be able to
  • Explain a hash function, a bucket, and a collision to a friend in 60 seconds.
  • Reach for dict or set instead of nested loops when a problem screams ‘lookup by key’.
  • Recognise the classic patterns: two-sum, group-by-anagram, longest-consecutive, subarray-sum-equals-k.
  • Explain amortised O(1) — why one insert is O(n) but the average is O(1).
  • Recognise hash-flooding attacks and know the defence (randomised hash seed).

Prerequisites

  • S023 · Arrays & Strings — you'll compare hashmap solutions against two-pointer.
  • S007 · Python collections — dict, set, defaultdict, Counter.


(a) Intuition · 5 min

A hashmap is a magic coat check
🌍 Real world

You hand a coat check attendant your coat. They compute a number from your name (‘Dinesh’ → 47), walk directly to slot 47, and hang the coat. Later you say ‘Dinesh’, they compute 47 again, walk to slot 47, and hand your coat back. No searching.

Sometimes two names hash to slot 47. Then that slot holds a tiny list of (name, coat) pairs, and the attendant scans the short list to find yours. As long as collisions are rare, this is still effectively O(1).

💻 Code world

d["dinesh"] = "coat" computes hash("dinesh") % table_size and writes the pair into that bucket. d["dinesh"] repeats the hash, jumps to the bucket, and returns the value. One hash + one comparison → O(1) amortised.

The magic breaks when: (1) your hash function returns the same bucket for many keys (collisions), or (2) the table gets full and needs to be doubled + all keys rehashed (resize). Both are managed automatically for you, and understanding them is what separates hashmap users from hashmap wizards.

The three ideas that unlock hashmaps

What you're really learning
  • Hash function — a deterministic, cheap-to-compute mapping from any key to an integer. Same key → same integer, always. Good hash: values are spread uniformly across the range.
  • Collision handling — when two keys hash to the same bucket. Two schemes: chaining (each bucket holds a list) and open addressing (probe the next bucket). CPython uses open addressing.
  • Load factor + resize — when the table is more than ~2/3 full, double its size and rehash everything. This is why insert is O(1) AMORTISED but occasionally O(n).

Timeline — hashmaps become the default

  1. 1953
    Hans Peter Luhn invents hashing
    IBM researcher writes an internal memo on ‘hashing’ for fast lookup on magnetic tape.
  2. 1963
    Robert Morris publishes ‘Scatter Storage Techniques’
    First public paper on hash tables. The name ‘hash’ sticks because it was internal jargon.
  3. 1990
    Perl associative arrays
    Perl 4 makes hash tables a first-class syntax citizen. Every scripting language since copies it.
  4. 2003
    Python dict with open addressing
    Tim Peters rewrites dict with perturbation-based probing. Best-in-class hash table.
  5. 2011
    SipHash & hash-flooding fix
    Aumasson & Bernstein publish SipHash. Every major language adopts randomised hash seeds after DoS attacks against PHP / Python.
  6. 2016
    PEP 468 — dict preserves insertion order
    Python 3.7 makes it official: dicts are ordered. Half of all JSON parsing bugs quietly go away.

(b) Visual walkthrough · 15 min

One insert, step by step

Chaining vs open addressing

Chaining

Java HashMap, Ruby Hash

  • Each bucket = linked list of entries
  • Simple, handles high load factor well
  • Extra pointer per entry
  • Cache-unfriendly for large lists
  • Under attack → very long chains
Open addressing

CPython dict, Rust HashMap

  • Everything in one big array
  • On collision → probe next slots
  • Cache-friendly (fewer allocations)
  • Must keep load factor low (~ 2/3)
  • Uses ‘perturbation’ so bad hashes still spread out
Robin Hood hashing

Rust HashMap default

  • Rich (short-probe) keys yield to poor (long-probe) ones on collision
  • Bounded max probe distance
  • Very consistent latency
  • Popular in performance-critical rewrites
Cuckoo hashing

Redis 6+ for some structures

  • Two hash functions, two candidate slots
  • On conflict, kick out the existing entry
  • Guaranteed O(1) lookup
  • Trickier insert, needs backup rehash

The classic hashmap patterns

11
‘Have I seen X before?’ → set

add + O(1) membership. Use for deduplication, cycle detection, ‘is this in the whitelist?’.

22
‘Count by key’ → Counter / defaultdict(int)

counts[key] += 1. Use for frequency tables, mode, word counts, top-K.

33
‘Group by key’ → defaultdict(list)

groups[key].append(item). Use for group-by-anagram, group-by-day, bucketing.

44
‘Complement lookup’ → dict

‘Have I seen target − x before?’ → the two-sum family. Store what you've seen; ask if the mate is there.

55
‘Memoisation’ → dict cache

cache[(args)] = result. The building block of dynamic programming (S033) and any expensive pure function.

When NOT to use a hashmap

Cases where a hashmap is the wrong tool

You need order by key
Use a sorted structure (SortedList, tree map, or sort the dict.items()). Python dicts preserve INSERTION order, not sorted order.
wrong tool
You need range queries (‘keys between 100 and 200’)
Hashmap has no locality. Use a sorted container or a BST/heap.
wrong tool
Keys are dense small integers 0..N
A plain list/array is faster and more memory-efficient. Hash overhead is pure loss.
wrong tool
You need cryptographic guarantees
Python's hash() is FAST, not secure. Use hashlib (SHA-256) for anything security-relevant.
wrong tool

Common misconception
✗ What most people think

"Dict and set lookups are O(1), so a hash lookup costs the same no matter what I put in them."

✓ What is actually true

O(1) is amortised average under the assumption that hashes spread keys evenly. Worst case is O(n) — every key landing in one bucket. And the constant factor is dominated by hash() itself, which is O(len) for strings and tuples, not O(1).

Why the myth is so sticky

Because the myth is true for the case you meet first: small dicts with short string keys, where Python caches the string hash on the object after the first computation. You never see the cost. It bites when keys are long tuples, big frozensets, or DataFrame index objects — hashing a 40-char composite key is 40 bytes of work before the bucket jump, and you pay it on every single lookup inside a loop.

Prove it to yourself

The hash cost is not free, and it scales with key size:

import timeit
short = 'a' * 8
long  = 'a' * 100000
d = {short: 1, long: 2}

# str hash is cached on the object after first use, so hash a FRESH copy
print(timeit.timeit(lambda: hash('a' * 8), number=100000))
print(timeit.timeit(lambda: hash('a' * 100000), number=100000))
# second is orders of magnitude slower - O(len), not O(1)
From first principles
Start with the question

Why must a hash table resize itself long before it is full — typically around 2/3 load factor — rather than at 100%? Wasting a third of your memory looks arbitrary. It isn't.

  1. 1
    A hash table maps a key to a slot via hash(key) % capacity. Two distinct keys can land in the same slot — a collision.
    forced by · the key space is unbounded and the slot count is finite; collisions are guaranteed by pigeonhole
  2. 2
    CPython resolves collisions by open addressing: probe to another slot in the same array until an empty one is found.
    forced by · probing keeps everything in one contiguous array — cache-friendly, no per-entry pointer allocation
  3. 3
    The expected number of probes for an insert into an open-addressed table with load factor a grows roughly as 1/(1-a).
    forced by · each probe has ~a chance of hitting an occupied slot, so the probe count is a geometric series in a
  4. 4
    That function is flat until it isn't. At a=0.5 it is ~2 probes; at 0.66 it is ~3; at 0.9 it is ~10; at 0.99 it is ~100.
    forced by · 1/(1-a) has a pole at a=1 — the cost does not degrade gracefully, it explodes
  5. 5
    So there is a knee. Below it you buy huge speed for modest memory; above it you buy tiny memory for catastrophic slowdown.
    forced by · the cost curve is convex, so the optimum sits where its slope starts rising steeply
⇒ Therefore

Therefore the table must grow (usually doubling, and rehashing everything) at a load factor near the knee — CPython uses 2/3. The ~33% waste is the price of staying on the flat part of the probe curve.

And note what this predicts: inserts are not uniformly cheap. Most are O(1), but the one that triggers a resize is O(n) because every key is rehashed into the new array. That is why "amortised O(1)" is the honest phrasing, and why a latency-sensitive path should pre-size its dict rather than grow it incrementally.

Mental modelCoat-check with numbered pegs

A hash table is a wall of numbered pegs. hash(key) is a function that turns your coat into a peg number instantly. You hang it there. To retrieve, you recompute the number and walk straight to that peg — you never search the wall.

If the peg is taken, you walk right until you find a free one. That is the whole data structure. Everything else — resizing, load factors, the immutability rule for keys — is a consequence of "the peg number must be computable and must never change".

  • Cost = hash(key) + a small number of probes. The probe count is what O(1) refers to; the hash cost is not constant.
  • Keys must be immutable, because a key whose peg number changes after hanging becomes permanently unreachable.
  • A set is a dict with the values thrown away — same pegs, same rules, same costs.
  • Order is not part of the contract of a hash table. CPython dicts happen to preserve insertion order since 3.7; set does not and never will.
🔔 Fires when you see

Fire this model the moment you see: an O(n²) nested loop that could be an O(n) dict join · unhashable type · a "random" iteration order changing between runs · a group-by written by hand · a de-dup pass · a lookup inside a hot loop that is somehow slow (check your key size) · a Spark shuffle skewing to one partition (same maths, different scale).

The tradeoff

You need to check membership repeatedly against a collection of N items. Keep it as a list, or build a set?

Scan a list
+ you gain zero build cost, zero extra memory, works with unhashable elements, and preserves order and duplicates
− you pay every membership test is O(N); K tests cost O(N·K), which is the single most common accidental quadratic in data code
pick when N is tiny (a handful of items) or you test membership only once or twice — building a set costs a full pass, so one lookup never pays for it
Build a set / dict
+ you gain each test drops to ~O(1); K tests cost O(N + K) instead of O(N·K)
− you pay one O(N) build pass, roughly 3–5× the memory of a list of the same items (empty slots + hash storage), elements must be hashable, order and duplicates are lost
pick when you will test more than a handful of times against the same collection — the crossover is low, effectively K greater than a small constant
Sort once, then binary search
+ you gain O(N log N) once then O(log N) per test, with contiguous memory and no hashing; supports range queries a set cannot answer
− you pay slower per lookup than hashing, requires a total order, and the collection must be static — an insert is O(N)
pick when you also need nearest / range / ordered-iteration semantics, or memory is tight enough that the set's overhead matters
What a senior engineer actually does

Reach for the set. The build pass is one linear scan and it converts a quadratic into a linear — that is the highest-leverage single-line change in most data pipelines. The mistake to avoid is rebuilding the set inside the loop, which silently restores the O(N·K) you were trying to kill.

Sorted-array-plus-binary-search is the one to remember when the question drifts from "is it present?" to "what is nearest?". Hash tables destroy order by design; the moment you need order back, hashing is the wrong tool and no amount of tuning fixes it.


(c) Hands-on · 25 min

Save as hashmaps.py. Zero dependencies beyond stdlib.

"""hashmaps.py — the canonical hashmap patterns + a mini hashmap from scratch.
 
Run:  python hashmaps.py
"""
from __future__ import annotations
from collections import defaultdict, Counter
 
 
# ------------------------------------------------------------------
# 1) Two Sum — the ‘complement lookup’ pattern
# ------------------------------------------------------------------
def two_sum(nums: list[int], target: int) -> tuple[int, int] | None:
    """Return indices (i, j) with nums[i] + nums[j] == target. O(n) time."""
    seen: dict[int, int] = {}                # value -> index
    for i, x in enumerate(nums):
        need = target - x
        if need in seen:                     # O(1) lookup
            return seen[need], i
        seen[x] = i
    return None
 
 
# ------------------------------------------------------------------
# 2) Group Anagrams — the ‘group by key’ pattern
# ------------------------------------------------------------------
def group_anagrams(words: list[str]) -> list[list[str]]:
    """Group words that are anagrams of each other. Key = sorted characters."""
    groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
    for w in words:
        key = tuple(sorted(w))
        groups[key].append(w)
    return list(groups.values())
 
 
# ------------------------------------------------------------------
# 3) Top-K frequent — the ‘count by key’ pattern
# ------------------------------------------------------------------
def top_k_frequent(nums: list[int], k: int) -> list[int]:
    counts = Counter(nums)
    return [val for val, _ in counts.most_common(k)]
 
 
# ------------------------------------------------------------------
# 4) Longest Consecutive Sequence — the ‘set as membership oracle’ pattern
# ------------------------------------------------------------------
def longest_consecutive(nums: list[int]) -> int:
    """Longest run of consecutive integers, in any order. O(n)."""
    pool = set(nums)
    best = 0
    for x in pool:
        # Only start counting from the LEFT end of a run.
        if x - 1 in pool:
            continue
        length = 1
        while x + length in pool:
            length += 1
        best = max(best, length)
    return best
 
 
# ------------------------------------------------------------------
# 5) Subarray Sum Equals K — the ‘prefix-sum + hashmap’ pattern
# ------------------------------------------------------------------
def subarray_sum_equals_k(nums: list[int], k: int) -> int:
    """Count contiguous subarrays whose sum == k. O(n).
 
    Trick: if prefix_sum[j] − prefix_sum[i-1] == k, that's a valid subarray.
    So we count, at each j, how many previous prefix sums equal prefix_sum[j] − k.
    """
    count = 0
    prefix = 0
    seen: dict[int, int] = {0: 1}            # sum 0 has occurred once (empty prefix)
    for x in nums:
        prefix += x
        count += seen.get(prefix - k, 0)     # O(1) lookup
        seen[prefix] = seen.get(prefix, 0) + 1
    return count
 
 
# ------------------------------------------------------------------
# 6) A toy hashmap — see the machinery
# ------------------------------------------------------------------
class MiniHashMap:
    """Open-addressing hashmap with linear probing. ~50 lines of the real thing."""
    _EMPTY = object()
    _TOMBSTONE = object()
 
    def __init__(self, capacity: int = 8) -> None:
        self._cap = capacity
        self._size = 0
        self._keys = [self._EMPTY] * capacity
        self._vals = [None] * capacity
 
    def _probe(self, key):
        """Yield indices to probe for `key`."""
        h = hash(key) & (self._cap - 1)      # cheap mod when cap is a power of 2
        for step in range(self._cap):
            yield (h + step) & (self._cap - 1)
 
    def __setitem__(self, key, value):
        if (self._size + 1) * 3 >= self._cap * 2:   # >2/3 full → resize
            self._resize()
        for idx in self._probe(key):
            slot = self._keys[idx]
            if slot is self._EMPTY or slot is self._TOMBSTONE or slot == key:
                if slot is self._EMPTY or slot is self._TOMBSTONE:
                    self._size += 1
                self._keys[idx] = key
                self._vals[idx] = value
                return
 
    def __getitem__(self, key):
        for idx in self._probe(key):
            slot = self._keys[idx]
            if slot is self._EMPTY:
                raise KeyError(key)
            if slot is not self._TOMBSTONE and slot == key:
                return self._vals[idx]
        raise KeyError(key)
 
    def _resize(self):
        old_keys, old_vals = self._keys, self._vals
        self._cap *= 2
        self._keys = [self._EMPTY] * self._cap
        self._vals = [None] * self._cap
        self._size = 0
        for k, v in zip(old_keys, old_vals):
            if k is not self._EMPTY and k is not self._TOMBSTONE:
                self[k] = v
 
 
if __name__ == "__main__":
    print("two_sum [2,7,11,15] target=9 :", two_sum([2, 7, 11, 15], 9))
    print("group_anagrams:", group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
    print("top_k [1,1,1,2,2,3] k=2 :", top_k_frequent([1, 1, 1, 2, 2, 3], 2))
    print("longest_consecutive [100,4,200,1,3,2] :", longest_consecutive([100, 4, 200, 1, 3, 2]))
    print("subarray_sum k=2 [1,1,1] :", subarray_sum_equals_k([1, 1, 1], 2))
 
    m = MiniHashMap()
    for w in "the quick brown fox jumps over the lazy dog".split():
        m[w] = m.__getitem__(w) + 1 if w in ("the",) and w in m._keys else 1
    print("MiniHashMap 'the' :", m["the"])
    print("MiniHashMap 'fox' :", m["fox"])

Anatomy of the script

What each block teaches

two_sum
The canonical complement-lookup pattern. Note: we store AFTER checking, so a value never matches itself.
pattern
group_anagrams
Group-by pattern with a derived key (sorted chars). tuple() because lists aren't hashable.
pattern
top_k_frequent
Counter is a dict subclass with .most_common(k) built-in — O(n + k log n) via a heap under the hood.
pattern
longest_consecutive
The trick is ‘only start from the left end of a run’ — that check keeps the total work O(n) instead of O(n²).
pattern
subarray_sum_equals_k
Prefix-sum + hashmap: an O(n²) brute force collapses to O(n). Learn this — it appears in dozens of interviews.
pattern
MiniHashMap._probe
Linear probing with power-of-2 capacity: hash mask instead of mod. Fast, but bad hashes cause clustering — real CPython uses a perturbation scheme.
internals
MiniHashMap._resize
When >2/3 full, double the capacity and rehash every live key. This is why insert is amortised O(1), not strict O(1).
internals
Try itAdd a delete method to MiniHashMap using the tombstone technique

Add this method to MiniHashMap:

def __delitem__(self, key):
    for idx in self._probe(key):
        slot = self._keys[idx]
        if slot is self._EMPTY:
            raise KeyError(key)
        if slot is not self._TOMBSTONE and slot == key:
            self._keys[idx] = self._TOMBSTONE
            self._vals[idx] = None
            self._size -= 1
            return
    raise KeyError(key)

Test:

m = MiniHashMap()
m["a"] = 1; m["b"] = 2; del m["a"]
try: m["a"]
except KeyError: print("a correctly gone")
print(m["b"])  # still works — probe skips the tombstone
💡 Hint · Deletion in open addressing can't just clear the slot — that would break the probe chain for later keys. Instead, mark the slot as TOMBSTONE. Lookups skip tombstones but keep probing. Inserts can reuse tombstone slots.

(d) Production reality · 15 min

War story Django / Ruby on Rails / PHP · 2011· 2011internet-wide DoS advisory
🔥 What broke

Aumasson & Bernstein disclosed hash-flooding: an attacker crafts hundreds of form fields whose keys all hash to the same bucket. The web framework's request.POST dict becomes O(n²) per insert → one HTTP POST pins a CPU for minutes.

A single laptop could DoS an entire web fleet by sending malicious form data with ~50k keys.

🧯 The fix

Every major language shipped emergency fixes:

  • Python: PYTHONHASHSEED randomises the hash on every interpreter start (PEP 456).
  • Ruby: switched to a keyed hash (SipHash-2-4).
  • Frameworks: cap form field count (Django's DATA_UPLOAD_MAX_NUMBER_FIELDS).
🎓 Lesson to steal
‘hash()’ is fast, deterministic within a process, and DIFFERENT across processes. Never persist Python hashes to disk or across a cluster — they will not match. Use hashlib for stable hashes.
Post-mortem
War story Google · early Gmailcache-miss latency mystery
🔥 What broke
Gmail's tag lookup was a hashmap keyed by (user_id, tag_name). Latency was fine in benchmarks, terrible in production — same code, 100× slower under real load.
🧯 The fix
The hash function had good statistical distribution but terrible cache locality: consecutive requests landed on totally different buckets and every lookup was a fresh L3 cache miss. Fix: change the key ordering so requests from the same user cluster in the same cache line.
🎓 Lesson to steal
Big-O ignores cache. At scale, memory locality can dominate over algorithm choice. Profile with perf / cachegrind, don't just measure wall-clock.
War story Common failure mode · every ML pipelinesilent bug family
🔥 What broke
An ML feature pipeline uses dict.items() to iterate features. Someone upgrades from Python 3.6 to 3.7 in one service and 3.5 in another. The feature vectors come out in different orders depending on Python version → subtle model score drift → mysterious A/B test regression.
🧯 The fix
Never depend on dict order in code that spans processes / versions unless you're on Python ≥ 3.7 EVERYWHERE. When in doubt, sorted(dict.items()). Or use collections.OrderedDict if you want to be explicit.
🎓 Lesson to steal
‘Dicts preserve insertion order’ is a Python 3.7+ guarantee. It's a 3.6 implementation detail. It's undefined in older versions. Version-pin your interpreter or sort your keys.

Where this shows up in the rest of the plan

Hashmaps power almost everything
S033 · Dynamic Programming
Memoisation cache = dict[args → result]. Every DP has a dict inside.
S037 · SQL Joins
Hash join = ‘build a hashmap of the small table, probe with the big one’. Same idea, database-scale.
S062 · Caching & Redis
Redis is a hashmap-as-a-service. Same collisions, same load factor, network-attached.
S085 · Tokenisation
BPE merge tables are hashmaps. Vocabulary lookups are hashmaps.
S102 · Consistent Hashing
Distributed hashmaps — how caches like memcached split keys across nodes.
S127 · Rate Limiting
Token buckets keyed by user_id or IP — again, a hashmap under the hood.

(e) Recall + stretch · 10 min

Recall — click to reveal · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, one minute each, no notes:

  1. What is a hash function and why does it turn lookups from O(n) to O(1)?
  2. What's a collision, and how do open-addressing hashmaps deal with it?
  3. Give one concrete case where a hashmap is the WRONG data structure.

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.