Dynamic programming: define a smaller problem
“Make amount 6 from reusable coins [1,3,4] using as few coins as possible. Why is taking the largest coin first insufficient?”
The answer is two 3s. Name what the answer for a smaller amount means, then list every possible final coin and the unreachable base cases.
The coding-practice chapter will apply this tool to complete problems. Here, focus on the mechanism and trace how its state changes.
Working example: Find the fewest coins for an amount with unlimited reuse. [1,3,4], 6 → 2.
The idea: dp[x] is the best count for amount x. Try each possible last coin: 1 + dp[x-coin].
First, what is dynamic programming?
The name means saving answers to smaller problems so they are not solved again and again. Let best[x] mean the minimum number of coins that make exactly amount x; an impossible amount has no finite answer. If the last coin is 3, the previous coins must optimally make x - 3. Thus every possible last coin contributes a candidate best[x - coin] + 1. “Take the largest available coin” is not proven optimal: for amount 6 and coins [1, 3, 4], greedy picks 4 + 1 + 1 (three), while 3 + 3 uses two.
coins, amount = [1, 3, 4], 6
best = [float("inf")] * (amount + 1) # unreachable so far
best[0] = 0 # zero coins make zero
for value in range(1, amount + 1):
for coin in coins:
if coin <= value:
best[value] = min(best[value], best[value - coin] + 1)
print(best[6]) # 2: last coin 3 after best[3] = 1
The code computes amounts in ascending order: best[value - coin] has already been determined when coin is positive. This small snippet calculates the count; the full problem also asks for a witness (which coins), so you must remember the selected last coin whenever a better candidate wins and reconstruct afterward.
Amount x |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| Fewest coins | 0 | 1 | 2 | 1 | 1 | 2 | 2 |
Read the animation: each branch tests a possible last coin; the shared smaller amounts are cached answers, not fresh recursive searches. Write the meaning of best[x] over the diagram before writing the loop.
Check the mechanism
Predict each expected result, then trace the state that produces it. Explain the boundary case before opening the reference.
Cost: Choice enumeration grows exponentially. Bottom-up DP: O(amount × coin_count) time and O(amount) space.
01 · Try this input
- Input / starting state
[1, 3, 4],6- Expected result
(2, [3, 3])
Why: Greedy's three coins are worse.
02 · Try this input
- Input / starting state
[2],3- Expected result
(-1, [])
Why: Amount 3 cannot be formed.
03 · Try this input
- Input / starting state
[1, 2],0- Expected result
(0, [])
Why: The empty selection forms zero.
04 · Try this input
- Input / starting state
[],5- Expected result
(-1, [])
Why: No denomination can form a positive amount.
05 · Try this input
- Input / starting state
[1, 1, 3],3- Expected result
(1, [3])
Why: Duplicate denominations add no new choices.
06 · Try this input
- Input / starting state
[0, 1],3- Expected result
ValueError
Why: A zero-value coin breaks the positive-progress rule.
For a = amount and m = number of coin denominations, the table has a+1 amounts, each considering up to m coins: O(a×m) time and O(a) extra space for counts and reconstruction pointers. Enumerating choices recursively without saved subproblem answers repeats work exponentially in general. If each coin may be used only once, the old recurrence and iteration order no longer represent the same problem.
Pass before moving on: Write state, base case, transition, computation order, and impossible-state policy before code.
Each coin may be used once. Explain why the recurrence/order must change.
After attempting: reference and explanation
Compare min_coins in algorithms.py (download file, source below). Use pattern notes for the invariant and contracts for complexity edge cases. Reimplement tomorrow without copying.
"""Reference solutions. Try the exercises in README.md before opening this file."""
from collections import Counter, OrderedDict, deque
from heapq import heappop, heappush, nlargest
def two_sum(nums, target):
visited = {}
for j, value in enumerate(nums):
complement = target - value
if complement in visited:
return visited[complement], j
visited.setdefault(value, j)
return None
def longest_unique(text):
left = best = 0
last_seen = {}
for right, char in enumerate(text):
left = max(left, last_seen.get(char, -1) + 1)
best = max(best, right - left + 1)
last_seen[char] = right
return best
def subarray_sum(nums, target):
prefix_counts = {0: 1}
prefix = result = 0
for value in nums:
prefix += value
result += prefix_counts.get(prefix - target, 0)
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
return result
def lower_bound(nums, target):
lo, hi = 0, len(nums)
while lo < hi:
mid = lo + (hi - lo) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def merge_intervals(intervals):
"""Closed intervals; touching endpoints merge. Does not mutate input."""
out = []
for start, end in sorted(intervals):
if start > end:
raise ValueError('reversed interval')
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
def top_k_frequent(nums, k):
if k < 0:
raise ValueError('k must be nonnegative')
counts = Counter(nums)
# Higher count wins; smaller value breaks ties deterministically.
return nlargest(k, counts, key=lambda value: (counts[value], -value))
def islands(grid):
"""Rectangular list of lists of '0'/'1'; input remains unchanged."""
if not grid:
return 0
cols = len(grid[0])
if any(len(row) != cols or any(x not in ('0', '1') for x in row) for row in grid):
raise ValueError('expected rectangular binary grid')
seen = set()
count = 0
for r, row in enumerate(grid):
for c, value in enumerate(row):
if value != '1' or (r, c) in seen:
continue
count += 1
seen.add((r, c))
queue = deque([(r, c)])
while queue:
x, y = queue.popleft()
for a, b in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)):
if 0 <= a < len(grid) and 0 <= b < cols and grid[a][b] == '1' and (a, b) not in seen:
seen.add((a, b)) # Mark on enqueue, not dequeue.
queue.append((a, b))
return count
def course_order(n, prerequisites):
"""Each pair is (course, prerequisite); [] means a cycle or no courses."""
if n < 0:
raise ValueError('negative course count')
adjacency = [set() for _ in range(n)]
degree = [0] * n
for course, prerequisite in prerequisites:
if not 0 <= course < n or not 0 <= prerequisite < n:
raise ValueError('course outside graph')
if course not in adjacency[prerequisite]:
adjacency[prerequisite].add(course)
degree[course] += 1
queue = deque(i for i, d in enumerate(degree) if d == 0)
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in adjacency[node]:
degree[neighbor] -= 1
if degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == n else []
def shortest_paths(n, edges, source):
"""Directed nonnegative weighted graph; unreachable distances are infinity."""
if not 0 <= source < n:
raise ValueError('invalid source')
graph = [[] for _ in range(n)]
for u, v, weight in edges:
if not 0 <= u < n or not 0 <= v < n or weight < 0:
raise ValueError('invalid edge')
graph[u].append((v, weight))
distance = [float('inf')] * n
distance[source] = 0
heap = [(0, source)]
while heap:
cost, node = heappop(heap)
if cost != distance[node]:
continue
for neighbor, weight in graph[node]:
candidate = cost + weight
if candidate < distance[neighbor]:
distance[neighbor] = candidate
heappush(heap, (candidate, neighbor))
return distance
class LRU:
"""Capacity in entries; None denotes a miss. Not thread-safe."""
def __init__(self, capacity):
if capacity < 0:
raise ValueError('negative capacity')
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if self.capacity == 0:
return
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
def min_coins(coins, amount):
if amount < 0 or any(c <= 0 for c in coins):
raise ValueError('nonnegative amount and positive coins required')
dp = [0] + [amount + 1] * amount
for total in range(1, amount + 1):
for coin in coins:
if coin <= total:
dp[total] = min(dp[total], dp[total-coin] + 1)
return -1 if dp[amount] > amount else dp[amount]
def daily_temperatures(temperatures):
answer = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
previous = stack.pop()
answer[previous] = i - previous
stack.append(i)
return answer
def word_exists(board, word):
"""4-neighbor word search; do not reuse a cell or mutate the board."""
if not word:
return True
if not board:
return False
cols = len(board[0])
if any(len(row) != cols for row in board):
raise ValueError('ragged board')
def visit(r, c, index, used):
if not (0 <= r < len(board) and 0 <= c < cols) or (r, c) in used or board[r][c] != word[index]:
return False
if index == len(word) - 1:
return True
used.add((r, c))
found = any(visit(a, b, index+1, used) for a, b in ((r+1,c),(r-1,c),(r,c+1),(r,c-1)))
used.remove((r, c))
return found
return any(visit(r, c, 0, set()) for r in range(len(board)) for c in range(cols))
class Trie:
END = object()
def __init__(self):
self.root = {}
def insert(self, word):
node = self.root
for char in word:
node = node.setdefault(char, {})
node[self.END] = True
def contains(self, word):
node = self.root
for char in word:
if char not in node:
return False
node = node[char]
return self.END in node