Dynamic Programming

Introduction

Dynamic Programming (DP) is an optimization technique that solves complex problems by breaking them into overlapping subproblems, solving each once, and storing the results. It's one of the most powerful algorithmic techniques and essential for coding interviews.

Learning Objectives

By the end of this reading, you will be able to:

  • Identify when to use dynamic programming
  • Apply top-down (memoization) and bottom-up (tabulation) approaches
  • Solve classic DP problems
  • Optimize space in DP solutions

1. When to Use DP

DP applies when a problem has:

  1. Optimal Substructure: Optimal solution contains optimal solutions to subproblems
  2. Overlapping Subproblems: Same subproblems are solved multiple times

Example: Fibonacci

# Without DP - O(2^n) time
def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n-1) + fib_naive(n-2)

# Visualize the problem:
# fib(5)
# ├── fib(4)
# │   ├── fib(3)
# │   │   ├── fib(2)
# │   │   └── fib(1)
# │   └── fib(2)      ← REPEATED
# └── fib(3)          ← REPEATED
#     ├── fib(2)      ← REPEATED
#     └── fib(1)

2. Two Approaches

Top-Down (Memoization)

Start with the original problem, recursively solve subproblems, cache results.

def fib_memo(n, cache=None):
    if cache is None:
        cache = {}

    if n in cache:
        return cache[n]

    if n <= 1:
        return n

    cache[n] = fib_memo(n-1, cache) + fib_memo(n-2, cache)
    return cache[n]

# Using decorator
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_cached(n):
    if n <= 1:
        return n
    return fib_cached(n-1) + fib_cached(n-2)

Bottom-Up (Tabulation)

Solve subproblems first, build up to original problem.

def fib_tabulation(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 - O(1) space
def fib_optimized(n):
    if n <= 1:
        return n

    prev2, prev1 = 0, 1
    for i in range(2, n + 1):
        current = prev1 + prev2
        prev2 = prev1
        prev1 = current

    return prev1

Comparison

AspectTop-DownBottom-Up
ImplementationOften easierMay need to think about order
OverheadRecursion overheadNone
SubproblemsOnly computes neededComputes all
Space optimizationHarderEasier

3. DP Problem-Solving Framework

Step 1: Define State

What information do we need to represent a subproblem?

Example: "What's the minimum cost to reach step i?"

Step 2: Define Recurrence

How does the solution to a subproblem relate to smaller subproblems?

Example: dp[i] = min(dp[i-1], dp[i-2]) + cost[i]

Step 3: Identify Base Cases

What are the smallest subproblems we can solve directly?

Example: dp[0] = cost[0], dp[1] = cost[1]

Step 4: Determine Order

For bottom-up: In what order should we solve subproblems?

Step 5: Optimize (Optional)

Can we reduce space complexity?


4. Classic DP Problems

Climbing Stairs

"""
You can climb 1 or 2 steps at a time. How many ways to reach step n?

State: dp[i] = number of ways to reach step i
Recurrence: dp[i] = dp[i-1] + dp[i-2]
Base: dp[0] = 1, dp[1] = 1
"""

def climb_stairs(n):
    if n <= 2:
        return n

    dp = [0] * (n + 1)
    dp[1] = 1
    dp[2] = 2

    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]

    return dp[n]

# Space optimized
def climb_stairs_opt(n):
    if n <= 2:
        return n

    prev2, prev1 = 1, 2
    for i in range(3, n + 1):
        current = prev1 + prev2
        prev2, prev1 = prev1, current

    return prev1

Coin Change (Minimum Coins)

"""
Given coins and amount, find minimum coins needed.

State: dp[i] = minimum coins to make amount i
Recurrence: dp[i] = min(dp[i - coin] + 1) for each coin
Base: dp[0] = 0
"""

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] != float('inf'):
                dp[i] = min(dp[i], dp[i - coin] + 1)

    return dp[amount] if dp[amount] != float('inf') else -1

Coin Change (Number of Ways)

"""
Count the number of ways to make amount.

State: dp[i] = number of ways to make amount i
Recurrence: dp[i] += dp[i - coin] for each coin
Base: dp[0] = 1
"""

def coin_change_ways(coins, amount):
    dp = [0] * (amount + 1)
    dp[0] = 1

    for coin in coins:  # Process each coin
        for i in range(coin, amount + 1):
            dp[i] += dp[i - coin]

    return dp[amount]

Longest Increasing Subsequence (LIS)

"""
Find length of longest strictly increasing subsequence.

State: dp[i] = length of LIS ending at index i
Recurrence: dp[i] = max(dp[j] + 1) for j < i where arr[j] < arr[i]
Base: dp[i] = 1 (each element is a subsequence)
"""

def longest_increasing_subsequence(arr):
    if not arr:
        return 0

    n = len(arr)
    dp = [1] * n  # Each element is LIS 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)

# O(n log n) solution using binary search
import bisect

def lis_optimized(arr):
    sub = []  # Smallest tail of all increasing subsequences

    for num in arr:
        pos = bisect.bisect_left(sub, num)
        if pos == len(sub):
            sub.append(num)
        else:
            sub[pos] = num

    return len(sub)

Longest Common Subsequence (LCS)

"""
Find length of longest common subsequence of two strings.

State: dp[i][j] = LCS of s1[0:i] and s2[0:j]
Recurrence:
  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])
Base: dp[0][j] = dp[i][0] = 0
"""

def longest_common_subsequence(s1, s2):
    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]

# Space optimized to O(n)
def lcs_optimized(s1, s2):
    m, n = len(s1), len(s2)
    prev = [0] * (n + 1)

    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                curr[j] = prev[j-1] + 1
            else:
                curr[j] = max(prev[j], curr[j-1])
        prev = curr

    return prev[n]

Edit Distance

"""
Minimum operations (insert, delete, replace) to transform s1 to s2.

State: dp[i][j] = edit distance between s1[0:i] and s2[0:j]
Recurrence:
  If s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1]
  Else: dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
Base: dp[0][j] = j, dp[i][0] = i
"""

def edit_distance(s1, s2):
    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
    for j in range(n + 1):
        dp[0][j] = j

    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]
            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]

0/1 Knapsack

"""
Given weights, values, and capacity. Maximize value without exceeding capacity.
Each item can be taken at most once.

State: dp[i][w] = max value using items 0..i-1 with capacity w
Recurrence:
  dp[i][w] = max(dp[i-1][w],                      # Don't take item i
                 dp[i-1][w-weight[i]] + value[i]) # Take item i
Base: dp[0][w] = 0
"""

def knapsack(weights, values, capacity):
    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-1
            dp[i][w] = dp[i-1][w]
            # Take item i-1 if possible
            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 to O(capacity)
def knapsack_optimized(weights, values, capacity):
    n = len(weights)
    dp = [0] * (capacity + 1)

    for i in range(n):
        # Traverse backward 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]

5. DP Patterns

Linear DP

Single array, each state depends on previous states.

# House Robber: dp[i] = max(dp[i-1], dp[i-2] + nums[i])
def rob(nums):
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]

    prev2, prev1 = 0, 0
    for num in nums:
        current = max(prev1, prev2 + num)
        prev2, prev1 = prev1, current

    return prev1

2D DP

Grid or two sequences.

# Unique Paths: dp[i][j] = dp[i-1][j] + dp[i][j-1]
def unique_paths(m, n):
    dp = [[1] * n for _ in range(m)]

    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]

Interval DP

Subproblem defined by interval [i, j].

# Matrix Chain Multiplication
def matrix_chain(dims):
    n = len(dims) - 1
    dp = [[0] * n for _ in range(n)]

    for length in range(2, n + 1):  # Length of chain
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = float('inf')
            for k in range(i, j):
                cost = dp[i][k] + dp[k+1][j] + dims[i]*dims[k+1]*dims[j+1]
                dp[i][j] = min(dp[i][j], cost)

    return dp[0][n-1]

State Machine DP

States represent different conditions.

# Best Time to Buy/Sell Stock with Cooldown
def max_profit_cooldown(prices):
    if not prices:
        return 0

    n = len(prices)
    hold = -prices[0]  # Holding stock
    sold = 0           # Just sold
    rest = 0           # Not holding, not sold

    for i in range(1, n):
        prev_hold = hold
        prev_sold = sold
        prev_rest = rest

        hold = max(prev_hold, prev_rest - prices[i])
        sold = prev_hold + prices[i]
        rest = max(prev_rest, prev_sold)

    return max(sold, rest)

6. Reconstructing Solutions

Often we need the actual solution, not just the optimal value.

def lcs_with_path(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # Fill DP table
    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])

    # Backtrack to find LCS
    lcs = []
    i, j = m, n
    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))

7. Tips and Tricks

Recognize DP Problems

  • "Count ways to..."
  • "Minimum/maximum..."
  • "Can you reach/make/achieve..."
  • "Longest/shortest..."
  • Choices at each step
  • Optimal substructure visible

Debug DP

  1. Print the DP table
  2. Verify base cases
  3. Check recurrence on small examples
  4. Ensure correct iteration order

Common Mistakes

  • Off-by-one errors in indices
  • Forgetting base cases
  • Wrong iteration order
  • Not handling empty input

Exercises

Basic

  1. Find the minimum cost to climb stairs (can climb 1 or 2 steps, each step has a cost).

  2. Count the number of ways to tile a 2×n board with 2×1 tiles.

  3. Find if a sum can be made using array elements (subset sum).

Intermediate

  1. Find the longest palindromic subsequence.

  2. Word Break: Can a string be segmented into dictionary words?

  3. Partition Equal Subset Sum: Can an array be partitioned into two subsets with equal sum?

Advanced

  1. Regular Expression Matching (. and * patterns).

  2. Burst Balloons: Maximize coins from bursting all balloons.

  3. Longest Valid Parentheses: Find length of longest valid parentheses substring.


Summary

  • DP applies to problems with optimal substructure and overlapping subproblems
  • Top-down (memoization): Recursive with caching
  • Bottom-up (tabulation): Iterative, fill table
  • Framework: Define state, recurrence, base cases, order
  • Space can often be optimized when only recent states needed
  • Common patterns: linear, 2D, interval, state machine

Next Reading

Greedy Algorithms →