Overview
Dynamic programming solves optimization problems by breaking them into smaller overlapping subproblems and storing solutions to avoid redundant computation. The technique applies to problems exhibiting optimal substructure, where an optimal solution contains optimal solutions to subproblems.
Richard Bellman coined the term in the 1950s while working on optimization problems at RAND Corporation. The method addresses problems where naive recursive solutions result in exponential time complexity due to repeated calculation of identical subproblems.
Dynamic programming transforms exponential algorithms into polynomial ones by trading memory for speed. A recursive Fibonacci implementation makes O(2^n) calls, while dynamic programming reduces this to O(n) with O(n) space.
The approach differs from divide-and-conquer algorithms. Divide-and-conquer splits problems into independent subproblems, while dynamic programming handles overlapping subproblems that share dependencies. Merge sort uses divide-and-conquer because subarrays split independently; computing Fibonacci numbers requires dynamic programming because F(5) depends on both F(4) and F(3), and F(4) itself depends on F(3).
Two implementation strategies exist: top-down with memoization and bottom-up with tabulation. Top-down starts with the original problem and recursively breaks it down, caching results. Bottom-up builds solutions iteratively from smallest subproblems upward.
Key Principles
Dynamic programming requires two properties in the problem structure:
Optimal Substructure: An optimal solution to the problem contains optimal solutions to its subproblems. For shortest path problems, if the shortest path from A to C goes through B, then the path segments A to B and B to C must themselves be shortest paths. This property allows building optimal solutions from optimal subsolutions.
Overlapping Subproblems: The recursive solution recomputes the same subproblems multiple times. Computing F(6) requires F(5) and F(4); F(5) requires F(4) and F(3); both paths compute F(4) independently. This overlap creates opportunities for caching.
The absence of either property makes dynamic programming inappropriate. Problems with optimal substructure but independent subproblems benefit from divide-and-conquer instead. Problems lacking optimal substructure require different approaches like greedy algorithms or backtracking.
Memoization implements top-down dynamic programming. The algorithm stores function results in a cache indexed by input parameters. When called with previously seen inputs, the function returns the cached result instead of recomputing. The recursion structure remains natural while eliminating redundant work.
def fibonacci_memo(n, memo = {})
return n if n <= 1
return memo[n] if memo.key?(n)
memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
end
fibonacci_memo(50) # Completes instantly
# => 12586269025
Tabulation implements bottom-up dynamic programming. The algorithm creates a table (typically an array or matrix) to store solutions to all subproblems, filling it iteratively from smallest to largest. This approach avoids recursion overhead and makes space optimization clearer.
def fibonacci_table(n)
return n if n <= 1
table = Array.new(n + 1)
table[0] = 0
table[1] = 1
(2..n).each do |i|
table[i] = table[i - 1] + table[i - 2]
end
table[n]
end
State definition determines the structure of dynamic programming solutions. The state captures all information needed to solve subproblems and make transitions. Poor state definitions lead to incorrect solutions or missed optimizations. For knapsack problems, the state includes both current item index and remaining capacity; tracking only one dimension loses critical information.
Transition equations define how to compute states from previously computed states. These recurrence relations form the mathematical core of dynamic programming solutions. For longest increasing subsequence, the transition compares extending existing sequences versus starting new ones.
Ruby Implementation
Ruby's built-in data structures make implementing dynamic programming solutions concise. Hash objects provide memoization caches with flexible key types. Array objects store tabulation tables with fast indexed access.
Hash-based memoization handles multi-dimensional state spaces naturally:
def min_path_sum(grid, row = 0, col = 0, memo = {})
return grid[row][col] if row == grid.length - 1 && col == grid[0].length - 1
return Float::INFINITY if row >= grid.length || col >= grid[0].length
key = [row, col]
return memo[key] if memo.key?(key)
down = min_path_sum(grid, row + 1, col, memo)
right = min_path_sum(grid, row, col + 1, memo)
memo[key] = grid[row][col] + [down, right].min
end
grid = [
[1, 3, 1],
[1, 5, 1],
[4, 2, 1]
]
min_path_sum(grid)
# => 7
Ruby's Array class supports multi-dimensional tables through nested arrays. Initializing tables requires care to avoid reference sharing:
# Incorrect - all rows share the same array object
table = Array.new(3, Array.new(3, 0))
table[0][0] = 1 # Modifies all rows!
# Correct - each row is a separate array
table = Array.new(3) { Array.new(3, 0) }
table[0][0] = 1 # Modifies only first row
The Enumerator class chains operations for concise bottom-up solutions:
def coin_change(coins, amount)
dp = Array.new(amount + 1, Float::INFINITY)
dp[0] = 0
(1..amount).each do |i|
coins.each do |coin|
dp[i] = [dp[i], dp[i - coin] + 1].min if coin <= i
end
end
dp[amount] == Float::INFINITY ? -1 : dp[amount]
end
coin_change([1, 2, 5], 11)
# => 3 # (5 + 5 + 1)
Ruby's default_proc for hashes enables automatic memoization:
def climb_stairs(n)
memo = Hash.new do |hash, key|
hash[key] = key <= 2 ? key : hash[key - 1] + hash[key - 2]
end
memo[n]
end
climb_stairs(10)
# => 89
Block syntax clarifies state transitions in tabulation:
def longest_common_subsequence(text1, text2)
m, n = text1.length, text2.length
dp = Array.new(m + 1) { Array.new(n + 1, 0) }
(1..m).each do |i|
(1..n).each do |j|
dp[i][j] = if text1[i - 1] == text2[j - 1]
dp[i - 1][j - 1] + 1
else
[dp[i - 1][j], dp[i][j - 1]].max
end
end
end
dp[m][n]
end
longest_common_subsequence("abcde", "ace")
# => 3
Method parameters in Ruby pass by reference, allowing mutable memo caches without explicit returns:
def word_break(s, word_dict, start = 0, memo = {})
return true if start == s.length
return memo[start] if memo.key?(start)
word_dict.each do |word|
next unless s[start...start + word.length] == word
if word_break(s, word_dict, start + word.length, memo)
memo[start] = true
return true
end
end
memo[start] = false
end
word_break("leetcode", ["leet", "code"])
# => true
Practical Examples
Knapsack Problem: Given items with weights and values, maximize value without exceeding capacity. The state tracks current item index and remaining capacity:
def knapsack(weights, values, capacity)
n = weights.length
dp = Array.new(n + 1) { Array.new(capacity + 1, 0) }
(1..n).each do |i|
(0..capacity).each do |w|
if weights[i - 1] <= w
take = values[i - 1] + dp[i - 1][w - weights[i - 1]]
skip = dp[i - 1][w]
dp[i][w] = [take, skip].max
else
dp[i][w] = dp[i - 1][w]
end
end
end
dp[n][capacity]
end
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
knapsack(weights, values, 8)
# => 10 # Items 2 and 4: values 4 + 6
Edit Distance: Find minimum operations (insert, delete, replace) to transform one string into another. The state represents prefixes of both strings:
def edit_distance(word1, word2)
m, n = word1.length, word2.length
dp = Array.new(m + 1) { Array.new(n + 1, 0) }
# Base cases: converting from/to empty string
(0..m).each { |i| dp[i][0] = i }
(0..n).each { |j| dp[0][j] = j }
(1..m).each do |i|
(1..n).each do |j|
if word1[i - 1] == word2[j - 1]
dp[i][j] = dp[i - 1][j - 1]
else
insert = dp[i][j - 1]
delete = dp[i - 1][j]
replace = dp[i - 1][j - 1]
dp[i][j] = 1 + [insert, delete, replace].min
end
end
end
dp[m][n]
end
edit_distance("horse", "ros")
# => 3 # horse -> rorse -> rose -> ros
Maximum Subarray Sum: Find contiguous subarray with largest sum. Kadane's algorithm tracks maximum sum ending at each position:
def max_subarray(nums)
current_max = global_max = nums[0]
nums[1..-1].each do |num|
current_max = [num, current_max + num].max
global_max = [global_max, current_max].max
end
global_max
end
max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4])
# => 6 # Subarray [4, -1, 2, 1]
Longest Palindromic Substring: Find longest substring reading same forwards and backwards. The state tracks whether substring from i to j forms a palindrome:
def longest_palindrome(s)
n = s.length
return s if n < 2
dp = Array.new(n) { Array.new(n, false) }
start, max_len = 0, 1
# Single characters are palindromes
n.times { |i| dp[i][i] = true }
# Check two-character substrings
(0...n - 1).each do |i|
if s[i] == s[i + 1]
dp[i][i + 1] = true
start, max_len = i, 2
end
end
# Check longer substrings
(3..n).each do |len|
(0..n - len).each do |i|
j = i + len - 1
if s[i] == s[j] && dp[i + 1][j - 1]
dp[i][j] = true
start, max_len = i, len
end
end
end
s[start, max_len]
end
longest_palindrome("babad")
# => "bab" # or "aba"
Partition Equal Subset Sum: Determine if array can be partitioned into two subsets with equal sum. Reduces to subset sum problem:
def can_partition(nums)
total = nums.sum
return false if total.odd?
target = total / 2
dp = Array.new(target + 1, false)
dp[0] = true
nums.each do |num|
(target).downto(num).each do |j|
dp[j] = true if dp[j - num]
end
end
dp[target]
end
can_partition([1, 5, 11, 5])
# => true # [1, 5, 5] and [11]
Common Patterns
One-Dimensional State Space: Problems where state depends on single parameter. Fibonacci, climbing stairs, and house robber problems use 1D arrays or simple variable tracking:
def rob(nums)
return 0 if nums.empty?
return nums[0] if nums.length == 1
prev2, prev1 = nums[0], [nums[0], nums[1]].max
nums[2..-1].each do |num|
current = [prev1, prev2 + num].max
prev2, prev1 = prev1, current
end
prev1
end
rob([2, 7, 9, 3, 1])
# => 12 # Rob houses 0, 2, 4
Two-Dimensional Grid Traversal: Problems involving paths through matrices. State represents position in grid with transitions limited by movement rules:
def unique_paths(m, n)
dp = Array.new(m) { Array.new(n, 1) }
(1...m).each do |i|
(1...n).each do |j|
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
end
end
dp[m - 1][n - 1]
end
unique_paths(3, 7)
# => 28
String Matching: Problems comparing or transforming sequences. State typically tracks positions in both sequences with transitions based on character comparisons:
def is_subsequence(s, t)
return true if s.empty?
s_idx = 0
t.each_char do |char|
s_idx += 1 if s[s_idx] == char
return true if s_idx == s.length
end
false
end
is_subsequence("abc", "ahbgdc")
# => true
Decision Problems: Problems choosing whether to include elements. State represents choices made with transitions exploring include/exclude options. Subset sum and partition problems follow this pattern.
State Compression: Reducing space complexity by observing that current state depends only on fixed number of previous states. Fibonacci needs only two previous values, not entire array:
def min_cost_climbing_stairs(cost)
prev2, prev1 = 0, 0
cost.each do |c|
current = c + [prev1, prev2].min
prev2, prev1 = prev1, current
end
[prev1, prev2].min
end
min_cost_climbing_stairs([10, 15, 20])
# => 15
Backtracking to Reconstruct Solution: Dynamic programming computes optimal values but may require additional work to reconstruct the solution path:
def coin_change_with_path(coins, amount)
dp = Array.new(amount + 1, Float::INFINITY)
parent = Array.new(amount + 1, -1)
dp[0] = 0
(1..amount).each do |i|
coins.each do |coin|
if coin <= i && dp[i - coin] + 1 < dp[i]
dp[i] = dp[i - coin] + 1
parent[i] = coin
end
end
end
# Reconstruct path
path = []
current = amount
while current > 0
path << parent[current]
current -= parent[current]
end
{ count: dp[amount], coins: path }
end
coin_change_with_path([1, 2, 5], 11)
# => { count: 3, coins: [1, 5, 5] }
Performance Considerations
Time complexity analysis starts with counting unique subproblems and work per subproblem. Fibonacci with memoization computes each F(i) once, doing O(1) work per subproblem, yielding O(n) total time. Two-dimensional problems like edit distance compute O(m × n) states with O(1) work each, resulting in O(m × n) time.
Space complexity depends on implementation strategy. Top-down memoization stores all computed subproblems in the cache, matching time complexity in space. Bottom-up tabulation creates tables for all subproblems but often allows optimization.
State compression reduces space when current computations depend only on recent states. Two-row techniques for grid problems maintain only current and previous rows instead of full matrix:
def min_path_sum_optimized(grid)
return 0 if grid.empty?
m, n = grid.length, grid[0].length
prev = Array.new(n)
curr = Array.new(n)
prev[0] = grid[0][0]
(1...n).each { |j| prev[j] = prev[j - 1] + grid[0][j] }
(1...m).each do |i|
curr[0] = prev[0] + grid[i][0]
(1...n).each do |j|
curr[j] = grid[i][j] + [prev[j], curr[j - 1]].min
end
prev, curr = curr, prev
end
prev[n - 1]
end
This optimization reduces O(m × n) space to O(n), maintaining O(m × n) time complexity.
Hash versus array trade-offs affect performance. Arrays provide O(1) access with minimal overhead but require contiguous integer indices. Hashes support arbitrary keys with slightly higher constant factors. For dense integer state spaces, arrays outperform hashes. For sparse or non-integer states, hashes provide better memory efficiency.
Memoization overhead includes hash lookups and recursive call stack. Tabulation avoids recursion but may compute unnecessary subproblems. For problems where only fraction of subproblems contribute to final answer, memoization computes fewer states. When all subproblems contribute, tabulation provides better cache locality and lower overhead.
Ruby's garbage collector impacts dynamic programming performance with large state spaces. Creating many small objects stresses memory allocation. Preallocating tables and reusing objects reduces allocation pressure:
# Less efficient - creates many intermediate arrays
def compute
n.times.map { |i| calculation(i) }
end
# More efficient - preallocates result array
def compute_optimized
result = Array.new(n)
n.times { |i| result[i] = calculation(i) }
result
end
Common Pitfalls
Incorrect Base Cases: Missing or wrong base cases cause infinite recursion or wrong answers. Fibonacci requires explicit handling of F(0) and F(1). Recursion without base cases never terminates:
# Wrong - missing base case for n == 1
def fib_wrong(n)
return 0 if n == 0
fib_wrong(n - 1) + fib_wrong(n - 2) # Infinite recursion at n == 1
end
# Correct
def fib_correct(n)
return n if n <= 1
fib_correct(n - 1) + fib_correct(n - 2)
end
State Definition Errors: Insufficient state information leads to incorrect solutions. For problems requiring multiple parameters, tracking only one dimension loses critical information:
# Wrong - tracks only amount, loses coin information
def coin_change_wrong(coins, amount, memo = {})
return memo[amount] if memo.key?(amount)
# State doesn't capture which coins were used
end
# Correct - no additional state needed for this problem
# but ensure memo key captures all relevant parameters
Off-by-One Errors: Array indexing mistakes corrupt state transitions. Converting between 0-based and 1-based indexing requires care:
# Wrong - accesses out of bounds
def lcs_wrong(s1, s2, i, j, memo = {})
return 0 if i == s1.length || j == s2.length
if s1[i] == s2[j] # Should be s1[i-1] if using 1-based
1 + lcs_wrong(s1, s2, i + 1, j + 1, memo)
end
end
Memoization Key Collisions: Using inadequate keys for multi-parameter problems causes cache collisions. String concatenation creates ambiguous keys:
# Wrong - "12" and "1,2" produce same key "12"
memo["#{i}#{j}"]
# Correct - array keys or delimited strings
memo[[i, j]]
memo["#{i},#{j}"]
Modifying Shared References: Ruby passes arrays and hashes by reference. Sharing default objects between table rows creates unintended aliasing:
# Wrong - all rows reference same array
dp = Array.new(m, Array.new(n, 0))
dp[0][0] = 1 # Modifies column 0 in ALL rows
# Correct - each row gets separate array
dp = Array.new(m) { Array.new(n, 0) }
Forgetting to Return Memoized Values: Computing and storing results without returning them wastes cache:
# Wrong - computes but doesn't use cached value
def compute(n, memo = {})
return base_case if n == 0
memo[n] = expensive_computation(n)
expensive_computation(n) # Recomputes instead of returning memo[n]
end
# Correct
def compute(n, memo = {})
return base_case if n == 0
memo[n] = expensive_computation(n)
end
Integer Overflow: Problems involving large numbers require careful handling. Ruby handles arbitrary precision integers automatically, but intermediate calculations might overflow in languages with fixed-width integers. Being aware of potential overflow helps when translating solutions:
# Ruby handles this automatically
def catalan(n)
return 1 if n <= 1
dp = Array.new(n + 1, 0)
dp[0] = dp[1] = 1
(2..n).each do |i|
(0...i).each do |j|
dp[i] += dp[j] * dp[i - 1 - j] # Can become very large
end
end
dp[n]
end
catalan(20)
# => 6564120420 # Ruby handles large integers
Unnecessary Recomputation: Failing to recognize computed values as final leads to redundant work. Once a subproblem is solved, its value should not change:
# Wrong - recomputes already solved subproblems
def fib_inefficient(n, memo = {})
return n if n <= 1
memo[n] = fib_inefficient(n - 1, memo) + fib_inefficient(n - 2, memo)
fib_inefficient(n - 1, memo) + fib_inefficient(n - 2, memo) # Duplicate work
end
Reference
Common Dynamic Programming Problems
| Problem | State Definition | Time Complexity | Space Complexity |
|---|---|---|---|
| Fibonacci | F(n) = nth number | O(n) | O(n) or O(1) compressed |
| Climbing Stairs | ways(n) = ways to reach step n | O(n) | O(n) or O(1) compressed |
| Coin Change | dp(i) = min coins for amount i | O(n × m) | O(n) |
| Longest Common Subsequence | dp(i,j) = LCS of prefixes i,j | O(m × n) | O(m × n) or O(n) compressed |
| Edit Distance | dp(i,j) = distance between prefixes | O(m × n) | O(m × n) or O(n) compressed |
| Knapsack | dp(i,w) = max value with items 0..i, capacity w | O(n × W) | O(n × W) or O(W) compressed |
| Maximum Subarray | max ending at position i | O(n) | O(1) |
| Longest Increasing Subsequence | dp(i) = LIS ending at i | O(n²) or O(n log n) | O(n) |
| Matrix Chain Multiplication | dp(i,j) = min cost for chain i..j | O(n³) | O(n²) |
| Palindrome Partitioning | dp(i,j) = is substring i..j palindrome | O(n²) | O(n²) |
Implementation Strategy Selection
| Characteristic | Top-Down Memoization | Bottom-Up Tabulation |
|---|---|---|
| Code Structure | Recursive, follows natural problem structure | Iterative, builds from base cases |
| Subproblem Computation | Computes only needed subproblems | Computes all subproblems |
| Stack Usage | Uses call stack, may overflow | No recursion stack |
| Space Optimization | Harder to optimize | Easier to apply compression |
| Debugging | Natural flow matches problem | Requires understanding iteration order |
| Best For | Sparse subproblem spaces | Dense subproblem spaces |
State Space Patterns
| Pattern | Examples | Typical Transitions |
|---|---|---|
| Linear Sequence | Fibonacci, Climbing Stairs, House Robber | Current depends on 1-2 previous |
| Grid Traversal | Unique Paths, Min Path Sum | Current from top and left |
| Two Sequences | LCS, Edit Distance, Interleaving String | Match, skip first, skip second |
| Substring Problems | Palindrome, Parentheses | Expand from centers or ends |
| Partitioning | Subset Sum, Partition Equal | Include or exclude current element |
| Interval DP | Matrix Chain, Burst Balloons | Split interval at different points |
Optimization Techniques
| Technique | When Applicable | Space Reduction |
|---|---|---|
| Two-Variable Compression | Linear DP with constant lookback | O(n) to O(1) |
| Rolling Array | Grid DP accessing previous row only | O(m × n) to O(n) |
| In-Place Modification | When input can be modified | Additional O(n) to O(1) |
| Coordinate Compression | Large sparse coordinate spaces | O(range) to O(distinct values) |
Complexity Analysis Guide
| Problem Size | Acceptable Complexity | Examples |
|---|---|---|
| n ≤ 20 | O(2ⁿ), O(n!) | Backtracking with pruning |
| n ≤ 100 | O(n⁴) | Four nested loops |
| n ≤ 1000 | O(n²) | Two-dimensional DP |
| n ≤ 10⁶ | O(n log n) | Efficient sorting, LIS with binary search |
| n ≤ 10⁹ | O(log n), O(1) | Binary search, mathematical formula |