DP problems — Fibonacci, coin change (count + min), 0 - 1 knapsack
1. Fibonacci — the "hello world" of overlapping subproblems
WHY it's the canonical DP example: Naive recursion recomputes the same values over and over.
F(5)
/ \
F(4) F(3) <- F(3) computed here
/ \ / \
F(3) F(2) F(2) F(1) <- F(3) AGAIN, F(2) thrice...
The recursion tree has nodes (exponential, ) but only distinct values. That gap is exactly what memoization kills.
HOW — three increasingly lean versions:
# (a) Top-down memoization
def fib(n, memo={}):
if n < 2: return n
if n in memo: return memo[n]
memo[n] = fib(n-1, memo) + fib(n-2, memo)
return memo[n]
# (b) Bottom-up table
def fib(n):
dp = [0, 1] + [0]*(n-1)
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
# (c) Space-optimized — we only ever need the last two
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a2. Coin Change — two flavors
You have coin denominations coins[] (unlimited supply each — this is unbounded knapsack
style) and a target amount.
2a. Count the number of ways to make amount
WHY order matters here. To count combinations (not permutations) we must fix coin order: loop coins on the outside, amount on the inside. This guarantees we never use coin 2 before coin 1 in two different orders and double count.
def count_ways(coins, amount):
dp = [0]*(amount+1)
dp[0] = 1 # one way: empty set
for c in coins: # coins OUTSIDE -> combinations
for a in range(c, amount+1):
dp[a] += dp[a-c]
return dp[amount]2b. Minimum number of coins to make amount
def min_coins(coins, amount):
INF = float('inf')
dp = [0] + [INF]*amount
for a in range(1, amount+1):
for c in coins:
if c <= a and dp[a-c] + 1 < dp[a]:
dp[a] = dp[a-c] + 1
return -1 if dp[amount] == INF else dp[amount]3. 0/1 Knapsack — each item used at most once
WHY a 2D state. For each item we make a binary choice; the only thing the future cares about is how much capacity remains and which items are still available. So state = (items considered, capacity used).
\underbrace{v_i + \text{dp}[i-1][w-w_i]}_{\text{take item }i,\ \text{if } w_i\le w}\big)$$ > [!intuition] WHY we reference row $i-1$ (not $i$) > Because item $i$ is used **at most once**. After taking it, we must continue with the *previous* > items only — so we look back to `dp[i-1][...]`. (In *unbounded* knapsack we'd use `dp[i]`, > allowing reuse — that single index change is the whole difference!) ```python def knapsack(weights, values, W): n = len(weights) dp = [[0]*(W+1) for _ in range(n+1)] for i in range(1, n+1): for w in range(W+1): dp[i][w] = dp[i-1][w] # skip if weights[i-1] <= w: # can we take it? take = values[i-1] + dp[i-1][w-weights[i-1]] dp[i][w] = max(dp[i][w], take) return dp[n][W] ``` **Space optimization (1D, iterate weight DOWNWARD):** ```python def knapsack(weights, values, W): dp = [0]*(W+1) for i in range(len(weights)): for w in range(W, weights[i]-1, -1): # REVERSE! dp[w] = max(dp[w], values[i] + dp[w-weights[i]]) return dp[W] ``` > [!mistake] Steel-man: "Loop weight forward in 1D knapsack" > **Why it feels right:** in coin-change *count* we looped amount forward and it worked! > **Why it's wrong for 0/1:** forward iteration lets `dp[w-w_i]` already include item $i$, > so item $i$ gets used *multiple times* → that's **unbounded** knapsack, not 0/1. > **Fix:** iterate $w$ from $W$ **down to** $w_i$, so `dp[w-w_i]` still holds the *previous* > item's value (item $i$ not yet placed). > [!example] Worked 0/1: w=[1,3,4,5], v=[1,4,5,7], W=7 > Best: items 2&3 (weights 3+4=7, value 4+5=**9**). Item 4 alone = 7. Item 1+4 = 8. > DP confirms `dp[7]=9`. *Why?* The (i-1) reference forbids reusing an item, so 3+4 beats any > single-item or reused combo. ![[3.7.09-DP-problems-—-Fibonacci,-coin-change-(count-+-min),-0-1-knapsack.png]] --- ## Decision cheat-sheet (the 80/20 core) | Problem | Loop order | Recurrence core | Base | |---|---|---|---| | Fibonacci | i: 2→n | `dp[i]=dp[i-1]+dp[i-2]` | dp[0]=0,dp[1]=1 | | Coin **count** | coin outer, amt inner ↑ | `dp[a]+=dp[a-c]` | dp[0]=1 | | Coin **min** | amt outer, coin inner | `dp[a]=min(dp[a-c]+1)` | dp[0]=0, rest ∞ | | Unbounded knap | weight inner ↑ | `dp[w]=max(dp[w], v+dp[w-wi])` | dp[..]=0 | | 0/1 knap | weight inner ↓ | `dp[w]=max(dp[w], v+dp[w-wi])` | dp[..]=0 | > [!mistake] Greedy ≠ DP for coin change > **Why it feels right:** for "nice" coin systems (1,5,10,25) greedy is optimal, so people > generalize. **Why wrong:** for coins=[1,3,4], target 6, greedy gives 4+1+1=3, DP gives 3+3=2. > **Fix:** use DP whenever denominations are arbitrary. > [!mnemonic] **"COIN-MUD"** > **CO**mbinations → coins **O**utside. **MU**ltiple-use (unbounded) → weight up. **D**istinct/0-1 > → weight **D**own. (And *min* coin change loops amount outside, trying every coin.) > [!recall]- Feynman: explain to a 12-year-old > Imagine climbing stairs and writing each step's answer on a sticky note so you never re-count. > **Fibonacci**: today's rabbits = yesterday's + day-before's; jot each day down once. > **Coin ways**: how many ways to fill a piggy bank — try adding one coin type at a time and tally. > **Coin min**: fewest coins to pay — for every price, peek at "price minus one coin" and add 1. > **Knapsack**: a bag with a weight limit; for each toy you ask "leave it or take it?" and remember > the best loot for every leftover space. Writing answers down = the whole trick. --- #flashcards/coding What two properties must a problem have to use DP? ::: Optimal substructure + overlapping subproblems. Naive Fibonacci recursion time complexity and why? ::: $\Theta(\phi^n)$ because $T(n)=T(n-1)+T(n-2)+1$ — same subproblems recomputed exponentially. Memoized Fibonacci complexity? ::: $\Theta(n)$ time, each $F(k)$ computed once. In coin-change COUNT, why loop coins on the outside? ::: To count combinations not permutations — fixing coin order prevents double counting. Base case for coin-change count and why? ::: dp[0]=1 — exactly one way (empty set) to make amount 0. Coin-change MIN recurrence? ::: dp[a] = min over coins c≤a of dp[a-c]+1, dp[0]=0, rest ∞. Why does greedy fail for coin change in general? ::: Arbitrary denominations (e.g. [1,3,4], target 6) break greedy: gives 3 coins, DP gives 2. 0/1 knapsack recurrence? ::: dp[i][w]=max(dp[i-1][w], v_i+dp[i-1][w-w_i]) if w_i≤w. Why does 0/1 knapsack reference row i-1 not i? ::: Each item used at most once, so after choosing we continue with previous items only. In 1D 0/1 knapsack why iterate weight downward? ::: So dp[w-w_i] holds the previous item's value, preventing reusing the same item (forward = unbounded). Difference between 0/1 and unbounded knapsack in one line of code? ::: 0/1 iterates weight downward (references i-1); unbounded iterates upward (references i, reuse allowed). ## Connections - [[Recursion and Memoization]] - [[Greedy Algorithms]] — when greedy is/ isn't enough - [[Time and Space Complexity]] - [[Unbounded Knapsack and Rod Cutting]] - [[Subset Sum and Partition Problems]] - [[Tabulation vs Memoization]] ## 🖼️ Concept Map ```mermaid flowchart TD DP[Dynamic Programming] OS[Optimal Substructure] OV[Overlapping Subproblems] DC[Divide and Conquer] MEMO[Memoization + Table] FIB[Fibonacci] CC[Coin Change] CNT[Count Ways] MIN[Min Coins] KNAP[0-1 Knapsack] DP -->|requires| OS DP -->|requires| OV OS -->|alone gives| DC OV -->|makes worthwhile| MEMO DP -->|implemented by| MEMO FIB -->|classic case of| OV MEMO -->|turns exponential into linear| FIB DP -->|applied to| FIB DP -->|applied to| CC DP -->|applied to| KNAP CC -->|flavor| CNT CC -->|flavor| MIN CNT -->|coins outer loop avoids| MIN KNAP -->|unbounded variant is| CC ``` ## 🔊 Hinglish (regional understanding) > [!intuition]- Hinglish mein samjho > Dekho, Dynamic Programming ka core idea bahut simple hai: **recursion + memory**. Matlab bade > problem ko chhote same-type problems mein todo, har chhote ka answer **ek hi baar** nikaalo, aur > ek table/dictionary mein store kar lo taaki dobara compute na karna pade. Fibonacci isi cheez ka > sabse clean example hai — agar tum naive recursion likhoge to F(3), F(2) baar-baar compute honge > (exponential time, phi^n), but memoize karte hi O(n) ho jaata hai. Yeh "ek baar compute, baar-baar > use" hi puri DP ki jaan hai. > > Coin change ke do flavour yaad rakhna. **Count ways** mein loop order critical hai: coins ko > **bahar** rakho, amount ko andar — tabhi combinations count hote hain, permutations nahi (warna > 1+2 aur 2+1 alag count ho jaayega, jo galat hai). Formula `dp[a] += dp[a-c]`, base `dp[0]=1` > (zero banane ka exactly ek tareeka — kuch mat lo). **Min coins** mein har amount ke liye har coin > try karo: `dp[a] = min(dp[a-c]+1)`. Yahan greedy mat lagana — coins=[1,3,4], target 6 pe greedy > 4+1+1 (3 coins) deta hai but DP 3+3 (2 coins) deta hai. Arbitrary denominations pe hamesha DP. > > **0/1 Knapsack** mein har item ke liye sirf ek choice: "lo ya chhodo". State = (kitne items dekhe, > kitni capacity bachi). Recurrence `dp[i][w]=max(skip, value + dp[i-1][w-wi])`. Yaad rakhna ki hum > `i-1` ko refer karte hain kyunki item ek hi baar use hota hai. Jab 1D space optimization karo to > weight ko **ulta (W se neeche)** chalana — warna same item baar-baar add ho jaayega aur woh > unbounded knapsack ban jaayega. Bas yeh ek direction ka difference 0/1 aur unbounded ko alag karta > hai. Mnemonic "COIN-MUD" se direction yaad rakhna: COmbinations→coins Outside, MUltiple-use→weight > up, Distinct/0-1→weight Down. ![[audio/3.7.09-DP-problems-—-Fibonacci,-coin-change-(count-+-min),-0-1-knapsack.mp3]]