Dynamic Programming (continued): Coin Change & Circular House Robber
Overview
Session 5 opened with two spaced-repetition reviews (Merge K Sorted Lists and Climb Stairs), then covered Coin Change as the canonical unbounded-knapsack problem, and finished with House Robber II as a variation that reduces a circular constraint to two independent linear subproblems.
Concept
Unbounded Knapsack
The classic 0/1 knapsack lets you use each item at most once. Unbounded knapsack removes that restriction — each item may be used any number of times. The recurrence changes one index:
| Variant | Recurrence |
|---|---|
| 0/1 | dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i]) |
| Unbounded | dp[i][w] = max(dp[i-1][w], dp[i][w-weight[i]] + value[i]) |
Coin Change is unbounded knapsack where all item values are 1 (each coin counts as one coin) and you minimize instead of maximize. The 2D table collapses to 1D because all coins are considered at every amount:
dp[i] = min over all coins c of (dp[i - c] + 1)
dp[0] = 0
dp[i] initialized to Infinity (sentinel for "not yet reachable")
Complexity: O(amount × |coins|) time, O(amount) space. No way to reduce space further — lookback depth equals the largest coin denomination, which can be as large as amount.
DP State Design Rule
DP state should represent the best answer to a complete subproblem. A constrained state (“dp[i] = best if you must rob house i”) forces unbounded lookback because you can’t reconstruct the full picture from one constrained slice alone. Either use the unconstrained best-so-far, or track two parallel constrained states that together cover all cases.
Implementation
Coin Change
const IMPOSSIBLE = -1
function minCoinCount(coins: number[], target: number): number {
const counts = new Array(target + 1)
counts[0] = 0
for (let i = 1; i <= target; ++i) {
counts[i] = Math.min(...coins.map(c => (counts[i - c] ?? Infinity) + 1))
}
const result = counts[target]
return result === Infinity ? IMPOSSIBLE : result
}
Notes on James’s original:
- Used
counts[i - c] ?? Infinityto handle coins larger thani(negative index returnsundefinedin JS). Clever and correct. - Had
new Array(target)(off-by-one) andcounts.at(-1) ?? IMPOSSIBLE— the??doesn’t catchInfinity, so impossible cases returnedInfinityinstead of-1.
House Robber II (circular)
function robCircular(houses: number[]) {
return Math.max(robLinear(houses.slice(0, -1)), robLinear(houses.slice(1)))
}
function robLinear(houses: number[]) {
if (houses.length === 0) return 0
if (houses.length === 1) return houses[0]
let prev2 = houses[0]
let prev1 = Math.max(houses[0], houses[1])
for (let i = 2; i < houses.length; ++i) {
const curr = Math.max(prev2 + houses[i], prev1)
prev2 = prev1
prev1 = curr
}
return prev1
}
Problems
Merge K Sorted Lists (spaced rep — heaps)
Problem: Given k sorted linked lists, merge them into one sorted list. Return the head.
Approach: Seed a min-heap with the head of each list. Each step: extract the minimum node, append it to the result, push its .next back into the heap if non-null. O(N log k).
Solution:
type Head<T> = { value: T; next: Head<T> | null }
type Comparator<T> = (a: T, b: T) => number
function mergeSortedLists<T>(heads: Head<T>[], comparator: Comparator<T>) {
const heap = new Heap<Head<T>>((a, b) => comparator(a.value, b.value))
for (const h of heads) heap.insert(h)
const sentinel: Head<T> = { value: null as any, next: null }
let tail = sentinel
let node: Head<T> | null
while ((node = heap.extractMin()) != null) {
tail.next = node
tail = node
if (node.next) heap.insert(node.next)
}
return sentinel.next
}
Key miss: James forgot to heap.insert(node.next) after extraction — the algorithm only drained the initial k heads without advancing into each list.
Climb Stairs (spaced rep — DP)
Problem: Climb n stairs 1 or 2 steps at a time. How many distinct ways?
Approach: Fibonacci with base cases dp[1]=1, dp[2]=2.
James’s off-by-one: loop condition i < n should be i <= n. Same error as Session 4 — habit to build: trace the boundary case by hand after writing the loop.
Coin Change
Problem: Given coin denominations and a target, return the minimum number of coins to make the target. Each denomination may be used unlimited times. Return -1 if impossible.
Approach: Unbounded knapsack. dp[i] = min(dp[i-c] + 1) for each coin c. Initialize dp[0]=0, rest to Infinity.
Key bug to avoid: Infinity ?? -1 short-circuits to Infinity because Infinity is not nullish. Check result === Infinity explicitly.
House Robber II (circular)
Problem: Houses are in a circle. Can’t rob two adjacent houses. Maximize loot.
Approach: Reduce to two linear subproblems: rob(houses[0..n-2]) and rob(houses[1..n-1]). Take the max. This works because houses 0 and n-1 can’t coexist, so you solve once excluding each.
James’s bugs in robLinear:
- Loop bound
i <= houses.length(should bei < houses.length) — accessesundefined - Recurrence
max(prev2 + h[i], prev1 + h[i])— the “skip” branch should not addh[i] - Init of
dp[1]washouses[1]instead ofmax(houses[0], houses[1])
Root cause of bugs 2 & 3: tried to define dp[i] as “best if you must rob house i” — a constrained state that forces unbounded lookback and can’t be composed into a clean recurrence.
Session transcript
Claude: Welcome back! This is Session 5. Before we dive into Coin Change, we have two spaced repetition reviews due: Merge K Sorted Lists (heaps) and Climb Stairs (DP).
Merge K Sorted Lists
Claude: Pattern and complexity?
James: As I pull each element (N), I need to identify which list has the lowest head. I can keep a Heap<{ list_ptr, head_value }>. Comparison is on head_value. Each step is heap.extract_min and then heap.insert(min.head). A list that’s empty falls off the heap. extract_min is O(1) and insert is O(log k). Target: O(n log k).
Claude: Spot on. Now implement it.
James: [wrote implementation with correct structure but forgot heap.insert(next.next) in the main loop]
Claude: One critical bug — you never feed the next node back into the heap. Without heap.insert(node.next), you only drain the k initial heads.
James: I definitely meant to insert the new next. I wrote out the API into the Heap stub. I just missed it in the caller.
Claude: Fair. Worth noting: the “feed the next node back” step is the entire algorithm — worth a quick re-read before moving on.
Climb Stairs (spaced rep)
Claude: No looking back — write it from scratch.
James: climb_count(n) = fib(n) with {1:1, 2:2} base case. [wrote iterative O(1)-space solution with i < n instead of i <= n]
Claude: Off-by-one in the loop bound. Trace n=4: loop only runs at i=3, returns 3 instead of 5. Condition should be i <= n. Same off-by-one as Session 4 — habit to build: trace the boundary case by hand.
Coin Change
Claude: Define dp[i] and write the recurrence.
James: dp[i] is the min number of coins that add up to i. It’s min(c in coins | dp[i-c] + 1).
Claude: Correct. What’s dp[0]? What do you initialize dp[i > 0] to?
James: dp[0] = 0. We’re using a min-over algorithm so default inputs should be +Infinity.
Claude: Exactly right. Write it.
James: [wrote correct solution but with new Array(target) off-by-one and counts.at(-1) ?? IMPOSSIBLE]
Claude: Two issues: (1) new Array(target) is one short — loop goes to counts[target] but array has indices 0..target-1. Works in JS due to dynamic arrays, latent bug elsewhere. (2) Infinity ?? IMPOSSIBLE returns Infinity — ?? only catches nullish, not Infinity. Need result === Infinity ? IMPOSSIBLE : result.
James: [asked about “unbounded knapsack”]
Claude: [explained 0/1 vs unbounded knapsack, mapped coin change to the unbounded variant]
House Robber II (circular)
Claude: Houses in a circle, can’t rob adjacent. Same recurrence as House Robber — how do you handle the circular constraint?
James: Reduce to two subproblems: rob[0..len-2] and rob[1..len-1].
Claude: Exactly right. Write it.
James: [wrote correct robCircular but robLinear had wrong recurrence, wrong init, and i <= houses.length loop bound]
Claude: Three bugs in robLinear: loop bound, recurrence (“skip” branch adds houses[i] when it shouldn’t), and init of memo[1] missing the max.
James: The recurrence I was trying: dp[i] = amount you can get if you rob (not skip) i. Help me understand why that isn’t right.
Claude: Valid to compute, but can’t build the answer from it. If you must rob i, you need dp[i] = houses[i] + max(dp[i-2], dp[i-3], ...) — arbitrarily deep lookback. The standard definition is complete: dp[i] = best over 0..i, rob or skip freely. dp[i-1] is self-contained so “skip” just carries it forward. Rule: DP state should represent the best answer to a complete subproblem, not a constrained one.