AI EngineeringZero to ProductionHome·About·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

Array indexing is O(1) — if you know the index. Hashing gives you the index from the key: a hash function maps a key (a string, a number, a tuple) to an integer, and you take that integer mod the array size to get a slot. Store the value there; to look it up, hash again and go straight to the slot. No scanning — that's the O(1).

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

The simplest collision strategy is separate chaining: each slot holds a small list ("bucket") of (key, value) pairs. Collisions just append to the bucket. Let's build a working dict.

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

Two different keys can hash to the same slot — a collision. It's not a bug, it's inevitable (the pigeonhole principle: more possible keys than slots). Every hash table is defined by how it resolves collisions. You just saw chaining (a list per slot). Its worst case is O(n) — if every key lands in one bucket — but with a good hash and a bounded load factor, the average is O(1).

OperationAverageWorst case
insert / lookup / deleteO(1)O(n) (all collide)
iterate allO(n)O(n)

4 · Open addressing (linear probing) expert essential

The other strategy stores everything in the array itself: on a collision, probe to the next slot until an empty one is found. This is cache-friendly (no scattered bucket lists) and is what CPython's dict actually uses (with a smarter probe sequence). Deletion needs a "tombstone" marker so probes don't stop early.

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

Now the payoff: you understand exactly what Python's two workhorse structures are. A set is a hash table storing only keys — O(1) membership and dedup. A dict stores key→value and, since Python 3.7, preserves insertion order (a compact-array design layered on the hash table).

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

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

A recursive function solves a problem by (a) handling a trivial base case directly, and (b) reducing everything else to a smaller subproblem and calling itself. Under the hood, each call is a frame on the call stack (D2) — recursion and stacks are two views of the same thing.

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

Naive recursion can be catastrophically slow when it recomputes the same subproblem over and over. Fibonacci is the textbook case: fib(n) recomputes fib(n-2) exponentially many times — O(2ⁿ). Memoization caches each result in a hash map, so every subproblem is computed once — collapsing it to O(n). That caching-of-subproblems is the essence of dynamic programming.

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 + bO(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

Backtracking explores a tree of choices depth-first: make a choice, recurse, and if it leads to a dead end, undo it ("backtrack") and try the next. It's how you generate all permutations/subsets and solve constraint puzzles (N-queens, Sudoku, word search). The structure is always: choose → explore → un-choose.

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