Chapter 8: Dynamic Programming
Introduction to Dynamic Programming
Dynamic Programming (DP) is a powerful algorithmic paradigm for solving optimization problems by breaking them down into simpler overlapping subproblems and storing the results to avoid redundant computations. The term "dynamic programming" was coined by Richard Bellman in the 1950s, though it has nothing to do with programming in the modern sense—it refers to the mathematical optimization method.
DP is particularly effective for problems that exhibit two key properties: overlapping subproblems and optimal substructure. It transforms exponential-time recursive solutions into polynomial-time algorithms, making it one of the most important techniques in competitive programming and algorithm design.
When to Use Dynamic Programming
Consider DP when you encounter:
- Optimization problems (minimize/maximize)
- Counting problems (how many ways)
- Decision problems (yes/no with conditions)
- Problems that can be broken into similar subproblems
- Recursive solutions with repeated calculations
Core Principles
Dynamic Programming is built on two fundamental principles:
- Overlapping Subproblems: The problem can be broken down into subproblems that are reused multiple times. Instead of recomputing solutions, we store them.
- Optimal Substructure: An optimal solution to the problem contains optimal solutions to its subproblems. This allows us to build solutions from smaller optimal solutions.
Example: Fibonacci Numbers
The classic example demonstrates the power of DP. Without DP, computing Fibonacci numbers recursively has exponential time complexity:
# Naive recursive approach - O(2^n)
def fibonacci_naive(n):
if n <= 1:
return n
return fibonacci_naive(n - 1) + fibonacci_naive(n - 2)
# With DP (memoization) - O(n)
memo = {}
def fibonacci_dp(n):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fibonacci_dp(n - 1) + fibonacci_dp(n - 2)
return memo[n]
Memoization vs Tabulation
Dynamic Programming can be implemented in two ways: memoization (top-down) and tabulation (bottom-up). Both achieve the same goal of avoiding redundant computations but use different approaches.
Memoization (Top-Down Approach)
Memoization starts with the original problem and recursively breaks it down, storing results as we go. It's called "top-down" because we start at the top (the problem) and work our way down to base cases.
# Memoization example: Climbing Stairs
memo = {}
def climb_stairs_memo(n):
"""How many ways to climb n stairs (1 or 2 steps at a time)"""
if n in memo:
return memo[n]
# Base cases
if n == 0 or n == 1:
return 1
# Recursive case with memoization
memo[n] = climb_stairs_memo(n - 1) + climb_stairs_memo(n - 2)
return memo[n]
# Advantages:
# - Natural recursive structure
# - Only computes needed subproblems
# - Easy to understand
# Disadvantages:
# - Recursion overhead
# - Stack overflow risk for deep recursion
# - Less control over computation order
Tabulation (Bottom-Up Approach)
Tabulation builds solutions iteratively from the bottom up, starting with base cases and building up to the desired solution. It fills a table (usually an array) with solutions to all subproblems.
# Tabulation example: Climbing Stairs
def climb_stairs_tab(n):
"""Bottom-up approach using tabulation"""
if n <= 1:
return 1
# Create table to store results
dp = [0] * (n + 1)
dp[0] = 1 # Base case
dp[1] = 1 # Base case
# Fill table from bottom up
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# Advantages:
# - No recursion overhead
# - Better space optimization possible
# - Predictable memory access patterns
# - Can optimize space (only keep needed values)
# Disadvantages:
# - Computes all subproblems (even unused ones)
# - Less intuitive for some problems
# - Need to determine computation order
Space-Optimized Tabulation
Many DP problems can be optimized to use O(1) space instead of O(n):
# Space-optimized version
def climb_stairs_optimized(n):
"""O(1) space instead of O(n)"""
if n <= 1:
return 1
prev2 = 1 # dp[i-2]
prev1 = 1 # dp[i-1]
for i in range(2, n + 1):
current = prev1 + prev2
prev2 = prev1
prev1 = current
return prev1
When to Use Each Approach
| Approach | Use When | Advantages |
|---|---|---|
| Memoization | Not all subproblems needed, natural recursion | Intuitive, only computes needed values |
| Tabulation | All subproblems needed, want to avoid recursion | Faster, space-optimizable, no stack overflow |
Common DP Patterns
Recognizing common DP patterns helps identify when and how to apply dynamic programming. Here are the most important patterns with detailed examples.
1. 1D DP (Linear DP)
Problems where the state depends on a single dimension. Often involves sequences or linear structures.
Pattern: Linear Sequence
# Template for 1D DP
def dp_1d(n):
dp = [0] * (n + 1)
dp[0] = base_case_0
dp[1] = base_case_1
for i in range(2, n + 1):
dp[i] = combine(dp[i-1], dp[i-2], ...)
return dp[n]
Example: Maximum Subarray Sum
def max_subarray_sum(arr):
"""Kadane's Algorithm - O(n) time, O(1) space"""
max_sum = current_sum = arr[0]
for i in range(1, len(arr)):
# Either extend previous subarray or start new one
current_sum = max(arr[i], current_sum + arr[i])
max_sum = max(max_sum, current_sum)
return max_sum
# DP interpretation:
# dp[i] = maximum sum ending at index i
# dp[i] = max(arr[i], dp[i-1] + arr[i])
2. 2D DP (Grid/Matrix DP)
Problems involving two dimensions, often grids, matrices, or two sequences.
Pattern: Grid Paths
# Template for 2D DP
def dp_2d(m, n):
dp = [[0] * n for _ in range(m)]
# Initialize first row and column
for i in range(m):
dp[i][0] = base_case
for j in range(n):
dp[0][j] = base_case
# Fill table
for i in range(1, m):
for j in range(1, n):
dp[i][j] = combine(dp[i-1][j], dp[i][j-1], ...)
return dp[m-1][n-1]
Example: Unique Paths
def unique_paths(m, n):
"""Number of unique paths from top-left to bottom-right"""
dp = [[0] * n for _ in range(m)]
# Base case: first row and column have only one path
for i in range(m):
dp[i][0] = 1
for j in range(n):
dp[0][j] = 1
# Fill table
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
# Space-optimized version (O(n) space)
def unique_paths_optimized(m, n):
prev = [1] * n
for i in range(1, m):
curr = [1] * n
for j in range(1, n):
curr[j] = prev[j] + curr[j-1]
prev = curr
return prev[n-1]
3. Knapsack Problems
Classic optimization problems involving selecting items with constraints.
0/1 Knapsack
def knapsack_01(weights, values, capacity):
"""0/1 Knapsack: Each item can be used at most once"""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
# Don't take item i
dp[i][w] = dp[i-1][w]
# Take item i (if it fits)
if weights[i-1] <= w:
dp[i][w] = max(
dp[i][w],
dp[i-1][w - weights[i-1]] + values[i-1]
)
return dp[n][capacity]
# Space-optimized (1D array)
def knapsack_01_optimized(weights, values, capacity):
dp = [0] * (capacity + 1)
for i in range(len(weights)):
# Iterate backwards to avoid using updated values
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
Unbounded Knapsack
def unbounded_knapsack(weights, values, capacity):
"""Unbounded: Each item can be used unlimited times"""
dp = [0] * (capacity + 1)
for w in range(1, capacity + 1):
for i in range(len(weights)):
if weights[i] <= w:
dp[w] = max(
dp[w],
dp[w - weights[i]] + values[i]
)
return dp[capacity]
4. Longest Common Subsequence (LCS)
def longest_common_subsequence(s1, s2):
"""Find length of longest common subsequence"""
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
def lcs_reconstruct(s1, s2, dp):
"""Reconstruct the actual LCS string"""
i, j = len(s1), len(s2)
lcs = []
while i > 0 and j > 0:
if s1[i-1] == s2[j-1]:
lcs.append(s1[i-1])
i -= 1
j -= 1
elif dp[i-1][j] > dp[i][j-1]:
i -= 1
else:
j -= 1
return ''.join(reversed(lcs))
5. Edit Distance (Levenshtein Distance)
def edit_distance(s1, s2):
"""Minimum operations to transform s1 into s2
Operations: insert, delete, replace"""
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base cases
for i in range(m + 1):
dp[i][0] = i # Delete all characters
for j in range(n + 1):
dp[0][j] = j # Insert all characters
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] # No operation needed
else:
dp[i][j] = 1 + min(
dp[i-1][j], # Delete
dp[i][j-1], # Insert
dp[i-1][j-1] # Replace
)
return dp[m][n]
6. Interval DP
Problems involving intervals or ranges, often solved by considering all possible splits.
# Template for interval DP
def interval_dp(arr):
n = len(arr)
dp = [[0] * n for _ in range(n)]
# Length 1 intervals
for i in range(n):
dp[i][i] = base_case
# Length 2 to n
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
for k in range(i, j):
dp[i][j] = min_or_max(
dp[i][j],
combine(dp[i][k], dp[k+1][j])
)
return dp[0][n-1]
7. State Machine DP
Problems where you transition between different states.
# Example: Best Time to Buy and Sell Stock
def max_profit(prices):
"""State machine: hold, sold"""
hold = float('-inf') # State: holding stock
sold = 0 # State: sold (not holding)
for price in prices:
# Can either keep holding or buy today
hold = max(hold, sold - price)
# Can either keep sold or sell today
sold = max(sold, hold + price)
return sold
Classic DP Problems
Here are detailed solutions to classic dynamic programming problems that appear frequently in interviews and competitions.
1. Fibonacci Sequence
The simplest DP problem, perfect for understanding the concept.
# Tabulation approach
def fibonacci(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]
# Space-optimized
def fibonacci_optimized(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
2. Longest Increasing Subsequence (LIS)
Find the length of the longest subsequence where elements are in increasing order.
def longest_increasing_subsequence(arr):
"""O(n²) solution"""
n = len(arr)
dp = [1] * n # Each element is a subsequence of length 1
for i in range(1, n):
for j in range(i):
if arr[j] < arr[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
def lis_optimized(arr):
"""O(n log n) solution using binary search"""
tails = []
for num in arr:
# Binary search for the position to replace
left, right = 0, len(tails)
while left < right:
mid = (left + right) // 2
if tails[mid] < num:
left = mid + 1
else:
right = mid
if left == len(tails):
tails.append(num)
else:
tails[left] = num
return len(tails)
3. Coin Change
Find the minimum number of coins needed to make a target amount.
def coin_change_min(coins, amount):
"""Minimum coins to make amount"""
dp = [float('inf')] * (amount + 1)
dp[0] = 0 # Base case: 0 coins for amount 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
def coin_change_ways(coins, amount):
"""Number of ways to make amount"""
dp = [0] * (amount + 1)
dp[0] = 1 # One way to make 0: use no coins
for coin in coins:
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
return dp[amount]
4. Longest Palindromic Subsequence
def longest_palindromic_subsequence(s):
"""Find length of longest palindromic subsequence"""
n = len(s)
dp = [[0] * n for _ in range(n)]
# Every single character is a palindrome of length 1
for i in range(n):
dp[i][i] = 1
# Fill table for lengths 2 to n
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][n-1]
5. Word Break
def word_break(s, word_dict):
"""Check if string can be segmented into dictionary words"""
n = len(s)
dp = [False] * (n + 1)
dp[0] = True # Empty string can always be segmented
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in word_dict:
dp[i] = True
break
return dp[n]
6. House Robber
def house_robber(nums):
"""Maximum money without robbing adjacent houses"""
if not nums:
return 0
if len(nums) == 1:
return nums[0]
dp = [0] * len(nums)
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, len(nums)):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[-1]
# Space-optimized
def house_robber_optimized(nums):
prev2 = 0 # dp[i-2]
prev1 = 0 # dp[i-1]
for num in nums:
current = max(prev1, prev2 + num)
prev2 = prev1
prev1 = current
return prev1
7. Decode Ways
def num_decodings(s):
"""Number of ways to decode a string (A=1, B=2, ..., Z=26)"""
if not s or s[0] == '0':
return 0
n = len(s)
dp = [0] * (n + 1)
dp[0] = 1 # Empty string has 1 way
dp[1] = 1 # Single digit has 1 way (if not '0')
for i in range(2, n + 1):
# Single digit decode
if s[i-1] != '0':
dp[i] += dp[i-1]
# Two digit decode
two_digit = int(s[i-2:i])
if 10 <= two_digit <= 26:
dp[i] += dp[i-2]
return dp[n]
8. Partition Equal Subset Sum
def can_partition(nums):
"""Check if array can be partitioned into two equal sum subsets"""
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
DP Optimization Techniques
1. Space Optimization
Many 2D DP problems can be reduced to 1D or even O(1) space by recognizing that we only need previous values.
# Example: 2D → 1D optimization
# Original 2D DP
dp = [[0] * n for _ in range(m)]
# Optimized 1D DP (if we only need previous row)
prev = [0] * n
curr = [0] * n
for i in range(1, m):
# Compute curr from prev
prev = curr
2. State Compression
For problems with limited states, use bitmasks to represent states compactly.
# Example: Traveling Salesman Problem
def tsp_dp(graph):
n = len(graph)
# dp[mask][last] = minimum cost visiting cities in mask, ending at last
dp = [[float('inf')] * n for _ in range(1 << n)]
dp[1][0] = 0 # Start at city 0
for mask in range(1, 1 << n):
for last in range(n):
if not (mask & (1 << last)):
continue
for next_city in range(n):
if mask & (1 << next_city):
continue
new_mask = mask | (1 << next_city)
dp[new_mask][next_city] = min(
dp[new_mask][next_city],
dp[mask][last] + graph[last][next_city]
)
return min(dp[(1 << n) - 1][i] + graph[i][0] for i in range(n))
3. Sliding Window Optimization
For problems where the recurrence only depends on a window of previous values.
# Example: Maximum sum of k consecutive elements
def max_sum_k_consecutive(arr, k):
window_sum = sum(arr[:k])
max_sum = window_sum
for i in range(k, len(arr)):
window_sum = window_sum - arr[i-k] + arr[i]
max_sum = max(max_sum, window_sum)
return max_sum
How to Identify DP Problems
Recognizing when to use DP is crucial. Look for these indicators:
Problem Characteristics
- Optimization Problem: Asking for minimum, maximum, or optimal solution
- Counting Problem: "How many ways" or "number of possibilities"
- Decision Problem: "Is it possible" with constraints
- Subproblem Overlap: Same subproblems appear multiple times
- Optimal Substructure: Optimal solution contains optimal sub-solutions
Common Problem Types
- Sequence/Array problems (LIS, LCS, Edit Distance)
- Grid/Matrix problems (Unique Paths, Minimum Path Sum)
- String problems (Palindrome, Word Break, Interleaving)
- Tree problems (Binary Tree DP, Tree Diameter)
- Game theory (Nim, Game of Stones)
Step-by-Step Approach
- Identify the State: What information do we need to track?
- Define the Recurrence: How do we compute current state from previous states?
- Base Cases: What are the smallest subproblems?
- Fill the Table: Compute states in the correct order
- Extract Solution: Get the answer from the DP table
Practice Problems
Here are recommended problems to practice dynamic programming:
Easy
- LeetCode 70: Climbing Stairs
- LeetCode 53: Maximum Subarray
- LeetCode 121: Best Time to Buy and Sell Stock
- LeetCode 198: House Robber
- LeetCode 746: Min Cost Climbing Stairs
Medium
- LeetCode 5: Longest Palindromic Substring
- LeetCode 62: Unique Paths
- LeetCode 64: Minimum Path Sum
- LeetCode 300: Longest Increasing Subsequence
- LeetCode 322: Coin Change
- LeetCode 416: Partition Equal Subset Sum
- LeetCode 1143: Longest Common Subsequence
- LeetCode 139: Word Break
Hard
- LeetCode 32: Longest Valid Parentheses
- LeetCode 72: Edit Distance
- LeetCode 85: Maximal Rectangle
- LeetCode 312: Burst Balloons
- LeetCode 887: Super Egg Drop
- LeetCode 115: Distinct Subsequences
Summary
Dynamic Programming is a powerful technique for solving optimization problems by breaking them into overlapping subproblems and storing solutions to avoid redundant computation.
Key takeaways:
- DP requires overlapping subproblems and optimal substructure
- Memoization (top-down) and tabulation (bottom-up) are two implementation approaches
- Common patterns include 1D DP, 2D DP, knapsack, interval DP, and state machine DP
- Space optimization can reduce memory usage significantly
- Practice recognizing DP problems by looking for optimization, counting, or decision problems
- Many problems can be solved by identifying the state, recurrence relation, and base cases
Mastery of dynamic programming opens doors to solving complex optimization problems efficiently and is essential for competitive programming and technical interviews.