Hashing, Sets & Recursion
Two of the most powerful ideas in all of computing. Hashing turns a key into an array index so lookups are O(1) — it's what makes Python's dict and set (and your agent's tool dispatch) fast. Recursion lets a function solve a problem in terms of smaller versions of itself — the natural way to walk trees and graphs, and the seed of memoization, dynamic programming, and backtracking.
Learning objectives
- Explain how a hash function + array gives average O(1) insert/lookup.
- Build a hash table from scratch with both chaining and open addressing.
- Understand load factor, resizing, and why keys must be immutable/hashable.
- Use hashing patterns: dedup, frequency counts, grouping, O(1) membership.
- Write correct recursion (base case + smaller subproblem) and convert it to memoized DP.
- Solve constraint problems with backtracking.
1 · The hashing idea basic
Picture the coat check at a big theatre. A thousand people hand over coats; at the end of the night everyone needs their own coat back, fast. The slow way is to pile them up and search — pull out coat after coat until you find the right one (that's a linear scan, O(n), and the queue would stretch out the door). The clever way is the numbered ticket: your coat goes on peg 237, your stub says 237, and the attendant walks straight to peg 237. No searching. That numbered-ticket trick is exactly what hashing does — and it's why looking a value up in a Python dict feels instant no matter how much you've stored.
Here's the tension it resolves. Back in D1 you learned that jumping to array[i] is O(1) — free at any size — but only if you already know the index i. Real keys aren't indices: you want to look things up by a tool name like "weather", a document ID, a word. So the whole problem becomes: how do I turn an arbitrary key into an array index? That translator is the hash function. It takes a key — a string, a number, a tuple — and grinds it into an integer. Then you take that integer mod the array size (hash(key) % size) to squeeze it into a valid slot number. Store the value at that slot; to fetch it later, hash the same key again, get the same slot, walk straight there. Because both the hash and the array jump are constant-time, the lookup is O(1) on average — no scanning, ever.
The one place beginners get the mental model wrong: they imagine the hash function "remembers" where it put things, like a little database. It remembers nothing. It's a pure, stateless calculation — feed it the same key and it always spits out the same number, which is precisely why you can find your value again: you don't look it up, you recompute its address. That also explains a rule that otherwise seems arbitrary — keys must be immutable. If a key could change after you stored it, re-hashing it later would compute a different slot, and your value would be stranded at the old address, unfindable. Hold that thought; the lab and the warning box right below make it concrete, and it's the seed of every collision headache in §3.
% size squeezes it to a valid slot, and the value goes there — recompute the same slot on lookup, so it's O(1), never a scan.
Follow the top row left-to-right: it is the whole hashing pipeline, from a raw key to the exact array slot its value lands in.
- The purple box on the left is the key you actually have — a string like
"weather", not an index. - The two blue boxes are the translation:
hash(key)grinds it to a big integer, then% 8(the table size) squeezes that integer down to a legal slot number — here, 2. - The row of numbered cells is the array; the green cell 2 is where the value is stored. The down-arrow shows the slot number picking out that cell.
- The payoff (bottom-left): to fetch the value later you re-run the same calculation on the same key, get slot 2 again, and jump straight there — nothing is remembered, nothing is searched.
In short: Key → integer → slot number → direct array jump. Because every step is constant-time, lookup is O(1) on average — and because the address is recomputed, keys must be immutable.
pythonhash("checkout-api") # some big int (varies per run for strings)
hash(42) # 42 — small ints hash to themselves
hash((1, 2)) # tuples are hashable (immutable)
# hash([1, 2]) -> TypeError: unhashable type: 'list' (mutable!)
# slot = hash(key) % table_size -> where to store/find the value
size = 8
for key in ["a", "b", "c"]:
print(key, "-> slot", hash(key) % size)
This first block shows the one idea hashing is built on: a hash function takes any key (a word, a number, a tuple) and turns it into an integer. Once you have an integer, you can turn it into an array slot number — and jumping straight to a known array slot is instant (that's the O(1) you keep hearing about).
hash("checkout-api")feeds a string into Python's built-in hash function and gets back a big integer. For strings the number changes each time you start Python (a security feature), but within one run it's stable.hash(42)returns42: small whole numbers just hash to themselves.hash((1, 2))works because a tuple is immutable (it can never change), so its hash is safe to rely on.- The commented-out
hash([1, 2])would raiseTypeError: unhashable type: 'list'. A list can change, so its hash could change too — Python forbids using it as a key. - The last three lines are the whole trick:
slot = hash(key) % size. The%(remainder) squeezes any giant hash integer down into the range0 .. size-1, i.e. a valid index into an array of that size.
What the output means: For each of "a", "b", "c" you get a line like a -> slot 5 — the array position where that key's value would live. Different keys usually land in different slots.
Try this: Change size from 8 to 4 and re-run: the slot numbers shrink (0–3) and you're more likely to see two keys share a slot — that shared-slot situation is a collision, the topic of §3.
list and dict can't be keys but str, int, and tuple can. A good hash function also spreads keys evenly so slots fill uniformly; a bad one clusters everything and destroys performance.2 · A hash table from scratch (chaining) intermediate → advanced
Reading about hashing and building one are different experiences — and the second is where it finally clicks. You've used dict a thousand times without a clue what's inside; in the next 25 lines you'll write the whole machine yourself, and afterwards Python's dict will never feel like magic again. There's a deep payoff to doing this by hand: you'll see exactly where the O(1) comes from, and exactly what makes it decay.
The first thing any real hash table must confront is the awkward fact you'll meet head-on in §3: two different keys will sometimes land in the same slot. That's a collision, and it's not rare or avoidable — it's guaranteed. So a hash table isn't really "a hash function plus an array"; it's "a hash function plus an array plus a plan for collisions." The plan we build here is the simplest one, and it has a lovely physical analogy: the coat pegs in a school cloakroom, where each numbered peg can hold several coats on one hook. In hashing terms that's separate chaining — each array slot holds not a single value but a small list (a "bucket") of (key, value) pairs. When two keys collide, you don't panic and you don't overwrite; you just hang the newcomer on the same peg. To look a key up, you jump to its slot and scan that one short bucket.
Why is that still fast? Because if your hash spreads keys evenly and you don't overstuff the table, each bucket stays tiny — one or two entries — so "scan the bucket" is basically constant work. The average case is O(1). The gotcha to keep in the back of your mind is the worst case: if every key happens to hash to the same slot (a terrible hash function, or an attacker feeding you adversarial keys), all your entries pile onto one peg and lookup degrades to a full linear scan — O(n). That's why the class below also tracks a load factor and resizes when buckets get crowded, doubling the array and re-hashing everything to spread the load back out. Watch for that resize logic as you read — it's the same amortized-doubling trick you saw with Python's list in D1, and it's what keeps the "average O(1)" promise honest as the table grows.
This picture shows the simplest way to build a real hash table, called separate chaining. Read it left-to-right: the key is hashed to a slot number, and each slot holds a little list (a bucket) of the entries that landed there.
- The tall column of numbered boxes on the left (0–4) is the array of slots — the backbone of the table. Each number is one slot / index.
- An arrow leaving a slot points to that slot's bucket: the small list of
key→valueentries stored there. Slot 2's arrow leads to"weather"→fn. - Slot 2 has two boxes chained in a row (
"weather"then"scale", marked ← collided). That means both keys hashed to slot 2 — a collision — so the second one is just appended to the same bucket. Nothing is overwritten. - The caption's timing: if buckets stay short, a lookup checks only a handful of entries, so it's effectively O(1). Only in the rare worst case where everything collides into one bucket does it degrade to O(n) (scanning the whole list).
In short: A collision is normal, not a bug. Chaining handles it by letting a slot hold more than one entry — like several coats on the same numbered peg.
pythonclass HashMap:
def __init__(self, size=8):
self._buckets = [[] for _ in range(size)] # list of buckets
self._n = 0 # item count
def _slot(self, key):
return hash(key) % len(self._buckets)
def put(self, key, value):
bucket = self._buckets[self._slot(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
bucket[i] = (key, value) # update existing
return
bucket.append((key, value)) # new key
self._n += 1
if self._n / len(self._buckets) > 0.75: # load factor threshold
self._resize()
def get(self, key, default=None):
for k, v in self._buckets[self._slot(key)]:
if k == key:
return v
return default
def _resize(self):
old = [pair for b in self._buckets for pair in b]
self._buckets = [[] for _ in range(len(self._buckets) * 2)]
self._n = 0
for k, v in old: # rehash everything into the bigger table
self.put(k, v)
m = HashMap()
m.put("weather", get_weather := "fn")
m.put("scale", "fn2")
print(m.get("weather")) # 'fn'
Here you build a working dictionary from scratch using chaining. This is the exact machinery inside Python's dict, shown in ~25 lines. Follow how a key becomes a slot, and how the class keeps buckets short as it grows.
__init__createsself._bucketsas a list of empty lists — one empty bucket per slot — andself._n, a running count of how many items are stored._slot(key)is the hash-to-index step from §1:hash(key) % len(self._buckets)gives the slot number for any key.putfinds the right bucket, then loops through it. If the key is already there it overwrites the value (a dict has no duplicate keys); otherwise it appends a new(key, value)pair and bumps the count.gethashes to the same slot and scans just that one short bucket for the key — checking a few entries, not the whole table. That's why lookup is fast on average.- The load factor check
self._n / len(self._buckets) > 0.75watches how full the table is. When buckets start getting crowded it calls_resize, which doubles the number of slots and re-inserts (rehashes) every existing pair so buckets stay short again.
What the output means: print(m.get("weather")) prints fn — the value you stored under "weather", fetched by hashing straight to its slot rather than scanning.
Try this: After the puts, add print(m.get("missing")). Because get has default=None, an unknown key returns None instead of crashing — the same behaviour as dict.get.
3 · Collisions — the heart of the matter advanced
Here's the fact that surprises everyone the first time: collisions aren't an edge case you might hit — they're mathematically unavoidable, and you'll hit them almost immediately. There's a famous, spooky-feeling version of this called the "birthday paradox": in a room of just 23 people, it's more likely than not that two share a birthday. The same counting logic applies to hash slots. You don't need anywhere near "as many keys as slots" before two keys collide — you need shockingly few. So a hash table that only worked when nothing ever collided would be useless in practice. Collisions are the normal weather, not a storm.
The unbeatable reason is the pigeonhole principle: if you have more pigeons than pigeonholes, at least one hole must hold two pigeons. A hash function maps a practically infinite universe of possible keys (every string you could ever type) down onto a finite array of slots — so it must map some different keys to the same slot. No hash function, however clever, escapes this. That reframes the whole subject: the interesting question was never "how do I avoid collisions?" (you can't) but "how do I handle collisions gracefully when they happen?" Every hash table in existence is defined by its answer to that one question — its collision-resolution strategy.
You've already met one answer: chaining, the list-per-slot approach from §2. Its cost profile is the table below, and the shape is worth internalising because it's the shape of all hashing. The common misconception to shed here is treating "O(1) average" and "O(n) worst case" as a contradiction — they're not; they describe different situations. With a decent hash function and a load factor kept in check (buckets short), the average lookup touches a handful of entries and behaves as O(1). The worst case — every single key colliding into one bucket — collapses to a linear O(n) scan, but it's a pathological scenario you engineer against (good hash, resizing) rather than one you meet by accident. The very next section shows the other major strategy, open addressing, which handles the same collisions with no bucket lists at all.
Read it left-to-right: a pile of keys on the left, a smaller row of slots on the right, and arrows squeezing the former into the latter.
- The purple boxes on the left are six distinct keys — more keys than the table has slots.
- The four stacked boxes on the right are the array's slots. There are only four of them, so six keys cannot each get a private slot.
- The arrows are the hash function assigning keys to slots. By simple counting, at least one slot is forced to receive two keys.
- The red slot 2 is that forced double-up — a collision. It is not a bug or bad luck; with fewer slots than keys it is mathematically unavoidable.
In short: More pigeons than holes ⇒ some hole holds two. That is why every hash table needs a collision-resolution plan — the real design question is how you handle a collision, never whether one happens.
| Operation | Average | Worst case |
|---|---|---|
| insert / lookup / delete | O(1) | O(n) (all collide) |
| iterate all | O(n) | O(n) |
4 · Open addressing (linear probing) expert essential
Think about parking in a busy lot where the rule is "park in your assigned spot." You drive to spot 12 — but someone's already there. You don't build a second deck on top of spot 12 (that's what chaining does with its bucket lists); you simply roll forward to spot 13, then 14, until you find an open space, and you remember "I'm somewhere near 12." That roll-forward-until-you-find-a-gap is open addressing, and it's a fundamentally different answer to the collision question than chaining: instead of hanging overflow off to the side, it keeps everything inside the one array.
Mechanically, when a key's home slot is occupied you probe — check the next slot, then the next, wrapping around the end back to the front — until you land on an empty slot. The simplest probe sequence, "just try slot+1, slot+2, …", is linear probing, and that's what the diagram and lab below implement. Why bother, when chaining already works? Two words: cache locality. A chained table scatters its bucket lists all over memory, so following a bucket means chasing pointers to far-flung addresses — slow for the CPU, which loves reading contiguous memory. Open addressing keeps entries packed side by side in one array, so probing a few neighbours is blisteringly fast on real hardware. This is exactly why CPython's actual dict uses open addressing (with a cleverer, "perturbed" probe sequence rather than plain +1) — it was tuned for speed on real machines over decades.
The trap that catches people, and it's a subtle one: deletion. Suppose weather is at slot 2 and scale probed past it to land at slot 4. If you delete weather and just blank out slot 2, then later search for scale — you hash to 2, find it empty, and conclude "not here," even though scale is sitting right there at slot 4. Blanking the slot broke the probe chain. The fix is a tombstone: a special marker meaning "something was deleted here, keep probing past me." Searches walk through tombstones; inserts may reuse them. It's a small detail with outsized consequences — forget it and your table silently loses entries. Keep an eye out for the _EMPTY sentinel in the code below; a production version would need a distinct tombstone marker alongside it.
This diagram shows the other way to resolve collisions, called open addressing: instead of a separate bucket list per slot, every entry lives directly in the one array. On a collision you probe — walk forward slot by slot until you find an empty one.
- The single row of boxes 0–5 is the whole table — one flat array, no side lists. Some slots already hold
weatherandpods. hash→2under the row means the new key"scale"wants slot 2. The curved arrows trace the probe: slot 2 is taken, so it hops to 3 (also taken), then lands in the free slot 4.- The formula
i = (i + 1) % sizeis the probe step: move to the next index, and the% sizemakes it wrap around from the last slot back to slot 0 so it never runs off the end. - Because entries sit right next to each other in one array, this is cache-friendly (fast for the CPU to scan) — which is why CPython's real
dictuses a smarter version of this approach.
In short: Chaining vs open addressing = 'a list hanging off each slot' vs 'keep walking to the next free slot in the same array'. Both solve collisions; they just store the overflow differently.
python_EMPTY = object() # sentinel for a never-used slot
class OpenAddrMap:
def __init__(self, size=8):
self._keys = [_EMPTY] * size
self._vals = [None] * size
self._n = 0
def put(self, key, value):
i = hash(key) % len(self._keys)
while self._keys[i] is not _EMPTY and self._keys[i] != key:
i = (i + 1) % len(self._keys) # probe the next slot (wraps around)
if self._keys[i] is _EMPTY:
self._n += 1
self._keys[i], self._vals[i] = key, value
def get(self, key, default=None):
i = hash(key) % len(self._keys)
while self._keys[i] is not _EMPTY:
if self._keys[i] == key:
return self._vals[i]
i = (i + 1) % len(self._keys)
return default
This is the open-addressing table in code — the diagram above turned into a class. Watch the while loop: that's the probing (walking to the next slot) you saw in the picture.
_EMPTY = object()makes a unique sentinel — a one-of-a-kind marker meaning "this slot was never used". Using a special object (notNone) meansNoneis still allowed as a real stored value.- Two parallel arrays hold the data:
self._keys(starts all_EMPTY) andself._vals. Sloti's key is_keys[i]and its value is_vals[i]. - In
put,i = hash(key) % len(self._keys)is the starting slot. Thewhileloop advancesi = (i + 1) % len(self._keys)while the slot is occupied by a different key — that's the linear probe, wrapping with%. It stops on an empty slot (new key) or on a matching key (update). getrepeats the same walk from the hashed slot, comparing keys, and returns the value when it matches. If it reaches an_EMPTYslot the key was never inserted, so it returnsdefault.
What the output means: This lab defines the class only (no prints). Create one and try it: mp = OpenAddrMap(); mp.put("x", 1); print(mp.get("x")) prints 1.
Try this: Insert several keys that collide, then print(mp._keys) to see how probing tucked them into nearby slots of the same array — no bucket lists anywhere.
5 · dict and set internals & guarantees intermediate
You've been driving these two every day without opening the hood — and now you can. Every time you wrote if x in seen against a set, or config["timeout"] against a dict, and it came back instantly, you were cashing in the hashing machinery from the last four sections. This is the satisfying moment where the from-scratch work pays off: Python's set and dict aren't special built-in magic — they're the exact hash tables you just built, hand-tuned in C. Knowing that turns "I use dicts" into "I know when a dict will and won't stay fast."
The mental model is simple. A set is a hash table that stores only keys, no values — so it answers exactly two questions superbly: "have I seen this?" (membership) and "give me each unique thing once" (dedup), both O(1) on average. A dict is the same table but each key carries a value alongside it, giving you O(1) keyed lookup. The everyday reflex worth building: the instant your code does "have I already seen this?" or "look this up by name," you want a set or dict, not a list. A list forces a scan (O(n)) for those questions; the hash table makes them free.
Two guarantees are worth pinning down because they trip people up. First, ordering: since Python 3.7 a dict officially preserves insertion order — iterate it and you get keys back in the order you added them. That's a deliberate feature of a compact-array design layered over the hash table, so you can rely on it. A set, by contrast, makes no order promise — never write code that assumes set iteration order, because it can shift between runs or versions. Second — and this is the common mistake — people assume "average O(1)" means "always fast." It doesn't: the same worst-case collision story from §3 still lurks underneath, and (as you saw) a dict resizes as it grows, so an occasional insert quietly pays an O(n) rehash. For virtually all real code these are non-issues, but knowing they exist is the difference between using the tool and understanding it. The lab makes the powers concrete: dedup, membership, set algebra, and keyed access, all backed by the hashing you now understand from the inside.
set stores keys alone (great for "seen it?" and dedup); a dict pairs each key with a value (keyed lookup). Both give average O(1) — a dict also preserves insertion order.
Compare the two panels side by side: same underlying hash machinery, one storing only keys, the other storing keys plus values.
- The left panel is a
set: each box is just a key. That is all a set holds, which is why it shines at membership ("x" in s) and dedup — both O(1). - The right panel is a
dict: each blue key box points to a green value box. Same hashing, but now every key carries a payload you retrieve withd["scale"]. - The dashed divider marks that these are the same structure specialised two ways — not two unrelated types.
- The green labels are the payoff: both do their lookup in O(1) average, and the
dictadditionally hands keys back in insertion order (Python 3.7+); asetmakes no order promise.
In short: set = keys only (membership, dedup); dict = keys + values (keyed lookup). The moment your code asks "seen this?" or "look this up by name," reach for one of these, not a list.
python# set: O(1) membership + dedup
seen = set()
seen.add("docA"); seen.add("docA")
print(len(seen)) # 1 — duplicates collapse
print("docA" in seen) # True — O(1), not a scan
# set algebra — each is O(len) not O(n*m)
a, b = {1,2,3}, {2,3,4}
print(a & b, a | b, a - b) # {2,3} {1,2,3,4} {1}
# dict: O(1) keyed access, ordered by insertion
dispatch = {"weather": "fn", "scale": "fn2"}
print(dispatch["scale"]) # 'fn2' — straight to the slot
Now the payoff: Python's everyday set and dict are just hash tables like the ones you built. This block shows the O(1) powers you get for free.
- A set stores only keys. Adding
"docA"twice keeps just one copy, solen(seen)is1— automatic dedup."docA" in seenis a hash lookup (O(1)), not a scan through every element. - Set algebra:
a & bis intersection (in both),a | bis union (in either),a - bis difference (inaonly). Each runs in time proportional to the set sizes, not the product — far cheaper than nested loops. - A dict maps key→value.
dispatch["scale"]hashes the key and jumps straight to its slot (O(1)). Since Python 3.7 a dict also remembers insertion order, so iterating gives keys back in the order you added them.
What the output means: Prints 1, then True, then {2, 3} {1, 2, 3, 4} {1}, then fn2 — dedup, fast membership, set algebra, and keyed lookup, all backed by hashing.
Try this: Swap the set for a list: seen = [] and use seen.append(...). Now "docA" in seen must scan the list (O(n)) and duplicates are kept — showing exactly what the hash-based set buys you.
DISPATCH[block.name](**args) (Ch 4) — O(1) name→function. The safety policy matrix _MATRIX[rung][risk] (Lab 8c) is nested dicts. Deduping retrieved chunk IDs or "already seen" findings uses a set, exactly as in the workflow loops. You now know the O(1) machinery beneath all of it.6 · The four hashing patterns intermediate → advanced
Sit in on enough coding interviews and you notice something almost funny: a huge share of the "medium" problems are the same four hashing moves wearing different costumes. The candidate who's seen the moves solves them in five minutes; the one who hasn't reinvents them under pressure and runs out of time. These four patterns aren't obscure tricks — they're the everyday tools that turn a slow, obvious solution into a fast one, and once you can name them you'll spot them in the wild constantly.
They all share one engine, which is worth stating outright because it's the whole point: a set or dict lets you replace "search through everything again" with an instant O(1) lookup. Any time your first-draft solution has a loop inside a loop — "for each item, go look through the rest" — that's a red flag for O(n²), and the fix is almost always to remember what you've already seen in a hash table so the inner search vanishes. Here are the four costumes that engine wears: (1) dedup — a set answers "seen this before?" so you keep only firsts; (2) frequency counting — a dict (or Counter) tallies occurrences in one pass; (3) grouping — a defaultdict(list) files items under a shared key; and (4) complement lookup — the "two-sum" move, where you store what you've seen so the partner you need is an O(1) question away.
The mistake to avoid is treating these as four unrelated recipes to memorise. They're one instinct — "can a hash map remember something so I don't have to search for it?" — pointed at four common shapes. Learn the instinct and the four fall out for free; memorise the four and you'll freeze the moment a problem looks slightly different. As you read the lab, notice that every single pattern is really the same question asked about a different thing: have I seen it (dedup), how often (count), what does it belong with (group), or is its partner here (complement).
Read it top-to-bottom: one engine at the top fans out into four named patterns, and each pattern is really just one question in disguise.
- The purple box at the top is the shared engine — a
setordictthat answers questions in O(1). Everything below rests on it. - The middle blue row names the four patterns you meet constantly: dedup, frequency count, grouping, complement (two-sum) lookup.
- The bottom green row is the single question each pattern is really asking of the hash map: seen it? · how often? · belongs with? · partner here? Same instinct, four targets.
- The bottom line is the payoff shared by all four: they let you drop the inner "search everything again" loop, collapsing an O(n²) solution to O(n).
In short: Don't memorise four recipes — learn one reflex ("can a hash map remember this?") and the four patterns fall out of it for free.
pythonfrom collections import Counter, defaultdict
nums = [3, 1, 3, 3, 2, 1]
# 1. Dedup while preserving order (set for membership, list for order)
seen, unique = set(), []
for x in nums:
if x not in seen:
seen.add(x); unique.append(x) # [3, 1, 2]
# 2. Frequency count — O(n)
freq = Counter(nums) # {3: 3, 1: 2, 2: 1}
print(freq.most_common(1)) # [(3, 3)]
# 3. Group items by a key
words = ["apple", "avocado", "banana"]
by_letter = defaultdict(list)
for w in words:
by_letter[w[0]].append(w) # {'a': [...], 'b': [...]}
# 4. Two-sum in O(n) — the hash-map version of D1's two-pointer
def two_sum(nums, target):
pos = {} # value -> index seen so far
for i, x in enumerate(nums):
if target - x in pos: # complement already seen? O(1)
return (pos[target - x], i)
pos[x] = i
return None
These are the four hashing patterns you'll reach for constantly. Each one uses a set or dict to replace slow "search everything again" work with instant O(1) lookups.
- 1 · Dedup keeping order: a
setnamedseenanswers "have I met this before?" in O(1), while alistnameduniquerecords first-seen order. You get uniqueness and original order. - 2 · Frequency count:
Counter(nums)walks the list once and tallies how many times each value appears — a dict of counts built in O(n).most_common(1)returns the top entry. - 3 · Group by a key:
defaultdict(list)auto-creates an empty list the first time a new key is used, soby_letter[w[0]].append(w)groups words by their first letter with no "if key missing" boilerplate. - 4 · Two-sum in O(n): the dict
posremembers each value's index as you go. For eachxyou ask "have I already seentarget - x?" — an O(1) check that turns a brute-force O(n²) search into a single O(n) pass.
What the output means: No prints except freq.most_common(1) → [(3, 3)] (the value 3 appears 3 times). The comments show each result: unique is [3, 1, 2], the grouping is {'a': [...], 'b': [...]}.
Try this: Memorize the pattern-4 question: "can a hash map remember what I've seen so the inner search becomes O(1)?" It's the single most useful speed-up in coding interviews.
7 · Recursion — a function that calls itself basic → intermediate
You've done recursion in real life, even if you'd never call it that. Imagine you're standing in a long cinema queue and want to know your position, but you can only see the person directly in front of you. So you tap their shoulder and ask, "what number are you?" They don't know either — so they ask the person in front of them, and so on, until the question reaches the very front of the line. That first person can answer directly: "I'm number 1." Then the answer travels back: each person adds one and passes it back to whoever asked. That is recursion, exactly: a problem you can't solve directly, so you delegate to a smaller version of the same problem, and combine the answer that comes back.
Every recursive function has precisely two parts, and you should hunt for both every time. The base case is the front of the queue — the trivial input you can answer with no further asking (person number 1; factorial(1) = 1; an empty list). The recursive case is the shoulder-tap — you reduce the problem to a strictly smaller subproblem and call yourself, then do a little work with the result. "Strictly smaller" is the load-bearing word: each call must move measurably closer to the base case, or the questions ripple down the queue forever. There's a beautiful equivalence hiding here that this whole track leans on: recursion and the stack from D2 are two views of the same thing. Each unfinished call is a frame stacked on Python's call stack, waiting; the base case stops the pile growing; then frames pop off one by one as answers combine back up. The diagram below draws that push-down/pop-up staircase.
The mistake that bites every beginner exactly once is the missing (or unreachable) base case. Forget it, or write a recursive step that doesn't actually shrink the input, and the calls stack up without end — Python protects you by capping the stack (around 1000 frames by default) and raising RecursionError rather than crashing your machine, but the bug is yours. So the discipline is a two-question checklist for every recursion you write: "What's my base case — and does every recursive call definitely get closer to it?" Get those two right and the function is correct; the rest is just deciding what small piece of work to do on the way back up. Recursion truly shines on branching, tree-shaped data — nested lists, file trees, the parse trees and graphs you'll meet in D4–D6 — where a loop would be clumsy but "solve me for each smaller part" is the natural fit.
RecursionError.
This diagram is the mental model for recursion — a function that calls itself. It shows factorial(3) as a stack of function calls that first pushes down, hits a stopping point, then pops back up combining results.
- Read the left, downward-stepping boxes top to bottom:
factorial(3)can't finish until it knowsfactorial(2), which needsfactorial(1). Each call pushes a new frame onto the call stack — that's the descending staircase. - The green box at the bottom,
base: return 1, is the base case — the simplest input the function can answer directly, with no further calls. It's what stops the descent. - The right-side arrow and green numbers show the return trip: as each frame gets its answer it pops off and multiplies —
1×1=1, then2×1=2, then3×2=6. The final answer, 6, comes back out of the top frame. - The caption's warning: with no base case the stack would keep pushing frames forever and Python raises
RecursionError. Every recursion needs a base case it can actually reach.
In short: Recursion = a stack you get for free. 'Calls push down' is the problem shrinking toward the base case; 'returns pop up' is the answers combining back together.
pythondef factorial(n):
if n <= 1: # base case — stops the recursion
return 1
return n * factorial(n - 1) # smaller subproblem
# Recursion shines on nested/tree-shaped data — flatten arbitrary nesting:
def flatten(items):
for x in items:
if isinstance(x, list):
yield from flatten(x) # recurse into sub-lists
else:
yield x
print(list(flatten([1, [2, [3, 4]], 5]))) # [1, 2, 3, 4, 5]
Two recursive functions. Every recursion has the same two ingredients: a base case (the trivial input answered directly) and a recursive case (reduce to a smaller version of the same problem and call yourself).
- In
factorial, the base case isif n <= 1: return 1— it stops the chain of calls. Without it the function would call itself forever. - The recursive case
return n * factorial(n - 1)is the same problem on a smaller input (n - 1). Because each call shrinksn, it must eventually hit the base case — matching the push-down/pop-up staircase in the diagram above. flattenshows why recursion suits nested data. For each item, if it's a list it recurses into it withyield from flatten(x)(dig one level deeper); otherwise ityields the plain value. This handles nesting of any depth with no idea how deep it goes.yield/yield frommake these generators — they hand back values one at a time, which is whylist(...)is used to collect them all.
What the output means: list(flatten([1, [2, [3, 4]], 5])) prints [1, 2, 3, 4, 5] — every nested value pulled out into one flat list, regardless of how deeply it was buried.
Try this: Call factorial(4) and trace it: 4 * 3 * 2 * 1 = 24. Then remove the if n <= 1 base case and call it — Python will raise RecursionError once the stack overflows.
RecursionError. 2. Python does not optimize tail calls — deep linear recursion should be a loop instead. Recursion is for genuinely branching problems (trees, graphs, divide-and-conquer), which we hit in D4–D6.8 · Memoization → dynamic programming advanced
Here's a failure you can feel. Write the textbook one-line recursive Fibonacci, run fib(35), and watch your terminal just… sit there for seconds. Bump it to fib(45) and you could go make coffee. Nothing is broken — the code is correct — yet it's uselessly slow. This is one of the most instructive performance cliffs in all of programming, because the fix is a single idea that turns a program that would run past the heat-death of the universe into one that finishes instantly. That idea is the bridge from recursion to dynamic programming, and Fibonacci is the perfect place to watch it happen.
The reason naive Fibonacci is catastrophic is repeated work, and it's worth seeing precisely why. fib(5) calls fib(4) and fib(3); but fib(4) also calls fib(3) — so fib(3) gets computed twice, and each of those recomputes fib(2), and so on. The call tree branches in two at every level and is riddled with the same subproblems solved over and over, so the total work roughly doubles with each added n — that's O(2ⁿ), exponential, the "falls off a cliff" class from D1. The analogy: it's like re-reading a book from page one every time you need to recall a fact, instead of jotting the fact on a sticky note the first time. The waste isn't in computing the answers — it's in computing the same answers again and again.
Memoization is the sticky note. The first time you compute fib(k), you store the result in a hash map keyed by k; every later request for fib(k) is then an O(1) lookup instead of a whole subtree of recomputation. Because each distinct subproblem is now solved exactly once, the cost collapses from O(2ⁿ) to O(n) — the same code, one cache, an astronomically different runtime. And that little move is the essence of dynamic programming: DP is nothing more mystical than "recursion with overlapping subproblems, where you remember each answer so you never recompute it." The one thing beginners overlook is that memoization only helps when subproblems actually repeat — caching a recursion whose calls are all distinct buys you nothing but memory. Fibonacci overflows with repeats, which is why it's the classic. The diagram contrasts the wasteful tree with the cached chain, and the lab shows three versions — naive, memoized, and a bottom-up form that drops the recursion entirely to reach O(n) time in O(1) space.
fib(3), fib(2)… many times; a hash-map cache turns those into instant lookups, collapsing O(2ⁿ) to O(n).
This two-panel diagram shows why naive recursion can be catastrophically slow, and how memoization (caching answers in a hash map) fixes it. Left = the wasteful version; right = the cached version.
- The left tree is
fib(5)expanded: each circle is a call, and a call splits into two children (fib(n-1)andfib(n-2)). Notice the same numbers appear again and again across the tree. - The amber-highlighted circles are calls that get recomputed from scratch — the same subproblem solved over and over. This duplication is what makes naive Fibonacci O(2ⁿ) (the tree roughly doubles each level).
- The right column of green boxes is the memoized run: each of
fib(2),fib(3),fib(4),fib(5)is computed once and cached (✓ cached). Any later request for that value is an instant hash-map lookup. - The bottom labels contrast the totals: computing every subproblem once gives O(n) on the right versus the exploding O(2ⁿ) tree on the left.
In short: Memoization = 'write the answer down the first time, look it up ever after'. The repeated work in the left tree simply disappears.
pythonfrom functools import lru_cache
# 1. Naive — O(2^n). fib(35) already crawls.
def fib_slow(n):
return n if n < 2 else fib_slow(n-1) + fib_slow(n-2)
# 2. Memoized — a hash map remembers each fib(k). O(n).
@lru_cache(maxsize=None)
def fib_memo(n):
return n if n < 2 else fib_memo(n-1) + fib_memo(n-2)
# 3. Bottom-up DP — no recursion, O(n) time, O(1) space
def fib_dp(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fib_memo(100)) # instant; fib_slow(100) would never finish
Three ways to compute Fibonacci, from disastrously slow to fast. This is the diagram above turned into code and shows the leap from plain recursion to dynamic programming.
- 1 · Naive
fib_slowis the left tree: it re-derivesfib(n-1)andfib(n-2)from scratch every time, so the work doubles at each level — O(2ⁿ).fib_slow(35)already crawls. - 2 · Memoized
fib_memois identical except for the@lru_cache(maxsize=None)decorator on top. That cache is a hash map that remembers eachfib(k): the second time a value is needed it's an O(1) lookup, collapsing the cost to O(n). - 3 · Bottom-up DP
fib_dpdrops recursion entirely. It keeps just the last two numbers ina, band rolls forward witha, b = b, a + b— O(n) time and only O(1) memory (no stack, no cache). - The single-line swap
a, b = b, a + bupdates both variables at once from their old values — a clean Python idiom for stepping a sequence forward.
What the output means: print(fib_memo(100)) returns instantly (354224848179261915075). fib_slow(100) would effectively never finish — the same answer, wildly different cost.
Try this: Add @lru_cache(maxsize=None) above fib_slow too and watch it become instant. That one line — caching subproblem results — is the entire idea of dynamic programming.
9 · Backtracking expert advanced
You already know backtracking — you did it the last time you were lost in a maze or a corn field. You walk down a path; it dead-ends; so you walk back to the last fork and try a different branch; that dead-ends too, back you go again. You never teleport to a fresh start — you rewind one step and try the next unexplored option. That "try it, and if it doesn't pan out, undo and try the next" discipline is the entire idea of backtracking, and it's the tool for a whole family of problems that otherwise look impossibly open-ended: generate all arrangements, or find a valid configuration among a combinatorial explosion of possibilities.
Concretely, backtracking is a depth-first walk over a tree of choices. At each step you make one choice (place this queen, take this element, fill this Sudoku cell), then recurse to decide the next choice on top of it; if you reach a complete solution you record it, and if you hit a dead end — or finish exploring a branch — you undo the last choice and try the next sibling. That rhythm is so reliable it's worth burning into memory as a three-beat skeleton: choose → explore → un-choose. It's really just the recursion from §7 with one addition — you actively rewind your shared state on the way back up, so the next branch starts from a clean slate. This is how you generate permutations and subsets (the labs below), and how the classic constraint puzzles — N-queens, Sudoku, word search — are solved.
The single most common bug, and it's always the same one: forgetting the un-choose. It's seductive to write "choose, then recurse" and stop — the choosing and exploring feel like the substance, and the undo feels like tidying up you can skip. But without it your shared path or used markers stay dirtied by the branch you just finished, so the next branch explores from a corrupted state and your output turns to garbage. The un-choose isn't cleanup — it's the load-bearing beam. (In the permutations lab, try deleting the path.pop(); used[i] = False line and watch the whole thing break; that one line is the backtracking.) A close second: on many problems you can prune — abandon a branch the moment it's provably hopeless (a queen already attacked, a partial sum already too big) instead of exploring it to the bitter end, which is what makes backtracking tractable on puzzles that would otherwise be astronomically large.
This diagram shows backtracking: exploring a tree of choices depth-first to generate every arrangement of [a, b, c]. The rhythm is always choose → explore → un-choose.
- The top circle (·) is the empty start — no item picked yet. Each level down fixes one more position in the arrangement.
- The three arrows from the top are the first choice: put
a,b, orcfirst. Following one arrow means choosing that item; then you explore what can follow it. - Under the
abranch, the two green leavesbandcgive the completed orderingsabcandacb— a leaf reached at the bottom is one full answer. - The un-choose step is the key: after finishing
abcyou undo the last pick and try its sibling to getacb. As the note says, each branch fixes the next item, then rewinds to try the next sibling — so every root-to-leaf path is a distinct permutation.
In short: Backtracking = depth-first search with an undo. Walk down making choices; when a path is finished (or hits a dead end), rewind one step and try the next option.
pythondef permutations(items):
result = []
used = [False] * len(items)
path = []
def backtrack():
if len(path) == len(items): # a complete arrangement
result.append(path[:]) # copy the current path
return
for i in range(len(items)):
if used[i]:
continue
used[i] = True; path.append(items[i]) # choose
backtrack() # explore
path.pop(); used[i] = False # un-choose (backtrack)
backtrack()
return result
print(permutations(["a", "b", "c"])) # 6 orderings
This is the backtracking skeleton in code — the choice tree above made real. It generates every ordering of a list. Learn this choose / explore / un-choose shape; it solves a whole family of "generate all" problems.
- Three pieces of shared state:
resultcollects finished answers,usedtracks which items are already placed, andpathis the arrangement being built right now. - The inner
backtrack()checks the base case first: whenlen(path) == len(items), the path is a complete arrangement, so it saves a copypath[:](a copy, becausepathkeeps changing) and returns. - Otherwise it loops over every item, skipping ones already
used. The three commented lines are the pattern: choose (mark used, append to path), explore (recurse), un-choose (pop and un-mark to restore state before trying the next sibling). - That
path.pop(); used[i] = Falseundo is what makes it backtracking — it rewinds one step so the next loop iteration explores a different branch of the tree.
What the output means: permutations(["a", "b", "c"]) returns all 6 orderings: [['a','b','c'], ['a','c','b'], ['b','a','c'], ['b','c','a'], ['c','a','b'], ['c','b','a']].
Try this: Remove the path.pop(); used[i] = False undo line and re-run — the output breaks, because state is never rewound. That one line is the whole point of backtracking.
choose / explore / un-choose skeleton is worth memorizing — it solves an entire category of "generate all / find a valid" problems.Exercises advanced
- Add a working
delete(key)to the chainingHashMap(and one toOpenAddrMapusing a tombstone). - Group a list of words into anagram clusters using a dict keyed by the sorted letters.
- Find the first non-repeating character in a string in one pass with a
Counter. - Memoize a recursive "count ways to climb n stairs (1 or 2 at a time)" function; then write the O(1)-space DP.
- Use backtracking to generate all subsets of a list (the power set).
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see D8 · Big Tech DSA patterns.
Store value→index as you go; for each x, check if target−x was already seen. O(n).
pythondef two_sum(nums, target):
seen = {}
for i, x in enumerate(nums):
if target - x in seen:
return (seen[target - x], i)
seen[x] = i
return None
The single most common hash-map interview question. The task: find two numbers in the list that add up to target, and return their positions — in one pass, O(n).
seen = {}is a dict mapping each value you've passed to its index. It's the "memory" that lets you avoid a second loop.for i, x in enumerate(nums)walks the list once, giving both the indexiand the valuex.- For each
x, the missing partner istarget - x.if target - x in seenis an O(1) hash lookup asking "did I already pass the number that completes this pair?" — if so, return both indices. - If not, record this value with
seen[x] = iand move on. Every element is visited once, so the whole thing is O(n) instead of the brute-force O(n²) of comparing all pairs.
What the output means: For two_sum([2, 7, 11], 9) it returns (0, 1), because nums[0] + nums[1] = 2 + 7 = 9.
Try this: This is hashing-pattern 4 from §6 in its purest form. The mental move — "remember what I've seen so the search is O(1)" — is exactly what interviewers are testing.
Each element is include-or-exclude → 2^n subsets. choose -> recurse -> un-choose.
pythondef subsets(nums):
res, path = [], []
def bt(start):
res.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i])
bt(i + 1)
path.pop()
bt(0)
return res
The backtracking classic: generate every subset (the power set) of a list. For n items there are 2ⁿ subsets, because each item is independently either in or out.
rescollects finished subsets;pathis the subset currently being built. Same two-variable setup as the permutations lab.bt(start)saves the currentpath[:](a copy) as a subset every time it's called — including the empty subset at the very start. There's no separate base case because every node of the tree is a valid answer.- The loop
for i in range(start, len(nums))only looks forward fromstart. That forward-only rule is what prevents duplicates like both[1,2]and[2,1](a subset is unordered). - The body is the same
choose / explore / un-chooserhythm:path.append(nums[i])(choose),bt(i + 1)(explore the rest),path.pop()(un-choose to try the next item).
What the output means: subsets([1, 2, 3]) returns all 8 subsets, from [] up to [1, 2, 3]: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]].
Try this: Count them: len(subsets([1,2,3])) is 8 = 2³. Add a fourth number and it becomes 16 — the power set doubles with each new element.
Checkpoint advanced
- Explain how hashing gives average O(1) lookup, and what a collision is.
- Contrast chaining vs open addressing, and describe load factor + resizing.
- Say why dict/set keys must be hashable, and reach for the right hashing pattern.
- Write recursion with a base case, convert it to memoized DP, and apply the backtracking skeleton.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Frequency counting with a dict is the most common single use of hashing, and the foundation of anagram, top-k, and dedup problems.
Your task: Count each character's occurrences in a string in O(n) using a dict, then report the most frequent character.
Requirements:
- Single pass,
O(n)time - Use a dict (or
Counter) mapping character → count - Return the character with the highest count
- Handle an empty string sensibly
💡 Hint: collections.Counter both tallies in one pass and exposes most_common for the winner.
Show solution
from collections import Counter
def most_frequent(s): # O(n) time, O(k) space
counts = Counter(s) # dict: char -> count, built in one pass
return counts.most_common(1)[0]
print(most_frequent("mississippi")) # ('s', 4) (ties -> first seen)
Hash-map lookups are O(1) average, so counting is a single O(n) sweep — the workhorse pattern.
Context: Grouping anagrams (classic) teaches the "canonical key" idea: map items that should collide to the same hashable signature.
Your task: Group a list of words so that anagrams of each other land together, keying each word by its sorted letters, in O(n·k log k).
Requirements:
- Words that are anagrams share one group
- Key each word by its sorted-letter signature
- Use a dict from signature → list of words
O(n·k log k)for n words of length k- Return the groups (order within a group need not be sorted)
💡 Hint: Sorting a word's letters gives a signature that is identical for all its anagrams; accumulate words into a defaultdict(list) under that key.
Show solution
from collections import defaultdict
def group_anagrams(words): # n words, k = max length
groups = defaultdict(list)
for w in words:
key = "".join(sorted(w)) # anagrams share a sorted signature
groups[key].append(w)
return list(groups.values())
print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]
A canonical key (here, sorted letters) collapses many inputs into one bucket — the core hashing pattern.
Context: Building a hash map from scratch with separate chaining demystifies what dict does and forces you to state its average vs worst-case cost.
Your task: Implement a hash map with separate chaining supporting put and get, handling collisions via per-bucket chains, and state the average vs worst-case complexity.
Requirements:
- An array of buckets, each holding a list/chain of key-value pairs
putupdates an existing key or appends to its bucketgetscans only the target key's bucket- State average-case
O(1)and worst-caseO(n)when all keys collide - Hash keys into a bucket index with a modulus over the table size
💡 Hint: Index a key by hash(key) % num_buckets; a good spread keeps chains short, but adversarial keys can pile into one bucket.
Show solution
class HashMap: # avg O(1) put/get; worst O(n) if all collide
def __init__(self, size=8):
self.size = size
self.buckets = [[] for _ in range(size)]
def _idx(self, key):
return hash(key) % self.size
def put(self, key, value):
bucket = self.buckets[self._idx(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
bucket[i] = (key, value); return
bucket.append((key, value))
def get(self, key, default=None):
for k, v in self.buckets[self._idx(key)]:
if k == key:
return v
return default
m = HashMap()
m.put("a", 1); m.put("b", 2); m.put("a", 9)
print(m.get("a"), m.get("b"), m.get("z", -1)) # 9 2 -1
Each bucket is a list of colliding entries. A good hash + resize keeps chains short (O(1) average); a bad hash degrades to one long chain (O(n)).
Context: Longest-consecutive-sequence (classic) is a set-membership trick: the sorting answer is O(n log n), but a set gets you to linear.
Your task: Given an unsorted array, return the length of the longest run of consecutive integers in O(n) — not O(n log n) — using a set.
Requirements:
- Put all values in a set for
O(1)membership tests - Start counting a run only from a value whose predecessor is absent
- Extend each run upward by membership checks
O(n)overall despite the nested-looking loop- Return the longest run length
💡 Hint: Only begin a count at x when x−1 is not in the set; that guarantees each run is walked exactly once.
Show solution
def longest_consecutive(nums): # O(n) time, O(n) space
s = set(nums)
best = 0
for x in s:
if x - 1 not in s: # only start counting at a run's beginning
length = 1
while x + length in s:
length += 1
best = max(best, length)
return best
print(longest_consecutive([100, 4, 200, 1, 3, 2])) # 4 (1,2,3,4)
The x-1 not in s guard means each run is walked exactly once, so despite the inner loop the total work is O(n).
Context: Memoization is the bridge from exponential recursion to linear DP, and Fibonacci is the cleanest place to see the whole progression.
Your task: Take naive O(2ⁿ) Fibonacci, add memoization to make it O(n), then show the bottom-up version that runs in O(1) space.
Requirements:
- Naive recursion is exponential because it recomputes subproblems
- A cache keyed by n makes each subproblem compute once →
O(n) - The bottom-up version keeps only the last two values →
O(1)space - All three agree on the same inputs
💡 Hint: The cache turns the recursion tree into a linear chain; the bottom-up form then throws away everything but the two most recent results.
Show solution
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n): # O(n) time/space after memo
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)
def fib_dp(n): # O(n) time, O(1) space (bottom-up)
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fib_memo(30), fib_dp(30)) # 832040 832040
Memoization caches overlapping subproblems (top-down); the DP version removes the recursion and the cache, keeping only the two values it needs.
Context: Generating the power set via backtracking (classic) is the template that generalises to permutations and combinations — worth internalising once.
Your task: Generate all subsets (the power set) of a list of distinct integers using the backtracking template, in O(n·2ⁿ).
Requirements:
- Produce all
2ⁿsubsets, including the empty set and the full set - Use the include/exclude recursion (choose, recurse, un-choose)
- No duplicate subsets (inputs are distinct)
O(n·2ⁿ)total work- Order of subsets need not be specified
💡 Hint: At each index decide to take the element or not, recording the current partial subset when you reach the end of the list.
Show solution
def subsets(nums): # 2^n subsets, each built in O(n)
result = []
def backtrack(start, path):
result.append(path[:]) # record the current subset
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1, path) # explore
path.pop() # un-choose (the backtrack)
backtrack(0, [])
return result
print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]
choose → explore → un-choose is the backtracking skeleton; swap the recursion rule to get permutations or combinations.
Knowledge check check yourself
In a hash table, what is a collision and how does chaining resolve it?
Show answer
How does memoization turn an exponential recursion into a polynomial one?