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.
🎯 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.
- 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
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).
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
- 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
- 1953Hans Peter Luhn invents hashingIBM researcher writes an internal memo on ‘hashing’ for fast lookup on magnetic tape.
- 1963Robert Morris publishes ‘Scatter Storage Techniques’First public paper on hash tables. The name ‘hash’ sticks because it was internal jargon.
- 1990Perl associative arraysPerl 4 makes hash tables a first-class syntax citizen. Every scripting language since copies it.
- 2003Python dict with open addressingTim Peters rewrites dict with perturbation-based probing. Best-in-class hash table.
- 2011SipHash & hash-flooding fixAumasson & Bernstein publish SipHash. Every major language adopts randomised hash seeds after DoS attacks against PHP / Python.
- 2016PEP 468 — dict preserves insertion orderPython 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
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
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
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
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
add + O(1) membership. Use for deduplication, cycle detection, ‘is this in the whitelist?’.
counts[key] += 1. Use for frequency tables, mode, word counts, top-K.
groups[key].append(item). Use for group-by-anagram, group-by-day, bucketing.
‘Have I seen target − x before?’ → the two-sum family. Store what you've seen; ask if the mate is there.
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
"Dict and set lookups are O(1), so a hash lookup costs the same no matter what I put in them."
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).
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.
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)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.
- 1A 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 - 2CPython 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
- 3The expected number of probes for an insert into an open-addressed table with load factor
agrows roughly as1/(1-a).forced by · each probe has ~achance of hitting an occupied slot, so the probe count is a geometric series ina - 4That 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 - 5So 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 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.
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
setis 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;
setdoes not and never will.
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).
You need to check membership repeatedly against a collection of N items. Keep it as a list, or build a set?
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
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(d) Production reality · 15 min
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.
Every major language shipped emergency fixes:
- Python:
PYTHONHASHSEEDrandomises 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).
(user_id, tag_name). Latency was fine in benchmarks, terrible in production — same code, 100× slower under real load.perf / cachegrind, don't just measure wall-clock.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.sorted(dict.items()). Or use collections.OrderedDict if you want to be explicit.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, one minute each, no notes:
- What is a hash function and why does it turn lookups from O(n) to O(1)?
- What's a collision, and how do open-addressing hashmaps deal with it?
- 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.