Dynamic Programming

Dynamic programming (DP) solves complex problems by breaking them into overlapping subproblems, solving each subproblem once, and storing the results. It applies when a problem has optimal substructure (the optimal solution to the whole contains optimal solutions to subproblems) and overlapping subproblems (the same subproblems are solved multiple times in a naive recursive approach).

Approaches

Top-Down (Memoization)

Write the natural recursive solution; cache results to avoid recomputation.

from functools import lru_cache

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

Advantages: only computes needed subproblems; code mirrors the recurrence.

Bottom-Up (Tabulation)

Iteratively fill in a table, solving smaller subproblems first.

def fib(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]

Advantages: no recursion overhead; explicit control over computation order; often easier to optimize space.

Classic Problems

0/1 Knapsack

Given $n$ items with weights $w_i$ and values $v_i$, and a knapsack capacity $W$: maximize total value without exceeding capacity. Each item can be included at most once.

Recurrence:

\[dp[i][c] = \max(dp[i-1][c], \; dp[i-1][c - w_i] + v_i) \quad \text{if } c \geq w_i\] \[dp[i][c] = dp[i-1][c] \quad \text{if } c < w_i\]

Time: $O(nW)$. Space: $O(nW)$; can reduce to $O(W)$ by using a 1D array.

def knapsack(weights, values, W):
    n = len(weights)
    dp = [0] * (W + 1)
    for i in range(n):
        for c in range(W, weights[i] - 1, -1):  # iterate right-to-left
            dp[c] = max(dp[c], dp[c - weights[i]] + values[i])
    return dp[W]

Longest Common Subsequence (LCS)

Given strings $s$ and $t$: find the length of their longest common subsequence (characters need not be contiguous).

Recurrence:

\[dp[i][j] = \begin{cases} dp[i-1][j-1] + 1 & \text{if } s[i] = t[j] \\ \max(dp[i-1][j], dp[i][j-1]) & \text{otherwise} \end{cases}\]

Time: $O(mn)$. Space: $O(mn)$; can reduce to $O(\min(m,n))$.

Longest Increasing Subsequence (LIS)

Find the length of the longest strictly increasing subsequence of an array.

$O(n^2)$ DP:

def lis_n2(arr):
    n = len(arr)
    dp = [1] * n
    for i in range(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)$ using patience sorting:

import bisect

def lis_nlogn(arr):
    tails = []
    for x in arr:
        pos = bisect.bisect_left(tails, x)
        if pos == len(tails):
            tails.append(x)
        else:
            tails[pos] = x
    return len(tails)

Edit Distance (Levenshtein Distance)

Minimum number of insertions, deletions, and substitutions to transform string $s$ into string $t$.

Recurrence:

\[dp[i][j] = \begin{cases} j & \text{if } i = 0 \\ i & \text{if } j = 0 \\ dp[i-1][j-1] & \text{if } s[i-1] = t[j-1] \\ 1 + \min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) & \text{otherwise} \end{cases}\]

Applications: spell checking, DNA sequence alignment, diff tools.

Coin Change

Given coin denominations and a target amount: find the minimum number of coins.

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for c in range(1, amount + 1):
        for coin in coins:
            if coin <= c:
                dp[c] = min(dp[c], dp[c - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1

Time: $O(\text{amount} \times \text{num coins})$.

Matrix Chain Multiplication

Given $n$ matrices, find the optimal parenthesization to minimize scalar multiplications.

\[dp[i][j] = \min_{i \leq k < j} (dp[i][k] + dp[k+1][j] + p_i \times p_{k+1} \times p_{j+1})\]

Time: $O(n^3)$.

DP on Sequences

Palindrome subproblems: dp[i][j] = is s[i..j] a palindrome? Longest palindromic subsequence, minimum insertions to make palindrome.

DP on intervals: dp[i][j] depends on dp[i+1][j], dp[i][j-1], and dp[i+1][j-1].

DP on Trees

Tree DP: define states at each node, typically using results from children.

Diameter of a binary tree: dp[node] = longest path through the node (max of left height + right height + 2). Propagate up.

Maximum independent set on a tree: either include the node (skip all children) or exclude it (take the maximum of children’s results).

Space Optimization

Many DP problems need only the previous row/column, enabling $O(n)$ space.

Example (LCS): iterate over rows; only keep current and previous rows.

State compression: for bitmask DP, represent subsets as integers. Common in problems involving $n \leq 20$ elements.

Identifying DP Problems

  • “Minimum/maximum cost/steps/length…”
  • “Count the number of ways…”
  • “Is it possible to…”
  • The problem involves sequences, subsets, partitions, or intervals.
  • Brute-force search with exponential branching.