Algorithms

Dynamic Programming

Break hard problems into overlapping subproblems, solve each once, and combine results. The technique behind efficient solutions to optimization, counting, and decision problems.

01 / Core Idea

Overlapping Subproblems + Optimal Substructure

Dynamic programming (DP) applies when a problem has two properties: overlapping subproblems (the same smaller problems recur many times) and optimal substructure (an optimal solution can be built from optimal solutions to subproblems).

Without overlapping subproblems, you just have divide-and-conquer (e.g., merge sort). Without optimal substructure, DP cannot guarantee correctness (e.g., longest simple path in a general graph).

DP Approach
Identify subproblems
Define recurrence
Set base cases
Compute & store
Key Insight
Naive recursion for Fibonacci computes fib(3) hundreds of times for large inputs. DP computes it once and reuses the result, dropping exponential time to linear.

Fibonacci: The Canonical Example

The naive recursive Fibonacci runs in O(2^n) because it recomputes the same values. With DP, we store each result and achieve O(n) time.

# Naive recursive - O(2^n)
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)

# DP bottom-up - O(n) time, O(n) space
def fib_dp(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(n) time, O(1) space
def fib_opt(n):
    if n <= 1: return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b
02 / Approaches

Top-Down vs Bottom-Up

There are two ways to implement DP. Top-down (memoization) starts from the original problem and recurses, caching results. Bottom-up (tabulation) fills a table iteratively from the smallest subproblems upward.

PropertyTop-Down (Memoization)Bottom-Up (Tabulation)
ImplementationRecursive + cache (dict/array)Iterative loops filling a table
Subproblems solvedOnly reachable ones (lazy)All subproblems (eager)
Stack overheadRecursion depth can cause stack overflowNo recursion; constant stack
Space optimizationHard to apply rolling arraysEasy to reduce to O(n) or O(1)
Ease of writingOften more natural; mirrors recurrenceRequires careful ordering of loops
Best whenSparse subproblem space; tree-shapedDense subproblem space; need space optimization
# Top-down: memoization with decorator
from functools import lru_cache

@lru_cache(maxsize=None)
def climb_stairs(n):
    """Number of ways to climb n stairs (1 or 2 steps at a time)."""
    if n <= 2: return n
    return climb_stairs(n-1) + climb_stairs(n-2)

# Bottom-up: tabulation
def climb_stairs_bu(n):
    if n <= 2: return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]
Rule of Thumb
Start with top-down to get the recurrence right, then convert to bottom-up if you need space optimization or want to avoid stack overflow on large inputs.
03 / Classic Problems

Coin Change, Knapsack, and More

Coin Change

Minimum coins to make amount. Unbounded: each coin reusable. dp[i] = min(dp[i], dp[i - coin] + 1)

0/1 Knapsack

Max value with weight limit. Each item used at most once. 2D state: dp[i][w].

Unbounded Knapsack

Like 0/1 but items are reusable. Inner loop direction changes from right-to-left to left-to-right.

Climbing Stairs

Count ways to reach step n taking 1 or 2 steps. Identical structure to Fibonacci.

Coin Change (Minimum Coins)

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 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

0/1 Knapsack

def knapsack_01(weights, values, capacity):
    n = len(weights)
    dp = [0] * (capacity + 1)
    for i in range(n):
        # Traverse RIGHT to LEFT to ensure each item used once
        for w in range(capacity, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[capacity]
Common Mistake
In 0/1 knapsack with a 1D array, you must iterate capacity right to left. Left-to-right would allow using the same item multiple times, turning it into unbounded knapsack.
04 / String DP

LCS, Edit Distance, Palindromes

String DP problems typically define states on indices into one or two strings. The table is usually 2D, with transitions that match, insert, delete, or replace characters.

Longest Common Subsequence (LCS)

Given strings X and Y, find the longest subsequence common to both. A subsequence need not be contiguous.

def lcs(X, Y):
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[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]
# Time: O(m*n), Space: O(m*n) — reducible to O(n)

Edit Distance (Levenshtein)

Minimum insertions, deletions, and replacements to transform one string into another.

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

Longest Palindromic Subsequence

The LPS of a string is the LCS of the string with its reverse. Alternatively, define dp[i][j] as the LPS length for substring s[i..j].

def longest_palindromic_subseq(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n): dp[i][i] = 1
    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]
05 / Grid & Path DP

Unique Paths and Minimum Cost

Grid problems define states as dp[r][c] representing a value at row r, column c. Transitions usually come from adjacent cells (above and left for top-left-to-bottom-right traversals).

Unique Paths

Count paths from top-left to bottom-right in an m x n grid, moving only right or down.

def unique_paths(m, n):
    dp = [1] * n          # first row: only one way to reach any cell
    for r in range(1, m):
        for c in range(1, n):
            dp[c] += dp[c-1]  # dp[c] (from above) + dp[c-1] (from left)
    return dp[n-1]
# O(m*n) time, O(n) space using rolling array

Minimum Path Sum

Find the path from top-left to bottom-right that minimizes the sum of values along the way.

def min_path_sum(grid):
    m, n = len(grid), len(grid[0])
    dp = [float('inf')] * n
    dp[0] = 0
    for r in range(m):
        dp[0] += grid[r][0] if r > 0 else grid[0][0]
        for c in range(1, n):
            if r == 0:
                dp[c] = dp[c-1] + grid[r][c]
            else:
                dp[c] = min(dp[c], dp[c-1]) + grid[r][c]
    return dp[n-1]
Grid DP State Transitions
dp[r-1][c] (above)
dp[r][c-1] (left)
dp[r][c]
06 / Techniques & Trade-offs

Space Optimization & DP vs Greedy

State Design

The hardest part of DP is defining the state. Ask: what information do I need to make a decision at each step? Common state dimensions include index, remaining capacity, boolean flags, and partial results. Minimize state dimensions to keep the table small.

Rolling Array (Space Optimization)

When dp[i] only depends on dp[i-1] (the previous row), you can drop the first dimension and reuse a single array, reducing O(m*n) space to O(n).

# LCS with O(n) space instead of O(m*n)
def lcs_optimized(X, Y):
    m, n = len(X), len(Y)
    prev = [0] * (n + 1)
    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                curr[j] = prev[j-1] + 1
            else:
                curr[j] = max(prev[j], curr[j-1])
        prev = curr
    return prev[n]

When DP vs Greedy

Greedy makes a locally optimal choice and never reconsiders. It works when the problem has the greedy choice property: a locally optimal choice leads to a globally optimal solution. DP is needed when local choices interact and you must consider all combinations.

ProblemGreedy Works?Why
Activity selectionYesChoosing earliest finish time is provably optimal
Fractional knapsackYesTake highest value/weight ratio first
0/1 KnapsackNoCan't take fractions; greedy by ratio fails
Coin change (arbitrary denominations)NoGreedy (largest first) fails for e.g. coins=[1,3,4], amount=6
Coin change (canonical systems like USD)YesDenomination structure guarantees greedy works
Huffman codingYesGreedy merge of two smallest frequencies is optimal
Counterexample: Greedy Coin Change
Coins = [1, 3, 4], amount = 6. Greedy picks 4 + 1 + 1 = 3 coins. DP finds 3 + 3 = 2 coins. Always verify greedy with a proof or counterexample before using it.
Decision Framework
If a problem has optimal substructure but NOT the greedy choice property, use DP. If you can prove the greedy choice property holds (usually via exchange argument), greedy is simpler and faster.

Test Yourself

Score: 0 / 10
Question 01
What two properties must a problem have for dynamic programming to apply?
DP requires overlapping subproblems (same subproblems recur) and optimal substructure (optimal solution builds from optimal sub-solutions). Greedy choice property is for greedy algorithms, not DP.
Question 02
What is the main advantage of bottom-up DP over top-down memoization?
Bottom-up uses iterative loops (no recursion stack) and makes it easy to apply rolling arrays for space optimization. Top-down solves only reachable subproblems (that's its advantage). Neither is universally easier or faster.
Question 03
In the 0/1 knapsack with a 1D DP array, why must you iterate capacity right to left?
Left-to-right iteration would read an already-updated dp[w - weight[i]], effectively using item i multiple times. Right-to-left ensures each dp[w] reads values from the previous item's row only.
Question 04
What is the time complexity of the standard LCS algorithm for strings of length m and n?
The standard DP table for LCS has m*n cells, each computed in O(1), giving O(m*n) total time. O(2^(m+n)) would be the brute-force approach without DP.
Question 05
For coins = [1, 3, 4] and amount = 6, what does the greedy approach (largest coin first) produce?
Greedy picks the largest coin first: 4, then two 1s = 3 coins. The DP solution finds 3 + 3 = 2 coins. This is a classic counterexample showing greedy fails for arbitrary coin denominations.
Question 06
How can you find the longest palindromic subsequence of a string S?
The longest palindromic subsequence of S equals the LCS of S and its reverse. Manacher's algorithm solves longest palindromic substring (contiguous), not subsequence.
Question 07
In the unique paths problem (m x n grid, move right or down), what is the answer?
You must make exactly (m-1) down moves and (n-1) right moves, in any order. The number of ways is C(m+n-2, m-1) = (m+n-2)! / ((m-1)! * (n-1)!). While DP can compute this, the closed-form combinatorial solution is also valid.
Question 08
What does the rolling array technique achieve in DP?
When dp[i] only depends on dp[i-1], you only need two rows (or even one, depending on access pattern). This drops space from O(m*n) to O(n). Time complexity stays the same.
Question 09
What is the edit distance between "kitten" and "sitting"?
kitten -> sitten (replace k with s) -> sittin (replace e with i) -> sitting (insert g). Three operations total, so edit distance = 3.
Question 10
Which problem can be solved greedily (without DP)?
Fractional knapsack allows taking fractions of items, so greedily picking by value/weight ratio is provably optimal. 0/1 knapsack, LCS, and edit distance all require DP because local greedy choices don't guarantee global optimality.