Back to Home

Dynamic Programming

"I thought dynamic programming was a good name. It was something not even a Congressman could object to."

— Richard Bellman, inventor of dynamic programming.

Prerequisites: Backtracking

Dynamic programming has a reputation for being intimidating, but applying it is usually more procedural than mystical. Once you know how to identify the right recurrence relation, the implementation tends to follow a small number of repeatable patterns.

At a high level, we apply DP in two steps:

StepGoal
Problem-solvingCome up with a recurrence relation that computes the output for the problem.
ImplementationTurn the recurrence relation into efficient code with memoization or tabulation.

Foundations

Problem-Solving Step

A recurrence relation is a function or formula defined in terms of itself on smaller inputs.

A classic example is Fibonacci:

F(0) = 1
F(1) = 1
F(n) = F(n - 1) + F(n - 2), for n > 1

In interview problems, we are usually not given the recurrence relation up front. Finding it is often the hardest part.

Implementation Step

Once we have a recurrence relation, we need to turn it into efficient code.

ApproachAlso CalledCore Idea
MemoizationTop-down DPStart from the original problem, recurse into smaller subproblems, and cache answers.
TabulationBottom-up DPFill smaller subproblems first and build the answer iteratively.

Memoization is usually the easiest way to start because recurrence relations are naturally recursive.

Practical Advice

For most interviews, mastering one of memoization and tabulation is enough. Memoization is often the better first tool because it lets you focus on modeling the recurrence before worrying about iteration order.

Recurrence Relations

The easiest way to find a recurrence relation is to reuse the decision tree mindset from backtracking. Backtracking explores that tree to find all full solutions. Dynamic programming shifts the focus:

In Backtracking, we care about partial solutions. In dynamic programming, we care about remaining subproblems.

Dynamic Programming Workflow

1. Model as Decision Tree

Model the problem as a decision tree.

2. Define Subproblems

Define the subproblem that remains at each node.

3. Write Recurrence Relation

Write a recurrence relation that combines child answers into the parent answer.

Final Result

If the recurrence is correct, the answer to the original problem is the value at the root.

Problem 40.1 Road Trip

We are driving down a road with n rest stops between us and our destination. For each rest stop, our navigation software tells us the detour time required to stop there.

We are given an array times of positive integers. If we do not want to go more than 2 rest stops without taking a break, what is the minimum total detour time?

  • Example 1: times = [8, 1, 2, 3, 9, 6, 2, 4]
    • Output: 6
  • Example 2: times = [8, 1, 2, 3, 9, 3, 2, 4]
    • Output: 5
  • Example 3: times = [10, 10]
    • Output: 0
Road Trip example showing the optimal rest stops for the first sample input.
Figure 1. A compact version of the chapter's road-trip example. One optimal plan is to stop at indices 1, 3, and 6.
Solution 40.1 Road Trip

It is tempting to always pick the smallest delay among the next three rest stops, but that greedy strategy is not correct.

We find the solution by systematically building a recurrence relation.

1. Decision Tree

Each node chooses the next rest stop where we will take a break. From a given stop, we have three choices: go to the next rest stop, skip one, or skip two.
Decision tree for the Road Trip problem with repeated amber nodes showing overlapping subproblems.
Figure 2. Different partial solutions can lead to the same remaining suffix, which is exactly what memoization exploits.

2. Remaining Subproblem

For each partial solution, the remaining subproblem is the suffix of rest stops after the current location.
Partial solution on the left and remaining suffix subproblem on the right.
Figure 3. Dynamic programming ignores how we got here and focuses on the work that still remains.

3. Recurrence Relation

Recurrence Relation: `delay(i)`

State Meaning

`delay(i)` represents the minimum delay needed to reach the destination starting from rest stop `i`.

delay(i)
General Case

At rest stop `i`, we incur `times[i]` delay, then we can jump ahead `1`, `2`, or `3` steps. We pick the jump that minimizes the remaining delay.

delay(i) = times[i] + min(delay(i + 1), delay(i + 2), delay(i + 3) )
Base Cases

If we have reached or passed the destination (index `n` or greater), no further delay is incurred.

delay(i) = 0, for i ≥ n
Original Problem

We can start our journey by skipping up to 2 rest areas. So our first stop can be index `0`, `1`, or `2`.

min(delay(0), delay(1), delay(2))

For times = [8, 1, 2, 3, 9, 6, 2, 4], the values look like this:

Index i01234567
times[i]81239624
delay(i)1367511624

So the original problem becomes:

min(delay(0), delay(1), delay(2)) = min(13, 6, 7) = 6

Elements Of A Recurrence Relation

PartPurposeRoad Trip Example
SignatureHow subproblems are identifieddelay(i)
DescriptionWhat the recurrence computesMinimum delay to reach the destination from rest stop i
Base casesDirectly solvable subproblemsdelay(n - 1), delay(n - 2), delay(n - 3)
General caseHow smaller answers combinetimes[i] + min(...)
Original problemHow to recover the final answermin(delay(0), delay(1), delay(2))

Typical aggregation methods:

Problem TypeAggregation
Maximizationmax
Minimizationmin
Countingsum
Feasibilitylogical or

For a recurrence relation to be correct, every valid subproblem must be covered by either a base case or the general case.

Direct Recursive Version

python
def delay(times):  # Inefficient -- do not use in interviews.
    n = len(times)

    def delay_rec(i):
        if i >= n:
            return 0
        return times[i] + min(delay_rec(i + 1), delay_rec(i + 2), delay_rec(i + 3))

    # Works even if n < 3 because delay_rec safely handles i >= n
    return min(delay_rec(0), delay_rec(1), delay_rec(2))

This is correct, but it is exponential because many suffixes get recomputed.

Memoized Version

python
def delay(times):
    n = len(times)
    memo = {}

    def delay_rec(i):
        if i >= n:
            return 0
        if i in memo:
            return memo[i]
        memo[i] = times[i] + min(
            delay_rec(i + 1), 
            delay_rec(i + 2), 
            delay_rec(i + 3)
        )
        return memo[i]

    # Works even if n < 3 because delay_rec safely handles i >= n
    return min(delay_rec(0), delay_rec(1), delay_rec(2))

Memoization turns the runtime from exponential to linear because each distinct suffix is only solved once.

Why Are The Base Cases At The End?

We defined:
delay(i): minimum delay to reach the destination starting from rest area i

We could also have defined:

delay(i): minimum delay to reach rest area i from the start

That alternative would reverse the direction of the recurrence:

  • base cases would appear at the beginning,
  • choices would point left instead of right,
  • the original problem would move to the end.

These are often called forward and backward recurrences. Both are valid, but they shine in different situations:

AspectForward DPBackward DP
Definition"What is the cost to finish the remaining journey from here?""What is the cost to have reached this point from the start?"
Original ProblemEvaluate from the start:
min(delay(0), delay(1), delay(2))
Evaluate at the destination:
min(delay(n-1), delay(n-2), delay(n-3))
Base CaseReaching the destination:
delay(i) = times[i] for i >= n-3
Starting point:
delay(i) = times[i] for i <= 2
General CaseCombines with future steps:
delay(i) = times[i] + min(delay(i+1), delay(i+2), delay(i+3))
Combines with previous steps:
delay(i) = times[i] + min(delay(i-1), delay(i-2), delay(i-3))
WhyEasiest for Top-Down Memoization. Matches a natural recursive decision tree.Most natural for Bottom-Up Tabulation (iterative DP). Builds up solutions from index 0 up to n.

Since we are focusing on Top-Down Memoization first, the forward version is easier to map to decision trees, so it is the one used throughout this chapter.

Caching "something" does not automatically make an algorithm dynamic programming. DP is specifically about caching overlapping subproblems.

Recipe 1: Memoization

python
memo = {}

def f(subproblem_id):
    if subproblem is a base case:
        return result directly
    if subproblem_id in memo:
        return memo[subproblem_id]
    memo[subproblem_id] = recurrence_relation_formula
    return memo[subproblem_id]

return f(initial_subproblem)
Python Tip Using `@lru_cache(None)` for memoization

In real Python code, this pattern can often be written more compactly with @lru_cache(None) from functools:

python
from functools import lru_cache

@lru_cache(None)
def f(subproblem_id):
    ...

This is perfectly fine in Python. However, in interviews, an explicit memo = {} is often slightly better because it makes the caching mechanism visible. Some interviewers prefer seeing exactly what is being memoized and when the lookup/store happens.

Memoization Analysis

Memoized DP is often analyzed with two numbers:

  1. The number of subproblems.
  2. The non-recursive work for each subproblem.

Multiply them to get the total work. For Road Trip:

  • Subproblems: n
  • Non-recursive work: O(1)
  • Total time: O(n)
  • Extra space: O(n)

DP Variations

2D Input

The same recipe applies to grids too. Consider Max-Sum Path:

Given a non-empty R x C grid of positive integers, find the path from the top-left corner to the bottom-right corner with the largest sum. You may only go down or right.

  • Example: grid = [[1, 5, 1], [2, 3, 2], [20, 1, 1]]
    • Output: 25
  • Example: grid = [[5]]
    • Output: 5
  • LeetCode 64 — Minimum Path Sum
    This is basically the mirror version of your problem.
    Your problem: maximize sum going right/down
    LC 64: minimize sum going right/down
Grid example showing two different partial paths arriving at the same cell.
Figure 4. In grid DP, a cell like (1, 1) can be reached from multiple partial paths, so it becomes a shared subproblem.

For this problem, each subproblem is identified by a coordinate pair (r, c).

Recurrence Relation: `max_path(r, c)`

State Meaning

`max_path(r, c)` means: the maximum path sum starting from cell `(r, c)` and ending at the bottom-right corner.

max_path(r, c)
Base Case

Bottom-right corner

If we are already at the destination, there are no more moves to make.

max_path(r, c) = grid[r][c]
Edge Case

Last row

We cannot move down anymore, so there is only one legal move left: go right.

max_path(r, c) = grid[r][c] + max_path(r, c + 1)
Edge Case

Last column

We cannot move right anymore, so there is only one legal move left: go down.

max_path(r, c) = grid[r][c] + max_path(r + 1, c)
Why Check Last Row / Column?

The general case assumes we have two choices: go down or go right. But cells on the last row or last column only have one valid move. We handle them separately to avoid calling the recurrence on cells outside the grid.

General case needs both neighbors
max(max_path(r + 1, c), max_path(r, c + 1))
Edge cells only have one neighbor
use right only or down only
General Case

If we are not on an edge, we can move either down or right, so we take the better of the two.

max_path(r, c) = grid[r][c] + max(max_path(r + 1, c), max_path(r, c + 1))
Original Problem

We start at the top-left corner of the grid.

max_path(0, 0)
python
import math

def max_path(grid):
    R, C = len(grid), len(grid[0])
    memo = {}

    def max_path_rec(r, c):
        # 1. Out-of-bounds base case (return -inf so it's ignored by max)
        if r >= R or c >= C:
            return -math.inf
            
        # 2. Destination base case
        if r == R - 1 and c == C - 1:
            return grid[r][c]
            
        if (r, c) in memo:
            return memo[(r, c)]
            
        # 3. General case for all valid cells
        memo[(r, c)] = grid[r][c] + max(
            max_path_rec(r + 1, c),
            max_path_rec(r, c + 1)
        )
        return memo[(r, c)]

    return max_path_rec(0, 0)

Analysis:

  • Subproblems: R * C
  • Non-recursive work: O(1)
  • Total time: O(R * C)
  • Extra space: O(R * C)

Non-Constant Number Of Choices

Sometimes each subproblem has a variable number of children.

Consider Min-Subarray-Sum Split:

Given a non-empty array arr of positive integers and a number k with 1 < k < n, split arr into k non-empty subarrays so that the largest subarray sum is minimized.

  • Example: arr = [10, 5, 8, 9, 11], k = 3
    • Output: 17
    • Explanation: We can split the array into [10, 5], [8, 9], and [11]. Their sums are 15, 17, and 11. The largest of these is 17. Any other split has a larger maximum sum (e.g. [10], [5, 8, 9], [11] has sums 10, 22, 11 -> largest is 22).
  • Example: arr = [10, 10, 10, 10, 10], k = 2
    • Output: 30
    • Explanation: We can split the array into [10, 10] and [10, 10, 10]. Their sums are 20 and 30. The largest is 30.

To solve this, our state needs two parameters: the current index i and the remaining number of subarrays x. At each step, we decide where the first subarray ends, and then recursively ask the subproblem to split the remaining elements into x - 1 subarrays.

Recurrence Relation: `min_split_rec(i, x)`

Base Case 1

Only 1 subarray left

If `x == 1`, we must put all remaining elements into one final subarray.

min_split_rec(i, 1) = sum(arr[i:])
Base Case 2

Exactly enough elements left

If the number of remaining elements equals `x`, each element must stand alone. The largest subarray sum is then just the largest remaining element.

min_split_rec(i, x) = max(arr[i:])
General Case

Choose where the first subarray ends.

Here, p is the ending index of the first subarray, so the first group is arr[i...p].

Step 1

Pick a split point `p` and compute the current subarray sum:

sum(arr[i...p])
Step 2

Recursively solve the rest of the array using `x - 1` subarrays:

min_split_rec(p + 1, x - 1)
Step 3

This split is only as good as its worst subarray, so take the maximum:

max(current, rest)
What Are All Valid `p`?

`p` must make the first subarray non-empty, so p >= i. It must also leave enough elements for the remaining x - 1 subarrays, with at least one element in each. If you count the inclusive segment from p to n - 1, its length is (n - 1) - p + 1. But the suffix after p is arr[p + 1...n - 1], so its length is one less: (n - 1) - p. We need that to be at least x - 1, so (n - 1) - p >= x - 1, which simplifies to p <= n - x.

p...n-1 has length (n - 1) - p + 1 = n - pipn-1first subarray starts at iremaining suffix after pi <= p <= n - x
p can be any integer from i to n - x

So in Python we write:

for p in range(i, n - x + 1):

Example: if n = 5, i = 0, and x = 3, then valid choices are p = 0, 1, 2. We cannot use p = 3 because that would leave only one element for the remaining two subarrays.

Final Rule

Try every valid split point `p`, then keep the one whose worst subarray sum is smallest.

min_split_rec(i, x) = min(max(sum(arr[i...p]), min_split_rec(p + 1, x - 1)) for all valid p)
Original Problem

Start at index `0` and split the entire array into `k` subarrays.

min_split_rec(0, k)
python
import math


def min_split(arr, k):
    n = len(arr)
    memo = {}

    def min_split_rec(i, x):
        if (i, x) in memo:
            return memo[(i, x)]

        if n - i == x:  # Put each element in its own subarray.
            memo[(i, x)] = max(arr[i:])
        elif x == 1:  # Put all remaining elements in one subarray.
            memo[(i, x)] = sum(arr[i:])
        else:
            current_sum = 0
            res = math.inf
            for p in range(i, n - x + 1):
                current_sum += arr[p]
                res = min(res, max(current_sum, min_split_rec(p + 1, x - 1)))
            memo[(i, x)] = res

        return memo[(i, x)]

    return min_split_rec(0, k)

The key line in the loop is:

res = min(res, max(current_sum, min_split_rec(p + 1, x - 1)))

Read it from the inside out:

  • current_sum = the sum of the first subarray if we cut at p
  • min_split_rec(p + 1, x - 1) = the best possible answer for the rest of the array
  • max(...) = if we choose this split, the final answer is the larger of those two values, because we care about the largest subarray sum
  • min(res, ...) = among all possible split points p, keep the split whose largest subarray sum is as small as possible

Analysis:

  • Subproblems: n * k
  • Non-recursive work: O(n)
  • Total time: O(n^2 * k)
  • Extra space: O(n * k)

Knapsack-Style DP

Another very common DP family is take / skip problems with a second parameter such as remaining capacity or remaining target.

Consider Subset Sum:

Given an array nums of positive integers and a target target, return whether some subset of nums sums exactly to target.

  • Example: nums = [3, 2, 7, 1], target = 6
    • Output: True
    • Explanation: We can take 3 + 2 + 1 = 6.
  • Example: nums = [4, 6, 10], target = 7
    • Output: False

The state needs two parameters:

  • which index we are considering
  • which target we still need to make

Recurrence Relation: `can_make(i, t)`

State Meaning

`can_make(i, t)` asks whether the suffix `nums[i:]` contains some subset whose sum is exactly `t`.

can_make(i, t)
Base Case

Target already reached

If `t == 0`, we already formed the target sum, so the answer is `True`.

can_make(i, 0) = True
Base Case

No items left

If we used up all items and `t > 0`, then we failed to build the target.

can_make(n, t) = False
General Case

At index `i`, we usually have two choices: skip `nums[i]` or take it.

Skip

Ignore the current number and ask the same question on the rest.

can_make(i + 1, t)
Take

If `nums[i] <= t`, include it and reduce the remaining target.

can_make(i + 1, t - nums[i])
Combine With `or`

This is a feasibility problem, so we use logical `or`: if either branch can make the target, the answer is `True`.

can_make(i, t) = can_make(i + 1, t) or can_make(i + 1, t - nums[i])
Original Problem

Start from the first item and try to build the full target.

can_make(0, target)
python
def subset_sum(nums, target):
    n = len(nums)
    memo = {}

    def can_make(i, t):
        if t == 0:
            return True
        if i == n:
            return False
        if (i, t) in memo:
            return memo[(i, t)]

        res = can_make(i + 1, t)
        if nums[i] <= t:
            res = res or can_make(i + 1, t - nums[i])

        memo[(i, t)] = res
        return res

    return can_make(0, target)

This is still dynamic programming: the same pair (i, t) can be reached through many different take / skip histories, so memoization prevents recomputation.

Analysis:

  • Subproblems: n * target
  • Non-recursive work: O(1)
  • Total time: O(n * target)
  • Extra space: O(n * target)

Sequence DP

Another major family is sequence DP. These problems usually compare prefixes or suffixes of one or two strings / arrays.

The most common state shape is:

  • f(i) for one sequence
  • f(i, j) for two sequences

A classic example is Longest Common Subsequence (LCS), which the chapter studies later in Problem 40.7.

Sequence DP Pattern

Common State Shapes

Sequence DP usually tracks where we are in one or two sequences.

f(i)
f(i, j)
Example State

For LCS, the natural state is:

lcs(i, j)

It means: the LCS length of suffixes `s1[i:]` and `s2[j:]`.

The Usual Decision

Sequence DP often branches on whether the current characters match.

Match

If `s1[i] == s2[j]`, we often take both and move diagonally.

1 + lcs(i + 1, j + 1)
No Match

If they do not match, try skipping one side or the other.

max(lcs(i + 1, j), lcs(i, j + 1))
Recognition Tip

If the problem compares two strings or arrays and asks what happens as you move forward through them, sequence DP is often the right model.

The important pattern is that sequence DP often asks:

  • what happens if the current characters match?
  • if they do not match, which smaller prefix / suffix should we try next?

Other common sequence-DP interview problems include:

  • Longest Common Subsequence
  • Longest Increasing Subsequence
  • Longest Palindromic Subsequence
  • Edit Distance
  • Decode Ways

Once you recognize that the state is "where am I in each sequence?", these problems become much easier to model.

Problem Set

Problem 40.2 Minivan Road Trip LeetCode 746

Similar to LeetCode 746. Min Cost Climbing Stairs

Consider the same road-trip setting as Problem 40.1, but now we are also given a positive integer k indicating how many consecutive rest areas we can skip.

What is the minimum total detour time?

  • Example: times = [8, 1, 2, 3, 9, 6, 2, 4], k = 2
    • Output: 6
  • Example: times = [8, 1, 2, 3, 9, 6, 2, 4], k = 3
    • Output: 4
Solution 40.2 Minivan Road Trip

This is a direct generalization of Road Trip. Instead of 3 choices, each node now has k + 1 choices.

Recurrence Relation: `delay(i)`

State Meaning

`delay(i)` represents the minimum delay to reach the destination starting from rest area `i`.

delay(i)
Base Case 1

Reached the end

If we have passed the last rest area, no more delay is needed.

delay(i) = 0, for i >= n
Base Case 2

k or fewer remaining stops

If we can reach the end by skipping the remaining stops, the only delay is at the current stop.

delay(i) = times[i], for n - k - 1 < i < n
General Case

At rest area `i`, we incur `times[i]` delay, then we can jump ahead `p` steps (where `p` is between `1` and `k + 1`). We want to pick the jump that minimizes the remaining delay.

delay(i) = times[i] + min(delay(i + p) for p from 1 to k + 1)
Original Problem

We can start our journey by skipping up to `k` rest areas at the very beginning. So our first stop can be anywhere from index `0` to `k`.

min(delay(p) for p from 0 to k)

Minivan Road Trip Animation (k=2)

Initialize delay array for times = [8, 1, 2, 3, 9, 6, 2, 4] with k = 2.
times[i]
8
0
1
1
2
2
3
3
9
4
6
5
2
6
4
7
delay[i]
python
def min_detour(times, k):
    n = len(times)
    memo = {}
    
    def delay(i):
        if i >= n:
            return 0
        if i > n - k - 1:
            return times[i]
        if i in memo:
            return memo[i]
            
        res = math.inf
        for p in range(1, k + 2):
            res = min(res, delay(i + p))
            
        memo[i] = times[i] + res
        return memo[i]
        
    return min(delay(p) for p in range(k + 1))

Analysis:

  • Subproblems: n
  • Non-recursive work: O(k)
  • Total time: O(n * k)
  • Extra space: O(n)
Problem 40.3 Restaurant Ratings LeetCode 198

Similar to LeetCode 198. House Robber

There are n restaurants along the route. We are given an array ratings containing their ratings in order.

We want to maximize the total rating of the places where we stop, with one constraint: we do not want to stop at two consecutive restaurants.

  • Example: ratings = [8, 1, 3, 9, 5, 2, 1]
    • Output: 19
  • Example: ratings = [8, 1, 3, 7, 5, 2, 4]
    • Output: 20
Solution 40.3 Restaurant Ratings

At each index we have two choices: eat here or skip this restaurant.

Recurrence Relation: `rating_sum(i)`

State Meaning

`rating_sum(i)` represents the maximum total rating obtainable using only restaurants from index `i` to `n - 1`.

rating_sum(i)
Base Case

If we have passed the last restaurant, we cannot collect any more ratings.

rating_sum(n) = 0
General Case

At restaurant `i`, we have two choices:

Choice 1: Eat Here

Take `ratings[i]`, but then we must skip the next restaurant.

ratings[i] + rating_sum(i + 2)
Choice 2: Skip

Do not take the rating, leaving us free to consider the very next restaurant.

rating_sum(i + 1)
rating_sum(i) = max(ratings[i] + rating_sum(i + 2), rating_sum(i + 1))
Original Problem

We start at the very first restaurant and want the maximum rating for the entire route.

rating_sum(0)
python
def max_rating(ratings):
    n = len(ratings)
    memo = {}
    
    def rating_sum(i):
        if i >= n:
            return 0
        if i in memo:
            return memo[i]
            
        memo[i] = max(
            ratings[i] + rating_sum(i + 2), 
            rating_sum(i + 1)
        )
        return memo[i]
        
    return rating_sum(0)

Alternatively, we can solve this using the bottom-up tabulation method. Instead of starting from index 0 and asking for the remaining suffix, we can start from the left and iteratively compute the maximum rating achievable up to index i.

python
def max_rating_bottom_up(ratings):
    n = len(ratings)
    if n == 0:
        return 0
    if n == 1:
        return ratings[0]
        
    dp = [0] * n
    dp[0] = ratings[0]
    dp[1] = max(ratings[0], ratings[1])
    
    for i in range(2, n):
        dp[i] = max(dp[i - 1], dp[i - 2] + ratings[i])
        
    return dp[n - 1]

Notice that to compute dp[i], we only ever look at dp[i - 1] and dp[i - 2]. We don't need the entire array. We can use rolling variables to optimize the space complexity from O(n) to O(1).

python
def max_rating_optimized(ratings):
    prev2 = 0
    prev1 = 0
    
    for rating in ratings:
        current = max(prev1, prev2 + rating)
        prev2 = prev1
        prev1 = current
        
    return prev1

Analysis:

  • Subproblems: n
  • Non-recursive work: O(1)
  • Total time: O(n)
  • Extra space: O(1) (thanks to rolling variables)
Problem 40.4 Count 0-Sum Paths LeetCode 62

Similar to LeetCode 62. Unique Paths

Given a non-empty binary grid, return the number of paths from the top-left corner to the bottom-right corner whose path sum is 0. You may move down, right, or diagonally down-right.

Example 1Output: 7

0
1
1
0
0
0
1
0
0
Empty space
Obstacle

Example 2Output: 0

1

The starting cell is an obstacle, so no paths exist.

Binary grid example for Count 0-Sum Paths with three example valid routes drawn on top.
Figure 5. Three of the seven valid zero-sum paths in the chapter's main example.
Solution 40.4 Count 0-Sum Paths

This is a counting problem, so the aggregation operator is sum.

Recurrence Relation: `paths(r, c, sum)`

State Meaning

`num_paths(r, c)` means: the number of valid zero-sum paths starting from cell `(r, c)` and ending at the bottom-right corner.

num_paths(r, c)
Invalid Cell

Blocked cell

If `grid[r][c] == 1`, no valid zero-sum path can continue through this cell.

num_paths(r, c) = 0
Base Case

Bottom-right corner

If we are at the destination and it is a zero-cell, that contributes exactly one valid path.

num_paths(r, c) = 1
Edge Case

Last row

We cannot move down anymore, so only the right move remains available.

num_paths(r, c) = num_paths(r, c + 1)
Edge Case

Last column

We cannot move right anymore, so only the down move remains available.

num_paths(r, c) = num_paths(r + 1, c)
Why The Recurrence Uses Sum

This is a counting problem. Each valid move contributes some number of paths, so we add them together instead of taking `min` or `max`.

Move 1
num_paths(r + 1, c)
Move 2
num_paths(r, c + 1)
Move 3
num_paths(r + 1, c + 1)
General Case

In the middle of the grid, add the counts from all three legal moves: down, right, and diagonal.

num_paths(r, c) = num_paths(r + 1, c) + num_paths(r, c + 1) + num_paths(r + 1, c + 1)
Original Problem

We start at the top-left corner of the grid.

num_paths(0, 0)
python
def count_zero_sum_paths(grid):
    if not grid or not grid[0]:
        return 0
        
    R, C = len(grid), len(grid[0])
    memo = {}
    
    def num_paths(r, c):
        if r >= R or c >= C or grid[r][c] == 1:
            return 0
        if r == R - 1 and c == C - 1:
            return 1
        if (r, c) in memo:
            return memo[(r, c)]
            
        memo[(r, c)] = (
            num_paths(r + 1, c) +
            num_paths(r, c + 1) +
            num_paths(r + 1, c + 1)
        )
        return memo[(r, c)]
        
    return num_paths(0, 0)

Analysis:

  • Subproblems: R * C
  • Non-recursive work: O(1)
  • Total time: O(R * C)
  • Extra space: O(R * C)
Problem 40.5 Magic Blackjack LeetCode 837

Similar to LeetCode 837. New 21 Game

We repeatedly draw cards from a magical deck. Each draw is a value from 1 to 10, and the card is immediately replaced. The dealer keeps drawing until either:

  • the total is between 16 and 21, or
  • the total exceeds 21 and the dealer busts.

Return the number of distinct draw sequences that bust.

Solution 40.5 Magic Blackjack

The probability language is a red herring. This is a counting problem, not a probability problem.

python
def num_ways():
    memo = {}

    def num_ways_rec(i):
        if i > 21:
            return 1
        if 16 <= i <= 21:
            return 0
        if i in memo:
            return memo[i]
        memo[i] = 0
        for card in range(1, 11):
            memo[i] += num_ways_rec(i + card)
        return memo[i]

    return num_ways_rec(0)

Analysis:

  • Subproblems: 22 (values 0 to 21)
  • Non-recursive work: O(10) = O(1)
  • Total time: O(1)
  • Extra space: O(1)
Problem 40.6 Minimum Steps To One LeetCode 397

Similar to LeetCode 397. Integer Replacement

Write a function that accepts a positive integer n and returns the minimum number of operations needed to reach 1, using:

  • subtract 1,
  • divide by 2 if divisible by 2,
  • divide by 3 if divisible by 3.
Solution 40.6 Minimum Steps To One

This problem is a good reminder that greedy instincts can fail.

Example: Why Greedy Fails for `n = 10`

Greedy Approach4 steps

Greedily dividing by 2 whenever possible:

105421
/2, -1, /2, /2
Optimal Approach3 steps

Subtracting 1 first allows two division steps:

10931
-1, /3, /3

Recurrence Relation: `num_steps(i)`

State Meaning

`num_steps(i)` is the minimum number of operations to get from number `i` to `1`.

num_steps(i)
Base Case

If we are already at `1`, zero operations are needed.

num_steps(1) = 0
General Case

From any number `i`, we can always subtract `1`. We can optionally divide by `2` or `3` if the number is divisible. We take the minimum among all valid choices, plus `1` for the step itself.

Always Valid
num_steps(i - 1)
If `i % 2 == 0`
num_steps(i / 2)
If `i % 3 == 0`
num_steps(i / 3)
num_steps(i) = 1 + min( num_steps(i - 1), num_steps(i / 2) if i % 2 == 0, num_steps(i / 3) if i % 3 == 0)
Original Problem

Start from `n` and find the minimum steps to reach `1`.

num_steps(n)
python
def min_steps_to_one(n):
    memo = {}
    
    def num_steps(i):
        if i == 1:
            return 0
        if i in memo:
            return memo[i]
            
        res = num_steps(i - 1)
        if i % 2 == 0:
            res = min(res, num_steps(i // 2))
        if i % 3 == 0:
            res = min(res, num_steps(i // 3))
            
        memo[i] = 1 + res
        return memo[i]
        
    return num_steps(n)

Analysis:

  • Subproblems: n
  • Non-recursive work: O(1)
  • Total time: O(n)
  • Extra space: O(n)
Problem 40.7 Longest Increasing Subsequence LeetCode 300

Similar to LeetCode 300. Longest Increasing Subsequence

Given an integer array nums, return the length of the longest strictly increasing subsequence.

  • Example: nums = [10, 9, 2, 5, 3, 7, 101, 18]
    • Output: 4
    • Explanation: The longest increasing subsequence is [2, 3, 7, 101].
Solution 40.7 Longest Increasing Subsequence

Let lis(i) be the length of the longest increasing subsequence that starts at index i.

lis(i): length of LIS starting at nums[i]

base cases:
none explicitly (every element is at least a subsequence of length 1)

general case:
lis(i) = 1 + max(lis(j) for all j > i if nums[j] > nums[i])
(if no such j exists, max is 0)

original problem:
max(lis(i) for all 0 <= i < n)
python
def length_of_lis(nums):
    n = len(nums)
    memo = {}

    def lis(i):
        if i in memo:
            return memo[i]
        
        max_len = 1
        for j in range(i + 1, n):
            if nums[j] > nums[i]:
                max_len = max(max_len, 1 + lis(j))
                
        memo[i] = max_len
        return max_len

    return max(lis(i) for i in range(n))

Analysis:

  • Subproblems: n
  • Non-recursive work: O(n) per subproblem (looping over the rest of the array)
  • Total time: O(n^2)
  • Extra space: O(n)
Problem 40.8 Longest Palindromic Subsequence LeetCode 516

Similar to LeetCode 516. Longest Palindromic Subsequence

Given a string s, find the longest palindromic subsequence's length in s.

  • Example: s = "bbbab"
    • Output: 4
    • Explanation: One possible longest palindromic subsequence is "bbbb".
  • Example: s = "cbbd"
    • Output: 2
    • Explanation: One possible longest palindromic subsequence is "bb".
Solution 40.8 Longest Palindromic Subsequence

Let lps(l, r) be the length of the longest palindromic subsequence within the substring s[l:r+1].

lps(l, r): length of LPS in s[l:r+1]

base cases:
if l > r: return 0
if l == r: return 1

general case:
if s[l] == s[r]:
    lps(l, r) = 2 + lps(l + 1, r - 1)
else:
    lps(l, r) = max(lps(l + 1, r), lps(l, r - 1))

original problem:
lps(0, len(s) - 1)
python
def longest_palindrome_subseq(s):
    memo = {}
    
    def lps(l, r):
        if l > r:
            return 0
        if l == r:
            return 1
            
        if (l, r) in memo:
            return memo[(l, r)]
            
        if s[l] == s[r]:
            memo[(l, r)] = 2 + lps(l + 1, r - 1)
        else:
            memo[(l, r)] = max(lps(l + 1, r), lps(l, r - 1))
            
        return memo[(l, r)]

    return lps(0, len(s) - 1)

Analysis:

  • Subproblems: n^2 / 2 (since l <= r)
  • Non-recursive work: O(1)
  • Total time: O(n^2)
  • Extra space: O(n^2)

Solution Reconstruction

So far, most optimization problems only asked for the value of the solution. Sometimes we want the actual optimal solution itself. The chapter uses Longest Common Subsequence (LCS) as the main example.

Problem 40.9 Longest Common Subsequence LeetCode 1143

Similar to LeetCode 1143. Longest Common Subsequence

Given two uppercase strings s1 and s2, return the length of their longest common subsequence.

  • Example: s1 = "HAHAH", s2 = "AAAAHH"
    • Output: 3
  • Example: s1 = "", s2 = "AA"
    • Output: 0
Solution 40.9 Longest Common Subsequence

Let lcs(i1, i2) be the length of the longest common subsequence of the suffixes s1[i1:] and s2[i2:].

lcs(i1, i2): length of the LCS of suffixes s1[i1:] and s2[i2:]

base cases:
lcs(n1, i2) = 0
lcs(i1, n2) = 0

general case:
if s1[i1] == s2[i2]:
    lcs(i1, i2) = 1 + lcs(i1 + 1, i2 + 1)
else:
    lcs(i1, i2) = max(lcs(i1 + 1, i2), lcs(i1, i2 + 1))

original problem:
lcs(0, 0)
python
def lcs(s1, s2):
    memo = {}

    def lcs_rec(i1, i2):
        if i1 == len(s1) or i2 == len(s2):
            return 0
        if (i1, i2) in memo:
            return memo[(i1, i2)]
        if s1[i1] == s2[i2]:
            memo[(i1, i2)] = 1 + lcs_rec(i1 + 1, i2 + 1)
        else:
            memo[(i1, i2)] = max(lcs_rec(i1 + 1, i2), lcs_rec(i1, i2 + 1))
        return memo[(i1, i2)]

    return lcs_rec(0, 0)

Analysis:

  • Subproblems: n1 * n2
  • Non-recursive work: O(1)
  • Total time: O(n1 * n2)
  • Extra space: O(n1 * n2)
Problem 40.10 Reconstruct Longest Common Subsequence

Given two strings s1 and s2, return one actual longest common subsequence.

  • Example: s1 = "HAHAH", s2 = "AAAAHH"
    • Output: "AAH" or "AHH"
  • Example: s1 = "", s2 = "AA"
    • Output: ""
Solution 40.10 Reconstruct Longest Common Subsequence

The straightforward approach is to store strings in the memo table instead of lengths:

python
def lcs_reconstruction(s1, s2):
    memo = {}

    def lcs_rec(i1, i2):
        if i1 == len(s1) or i2 == len(s2):
            return ""
        if (i1, i2) in memo:
            return memo[(i1, i2)]
        if s1[i1] == s2[i2]:
            memo[(i1, i2)] = s1[i1] + lcs_rec(i1 + 1, i2 + 1)
        else:
            opt1 = lcs_rec(i1 + 1, i2)
            opt2 = lcs_rec(i1, i2 + 1)
            if len(opt1) >= len(opt2):
                memo[(i1, i2)] = opt1
            else:
                memo[(i1, i2)] = opt2
        return memo[(i1, i2)]

    return lcs_rec(0, 0)

That works, but it inflates both time and space.

  • Subproblems: n1 * n2
  • Non-recursive work: O(n1 + n2)
  • Total time: O(n1 * n2 * (n1 + n2))
  • Extra space: O(n1 * n2 * (n1 + n2))

The more efficient approach is solution reconstruction: keep the value-only memoization, then walk down one optimal path.

python
def lcs_reconstruction_optimal(s1, s2):
    memo = {}

    def lcs_rec(i1, i2):
        if i1 == len(s1) or i2 == len(s2):
            return 0
        if (i1, i2) in memo:
            return memo[(i1, i2)]
        if s1[i1] == s2[i2]:
            memo[(i1, i2)] = 1 + lcs_rec(i1 + 1, i2 + 1)
        else:
            memo[(i1, i2)] = max(lcs_rec(i1 + 1, i2), lcs_rec(i1, i2 + 1))
        return memo[(i1, i2)]

    i1, i2 = 0, 0
    res = []

    while i1 < len(s1) and i2 < len(s2):
        if s1[i1] == s2[i2]:
            res.append(s1[i1])
            i1 += 1
            i2 += 1
        elif lcs_rec(i1 + 1, i2) > lcs_rec(i1, i2 + 1):
            i1 += 1
        else:
            i2 += 1

    return ''.join(res)

This keeps the asymptotic complexity at:

  • Subproblems: n1 * n2
  • Non-recursive work: O(1)
  • Total time: O(n1 * n2)
  • Extra space: O(n1 * n2)

Reusable Idea: Solution Reconstruction

When a DP problem asks for the solution itself instead of only the value, there are usually two approaches:

  1. Store full solutions in the memo table. This is straightforward but often expensive.
  2. Store only values, then reconstruct the answer later by following one optimal path and repeatedly querying the value-based recurrence.

This technique is not specific to LCS. It is reusable across many DP problems.

Extra Credit: Tabulation

Tabulation starts with the same recurrence relation as memoization. The main difference is that we fill subproblems iteratively instead of recursively.

The critical question is iteration order. We must fill the table in an order that respects dependencies.

For Road Trip:

delay(i) = times[i] + min(delay(i + 1), delay(i + 2), delay(i + 3))

In tabulation form:

dp[i] = times[i] + min(dp[i + 1], dp[i + 2], dp[i + 3])

Because dp[i] depends on larger indices, we must iterate from right to left.

python
def delay(times):
    n = len(times)
    # Pad with 3 extra zeros to represent the base case: delay(i) = 0 for i >= n
    dp = [0] * (n + 3)

    for i in range(n - 1, -1, -1):
        dp[i] = times[i] + min(dp[i + 1], dp[i + 2], dp[i + 3])

    return min(dp[0], dp[1], dp[2])

Analysis:

  • Subproblems: n
  • Work per iteration: O(1)
  • Total time: O(n)
  • Extra space: O(n)

Rolling Array Optimization

Road Trip only reads the next three DP values at each step, so the full array is unnecessary.

python
def delay(times):
    n = len(times)
    # dp1, dp2, dp3 track delay(i+1), delay(i+2), delay(i+3)
    dp1 = dp2 = dp3 = 0

    for i in range(n - 1, -1, -1):
        cur = times[i] + min(dp1, dp2, dp3)
        dp1, dp2, dp3 = cur, dp1, dp2

    return min(dp1, dp2, dp3)

This reduces the extra space from O(n) to O(1) without changing the time complexity.

Key Takeaways

  • Start dynamic programming by writing down a recurrence relation.
  • DP is especially common in optimization and counting problems.
  • If the brute-force solution is backtracking and the same smaller inputs keep reappearing, you probably have overlapping subproblems.
  • Memoization is often the most intuitive way to implement a recurrence relation.
  • Tabulation can unlock rolling-array space optimizations. (The Climbing Stairs problem is a classic example of this).
  • Returning the value of a solution is often easier than returning the solution itself.

Dynamic programming does not work for every problem. If the choices already made change the nature of the remaining work too much, subproblems may stop overlapping and DP becomes much less useful.