Skip to content

2-D Dynamic Programming

Mark as:

One-liner: when one number is not enough to describe a subproblem — a cell in a grid, a position in each of two strings, or a range i..j — the DP table becomes a rectangle, and every cell is filled from neighbours that are already filled.

The analogy

A spreadsheet where every cell is a formula that refers to the cell above it, the cell to its left, or the one diagonally up-left. You fill it top to bottom, left to right, and the bottom-right cell holds the answer. You never recompute anything, because each cell reads the finished values next to it. If you can finish this sentence — "dp[i][j] is the answer for ___" — you have already solved half the problem. Start with 1-D DP if that sentence is new to you.

Recognition signals

  1. Two sequences (two strings, or a string and a pattern) and a question about how they match: common subsequence, edit distance, interleaving, regex.
  2. A grid where you move only in certain directions and count or optimize paths.
  3. A knapsack-like choice with two things to track: the item you are looking at and the total so far (Target Sum, Coin Change II).
  4. A range l..r where the best split point matters (Burst Balloons).
  5. A state machine per day (hold / sold / rest) in stock problems.

Step-by-step walkthrough

Unique Paths on a 3×3 grid, moving only right or down. dp[r][c] = number of ways to reach cell (r, c).

  1. The whole top row and left column have exactly 1 way (straight line).
  2. Cell (1,1) is reached from above or from the left: 1 + 1 = 2.
  3. (1,2): 1 + 2 = 3. (2,1): 2 + 1 = 3.
  4. (2,2): 3 + 3 = 6. That is the answer.

Code template

The shape is always: define the cell, fill the base row/column, then two loops. Unique Paths shows it, already shrunk to one row because a cell only needs the row above and the cell to its left:

UniquePaths — go/dp2d/dp2d.go
// UniquePaths: robot moves right or down on an m×n grid.
// dp[c] holds the ways to reach column c of the current row; it is updated row by row in one slice.
func UniquePaths(m, n int) int {
	dp := make([]int, n)
	for c := range dp {
		dp[c] = 1 // top row: only one way to reach each cell
	}
	for r := 1; r < m; r++ {
		for c := 1; c < n; c++ {
			dp[c] += dp[c-1] // from above (old dp[c]) + from the left (new dp[c-1])
		}
	}
	return dp[n-1]
}
1dp[c] = 1
Why:

Base case. Nothing above the first row, so each cell there has one path.

2dp[c] += dp[c-1]
Why:

Before this line dp[c] still holds the value from the row above; dp[c-1] is already the new value of the cell on the left. Adding them is "from above + from the left".

The real solutions

Two strings: the table has one row and column per prefix. The two classic ones are on the DP page:

LongestCommonSubsequence
// LongestCommonSubsequence.
// State: dp[i][j] = LCS length of a[:i] and b[:j]. Row 0 and column 0 are the empty-prefix base cases.
func LongestCommonSubsequence(a, b string) int {
	dp := make([][]int, len(a)+1)
	for i := range dp {
		dp[i] = make([]int, len(b)+1)
	}
	for i := 1; i <= len(a); i++ {
		for j := 1; j <= len(b); j++ {
			if a[i-1] == b[j-1] {
				dp[i][j] = dp[i-1][j-1] + 1 // match: extend the diagonal
			} else {
				dp[i][j] = max(dp[i-1][j], dp[i][j-1]) // drop one character
			}
		}
	}
	return dp[len(a)][len(b)]
}
MinDistance
// MinDistance: fewest insert / delete / replace edits to turn a into b.
// State: dp[i][j] = edits to turn a[:i] into b[:j].
func MinDistance(a, b string) int {
	dp := make([][]int, len(a)+1)
	for i := range dp {
		dp[i] = make([]int, len(b)+1)
		dp[i][0] = i // delete everything
	}
	for j := 0; j <= len(b); j++ {
		dp[0][j] = j // insert everything
	}
	for i := 1; i <= len(a); i++ {
		for j := 1; j <= len(b); j++ {
			if a[i-1] == b[j-1] {
				dp[i][j] = dp[i-1][j-1]
			} else {
				dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) // replace, delete, insert
			}
		}
	}
	return dp[len(a)][len(b)]
}

Coin Change II counts combinations, so coins are the outer loop. Swap the loops and you count orderings instead:

Change
// Change: number of COMBINATIONS of coins that make amount.
// Coins are the OUTER loop, so each combination is counted once, in a fixed coin order.
func Change(amount int, coins []int) int {
	ways := make([]int, amount+1)
	ways[0] = 1
	for _, coin := range coins {
		for a := coin; a <= amount; a++ {
			ways[a] += ways[a-coin]
		}
	}
	return ways[amount]
}

Target Sum looks like a +/− puzzle, but algebra turns it into "count subsets with a given sum":

FindTargetSumWays
// FindTargetSumWays: choose + or - for every number so the total is target.
// The + group P and - group N satisfy P - N = target and P + N = sum, so P = (sum + target) / 2:
// count subsets that add up to P.
func FindTargetSumWays(nums []int, target int) int {
	sum := 0
	for _, n := range nums {
		sum += n
	}
	if (sum+target)%2 != 0 || sum+target < 0 || target > sum || -target > sum {
		return 0
	}
	want := (sum + target) / 2
	ways := make([]int, want+1)
	ways[0] = 1
	for _, n := range nums {
		for s := want; s >= n; s-- { // descending: each number used at most once
			ways[s] += ways[s-n]
		}
	}
	return ways[want]
}

Stock with cooldown is a three-state machine per day:

MaxProfitCooldown
// MaxProfitCooldown: unlimited trades, but you must rest one day after selling.
// Three states per day: holding a stock, just sold (cooling down), or resting with nothing.
func MaxProfitCooldown(prices []int) int {
	hold, sold, rest := -1<<31, 0, 0
	for _, p := range prices {
		prevSold := sold
		sold = hold + p
		hold = max(hold, rest-p)
		rest = max(rest, prevSold)
	}
	return max(sold, rest)
}

Interleaving String — after taking i letters of one string and j of the other, the next letter of s3 sits at i + j:

IsInterleave
// IsInterleave: can s3 be formed by weaving s1 and s2 without reordering either?
// dp[i][j] = the first i letters of s1 and j letters of s2 can form the first i+j letters of s3.
func IsInterleave(s1, s2, s3 string) bool {
	if len(s1)+len(s2) != len(s3) {
		return false
	}
	dp := make([][]bool, len(s1)+1)
	for i := range dp {
		dp[i] = make([]bool, len(s2)+1)
	}
	dp[0][0] = true
	for i := 0; i <= len(s1); i++ {
		for j := 0; j <= len(s2); j++ {
			if i > 0 && dp[i-1][j] && s1[i-1] == s3[i+j-1] {
				dp[i][j] = true
			}
			if j > 0 && dp[i][j-1] && s2[j-1] == s3[i+j-1] {
				dp[i][j] = true
			}
		}
	}
	return dp[len(s1)][len(s2)]
}

Longest Increasing Path in a Matrix — the DP order is not row by row, so use memoized DFS. Strict increase means no cycles:

LongestIncreasingPath
// LongestIncreasingPath: memoized DFS. Strictly increasing steps cannot form a cycle,
// so every cell's answer can be cached the first time it is computed.
func LongestIncreasingPath(m [][]int) int {
	if len(m) == 0 {
		return 0
	}
	memo := make([][]int, len(m))
	for i := range memo {
		memo[i] = make([]int, len(m[0]))
	}
	var dfs func(r, c int) int
	dfs = func(r, c int) int {
		if memo[r][c] != 0 {
			return memo[r][c]
		}
		best := 1
		for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
			nr, nc := r+d[0], c+d[1]
			if nr >= 0 && nr < len(m) && nc >= 0 && nc < len(m[0]) && m[nr][nc] > m[r][c] {
				best = max(best, 1+dfs(nr, nc))
			}
		}
		memo[r][c] = best
		return best
	}
	longest := 0
	for r := range m {
		for c := range m[r] {
			longest = max(longest, dfs(r, c))
		}
	}
	return longest
}

Distinct Subsequences — when letters match you can use the match or skip it. Looping j downward lets one row do the work:

NumDistinct
// NumDistinct: how many ways does t appear as a subsequence of s?
// dp[j] = ways to build the first j letters of t from the part of s seen so far.
func NumDistinct(s, t string) int {
	dp := make([]int, len(t)+1)
	dp[0] = 1
	for i := 0; i < len(s); i++ {
		for j := len(t); j >= 1; j-- { // descending so dp[j-1] still holds the previous row
			if s[i] == t[j-1] {
				dp[j] += dp[j-1]
			}
		}
	}
	return dp[len(t)]
}

Burst Balloons — the trick is thinking about the balloon you burst last in a range:

MaxCoins
// MaxCoins: interval DP. Choose the balloon k that bursts LAST in (l, r);
// then its neighbours are the fixed ends l and r, and the two sides are independent subproblems.
func MaxCoins(nums []int) int {
	a := append([]int{1}, append(append([]int{}, nums...), 1)...)
	n := len(a)
	dp := make([][]int, n)
	for i := range dp {
		dp[i] = make([]int, n)
	}
	for length := 2; length < n; length++ {
		for l := 0; l+length < n; l++ {
			r := l + length
			for k := l + 1; k < r; k++ {
				dp[l][r] = max(dp[l][r], dp[l][k]+dp[k][r]+a[l]*a[k]*a[r])
			}
		}
	}
	return dp[0][n-1]
}

Regular Expression Matching — a * gives two options: zero copies, or one more copy:

IsMatch
// IsMatch: regular expression matching with '.' and '*'.
// dp[i][j] = the first i characters of s match the first j characters of p.
func IsMatch(s, p string) bool {
	dp := make([][]bool, len(s)+1)
	for i := range dp {
		dp[i] = make([]bool, len(p)+1)
	}
	dp[0][0] = true
	for j := 2; j <= len(p); j++ {
		if p[j-1] == '*' {
			dp[0][j] = dp[0][j-2] // "a*" can match the empty string
		}
	}
	for i := 1; i <= len(s); i++ {
		for j := 1; j <= len(p); j++ {
			if p[j-1] == '*' {
				dp[i][j] = dp[i][j-2] // zero copies of the starred letter
				if p[j-2] == '.' || p[j-2] == s[i-1] {
					dp[i][j] = dp[i][j] || dp[i-1][j] // one more copy
				}
			} else if p[j-1] == '.' || p[j-1] == s[i-1] {
				dp[i][j] = dp[i-1][j-1]
			}
		}
	}
	return dp[len(s)][len(p)]
}

Complexity

ApproachTimeSpace
Plain recursion on two indexesO(2^(m+n))O(m+n)
Memoized / table DPO(m·n)O(m·n)
Table shrunk to one rowO(m·n)O(n)
Interval DP (Burst Balloons)O(n³)O(n²)

Common mistakes

Practice ladder

  1. 1.
    Unique Paths
    You can only arrive from two directions.
    Medium
  2. 2.
    Longest Common Subsequence
    Medium
  3. 3.
    Coin Change II
    Combinations, not permutations.
    Medium
  4. 4.
    Target Sum
    Two groups, one equation.
    Medium
  5. 5.
    Best Time to Buy and Sell Stock with Cooldown
    Medium
  6. 6.
    Interleaving String
    Medium
  7. 7.
    Edit Distance
    Medium
  8. 8.
    Longest Increasing Path in a Matrix
    Hard
  9. 9.
    Distinct Subsequences
    Hard
  10. 10.
    Burst Balloons
    Which balloon goes LAST?
    Hard
  11. 11.
    Regular Expression Matching
    Hard

Which pattern? Drills

Question 1 of 5

Given two words, find the minimum number of insertions, deletions and replacements to turn the first into the second.

Which pattern?