AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Data Structures & Algorithms · Part 3

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.

⏱️ ~2 hours🎯 Basic → Expert#️⃣ O(1) lookuprunnable

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.

key → hash() → % size → array slot → jump straight there "weather" hash(key)3193…417 % 8 = 2 0 1 2 3 4 value lives at slot 2 to fetch later: hash the same key, get slot 2 again — no scanning Hashing turns a key into an address. A pure calculation grinds the key to an integer, % 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.
🗺️ How to read this diagram

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — Python's built-in hash
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)
▶ How this works

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).

  1. 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.
  2. hash(42) returns 42: 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.
  3. The commented-out hash([1, 2]) would raise TypeError: unhashable type: 'list'. A list can change, so its hash could change too — Python forbids using it as a key.
  4. The last three lines are the whole trick: slot = hash(key) % size. The % (remainder) squeezes any giant hash integer down into the range 0 .. 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.

Why keys must be hashable (immutable)If a key could change after insertion, its hash would change, and you'd never find it again — it'd be in the "wrong" slot. That's why 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.

slot = hash(key) % size · collisions chain in a bucket list 0 1 2 3 4 buckets "weather"→fn "scale"→fn2 ← collided "pods"→fn3 Separate chaining. The hash picks a slot; entries that collide are appended to that slot's bucket. Average lookup is O(1) (short buckets); worst case O(n) if everything collides.
🗺️ How to read this diagram

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→value entries 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.

Try it — a real hash map
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'
▶ How this works

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.

  1. __init__ creates self._buckets as a list of empty lists — one empty bucket per slot — and self._n, a running count of how many items are stored.
  2. _slot(key) is the hash-to-index step from §1: hash(key) % len(self._buckets) gives the slot number for any key.
  3. put finds 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.
  4. get hashes 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.
  5. The load factor check self._n / len(self._buckets) > 0.75 watches 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.

Load factor & why we resizeThe load factor is items ÷ slots. As it rises, buckets get longer and lookups drift from O(1) toward O(n). Resizing (doubling the table and rehashing) keeps buckets short — averaging back to O(1). This is the same amortized-doubling idea as the dynamic array in D1; the occasional O(n) rehash is spread across many O(1) puts.

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.

6 keys, only 4 slots → at least one slot must hold two "cat" "dog" "fox" "owl" "ant" "bee" 6 keys slot 0 slot 1 slot 2 · two keys! slot 3 collision 4 slots Collisions are guaranteed, not rare. The pigeonhole principle: map more possible keys than slots and some keys must share a slot (red). No hash function escapes this — so a table is defined by how it handles collisions, not whether it avoids them.
🗺️ How to read this diagram

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.

OperationAverageWorst case
insert / lookup / deleteO(1)O(n) (all collide)
iterate allO(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.

insert "scale" — its slot 2 is taken, so probe forward 0 1 weather pods scale 5 hash→2 taken taken → land at slot 4 i = (i + 1) % size until an empty slot Open addressing keeps everything in one array. On a collision it probes to the next slot until it finds a free one — no bucket lists, so it's cache-friendly (this is what CPython's dict does). Deleting needs a "tombstone" so later probes don't stop early.
🗺️ How to read this diagram

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 weather and pods.
  • hash→2 under 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) % size is the probe step: move to the next index, and the % size makes 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 dict uses 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.

Try it — linear probing
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
▶ How this works

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.

  1. _EMPTY = object() makes a unique sentinel — a one-of-a-kind marker meaning "this slot was never used". Using a special object (not None) means None is still allowed as a real stored value.
  2. Two parallel arrays hold the data: self._keys (starts all _EMPTY) and self._vals. Slot i's key is _keys[i] and its value is _vals[i].
  3. In put, i = hash(key) % len(self._keys) is the starting slot. The while loop advances i = (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).
  4. get repeats the same walk from the hashed slot, comparing keys, and returns the value when it matches. If it reaches an _EMPTY slot the key was never inserted, so it returns default.

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.

Chaining vs open addressingChaining is simpler and degrades gracefully under high load. Open addressing is faster when load stays low (better cache locality, no per-slot allocation) but suffers "clustering" as it fills and needs tombstones for deletion. CPython's dict uses open addressing with perturbation-based probing — a hybrid tuned over decades.

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 — hash table of keys only "docA" "docB" "docC" "x" in s → O(1) answers: seen it? · unique things dict — same table, key carries a value "weather" → fn "scale" → fn2 "pods" → fn3 d["scale"] → O(1) keeps insertion order (3.7+) Two faces of one hash table. A 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.
🗺️ How to read this diagram

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 with d["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 dict additionally hands keys back in insertion order (Python 3.7+); a set makes 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.

Try it
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
▶ How this works

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.

  1. A set stores only keys. Adding "docA" twice keeps just one copy, so len(seen) is 1 — automatic dedup. "docA" in seen is a hash lookup (O(1)), not a scan through every element.
  2. Set algebra: a & b is intersection (in both), a | b is union (in either), a - b is difference (in a only). Each runs in time proportional to the set sizes, not the product — far cheaper than nested loops.
  3. 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.

🔗 Used in the courseThe agent's tool dispatch is a dict: 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).

one engine — "let a hash map remember it" — four shapes set / dict = O(1) memory 1 · dedup 2 · frequency 3 · grouping 4 · complement seen it? how often? belongs with? partner here? all four kill an inner loop → O(n²) becomes O(n) Four costumes, one engine. Dedup, frequency count, grouping, and complement lookup are the same instinct — "can a hash map remember this so I don't search again?" — each turning an O(n²) nested scan into an O(n) pass.
🗺️ How to read this diagram

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 set or dict that 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.

Try it — the patterns you'll reuse constantly
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
▶ How this works

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. 1 · Dedup keeping order: a set named seen answers "have I met this before?" in O(1), while a list named unique records first-seen order. You get uniqueness and original order.
  2. 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. 3 · Group by a key: defaultdict(list) auto-creates an empty list the first time a new key is used, so by_letter[w[0]].append(w) groups words by their first letter with no "if key missing" boilerplate.
  4. 4 · Two-sum in O(n): the dict pos remembers each value's index as you go. For each x you ask "have I already seen target - 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.

The hash-map superpowerWhenever a brute force does "for each item, search the rest" (O(n²)), ask: can a hash map remember what I've seen so the search becomes O(1)? That single question converts a huge class of O(n²) problems to O(n). It's the most useful optimization instinct in this whole track.

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.

calls push down… …returns pop back up factorial(3) factorial(2) factorial(1) base: return 1 1 × 1 = 1 2 × 1 = 2 3 × 2 = 6 Recursion IS a stack. Each call pushes a frame until the base case stops the descent; then results multiply back up as frames pop. No base case → the stack grows forever → RecursionError.
🗺️ How to read this diagram

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 knows factorial(2), which needs factorial(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, then 2×1=2, then 3×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.

Try it
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]
▶ How this works

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).

  1. In factorial, the base case is if n <= 1: return 1 — it stops the chain of calls. Without it the function would call itself forever.
  2. The recursive case return n * factorial(n - 1) is the same problem on a smaller input (n - 1). Because each call shrinks n, it must eventually hit the base case — matching the push-down/pop-up staircase in the diagram above.
  3. flatten shows why recursion suits nested data. For each item, if it's a list it recurses into it with yield from flatten(x) (dig one level deeper); otherwise it yields the plain value. This handles nesting of any depth with no idea how deep it goes.
  4. yield/yield from make these generators — they hand back values one at a time, which is why list(...) 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.

Two recursion gotchas1. Always have a reachable base case or you'll hit Python's recursion limit (~1000 frames) → 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.

naive fib(5): the same nodes recomputed memoized: each computed once 5 4 3 3 2 2 1 2 1 amber nodes = recomputed → O(2ⁿ) fib(2)=1 ✓ cached fib(3)=2 ✓ cached fib(4)=3 ✓ cached fib(5)=5 ✓ cached each key computed once → O(n) Memoization kills the repeated work. The naive tree recomputes fib(3), fib(2)… many times; a hash-map cache turns those into instant lookups, collapsing O(2ⁿ) to O(n).
🗺️ How to read this diagram

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) and fib(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.

Try it — three versions of Fibonacci
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
▶ How this works

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. 1 · Naive fib_slow is the left tree: it re-derives fib(n-1) and fib(n-2) from scratch every time, so the work doubles at each level — O(2ⁿ). fib_slow(35) already crawls.
  2. 2 · Memoized fib_memo is identical except for the @lru_cache(maxsize=None) decorator on top. That cache is a hash map that remembers each fib(k): the second time a value is needed it's an O(1) lookup, collapsing the cost to O(n).
  3. 3 · Bottom-up DP fib_dp drops recursion entirely. It keeps just the last two numbers in a, b and rolls forward with a, b = b, a + b — O(n) time and only O(1) memory (no stack, no cache).
  4. The single-line swap a, b = b, a + b updates 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.

🔗 Used in the courseMemoization is caching by input — the same idea as caching an embedding or tool result so repeated calls are free (P5, Ch 6). The edit-distance DP underlies fuzzy string matching used in eval scoring (Ch 5).

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.

permutations of [a, b, c] — choose · explore · un-choose · a b c b c abc acb …each branch fixes the next item, then undoes it to try the sibling. Backtracking is DFS over a tree of partial solutions. At each node you pick an unused item (choose), recurse (explore), then remove it (un-choose) to try the next sibling — every root-to-leaf path is one complete answer.
🗺️ How to read this diagram

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, or c first. Following one arrow means choosing that item; then you explore what can follow it.
  • Under the a branch, the two green leaves b and c give the completed orderings abc and acb — a leaf reached at the bottom is one full answer.
  • The un-choose step is the key: after finishing abc you undo the last pick and try its sibling to get acb. 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.

Try it — all permutations, the backtracking way
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
▶ How this works

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.

  1. Three pieces of shared state: result collects finished answers, used tracks which items are already placed, and path is the arrangement being built right now.
  2. The inner backtrack() checks the base case first: when len(path) == len(items), the path is a complete arrangement, so it saves a copy path[:] (a copy, because path keeps changing) and returns.
  3. 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).
  4. That path.pop(); used[i] = False undo 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.

Backtracking = DFS + undoEvery backtracking algorithm is a depth-first search (D4/D5) over a tree of partial solutions, where you prune branches that can't work and rewind state on the way back up. The choose / explore / un-choose skeleton is worth memorizing — it solves an entire category of "generate all / find a valid" problems.

Exercises advanced

Practice
  1. Add a working delete(key) to the chaining HashMap (and one to OpenAddrMap using a tombstone).
  2. Group a list of words into anagram clusters using a dict keyed by the sorted letters.
  3. Find the first non-repeating character in a string in one pass with a Counter.
  4. Memoize a recursive "count ways to climb n stairs (1 or 2 at a time)" function; then write the O(1)-space DP.
  5. 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.

Two sum (classic) — the hashmap classic

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
▶ How this works

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).

  1. seen = {} is a dict mapping each value you've passed to its index. It's the "memory" that lets you avoid a second loop.
  2. for i, x in enumerate(nums) walks the list once, giving both the index i and the value x.
  3. For each x, the missing partner is target - x. if target - x in seen is an O(1) hash lookup asking "did I already pass the number that completes this pair?" — if so, return both indices.
  4. If not, record this value with seen[x] = i and 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.

Subsets (classic) — backtracking

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
▶ How this works

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.

  1. res collects finished subsets; path is the subset currently being built. Same two-variable setup as the permutations lab.
  2. bt(start) saves the current path[:] (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.
  3. The loop for i in range(start, len(nums)) only looks forward from start. That forward-only rule is what prevents duplicates like both [1,2] and [2,1] (a subset is unordered).
  4. The body is the same choose / explore / un-choose rhythm: 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.

Exercise 1 · Count frequenciesBeginner

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.

Exercise 2 · Group anagramsIntermediate

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.

Exercise 3 · A hash table from scratch (chaining)Advanced

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
  • put updates an existing key or appends to its bucket
  • get scans only the target key's bucket
  • State average-case O(1) and worst-case O(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)).

Exercise 4 · Longest consecutive sequenceExpert

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).

Exercise 5 · Memoize to turn exponential into linearProfessional

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.

Exercise 6 · Backtracking: subsetsIndustry scenario

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

✓ Knowledge check

In a hash table, what is a collision and how does chaining resolve it?

Show answer
A collision is when two keys hash to the same bucket index. Chaining stores a list (chain) at each bucket, so colliding keys are appended to that bucket's list and searched linearly within it.
✓ Knowledge check

How does memoization turn an exponential recursion into a polynomial one?

Show answer
It caches the result of each distinct subproblem the first time it is computed, so repeated calls return in O(1) instead of recomputing — collapsing an exponential recursion tree into one evaluation per unique subproblem.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in