01 Β· DSA β file 07
Recursion, Backtracking & Dynamic Programming
This is the topic that decides mid-level interviews. Arrays and hashmaps get you through round one; DP recognition is where candidates separate. The good news: DP is not 50 problems β it's one idea (overlapping subproblems + a cache) wearing 50 costumes. Learn the idea, then the costume-detection, in that order.
01Recursion: the leap of faith
The mental model that makes recursion click: assume the recursive call already works, and just do your one step.
When you write sorted_left = merge_sort(left_half), do NOT trace into it. Treat it like calling a library function some competent colleague wrote β a service you await without reading its source. Your only jobs:
The three-obligation contract:
- Base case β the input so small the answer is trivial. (No base case = infinite recursion = stack overflow.)
- One step β given correct answers for smaller inputs, produce the answer for this input.
- Shrinkage β every call must move toward the base case.
Why the faith is justified: it's mathematical induction wearing a hoodie. If the base case is right, and "smaller-correct β this-correct," then all sizes are correct. Tracing the call tree in your head is how people get lost; the induction contract is how people get fluent.
def factorial(n):
if n == 0: # 1. base case
return 1
return n * factorial(n - 1) # 2. one step, 3. shrinks toward 0
Two practical Python caveats to say out loud: the call stack costs O(depth) memory, and CPython's recursion limit is ~1000 frames (no tail-call optimization). For deep linear recursions, convert to a loop or an explicit stack.
02Backtracking: exhaustive search with an undo button
When: "generate ALL ..." (subsets, permutations, combinations, paths, valid boards) or "does ANY configuration exist." Constraints screaming n β€ 20 (file 01's decoder ring) confirm it β the answer space is exponential and the interviewer knows.
The idea: build a candidate solution one choice at a time. After exploring a choice fully, undo it and try the next. You're doing DFS over the tree of partial solutions:
subsets of [1,2,3] β at each element: include it, or don't
[]
βββββββββ΄ββββββββ
include 1 exclude 1
[1] []
βββββ΄ββββ βββββ΄ββββ
[1,2] [1] [2] []
βββ΄ββ βββ΄ββ βββ΄ββ βββ΄ββ
[1,2,3][1,2][1,3][1] [2,3][2] [3] [] β 2Β³ = 8 leaves
The universal template β internalize this shape and most backtracking problems become fill-in-the-blanks:
def backtrack(state, choices):
if is_complete(state):
results.append(state.copy()) # β .copy()! the #1 backtracking bug
return
for choice in choices:
if not is_valid(choice, state): # prune early β this line is the whole game
continue
state.append(choice) # CHOOSE
backtrack(state, next_choices) # EXPLORE (leap of faith)
state.pop() # UN-CHOOSE β the "undo button"
Why state.copy(): you're mutating one shared list the whole time (that's what makes it O(1) per move); appending the live reference means every "result" later mutates into the same empty husk.
The three canonical problems
Subsets β at each index: skip or take.
def subsets(nums):
res, cur = [], []
def bt(i):
if i == len(nums):
res.append(cur[:])
return
bt(i + 1) # exclude nums[i]
cur.append(nums[i]) # include nums[i]
bt(i + 1)
cur.pop()
bt(0)
return res
Permutations β at each position: any unused element.
def permutations(nums):
res, cur, used = [], [], [False] * len(nums)
def bt():
if len(cur) == len(nums):
res.append(cur[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True; cur.append(nums[i])
bt()
cur.pop(); used[i] = False
bt()
return res
Combination Sum β reuse allowed; avoid duplicate sets by never looking backwards:
def combination_sum(candidates, target):
res, cur = [], []
def bt(start, remaining):
if remaining == 0:
res.append(cur[:]); return
if remaining < 0:
return # prune
for i in range(start, len(candidates)):
cur.append(candidates[i])
bt(i, remaining - candidates[i]) # i, not i+1 β may reuse; β₯start β no dup orders
cur.pop()
bt(0, target)
return res
The start index is the idea to narrate: combinations are order-insensitive, so I canonicalize by only choosing forward β each set is generated exactly once, in sorted-position order.
Pruning is the skill being tested. Bare enumeration is table stakes; noticing "remaining < 0, stop," "sort first so I can break early," "this queen placement already conflicts" is what turns 2βΏ into something that finishes. Same instinct as adding a WHERE clause before the join instead of filtering after.
03Dynamic Programming, from first principles
Step 1: watch plain recursion fail
Fibonacci, naively:
def fib(n):
if n <= 1: return n
return fib(n - 1) + fib(n - 2)
The tree is huge but contains only n+1 distinct subproblems β recomputed exponentially many times, O(2βΏ). DP is nothing more than: recursion + never solve the same subproblem twice. Two prerequisites (say these words):
- Overlapping subproblems β the recursion revisits the same states. (Merge sort recurses but never overlaps β so it's divide & conquer, not DP.)
- Optimal substructure β the optimal answer composes from optimal sub-answers.
Step 2: memoization (top-down) β the honest fix
Cache results by input. It IS your recursive solution + a dict. This is caching β the same instinct as putting Redis in front of an idempotent expensive call: pure function of its inputs? Memoize it.
def fib(n, memo={}):
if n <= 1: return n
if n not in memo:
memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
return memo[n]
# or, idiomatically:
from functools import lru_cache
@lru_cache(maxsize=None) # a.k.a. @cache in 3.9+
def fib(n):
return n if n <= 1 else fib(n - 1) + fib(n - 2)
O(2βΏ) β O(n): n distinct states Γ O(1) work each. The general law: DP cost = (number of states) Γ (work per state).
Step 3: tabulation (bottom-up) β same idea, no recursion
Fill a table from base cases upward, so every dependency is ready before it's needed:
def fib(n):
if n <= 1: return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
And when today only depends on a fixed window of yesterdays, shrink the table to the window β O(1) space:
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
Memoization vs tabulation β which to use in interviews: start top-down. It's a mechanical transformation of the recursion you naturally derive ("recurrence + cache"), it only computes reachable states, and it's harder to get iteration order wrong. Convert to bottom-up if asked, or when recursion depth / constant factors matter. Knowing both and saying why you chose is the senior move.
The 4-step recipe for any DP problem
- Define the state in words: "dp[i] = the answer for the first i items" / "dp[i][j] = best using items β€ i with capacity j." If you can't say it in a sentence, stop and redefine.
- Write the recurrence: dp[i] in terms of smaller states = "what was my last choice?" enumerated.
- Base cases: the states with trivial answers (empty prefix, zero capacity).
- Order/extract: memoize (order handles itself) or iterate so dependencies come first; read off the answer state.
04The classic set β five problems, five archetypes
These five are your basis vectors β most interview DP is a linear combination of them:
Linear DP
Climbing Stairs β "what was the last step?" dp[i] = dp[i-1] + dp[i-2]. Fibonacci in a costume.
Unbounded knapsack
Coin Change β min over "what was the last coin?"; items reusable.
"Ending at i"
LIS β state pinned to the last element so the recurrence can extend it.
Take / skip under budget
0/1 Knapsack β 2-D choice; each item once; 1-D table iterated backwards.
Two sequences
Edit Distance / LCS β grid over prefixes; look at the last characters.
Climbing Stairs β the hello world (archetype: linear DP)
n stairs, 1 or 2 steps at a time, count the ways. Last move was 1 or 2 β dp[n] = dp[n-1] + dp[n-2]. It's Fibonacci in a costume β and the costume-lesson generalizes: House Robber (dp[i] = max(dp[i-1], dp[i-2] + a[i])), Decode Ways, Min Cost Climbing.
Coin Change β archetype: unbounded knapsack / min over choices
Fewest coins to make amount. State: dp[a] = fewest coins for amount a. Last coin used was some c β:
def coin_change(coins, amount):
dp = [0] + [float('inf')] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
O(amount Γ len(coins)). Why greedy fails (interviewer favorite): coins [1, 3, 4], amount 6 β greedy takes 4+1+1 = 3 coins; optimal is 3+3 = 2. Greedy needs a matroid-like structure; DP doesn't.
Longest Increasing Subsequence β archetype: "ending at i"
State: dp[i] = length of the LIS ending exactly at index i. That "ending at" trick is what makes the recurrence composable:
def lis(nums):
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)
O(nΒ²). (There's an O(n log n) patience-sorting version with bisect β name it, derive only if pushed.)
0/1 Knapsack β archetype: two-dimensional choice
Items with (weight, value), capacity W, each item once. State: dp[i][w] = best value using the first i items within capacity w. The recurrence is the eternal binary: skip item i, or take it:
dp[i][w] = max( dp[i-1][w], # skip
dp[i-1][w - wt[i]] + val[i] ) # take (if it fits)
def knapsack(wt, val, W):
dp = [0] * (W + 1)
for i in range(len(wt)):
for w in range(W, wt[i] - 1, -1): # reversed! see below
dp[w] = max(dp[w], dp[w - wt[i]] + val[i])
return dp[W]
The 1-D compression iterates capacity backwards so each item is counted once (forwards would let item i reuse its own updated row = unbounded knapsack β which is exactly the Coin Change loop direction; the two problems differ by a loop order, a genuinely satisfying fact to point out). Costumes: Partition Equal Subset Sum, Target Sum.
Edit Distance β archetype: two sequences
Min insert/delete/replace to turn word1 into word2. State: dp[i][j] = distance between the first i chars and first j chars. Look at the last characters:
if word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] # free match
else: dp[i][j] = 1 + min(dp[i-1][j], # delete from word1
dp[i][j-1], # insert into word1
dp[i-1][j-1]) # replace
base: dp[i][0] = i, dp[0][j] = j
O(nΒ·m). The grid-over-two-sequences shape also gives you Longest Common Subsequence (== β 1+diag, else max(up, left)) β LCS and edit distance are the template for every "two strings" DP, and edit distance is literally how diff, spellcheckers, and fuzzy string matching score similarity.
05Recognizing DP β the actual interview skill
The tells, strongest first:
- Ask-type: "minimum/maximum cost/length/value," "count the number of ways," "can it be done (yes/no over combinations)." Counting-ways is almost a guarantee.
- Choice-type: at each step a small set of choices (take/skip, which coin, step size), and choices compound.
- Brute force is exponential, but the "state" is small. You could recurse β and the recursion would revisit states. That revisit is the smell.
- Greedy feels almost right but you can cook a counterexample (coins [1,3,4] energy).
- Constraints: n β€ ~1000β5000 with an expected O(nΒ²)-ish answer often means 2-D DP; "generate all" means backtracking instead β DP counts/optimizes, backtracking enumerates. (If they want the actual list of solutions, no cache can save you β output itself is exponential.)
The interview script when you smell DP:
"This looks like DP: we're optimizing over compounding choices and a brute-force recursion would recompute subproblems. Let me define the state... dp[i] = β¨sentenceβ©. The recurrence considers the last choice: ... Base case: ... That's O(states Γ transitions) = O(...)."
Deriving live beats pattern-matching from memory β interviewers vary the problem precisely to break memorizers. The five archetypes above are your basis vectors: linear ("last step"), knapsack ("take/skip under budget"), ending-at-i, two-sequence grid, and unbounded (coin) β most interview DP is a linear combination.
The state-design heuristic when stuck: ask "what's the minimal information I need about the past to make future decisions?" That's your state. (Same question you ask when designing what goes in a session token vs what can be recomputed.)
Common interview questions
Q1. Memoization vs tabulation?
Same asymptotics; top-down = recursion + cache (easy to derive, computes only reachable states, costs stack); bottom-up = explicit fill order (no recursion limit, enables space compression).
Q2. Why is naive Fibonacci O(2βΏ) but memoized O(n)?
n+1 distinct states; without cache the call tree re-solves them exponentially; with cache each is solved once at O(1).
Q3. Climbing stairs with up to k steps?
dp[i] = sum(dp[i-1..i-k]); sliding-window the sum for O(n).
Q4. Why does greedy fail on coin change?
Largest-coin-first is locally optimal, not globally: [1,3,4], 6 β 4+1+1 vs 3+3. DP explores all last-coin options.
Q5. Subsets vs permutations β count and template difference?
2βΏ vs n!; subsets branch include/exclude per index (order canonical); permutations pick any unused element per position (order matters, needs used).
Q6. What breaks if you append(cur) instead of append(cur[:])?
All results alias one list that ends empty after unwinding. The classic backtracking bug.
Q7. 0/1 vs unbounded knapsack in the 1-D table?
Capacity loop direction: backwards = each item once (0/1); forwards = items reusable (unbounded/coin change).
Q8. House Robber recurrence and why?
dp[i] = max(dp[i-1], dp[i-2] + a[i]) β skip house i, or rob it and skip iβ1. Linear DP, O(1) space via two variables.
Q9. Word Break?
dp[i] = "first i chars segmentable"; dp[i] = any(dp[j] and s[j:i] in dict). Words-as-set makes the membership O(1)-ish.
Q10. When is it backtracking, not DP?
When the output is the set of all solutions (exponential output β no polynomial algorithm exists); DP applies when you only need a count/optimum over them.
Self-test
- State the leap-of-faith contract (three obligations) and say why it's just induction.
- Write the backtracking template from memory. Which two lines are "choose/un-choose," and why is
.copy()load-bearing? - Generate subsets of [1,2,3] on paper via the include/exclude tree β list the leaves in the order your code would emit them.
- In Combination Sum, what exactly does passing
start(and recursing withi, noti+1) accomplish? Two distinct things. - Give the two prerequisites for DP, with one example that has recursion but isn't DP.
- Convert this to memoized then tabulated: "min cost to reach stair n, paying cost[i] to step from i, moving 1 or 2."
- Coin change [1,3,4], amount 6 β fill dp[0..6] by hand. Where does greedy diverge?
- Define the LIS state precisely. Why "ending at i" rather than "within the first i"?
- Write the knapsack recurrence in both 2-D and 1-D forms. Why must the 1-D capacity loop run backwards?
- Edit distance "horse" β "ros": set up the first two rows of the table and state what each of the three transitions means.
- For each, name the technique in β€ 3 seconds: (a) count paths in a grid with obstacles, (b) all valid IP address splits of a string, (c) min deletions to make two strings equal, (d) can array be partitioned into two equal-sum halves?
Spot-checks
- Base case / one correct step assuming sub-calls correct / shrinking input; base = induction base, step = inductive step.
- append+recurse+pop; without copy all stored results alias the mutated list.
- Depends on branch order; with exclude-first: [], [3], [2], [2,3], [1], [1,3], [1,2], [1,2,3] (or mirror).
- Canonical order kills duplicate sets; recursing with i (not i+1) permits reuse of the same coin.
- Overlapping subproblems + optimal substructure; merge sort recurses without overlap.
- dp[i] = cost[i] + min(dp[i+1], dp[i+2]) memoized, or forward table; O(n)/O(1).
- dp = [0,1,2,1,1,2,2]; greedy takes 4 first and lands on 3 coins, dp[6]=2 via 3+3.
- dp[i] = LIS length ending exactly at i β otherwise you don't know the last element and can't extend.
- max(skip, take); backwards so dp[wβwt] still reflects "without this item."
- Row0 = 0..3 (build "ros" by inserts), Row1 ("h") = 1,1,2,3; transitions = delete/insert/replace.
- (a) grid DP; (b) backtracking (enumerate all); (c) LCS DP (n+mβ2Β·LCS); (d) subset-sum knapsack.