📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 36 · Dynamic programming

DP — 2-D & grids

📖 Walk me through it — plain English

What this is. "Dynamic programming" (DP) means solving a big problem by filling in a table of answers to smaller versions of the same problem, where each entry is built from entries you already filled. In 2-D DP the table is a grid: the answer to one cell depends on two things changing at once — usually how much of string A you've handled and how much of string B you've handled. We write that cell as dp[i][j], read "dp at row i, column j". The anchor problem here is Edit Distance: the fewest single-character edits (insert a letter, delete a letter, or replace one letter with another) to turn string A into string B. dp[i][j] means "cheapest way to make the first i letters of A look like the first j letters of B." A "prefix" is just the front chunk of a string — the first i characters.

An analogy. Imagine two grocery lists and you want to edit yours until it matches your roommate's, item by item from the top. At every point you ask: should I cross an item off mine (a delete), jot down a new item from theirs (an insert), or scribble over one of mine to fix it (a replace)? If the next item already matches on both lists, you do nothing and move on for free. The grid records the cheapest total edits to reconcile the first i of your items with the first j of theirs. Each cell looks at three neighbors it already computed — the one up (dp[i-1][j], = delete a letter of A), left (dp[i][j-1], = insert a letter of B), and diagonal up-left (dp[i-1][j-1], = replace, or free if the letters already match) — and takes the cheapest.

One off-by-one warning before the trace: the table has an extra leading row and column for the empty prefix, so cell dp[i][j] actually compares the characters a[i-1] and b[j-1], not a[i] and b[j].

Let's turn A = "ab" into B = "bc". The table is 3 rows by 3 columns (one extra each for the empty prefix). Step 1 · Fill the base row, dp[0][j] = j: starting from "" (empty), building up to the first j letters of "bc" costs exactly j inserts. Row index 0, columns 0,1,2.
0
1
2
Step 2 · Fill the base column, dp[i][0] = i: collapsing the first i letters of "ab" down to "" costs i deletes. So column 0 going down reads 0, 1, 2. Here are the three left-edge cells stacked as a column (top is dp[0][0], then dp[1][0], then dp[2][0]).
0
1
2
Step 3 · Cell dp[1][1]: compare a[0]='a' vs b[0]='b'. They differ, so cost = 1 + min(up=dp[0][1]=1, left=dp[1][0]=1, diag=dp[0][0]=0) = 1 + 0 = 1 (cheapest move was the diagonal: replace 'a' with 'b'). The three neighbors it read are shown, then the result.
1
1
0
1
Step 4 · Finish row 1. dp[1][2]: 'a' vs 'c' differ → 1 + min(up=2, left=dp[1][1]=1, diag=1) = 1 + 1 = 2. Row 1 now reads: dp[1][0]=1, dp[1][1]=1, dp[1][2]=2.
1
1
2
Step 5 · Row 2. dp[2][1]: compare a[1]='b' vs b[0]='b' — they MATCH, so the diagonal is free: dp[2][1] = dp[1][0] = 1 (no +1). Then dp[2][2]: 'b' vs 'c' differ → 1 + min(up=dp[1][2]=2, left=dp[2][1]=1, diag=dp[1][1]=1) = 1 + 1 = 2. Row 2 reads dp[2][0]=2, dp[2][1]=1, dp[2][2]=2.
2
1
2
Step 6 · The answer is the bottom-right cell dp[2][2] = 2 — full A vs full B. Sanity check: "ab" → replace 'a'→'b' (gives "bb") → replace 'b'→'c' (gives "bc"). That is 2 edits. Matches.
2

Why it works. Every way to edit A into B must, at its last step, either delete A's last char, insert B's last char, or line the last chars up (matching for free, or replacing). Those three options are exactly the up, left, and diagonal neighbors — each a strictly smaller subproblem we already solved — so taking the cheapest of the three is guaranteed optimal. We fill row by row so a cell's three neighbors are always computed before it.

Why the speed. There are (m+1) × (n+1) cells and each does a constant amount of work (one min over three numbers), giving O(m*n) time, where m and n are the two string lengths. The full table is O(m*n) space, but since each cell only needs the previous row plus the current row's left neighbor, you can keep just two 1-D rows and drop to O(min(m,n)) space. The same grid, with a different rule per cell, also solves Unique Paths, Min Path Sum, Longest Common Subsequence, and Longest Palindromic Subsequence — only the transition changes.

When the answer depends on two moving indices — a position in a grid, or a prefix of each of two strings — your state grows a second dimension: dp[i][j]. The whole game is figuring out which already-solved neighbors up (dp[i-1][j]), left (dp[i][j-1]), and diagonal (dp[i-1][j-1]) feed cell (i,j). On a grid those neighbors are literal squares; on two sequences they mean "I already solved the shorter prefixes." Concrete anchor: Edit Distance turns "horse" into "ros". Each cell asks "cheapest way to make the first i chars of A look like the first j chars of B?" — and the answer is always 1 + min(up, left, diagonal), except the diagonal is free when the two characters already match. Fill the table row by row and the bottom-right corner is your answer. Every problem below is this same table with a different transition.

Trigger signals
  • Two strings/arrays compared prefix-by-prefix → indices i over A, j over B
  • An m x n grid where you only move right/down
  • "Min cost / count of ways / longest common ___" between two sequences
  • Transform one string into another (insert, delete, replace)
  • The recurrence naturally references up, left, or diagonal cells
The four canonical problems
  • Unique Paths / Min Path Sum — grid accumulation: cell + (up or left)
  • LCS — match → diag+1, else max(up,left)
  • Edit Distance1 + min(up,left,diag), diag free on match
  • Longest Palindromic Subseq — LCS of s and reversed(s)
Template — Edit Distance (2-D table)
def min_distance(a, b):
    m, n = len(a), len(b)
    # dp[i][j] = edit cost: first i chars of a -> first j chars of b
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # base column: turn first i chars into "" => i deletes
    for i in range(m + 1):
        dp[i][0] = i
    # base row: build first j chars from "" => j inserts
    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 a[i - 1] == b[j - 1]:        # chars match -> diagonal is free
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j],      # up    = delete a[i-1]
                    dp[i][j - 1],      # left  = insert b[j-1]
                    dp[i - 1][j - 1],  # diag  = replace
                )
    return dp[m][n]

# Rolling array: O(min(m,n)) space — each cell needs only the
# previous row + the current row's left cell, so keep two 1-D rows.
def min_distance_rolling(a, b):
    if len(a) < len(b):
        a, b = b, a                       # make b the shorter dimension
    m, n = len(a), len(b)
    prev = list(range(n + 1))         # base row 0..n
    for i in range(1, m + 1):
        cur = [i] + [0] * n            # cur[0] = i deletes
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                cur[j] = prev[j - 1]
            else:
                cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])
        prev = cur
    return prev[n]
function minDistance(a: string, b: string): number {
  const m = a.length, n = b.length;
  // dp[i][j] = edit cost: first i chars of a -> first j chars of b
  const dp: number[][] = Array.from(
    { length: m + 1 },
    () => new Array(n + 1).fill(0)
  );
  for (let i = 0; i <= m; i++) dp[i][0] = i;   // i deletes
  for (let j = 0; j <= n; j++) dp[0][j] = j;   // j inserts

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (a[i - 1] === b[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1];           // match: diagonal free
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j],                          // delete
          dp[i][j - 1],                          // insert
          dp[i - 1][j - 1],                      // replace
        );
      }
    }
  }
  return dp[m][n];
}
Same table, three other transitions
  • Unique Paths: dp[i][j] = dp[i-1][j] + dp[i][j-1]; first row & column all 1 (one way along an edge). Min Path Sum: grid[i][j] + min(up, left); edges accumulate the only path.
  • LCS: on a char match dp[i][j] = dp[i-1][j-1] + 1 (extend the diagonal), else max(dp[i-1][j], dp[i][j-1]) — drop a char from whichever string and keep the better answer. Base row/column are all 0 (empty prefix shares nothing).
  • Longest Palindromic Subsequence: it is exactly LCS(s, reversed(s)) — a palindrome reads the same forward and backward, so the longest subsequence shared with the reverse is the longest palindromic one. (Equivalent interval form: dp[i][j] over substring s[i..j], +2 when ends match, filled by increasing length.)
Complexity & gotchas
  • Time O(m*n), one pass per cell. Space O(m*n) for the table, or O(min(m,n)) with a rolling two-row (swap so the shorter string is the inner dimension).
  • Initialize the first row and column — they encode the cost of building from / collapsing to an empty prefix. Skip them and every later cell reads garbage. For Edit Distance they are 0..i and 0..j; for LCS they are all 0; for Unique Paths all 1.
  • String index vs dp index is off by one. Cell dp[i][j] compares a[i-1] and b[j-1], not a[i]/b[j] — the table has an extra leading row/column for the empty prefix.
  • The diagonal is the match case. Up/left always represent spending an operation (delete/insert, or dropping a char); the diagonal is where a match lets you carry a result forward for free (LCS +1, Edit Distance +0).
  • Answer lives in the bottom-right corner dp[m][n] — the full-prefix-vs-full-prefix cell — for all of these (the interval LPS form reads dp[0][n-1] instead).

Unique paths in an m×n grid — 2-D DP collapsed to one row:

→ Going deeper: 2-D DP tables mirror grid paths. See Matrix & grid techniques.