datarekha

Dynamic Programming

Turning exponential recursion into polynomial time by solving each subproblem exactly once — the technique behind spell-check, sequence alignment, and optimal planning.

10 min read Advanced Data Structures & Algorithms Lesson 27 of 32

What you'll learn

  • What overlapping subproblems and optimal substructure mean — and why both are required
  • Memoization (top-down) versus tabulation (bottom-up), and when to reach for each
  • How edit distance fills a 2-D table to find the cheapest sequence of edits
  • Where DP runs in production — spell-check, record dedup, sequence alignment

Before you start

Dynamic programming is a name that intimidates, but the idea under it is plain: if you are going to solve the same subproblem more than once, solve it once and remember the answer.

For that to help, two things must both be true. There must be overlapping subproblems — the recursion keeps revisiting the same smaller inputs (naive Fibonacci computes fib(3) over and over). And there must be optimal substructure — the best answer to the whole is built from best answers to its parts (the shortest path from A to C through B is the shortest A-to-B path plus the shortest B-to-C path). When both hold, you have a choice of two equivalent styles.

Two directions: memoize or tabulate

Memoization keeps the natural recursion and just caches results — top-down. You write the algorithm the obvious way and check the cache before doing real work:

from functools import lru_cache

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

The cache turns the call tree from O(2ⁿ) into O(n): each fib(k) is computed once and read thereafter. Tabulation flips the direction — bottom-up. You fill a table from the smallest subproblems forward, with no recursion at all:

def fib_tab(n):
    if n <= 1:
        return n
    dp = [0, 1]
    for i in range(2, n + 1):
        dp.append(dp[i - 1] + dp[i - 2])
    return dp[n]

Same O(n), but no call stack to overflow, and often you can shrink the table — here, keeping only the last two values makes it O(1) space. Memoization is easiest when you already have the recursion and only some subproblems matter; tabulation wins when inputs are huge or you need the whole table to reconstruct the answer.

The shape of every DP: a recurrence

Every DP problem hides a recurrence. The 0/1 knapsack — fit the most value into a bag of capacity W, each item taken or not — is dp[i][w] = max(skip item i, take item i). Find that one line and the rest is bookkeeping. Let us see it in full on a problem you use every day.

Edit distance, the full picture

Edit distance is the fewest single-character insertions, deletions, and substitutions to turn one word into another. Lay the two words on the axes of a grid; cell dp[i][j] is the cost to convert the first i letters of one into the first j of the other. If the current letters match, the cost is whatever the diagonal already held — free. If they differ, it is one plus the cheapest of three neighbours: diagonal (substitute), up (delete), left (insert).

carcat0123101221013211cat → carcost 1: swap t→r
”cat” → “car”: the bottom-right cell is 1 (just swap t for r). Each cell is the cheapest of its three neighbours, plus 1 unless the letters already match.
def edit_distance(a, b):
    m, n = len(a), len(b)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1): dp[i][0] = i        # delete i chars to reach ""
    for j in range(n + 1): dp[0][j] = j        # insert j chars from ""
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]                 # match — free
            else:
                dp[i][j] = 1 + min(dp[i - 1][j - 1],        # substitute
                                   dp[i - 1][j],            # delete
                                   dp[i][j - 1])            # insert
    return dp[m][n]

for a, b in [("cat", "car"), ("kitten", "sitting"), ("sunday", "saturday")]:
    print(f"{a} -> {b}: {edit_distance(a, b)}")
cat -> car: 1
kitten -> sitting: 3
sunday -> saturday: 3

Filling the table is O(m·n), and once it is full, tracing the chosen neighbours back from the corner reconstructs the actual edits.

Practice

Quick check

0/3
Q1A problem has overlapping subproblems but NOT optimal substructure. Can DP give the globally optimal answer?
Q2Naive recursive Fibonacci is O(2ⁿ). With memoization, what is the time complexity?
Q3In dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) for edit distance, which operation is dp[i-1][j]?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
Implement Fibonacci with memoization in Python. What problem does memoization solve, and what is the time complexity before and after?

Naive recursive Fibonacci is O(2^n) because it recomputes the same subproblems exponentially. Memoization caches results of subproblems, reducing time to O(n) with O(n) space. Python's functools.lru_cache makes this a one-line decorator.

Find all unique combinations of candidates that sum to a target, where each candidate may be used an unlimited number of times.

Use backtracking with a running total. At each step, try adding a candidate to the current path. If the total equals the target, record the path. If it exceeds the target, prune. Passing the same start index (not i+1) back into the recursion allows unlimited reuse of the same element.

Find the length of the longest common subsequence (LCS) of two strings.

Build a 2-D DP table where dp[i][j] is the LCS length of the first i characters of text1 and first j characters of text2. If the characters match, dp[i][j] = dp[i-1][j-1] + 1; otherwise, dp[i][j] = max(dp[i-1][j], dp[i][j-1]). The answer is dp[m][n].

Generate all subsets of a set — the power set — using backtracking.

At each step of a recursive walk through the input, you make a binary choice: include the current element or skip it. Recording the current path at every node of the recursion tree (not just the leaves) collects all 2^n subsets. A `start` index prevents duplicates by ensuring elements are only considered left-to-right.

Related lessons

Explore further

Skip to content