Patterns/Dynamic Programming

Dynamic Programming

Top interview pattern
120 min read
Updated June 2026
What you'll learn
  • Apply a repeatable five-step framework (define state, write recurrence, set base cases, determine fill order, extract answer) to structure any DP problem
  • Classify problems into DP families (linear, grid, interval, knapsack, string) based on what changes between subproblems, so the state definition follows naturally
  • Reduce space complexity from O(n²) to O(n) by identifying when each row depends only on the previous row and applying the rolling-array technique
  • Distinguish problems that require DP from those where greedy is sufficient, based on whether subproblems overlap and choices are interdependent

Dynamic programming solves repeated decision states once and reuses the answer whenever the same state appears again. The hard part is choosing the right state. Once the state is right, the recurrence, base cases, and fill order follow from it.

What DP Actually Does

Many problems have recursive structure. Fibonacci, shortest paths, counting combinations, optimal selections. The recursive logic is easy to write. But the recursion recomputes the same subproblems thousands or millions of times. A naive call tree for fib(50) makes billions of redundant calls for a problem with only 50 unique answers.

Dynamic programming eliminates this redundancy. Compute each subproblem once, store the result, and look it up when needed again. The exponential recursion collapses to polynomial time because each subproblem is solved exactly once.

Without Memoization · fib(4)

Total Calls
1
Unique Results
0
Action
CALL
fib(4)fib(3)fib(2)fib(2)fib(1)fib(1)fib(0)fib(1)fib(0)

Call fib(4)

fib(4) = fib(3) + fib(2). We need both children.

Step 1 of 21

Two Conditions That Enable DP

DP requires two properties working together. Overlapping subproblems means the same computation appears multiple times in the recursion. fib(3) appears under both fib(5) and fib(4). Caching fib(3) after the first computation saves all redundant calls.

Optimal substructure means the optimal answer can be built from optimal sub-answers. The minimum coins to make $11 contains the minimum coins to make $6 (after choosing a $5 coin). The shortest path from A to C through B contains the shortest A-to-B path. If a better sub-answer existed, it could be swapped in to improve the whole, contradicting optimality.

Overlapping subproblems alone means caching helps, but without optimal substructure there is no recurrence to exploit. Optimal substructure alone means a recurrence exists, but if subproblems never repeat, there is nothing to cache. That is Divide & Conquer (Merge Sort, Quick Sort). Both together give DP: a recurrence to exploit and redundancy to eliminate.

Key Insight
Cache Repeated States
If a recursive function is called with the same arguments more than once, caching that result converts exponential time to polynomial. Fibonacci goes from 2^n calls to n. Knapsack goes from 2^n to n*W. The cache size equals the number of unique states.
Key Insight
Overlap Creates the Need for DP
Draw a recursion tree for n=5 or 6. If the same function call appears in multiple branches, subproblems overlap and caching saves work. No repeated nodes means each subproblem is unique, which is Divide & Conquer.
Key Insight
Recurrence Creates the DP
If the optimal answer can be described as "make one choice, then optimally solve the rest," a recurrence exists. House Robber: the optimal for houses 0..n either includes house n (requiring optimal for 0..n-2) or excludes it (requiring optimal for 0..n-1). Both paths demand an optimal sub-answer, so dp[i] = max(dp[i-1], dp[i-2] + nums[i]) follows directly.

DP vs Greedy vs Divide & Conquer

ApproachSubproblems Overlap?Decision StrategyExample
Dynamic ProgrammingYesConsider all choices, combine optimal subsolutionsCoin Change, LCS
GreedyNo / IrrelevantMake locally optimal choice, never reconsiderActivity Selection, Huffman
Divide & ConquerNoSplit problem, solve independently, merge resultsMerge Sort, Quick Sort

Greedy commits to the locally optimal choice and never reconsiders. DP explores all choices at each state and combines the best sub-answers. Divide & Conquer splits the problem into independent subproblems that do not overlap, so there is nothing to cache.

Common Mistake
Why Greedy Fails Here
For Coin Change with coins [1, 3, 4] and amount 6: greedy picks 4 + 1 + 1 = 3 coins, but DP finds 3 + 3 = 2 coins. The DP recurrence dp[a] = min(dp[a - coin] + 1) tries every coin at every amount, which is exactly what greedy skips. DP evaluates all choices, greedy commits to one.

Recognizing DP Problems

PhraseDP WhenNot DP When
"Find min/max..."Choices are interdependent (Coin Change, House Robber)Single comparison or greedy works
"Count the ways..."Paths through decisions overlap (Climbing Stairs)Just factorial/permutation math
"Is it possible..."Must try all combinations (Partition Sum)Greedy tracking works (Jump Game)
"Find longest/shortest..."Build on previous answers (LIS, LCS)Sliding window suffices

What a DP State Must Capture

A DP state must contain enough information that the remaining decision is independent of how the algorithm got there. If two different paths through the problem arrive at the same state, the optimal continuation must be the same for both. That is what makes caching correct: a cached answer can be reused by any path that reaches the same state.

In practice, define a state like dp[i] that represents the answer to a specific subproblem. Write a recurrence relating dp[i] to smaller states. If the recurrence cannot be written because it needs information that the state does not provide, the state is incomplete. Add a dimension.

What varies between subproblems determines the state shape. One position in a sequence gives Linear DP. Two positions (one in each string) gives String DP. A range with start and end gives Interval DP. Grid coordinates give Grid DP. Items and remaining capacity give Knapsack.

Additional constraints add dimensions. Just the index gives a 1D state. Index plus a constraint (remaining capacity, whether the previous item was taken, current mode) gives a 2D or state machine state.

First Walkthrough: Climbing Stairs

Problem: There are n stairs. Each step can climb 1 or 2 stairs. How many distinct ways are there to reach the top?

State: dp[i] = number of ways to reach stair i. This captures exactly what needs to be known (the count of paths to each intermediate stair) which enables building the count for higher stairs.

Recurrence: To reach stair i, the last move was either from stair i-1 (one step) or from stair i-2 (two steps). These are the only options. The total ways to reach stair i equals the ways to reach i-1 plus the ways to reach i-2: dp[i] = dp[i-1] + dp[i-2].

Base Cases: dp[0] = 1 (one way to be at the ground: do nothing). dp[1] = 1 (one way to reach stair 1: take one step). These are small enough to answer directly.

Fill Order: dp[i] needs dp[i-1] and dp[i-2]. Both have smaller indices. Fill left to right: dp[0], dp[1], dp[2], ... dp[n].

Answer: The question asks for ways to reach stair n, so the answer is dp[n].

Every DP problem follows these same five decisions. The sections below explain each one in detail with harder examples.

The Five-Step Framework

Every DP solution requires these five decisions in sequence. Each builds on the previous. When stuck, the issue is usually in the state definition or the recurrence. Revisit those before proceeding.

Step 1: Define the State

What does dp[i] represent? This must capture all information needed to make the next decision.

Step 2: Find the Recurrence

What choices exist at each state? How does each choice connect to smaller subproblems?

Step 3: Set Base Cases

Which subproblems can be answered directly without using the recurrence?

Step 4: Determine Fill Order

Ensure every value the recurrence needs is already computed when needed.

Step 5: Extract the Answer

Where in the filled table is the answer to the original question?

Define the State

State definition is where most people get stuck. The recurrence, base cases, and fill order all follow from a correct state. Test your state by attempting the recurrence. If you cannot express dp[i] from smaller states, the state is incomplete. Add a dimension.

State Discovery Method

Start with what the problem asks for. "Maximum money" means dp[i] returns a maximum value. "Number of ways" means dp[i] returns a count. "Is it possible" means dp[i] returns true/false.

Make the simplest guess. Often dp[i] = "the answer for the first i elements" or "the answer ending at position i". Try to write a recurrence with this state.

If the recurrence needs information not in the state, add a dimension. This is how to discover that 2D is needed.

Example: House Robber State Discovery

Wrong Attempt 1: dp[i] = "money from robbing house i only". The optimal strategy for houses 0..i requires knowing what happened at house i-1, but this state only knows about house i, so no recurrence can be written.

Wrong Attempt 2: dp[i] = "maximum money robbing houses 0..i, where house i is robbed". This forces including house i, but the optimal for 0..i might skip house i entirely, so this state cannot express the optimal answer.

Correct State: dp[i] = "maximum money robbing from houses 0..i, with no restriction on whether i is robbed". Now at position i, the algorithm can choose to rob i (gaining nums[i] but skipping i-1) or skip i (taking dp[i-1]). The recurrence dp[i] = max(dp[i-1], dp[i-2] + nums[i]) works.

Key Insight
The Recurrence Test
If the recurrence cannot be written, the state is wrong. Go back and ask: "What information is the recurrence trying to use that my state doesn't provide?" Add that information to the state.

Choosing 1D vs 2D State

A well-defined state has a complete answer. For House Robber, dp[3] means "maximum money from houses 0 through 3", fully specified. For Knapsack, dp[3] is incomplete: maximum value using which items? With what remaining capacity? When a single index leaves questions unanswered, add dimensions until the state is unambiguous.

Common reasons for 2D: A resource is being consumed, requiring you to track remaining capacity or budget. Two sequences are being compared, requiring a position in each. A constraint depends on the previous choice, like whether you're currently holding a stock. A range rather than a prefix is relevant, requiring both start and end indices.

Common reasons 1D suffices: Position alone determines the subproblem. No resource is consumed. Only one sequence exists. Constraints are encoded in the recurrence, not the state (House Robber encodes "can't rob adjacent" by using dp[i-2] when robbing).

Interview Tip
If You're Stuck on the State
Think backwards. What does the final answer need to know? For House Robber, the final answer is "max money for all houses", so dp[n] should give that directly. Work backwards: what does dp[n] need? dp[n-1] and dp[n-2] with nums[n-1]. This reverse engineering often reveals the state.
Key Insight
Test the State with the Recurrence
Try writing the recurrence with your current state definition. If the recurrence needs information that the state does not provide (remaining capacity, whether the previous item was taken, position in a second string), add that as a new dimension. The recurrence is the test. If it cannot be written, the state is incomplete.

Find the Recurrence

Every DP recurrence follows the same pattern. At the current state, enumerate all possible choices. For each choice, compute the resulting subproblem plus the cost/value of that choice. Take the best (max, min, sum, OR) across all choices.

The Universal Recurrence Formula
1# The Universal Recurrence Pattern
2dp[state] = combine_over_all_choices(
3 dp[state_after_choice] + value_of_choice
4)
5
6# Optimization: combine = max or min
7dp[i] = max(dp[i-1], dp[i-2] + nums[i]) # House Robber
8dp[a] = min(dp[a - coin] + 1 for each coin) # Coin Change
9
10# Counting: combine = sum
11dp[i] = dp[i-1] + dp[i-2] # Climbing Stairs
12
13# Feasibility: combine = OR
14dp[s] = any(dp[s - num] for num in nums) # Partition Sum

Deriving the Recurrence

Question 1: What choices exist at the current state? At house i: rob or skip. At amount a: use any valid coin as the last coin. At string position i: match current char or skip.

Question 2: After making each choice, what smaller subproblem remains? Robbing house i leaves dp[i-2] as the remaining subproblem (can't use i-1). Skipping it leaves dp[i-1]. Using a 5-coin at amount 11 leaves dp[6].

Question 3: How do the choices combine? Want maximum? Take max. Want count? Take sum. Want feasibility? Take OR.

House Robber. At house i, the decision is binary: rob it or skip it. Consider each choice separately.

If house i is skipped, nothing is taken from it. The total money is whatever could be optimally obtained from houses 0 through i-1. By the state definition, that is exactly dp[i-1].

If house i is robbed, nums[i] dollars are gained. But adjacent houses cannot both be robbed. That is the constraint. So if house i is robbed, house i-1 was not. The best outcome from houses 0 through i-2 is dp[i-2]. The total becomes dp[i-2] + nums[i].

Which choice is better? It depends on the specific values, so it cannot be decided in advance. The maximum of both options is taken: dp[i] = max(dp[i-1], dp[i-2] + nums[i]).

Coin Change. The goal is to make amount a using minimum coins. Think backwards: what was the last coin used?

If the last coin was a 1-coin, then before adding it, amount a-1 remained. The minimum coins to make a-1 is dp[a-1]. Adding that 1-coin gives dp[a-1] + 1 total coins.

If the last coin was a 5-coin, amount a-5 remained before. Minimum coins for a-5 is dp[a-5], so the total is dp[a-5] + 1.

Which coin to use last cannot be known in advance because it depends on the amounts. The algorithm tries all valid coins and takes the minimum: dp[a] = min(dp[a-coin] + 1) for each coin where a >= coin.

Longest Increasing Subsequence. The goal is the longest increasing subsequence ending at index i (which must include nums[i]).

To form an increasing subsequence ending at i, extension from some earlier position j where nums[j] < nums[i] is required. Extending from j yields dp[j] + 1: the longest subsequence ending at j, plus the current element.

Which j to extend from? The goal is the longest result, so all valid j values are checked and the maximum is selected. If no valid j exists (nums[i] is smaller than everything before it), a fresh start with just nums[i] gives length 1.

The recurrence is: dp[i] = 1 + max(dp[j]) for all j < i where nums[j] < nums[i]. If no such j exists, dp[i] = 1.

Interview Tip
Talk Through Your Recurrence Out Loud
In interviews, walk through this reasoning out loud: "At position i, my choices are rob or skip. If I rob, I get nums[i] plus dp[i-2] because I cannot use i-1. If I skip, I get dp[i-1]. I want the max." This shows clear thinking even if you need to correct yourself.

Set Base Cases

Base cases are subproblems small enough to solve directly, without applying the recurrence. The recurrence formula references smaller subproblems (dp[i-1], dp[i-2], etc.), but those references eventually hit invalid indices. The base cases fill in those values so the recurrence has something to work with.

Examine the recurrence. If it accesses dp[i-1] and dp[i-2], then when i=0 or i=1, those indices don't exist. dp[0] and dp[1] must be set directly. If the recurrence accesses dp[i-1][j] and dp[i][j-1], the entire first row and first column must be filled in advance.

To determine base case values, translate dp[0] back to the original problem. Climbing Stairs: dp[0] = ways to reach step 0. Answer: 1 (the starting position). Coin Change: dp[0] = minimum coins for amount 0. Answer: 0 (no coins needed). LCS: dp[0][j] or dp[i][0] = LCS when one string is empty. Answer: 0 (nothing to match).

Verification: compute dp[2] manually. Substitute the base case values into the recurrence and confirm the result matches what is expected from the problem. This catches off-by-one errors before they propagate.

Common Mistake
The Most Common DP Bug: Wrong Base Cases
Most DP bugs are wrong base cases. Common mistakes: confusing "dp[0] = zero elements" vs "dp[0] = first element", off-by-one on string indices (dp[i][j] means first i chars, not char at index i), and forgetting your recurrence might access dp[i-2] not just dp[i-1]. Always trace dp[2] by hand to catch these.

Determine Fill Order

When computing dp[x], every value that dp[x] depends on must already be computed. Violating this reads uninitialized garbage or uses wrong values from a previous state.

What Happens With Wrong Fill Order

Consider computing dp[5] when dp[4] and dp[3] haven't been filled yet. The array was initialized to 0 (or some default). dp[5] = max(dp[4], dp[3] + nums[4]) reads 0 for dp[4] and dp[3], producing a wrong answer. The recurrence is correct, but the values it reads are not. The algorithm silently returns garbage.

Determining Fill Order from the Recurrence

Linear DP: dp[i] depends on dp[i-1], dp[i-2], etc. Dependencies point left. Fill left to right.

Grid DP: dp[i][j] depends on dp[i-1][j] (above) and dp[i][j-1] (left). Fill top-to-bottom, left-to-right within each row.

Interval DP: dp[i][j] depends on dp[i][k] and dp[k+1][j] where i ≤ k < j. Both are shorter intervals. Fill by increasing interval length: all length-1 intervals, then length-2, then length-3.

Stock DP with multiple states: When holding[i] and notHolding[i] both depend on day i-1 values, computing one before the other can overwrite needed values. Either use temp variables for yesterday's values, or compute all of today's states before overwriting.

Key Insight
Dependencies Decide Fill Order
Draw arrows from each cell to the cells it reads. If dp[i] reads dp[i-1] and dp[i-2], arrows point left, so fill left to right. If dp[i][j] reads dp[i-1][j] and dp[i][j-1], arrows point up and left, so fill top to bottom, left to right. If dp[i][j] reads shorter intervals inside [i,j], fill by increasing interval length. The arrow direction always determines the fill direction.

Extract the Answer

Does the state definition directly answer the original question, or does it answer a constrained version that requires further aggregation?

When the Answer Is in One Cell

If dp[n] is defined as "the answer for the entire input", then dp[n] is the final answer. Climbing Stairs: dp[n] = ways to reach step n. That is exactly the question asked. Coin Change: dp[amount] = minimum coins for that amount. Directly answers the question.

When Aggregation Is Needed

LIS (common bug): dp[i] = length of LIS ending at index i. The question asks for the longest in the entire array, but the LIS might end at index 3, index 7, or the last index. The state constrains where the subsequence ends. The answer requires max(dp[0], dp[1], ..., dp[n-1]).

Returning dp[n-1] is wrong unless the longest subsequence happens to end at the last element. This is a frequent interview bug.

State machine counting: If paths can end in state A or state B, and the question asks for total valid paths, the answer is dp[A] + dp[B]. Each cell counts paths ending in that specific state; summing covers all valid endings.

ProblemAnswer LocationWhy
Climbing Stairsdp[n]State directly answers "ways to reach n"
Coin Changedp[amount]State directly answers "min coins for amount"
LCSdp[m][n]State at full lengths gives LCS of complete strings
LISmax(dp[i]) over all iState constrains ending position; must find best ending
0/1 Knapsackdp[n][W]All items considered, full capacity available
Interval DPdp[0][n-1]Indices represent full range
Interview Tip
Check Your State Definition
Before coding, ask: "Does dp[n] give me the final answer, or do I need to aggregate?" If the state fixes something (ending position, current state in a machine), that fixed aspect might not match the question, requiring a scan or sum.

Top-Down vs Bottom-Up

There are two ways to implement DP: top-down (memoization) and bottom-up (tabulation). Both have the same time complexity, but differ in implementation and practical trade-offs.

Top-Down (Memoization)

Write the recursive solution first, then add a cache. When a function is called, check if the answer is already cached. If yes, return it. If no, compute it, cache it, then return it.

Memoization Template
1from functools import lru_cache
2
3@lru_cache(maxsize=None)
4def solve(state):
5 if is_base_case(state):
6 return base_value
7 return best(solve(sub) + cost for sub in choices(state))

@lru_cache handles memoization automatically. Avoid memo={} as a default argument. Mutable defaults persist across calls in Python, which is a subtle bug.

Pros: Mirrors the recurrence directly (just add caching to recursion), only computes needed states. Cons: Recursion overhead, possible stack overflow for deep recursion.

Bottom-Up (Tabulation)

Build a table iteratively, starting from base cases and filling in the order of dependencies. No recursion needed.

Climbing Stairs: Bottom-Up Tabulation

Current Index
-
Dependencies
-
Action
INIT
Step012345dp[]0[0]0[1]0[2]0[3]0[4]0[5]

Climbing Stairs: Find the number of ways to climb n=5 steps. You can take 1 or 2 steps at a time. We'll fill a dp array from left to right.

Tabulation Template
1def solve_iterative(n):
2 # Create table
3 dp = [0] * (n + 1)
4
5 # Base cases
6 dp[0] = base_value_0
7 dp[1] = base_value_1
8
9 # Fill table in order of dependencies
10 for i in range(2, n + 1):
11 dp[i] = f(dp[i-1], dp[i-2]) # Your recurrence here
12
13 return dp[n]

Pros: No recursion overhead, easier to optimize space, often faster in practice. Cons: Must compute all states (even unused ones), order must be carefully determined.

When to Use Which

Neither approach is universally better. The choice depends on the problem structure.

SituationRecommended Approach
Need space optimizationBottom-up (easier to reduce dimensions)
Sparse state space (many states never visited)Top-down (only computes needed states)
Very deep recursion possibleBottom-up (avoids stack overflow)
Complex state transitionsTop-down (mirrors the recurrence directly)
Interview defaultBottom-up (demonstrates structural understanding)

Use memoization (top-down) when only a sparse subset of states is reachable. Many game-theory and constraint problems have huge state spaces where most entries are never touched. Tabulation (bottom-up) is the better choice when most states will be visited anyway, and you want to avoid recursion depth limits or need to optimize space with a rolling array.

Interview Tip
Start Top-Down, Convert to Bottom-Up
Start with top-down if the recursion is clearer, then convert to bottom-up if asked about optimization. Most interviewers expect bottom-up for classic problems like Knapsack and LCS.

DP Families by State Shape

After confirming DP applies, classify the problem by its state shape. DP problems cluster into families with similar state definitions, recurrences, and fill orders. Learning these patterns provides a structured starting point for new problems. Recognizing "this looks like Grid DP" immediately reveals that the state is dp[i][j], the recurrence combines the cell above and to the left, and the fill order is top-to-bottom, left-to-right.

The patterns below cover the vast majority of interview DP problems. For each family, note the state shape, recurrence structure, and fill order.

Linear DP

Linear DP is the simplest and most common DP pattern. The algorithm traverses an array once, left to right, and at each position makes a decision that only requires information from a constant number of previous positions. The "linear" name comes from this single pass through the data.

At position i, the optimal choice depends only on positions already computed. In House Robber, the decision at house i is binary: take house i plus the best achievable through house i-2 (can't rob adjacent houses), or skip house i and keep the best through house i-1. Only two values are needed to make this decision.

Common Variations

Most Linear DP problems fall into one of these dependency patterns. Climbing Stairs and Fibonacci use dp[i-1] and dp[i-2]. House Robber uses the same structure but with a max() choice. Maximum Subarray (Kadane's algorithm) only needs dp[i-1]. Longest Increasing Subsequence needs all of dp[0..i-1] because finding the best previous element smaller than the current one is required.

Linear DP: House Robber

Index
--
Recurrence
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
Action
DEFINE
houses2[0]7[1]9[2]3[3]1[4]dp[]

House Robber: given house values [2, 7, 9, 3, 1], maximize loot. Constraint: you can't rob two adjacent houses. We define dp[i] = max money from houses 0 to i.

Step 1 of 9

House Robber Solution
1# House Robber
2def rob(nums):
3 if not nums:
4 return 0
5 if len(nums) == 1:
6 return nums[0]
7
8 dp = [0] * len(nums)
9 dp[0] = nums[0] # One house: take it
10 dp[1] = max(nums[0], nums[1]) # Two houses: take the richer one
11
12 for i in range(2, len(nums)):
13 dp[i] = max(dp[i-1], # Skip house i
14 dp[i-2] + nums[i]) # Rob house i, add best through i-2
15
16 return dp[-1]
Key Insight
The Lookback Distance
Count how many previous positions the recurrence reads. Lookback-1 (Kadane's): one variable suffices. Lookback-2 (House Robber, Fibonacci): two variables. Lookback-all (LIS): full array needed. This number directly determines how far you can optimize space.
Common Mistake
Confusing 'best ending at i' with 'best up to i'
Maximum Subarray needs dp[i] = best subarray ending at i. Not best subarray anywhere in 0..i. With the wrong definition, the recurrence max(dp[i-1], dp[i-1] + nums[i]) includes subarrays that don't touch position i, which makes extending the subarray meaningless. The code compiles and runs. It gives wrong answers on half the test cases.

When Linear DP Breaks Down

If solving position i requires information from positions not yet computed (future positions), a different fill order or a different approach is needed. Decode Ways looks linear at first, but once you realize dp[i] depends on both dp[i-1] and dp[i-2] with different conditions (single digit vs two-digit number), the recurrence is still lookback-2 and works fine. The real break happens when dependencies are unbounded or bidirectional. If the dependency pattern isn't "look backward a fixed distance," 2D DP or a different algorithm may be required.

Grid DP

Grid DP works because movement is constrained. When only right or down moves are allowed, every cell has exactly two predecessors: the cell above and the cell to the left. This constraint makes dependencies predictable and guarantees no cycles.

The core pattern is almost always the same: dp[i][j] combines information from dp[i-1][j] (came from above) and dp[i][j-1] (came from left), then incorporates the current cell's value. For counting paths, they are added. For minimum cost, the min is taken and the current cost added. For maximum path, the max is taken.

Fill Order

The fill order is always top-left to bottom-right because that's the direction of movement. When cell (i, j) is reached, cells (i-1, j) and (i, j-1) are already computed. The first row and first column are often base cases since they have only one predecessor each.

Unique Paths: Grid DP

Current Cell
-
Action
INIT
0123012STARTEND

Unique Paths: 3x4 grid. Start at top-left, reach bottom-right. Can only move right or down. Count all possible paths.

Unique Paths Solution
1# Unique Paths
2def unique_paths(m, n):
3 dp = [[0] * n for _ in range(m)]
4
5 for j in range(n):
6 dp[0][j] = 1 # First row: only right moves possible
7
8 for i in range(m):
9 dp[i][0] = 1 # First col: only down moves possible
10
11 for i in range(1, m):
12 for j in range(1, n):
13 dp[i][j] = dp[i-1][j] + dp[i][j-1] # Paths from above + paths from left
14
15 return dp[m-1][n-1]
Interview Tip
Handle boundaries before the interior loop
Row 0 and column 0 are base cases: each cell has exactly one predecessor. Set these first. For Minimum Path Sum, boundaries accumulate running sums. For Unique Paths with Obstacles, a boundary cell after an obstacle becomes 0 and stays 0 for the rest of that row or column.
Common Mistake
Obstacles must be explicitly zeroed, not skipped
In Unique Paths II, the common bug is skipping obstacle cells without setting dp[i][j] = 0. If skipped, the cell retains whatever value it was initialized with. The fix: if grid[i][j] == 1: dp[i][j] = 0; continue. Explicit zeroing prevents stale values from propagating.

When Grid DP Breaks Down

If movement in all four directions is allowed, cycles exist and DP won't work directly. Use BFS or DFS with memoization instead (or recognize it's a shortest path problem). If obstacles exist, Grid DP still applies but blocked cells are set to 0 or infinity depending on the problem.

Dungeon Game reverses the fill direction. The knight needs at least 1 HP at every cell, and the ending cell's health requirement propagates backward. dp[i][j] = minimum health needed to reach the princess from cell (i, j). Fill from bottom-right to top-left: dp[i][j] = max(1, min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j]). Top-left fill fails because the minimum starting health depends on the worst cell along the entire path, which is unknown until the end is reached.

String DP

String DP problems compare two strings by comparing all possible prefixes. The state dp[i][j] represents the answer for the first i characters of s1 and the first j characters of s2. The table is filled cell by cell, where each cell answers: "What's the result given only s1[0..i-1] and s2[0..j-1]?"

Why 1-Indexed Tables

1-indexed tables are used so dp[0][j] and dp[i][0] represent empty prefixes. An empty prefix gives clean base cases: the LCS of any string with an empty string is 0, the edit distance from a string to an empty string is the string's length. Without this, special handling for the first character would be required.

The Decision at Each Cell

At cell (i, j), s1[i-1] is compared with s2[j-1] (the i-th and j-th characters, but 0-indexed in the actual string). If they match, whatever was at (i-1, j-1) can be extended by including this matching character. If they don't match, the best result from either excluding the last character of s1 (dp[i-1][j]) or excluding the last character of s2 (dp[i][j-1]) is taken.

The Off-By-One Trap

The most common bug is using s1[i] instead of s1[i-1]. The table is 1-indexed (dp has size (m+1) x (n+1)) but the strings are 0-indexed. When filling cell (i, j), the question is about the i-th character of s1, which is s1[i-1]. This indexing mismatch is a frequent source of bugs. Tracing through a small example on paper before coding is recommended.

LCS Table: "ABCD" vs "AEBD"

Position
-
Comparing
-
LCS Length
-
Action
INIT
""AEBD""ABCD

Goal: Find the longest common subsequence (LCS) of "ABCD" and "AEBD". A subsequence keeps characters in order but can skip some (e.g., "ABD" is a subsequence of "ABCD"). We build a table where dp[i][j] = LCS length using first i chars of str1 and first j chars of str2.

LCS Solution
1# Longest Common Subsequence
2def lcs(s1, s2):
3 m, n = len(s1), len(s2)
4 dp = [[0] * (n + 1) for _ in range(m + 1)] # +1 for empty-prefix row and col
5
6 for i in range(1, m + 1):
7 for j in range(1, n + 1):
8 if s1[i-1] == s2[j-1]: # Characters match
9 dp[i][j] = dp[i-1][j-1] + 1 # Extend from diagonal
10 else: # No match
11 dp[i][j] = max(dp[i-1][j], dp[i][j-1]) # Best of dropping either char
12
13 return dp[m][n]
Key Insight
Match goes diagonal, mismatch takes the better neighbor
Every string DP problem follows this skeleton. When characters match, the answer extends from the diagonal cell (both strings advance). When they don't, the answer comes from the better adjacent cell (one string advances, the other stays). Edit Distance adds one more option on mismatch: replace from the diagonal with +1 cost. LCS, Edit Distance, and Longest Common Substring are the same structure with different recurrences.
Interview Tip
Edit Distance adds one branch to LCS
LCS has 2 options on mismatch: drop from s1 or drop from s2. Edit Distance has 3: insert, delete, replace. The table shape and fill order are identical. If you can write LCS, Edit Distance is one extra min() argument and adjusted base cases (dp[i][0] = i, dp[0][j] = j instead of all zeros).

Interval DP

Interval DP applies when the problem requires processing a contiguous segment, and processing that segment involves choosing where to split it. The subproblems are smaller segments within the original.

Why Intervals Need Two Indices

In interval problems, the subproblem isn't "what's the answer up to position i." It's "what's the answer for the segment from i to j." Both endpoints are needed because shrinking from either end gives a different subproblem. dp[2][5] and dp[2][4] are different subproblems, as are dp[2][5] and dp[3][5].

Core Pattern

To solve for range [i, j], the question is: where should this segment be split? Every split point k between i and j-1 is tried. Each split gives two smaller segments [i, k] and [k+1, j]. Those are already solved, so their answers are combined (plus any cost for making that particular split). The best result across all split points is taken.

Why Fill by Interval Length

Filling row by row or column by column fails because dp[i][j] depends on dp[i][k] and dp[k+1][j], which span non-adjacent cells. But both subarrays are shorter than [i, j]. Fill all length-1 intervals first, then length-2, then length-3, and so on. This guarantees every dependency is computed before it's needed.

Interval DP Template
1# Interval DP Template
2def interval_dp(arr):
3 n = len(arr)
4 dp = [[0] * n for _ in range(n)]
5
6 # Base case: single elements
7 for i in range(n):
8 dp[i][i] = base_value # Often 0 or arr[i]
9
10 # Outer loop: interval length (smallest gaps first)
11 for length in range(2, n + 1):
12 for i in range(n - length + 1): # Start index
13 j = i + length - 1 # End index
14 dp[i][j] = float('inf') # -inf for maximization
15 for k in range(i, j): # Try every split point
16 cost = dp[i][k] + dp[k+1][j] + split_cost(i, k, j)
17 dp[i][j] = min(dp[i][j], cost)
18
19 return dp[0][n-1] # Answer for full range

Burst Balloons: The Backward Thinking Insight

Why forward thinking fails: If balloon k is burst first in range [i,j], its neighbors i-1 and k+1 become adjacent. Now the subproblems [i, k-1] and [k+1, j] interact. The coins from bursting in one range depend on what remains in the other. The subproblems are not independent.

The insight: think about which balloon is burst last. If balloon k is the last one burst in range [i, j], then at that moment:

  • All balloons in [i, k-1] are already gone
  • All balloons in [k+1, j] are already gone
  • Balloon k's neighbors are now nums[i-1] and nums[j+1] (the boundaries of the original range)

The coins from bursting k last = nums[i-1] x nums[k] x nums[j+1]. The left and right subproblems are now independent because the order within each subrange doesn't affect what neighbors k sees when it's burst last.

Burst Balloons - Thinking Backwards
1# Burst Balloons — dp[i][j] = max coins bursting all balloons in (i, j)
2# Boundaries are exclusive: balloons at i and j are NOT burst
3def maxCoins(nums):
4 nums = [1] + nums + [1] # Sentinel 1s so boundaries always exist
5 n = len(nums)
6 dp = [[0] * n for _ in range(n)]
7
8 for gap in range(2, n): # Gap between exclusive boundaries
9 for i in range(n - gap):
10 j = i + gap
11 for k in range(i + 1, j): # k = last balloon to burst
12 coins = nums[i] * nums[k] * nums[j] # Neighbors at burst time
13 coins += dp[i][k] + dp[k][j] # Left + right subproblems
14 dp[i][j] = max(dp[i][j], coins)
15
16 return dp[0][n-1]
Key Insight
When 'first' creates coupling, ask 'last'
Choosing what to process first in an interval can make left and right subproblems depend on each other. The fix: ask what gets processed last instead. The last element's neighbors are fixed (the interval boundaries), so left and right become independent. Burst Balloons, Matrix Chain Multiplication, and Strange Printer all use this reversal.
Common Mistake
Exclusive vs inclusive boundaries
Burst Balloons uses exclusive boundaries: dp[i][j] covers balloons strictly between i and j. Most other interval problems use inclusive: dp[i][j] means the segment from i to j. Mixing them shifts every index by one. The wrong version passes small tests and fails on larger inputs because the off-by-one only matters when ranges get long enough.

Stone Game variants use the same interval structure but with min-max game theory. dp[i][j] = best score difference for the current player on range [i,j]. The recurrence alternates: current player maximizes, opponent minimizes.

State Machine DP

Try dp[i] = max profit through day i. The recurrence can't be written. Day i's optimal action depends on whether a stock is held, which isn't captured by a single value. That missing information needs its own dimension. The state becomes dp[i][mode], where mode tracks which actions are legal.

State Machine DP applies whenever the decision at each step depends on what "mode" is active. If the valid actions depend on what happened before (not just the current position), that context must be tracked as a state dimension.

How to Identify States

Ask: "What constrains my choices at this position?" For Stock with Cooldown, holding a stock vs not holding changes the legal actions. Not holding splits further: "just sold" forces a wait, "ready to buy" doesn't. That gives three states: CAN_BUY, HOLDING, COOLDOWN.

Each constraint becomes a state dimension. Stock holding (2 states: yes/no). Cooldown remaining (splits "not holding" into 2). Transactions used for Best Time III/IV (0 to k). The state space is the cross-product of all constraints.

Key Insight
Same legal actions means same state
Two positions belong to the same state when the same actions are available at both. Holding a stock allows {sell, wait}. Not holding and ready allows {buy, wait}. Cooldown allows only {wait}. Different action sets require different states. List the legal actions at every configuration. Distinct lists mean distinct states.

State Transitions

For Stock with Cooldown, the states and transitions form this pattern:

State Machine for Stock with Cooldown
1State: CAN_BUY (no stock, ready to buy)
2 → buy: pay prices[i], go to HOLDING
3 → wait: stay in CAN_BUY
4
5State: HOLDING (have stock, can sell)
6 → sell: gain prices[i], go to COOLDOWN
7 → wait: stay in HOLDING
8
9State: COOLDOWN (just sold, must wait one day)
10 → wait: go to CAN_BUY (only option)
11
12Recurrence (read each line from the diagram):
13 can_buy[i] = max(can_buy[i-1], cooldown[i-1]) # Stayed ready, or cooldown ended
14 holding[i] = max(holding[i-1], can_buy[i-1] - prices[i]) # Kept holding, or bought today
15 cooldown[i] = holding[i-1] + prices[i] # Sold today, entering cooldown

State Machine DP: Stock with Cooldown

buysellexitwaitwaitCANBUYHOLDCOOLDOWNprices1d02d13d20d32d4can_buyholdingcooldown

Three states govern stock trading with cooldown. CAN_BUY: no stock held, free to purchase. HOLDING: stock in hand, can sell or wait. COOLDOWN: just sold, must wait one day before buying again.

Step 1 of 15

Top-Down: Memoized Recursion

Stock with Cooldown — Top-Down
1def maxProfit(prices):
2 memo = {}
3
4 def dp(i, buying):
5 if i >= len(prices):
6 return 0
7 if (i, buying) in memo:
8 return memo[(i, buying)]
9
10 if buying: # CAN_BUY state
11 buy = dp(i + 1, False) - prices[i] # Buy: pay price, enter HOLDING
12 wait = dp(i + 1, True) # Wait: stay in CAN_BUY
13 result = max(buy, wait)
14 else: # HOLDING state
15 sell = dp(i + 2, True) + prices[i] # Sell: gain price, skip i+1 (cooldown)
16 wait = dp(i + 1, False) # Wait: stay in HOLDING
17 result = max(sell, wait)
18
19 memo[(i, buying)] = result
20 return result
21
22 return dp(0, True)

Bottom-Up: O(1) Space

Stock with Cooldown — Bottom-Up O(1)
1def maxProfit(prices):
2 if not prices:
3 return 0
4
5 can_buy = 0 # Max profit when free to buy
6 holding = -prices[0] # Max profit when holding (bought day 0)
7 cooldown = float('-inf') # Impossible on day 0
8
9 for i in range(1, len(prices)):
10 prev_buy = can_buy # Save before overwriting
11 can_buy = max(can_buy, cooldown) # Was ready, or cooldown just ended
12 cooldown = holding + prices[i] # Sold today
13 holding = max(holding, prev_buy - prices[i]) # Kept holding, or bought today
14
15 return max(can_buy, cooldown)
Interview Tip
Draw the state machine before writing code
Draw states as circles, transitions as arrows. Label each arrow with its action and cost. The recurrence writes itself from the diagram: each state's value is the max over incoming arrows. This takes 30 seconds and catches the most common mistake, which is forgetting a transition or getting the direction wrong.

Stock Problem Variants

ProblemStatesApproach
Best Time Imin_price, max_profitTrack minimum price seen so far, update max profit at each day. One pass, O(1) space. A single running minimum solves it because you can only transact once.
Best Time IIholding, not_holdingAdd every upswing: whenever prices[i] > prices[i−1], take the profit. Unlimited transactions means every local gain is globally optimal. Equivalent to sum of all positive deltas.
With Cooldowncan_buy, holding, cooldownSelling forces a one-day wait before the next buy. This splits "not holding" into two states: ready to buy vs waiting out cooldown. Three recurrences, O(1) space after optimization.
With Feeholding, not_holdingSame structure as unlimited transactions but subtract fee on each sell. The fee prevents collecting tiny gains. Only swings above the fee threshold contribute. Same 2-state recurrence, holding[i] adjusts by fee.
Best Time IIIdp[i][k][holding]At most 2 transactions. Add a transaction counter as a state dimension. Each buy decrements k. With k=2, this is 4 states total. Generalize to Best Time IV by making k a parameter.
Best Time IVdp[i][j][holding]At most k transactions. j counts transactions used. When k ≥ n/2, unlimited transactions apply (Best Time II shortcut). Otherwise O(n·k) time.
Common Mistake
Variable update order in bottom-up
In the bottom-up version, can_buy must be saved before updating. Otherwise, holding = max(holding, can_buy - prices[i]) uses today's can_buy instead of yesterday's, which lets you buy on the same day you exit cooldown. Save prev_buy = can_buy before the loop body.

Beyond stocks: House Robber is a 2-state machine (rob/skip). Paint House is a 3-state machine (one per color, can't repeat). Regex matching maps directly to a finite automaton. The pattern applies whenever legal choices depend on a categorical decision made earlier.

Tree DP

Tree DP works because trees have no cycles: every node has exactly one parent, so the optimal solution at a node can be computed from the optimal solutions of its subtrees. Children are processed before parents (postorder traversal).

Return a Tuple, Not a Single Value

Unlike array DP where dp[i] stores one answer, Tree DP functions return multiple values because the parent's decision depends on which option the child took.

If dfs(child) returns just "best value for this subtree", the parent doesn't know whether that value includes the child node or not. But if the parent wants to include itself, it can't also include its children (adjacent nodes). The parent needs both options from each child: "best if child is included" and "best if child is excluded."

Return a tuple (include_this_node, exclude_this_node). The parent can choose: if including itself, use exclude values from children. If excluding itself, use whichever is better from each child.

Tree DP: House Robber III

32331

House Robber III: maximize loot from a binary tree. Adjacent nodes (parent-child) cannot both be robbed. Postorder DFS visits children before parents.

Step 1 of 10

House Robber III: Tuple Pattern in Action

The constraint is that two adjacent nodes (connected by an edge) cannot both be robbed. If a node is robbed, its children cannot be. If a node is skipped, its children are free to be robbed or skipped. For each node, two values are computed: rob_this (this node's value plus the skip values of both children) and skip_this (the max of rob or skip from each child).

House Robber III
1def rob(root):
2 def dfs(node):
3 if not node:
4 return (0, 0) # (rob, skip) for null
5
6 left = dfs(node.left) # Both options from left child
7 right = dfs(node.right) # Both options from right child
8
9 rob_this = node.val + left[1] + right[1] # Rob current: must skip children
10 skip_this = max(left) + max(right) # Skip current: free choice per child
11
12 return (rob_this, skip_this)
13
14 return max(dfs(root)) # Best of rob or skip root
Interview Tip
The return tuple separates states for the parent
The tuple in Tree DP serves the same purpose as state dimensions in array DP. Each element gives the parent a different piece of information. Binary Tree Maximum Path Sum returns (best through this node, best the parent can extend). Diameter of Binary Tree returns (max diameter, max depth). When the parent's decision depends on the child's choice, ask what tuple DFS should return.
Common Mistake
Tree DP returns a pair: answer and what parent needs
Binary Tree Maximum Path Sum: return (best path going through this node, best path that parent can extend). Diameter of Binary Tree: return (max diameter in subtree, max depth for parent to use). The tuple captures "answer for this subtree" and "what parent needs to make its decision."

Why No Memoization?

In grid or string DP, the same cell dp[i][j] can be needed by multiple later cells, creating overlapping subproblems. In a tree, each node has exactly one parent. Every subtree result is computed once and consumed once. No subproblem is ever recomputed.

Reaching for a memo dict in tree DP usually means the structure is actually a graph with cycles, not a tree. If nodes can be reached through multiple paths, the problem requires graph DP with visited tracking, not tree DP.

The Knapsack Family

Knapsack problems are everywhere in interviews, often in disguise. The core structure: items with weights and values, a capacity constraint, and an objective to optimize. Coin Change is knapsack (coins are items, amount is capacity). Partition Equal Subset Sum is knapsack (numbers are items, half the sum is capacity). Target Sum is knapsack with a twist.

The critical distinction is whether each item can be used once (0/1 Knapsack) or unlimited times (Unbounded Knapsack). This single difference changes the recurrence and the loop direction in space-optimized solutions. Getting it wrong means solving a completely different problem.

0/1 Knapsack

In 0/1 Knapsack, each item can be taken at most once, and the goal is to maximize total value without exceeding capacity. The "0/1" name comes from the binary choice: for each item, either take it (1) or leave it (0).

The Decision at Each Cell

dp[i][w] asks: "Using only the first i items and a knapsack of capacity w, what's the maximum value achievable?" For each item, there are two choices. Don't take it: the answer is dp[i-1][w] (best achievable without this item). Take it: the answer is the item's value plus dp[i-1][w - weight] (best achievable with the remaining capacity, using previous items only). Whichever is better is taken.

Why dp[i-1] and Not dp[i]

When taking an item, the lookup is dp[i-1][w - weight], not dp[i][w - weight]. This is critical. Looking at the previous row ensures each item can only be used once. Looking at the current row would allow taking the same item multiple times (that's Unbounded Knapsack). This distinction matters for space optimization: in 0/1 Knapsack, capacity is iterated backwards to avoid reusing items.

0/1 Knapsack: Capacity = 8

Cell
-
Skip Value
-
Include Value
-
Decision
INIT
Item 1
w=2, v=3
Item 2
w=3, v=4
Item 3
w=4, v=5
Item 4
w=5, v=6
012345678Capacity01234Item

We have 4 items, each with a weight and value. Our bag holds capacity 8. The key question at each cell: "Is it better to include this item or skip it?" dp[i][w] = best value using items 1 through i with capacity w.

0/1 Knapsack Solution
1def knapsack_01(weights, values, capacity):
2 n = len(weights)
3 dp = [[0] * (capacity + 1) for _ in range(n + 1)]
4
5 for i in range(1, n + 1):
6 for w in range(capacity + 1):
7 # Do not take item i
8 dp[i][w] = dp[i-1][w]
9 # Take item i if it fits
10 if weights[i-1] <= w:
11 dp[i][w] = max(dp[i][w],
12 dp[i-1][w - weights[i-1]] + values[i-1])
13
14 return dp[n][capacity]

Unbounded Knapsack

Unbounded Knapsack allows using each item unlimited times. Coin Change is the classic example: each coin denomination can be used as many times as needed. The question becomes "what's the minimum number of coins to make this amount?"

Why the Loop Order Differs

In Coin Change, when computing dp[amount], the lookup is dp[amount - coin]. Unlike 0/1 Knapsack, using the same row IS desired because reusing coins is allowed. If dp[5] was computed using a coin of value 2, and now dp[7] is being computed, using dp[5] means "take another coin of value 2," which is exactly correct.

This is why space-optimized Unbounded Knapsack iterates capacity forwards while 0/1 Knapsack iterates backwards. Forward iteration allows reusing items. Backward iteration prevents reuse.

Coin Change Solution
1def coin_change(coins, amount):
2 dp = [float('inf')] * (amount + 1)
3 dp[0] = 0
4
5 for a in range(1, amount + 1):
6 for coin in coins:
7 if coin <= a and dp[a - coin] != float('inf'):
8 dp[a] = min(dp[a], dp[a - coin] + 1)
9
10 return dp[amount] if dp[amount] != float('inf') else -1

Recognizing Unbounded vs 0/1

The key question: "Can this item/coin/choice be used more than once?" If yes, it's unbounded. Coin Change, Climbing Stairs (1 or 2 steps can be taken any number of times), and Cutting Rod are unbounded. Subset Sum, Partition Equal Subset, and the classic Knapsack are 0/1. Getting this distinction wrong means the solution either misses valid answers or counts invalid ones.

Space Optimization

This is an essential interview skill, not optional. After presenting a working DP solution, interviewers frequently ask: "Can we reduce the space?" Having this answer ready demonstrates depth. The pattern is predictable: examine what the recurrence actually uses, keep only that.

Why Space Can Be Reduced

Most DP recurrences only look back a fixed distance. House Robber uses dp[i-1] and dp[i-2], just two values, not the entire history. Knapsack row i only uses row i-1. LCS at dp[i][j] only uses the previous row and current row. The full table stores nxm values but only a fraction are ever accessed simultaneously.

Key Insight
Keep Only What the Recurrence Reads
The recurrence decides how much to store. Lookback of 2 positions (Fibonacci, House Robber) means two variables. Lookback of one row (Knapsack, LCS) means one row. Lookback of the entire table (Interval DP, LIS) means the table stays. Count how far back the recurrence reaches and keep exactly that much.

Three Levels of Optimization

2D to 1D: If the 2D table only references the previous row, keep one row and overwrite. Works for Knapsack, LCS, Edit Distance.

1D to constant: If the 1D array only references the previous 1-2 values, use variables instead. Works for Fibonacci, House Robber, Climbing Stairs.

Full grid to single row: Process along the longer dimension, store along the shorter. For a 1000x10 grid, store 10 values, iterate 1000 times.

2D to 1D Reduction

If dp[i][w] only depends on dp[i-1][...], only the previous row needs to be kept. For 0/1 Knapsack, there is a critical twist: iteration must go backwards.

Space Optimization: 2D to 1D

Space
O(n*W)
Iteration
-
Status
INIT
prevcurr01234500333300344

Standard 2D Knapsack uses two rows: prev (previous item's results) and curr (current item). Look at any cell in curr: where does it get its data from?

Space-Optimized Knapsack
1# 0/1 Knapsack - Space Optimized
2def knapsack_optimized(weights, values, capacity):
3 dp = [0] * (capacity + 1)
4
5 for i in range(len(weights)):
6 # Iterate backwards to avoid using same item twice
7 for w in range(capacity, weights[i] - 1, -1):
8 dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
9
10 return dp[capacity]
11
12# Unbounded Knapsack - Space Optimized
13def coin_change_optimized(coins, amount):
14 dp = [float('inf')] * (amount + 1)
15 dp[0] = 0
16
17 # Iterate FORWARDS (we want to reuse coins)
18 for a in range(1, amount + 1):
19 for coin in coins:
20 if coin <= a and dp[a - coin] != float('inf'):
21 dp[a] = min(dp[a], dp[a - coin] + 1)
22
23 return dp[amount] if dp[amount] != float('inf') else -1
Interview Tip
Why Backwards for 0/1 Knapsack?
When iterating backwards, dp[w - weight] still has its value from the previous item (we have not updated it yet in this pass). When iterating forwards, dp[w - weight] might already be updated, meaning we could use the same item multiple times. Backwards ensures each item is used at most once.

Path Reconstruction

Often the requirement is not just the optimal value, but the actual solution that achieves it. This requires storing decisions, not just values.

LCS with Path Reconstruction
1def lcs_with_path(s1, s2):
2 m, n = len(s1), len(s2)
3 dp = [[0] * (n + 1) for _ in range(m + 1)]
4
5 # Build DP table
6 for i in range(1, m + 1):
7 for j in range(1, n + 1):
8 if s1[i-1] == s2[j-1]:
9 dp[i][j] = dp[i-1][j-1] + 1
10 else:
11 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
12
13 # Backtrack to find LCS
14 i, j = m, n
15 lcs = []
16 while i > 0 and j > 0:
17 if s1[i-1] == s2[j-1]:
18 lcs.append(s1[i-1])
19 i -= 1
20 j -= 1
21 elif dp[i-1][j] > dp[i][j-1]:
22 i -= 1
23 else:
24 j -= 1
25
26 return ''.join(reversed(lcs))

How to backtrack: Start at the answer cell and work backwards. At each cell, ask: "Did including this element improve the value?"

Concrete example with LCS: For strings "ABCD" and "AEBD", the answer is at dp[4][4]=3. Check dp[3][3]=2. Different values mean 'D' matched. Record it, move diagonal to [3][3]. At dp[3][3]=2, check dp[2][3]=2. Same value means 'C' did not match. Move to the larger neighbor (left or up). Continue until row 0 or column 0 is reached. Reverse what was collected: "ABD".

The same logic works for Knapsack (which items?), Edit Distance (which operations?), or any DP where the path is needed, not just the number.

Complete Worked Example

Solving Longest Increasing Subsequence from scratch, covering state definition, recurrence, base case, and optimization.

Worked Example: Longest Increasing Subsequence

Given nums = [10, 9, 2, 5, 3, 7, 101, 18], find the length of the longest strictly increasing subsequence.

Step 1: Recognize This Is DP

The word "longest" signals optimization. The goal isn't finding any subsequence, but the best one. There are many increasing subsequences that must be compared. That's the first hint.

Next, check for overlapping subproblems. When computing the LIS ending at index 5, the LIS ending at each earlier index is needed to see which ones can be extended. When later computing the LIS ending at index 6, those same earlier values are needed again. The subproblem "LIS ending at index 3" gets reused when computing results for indices 4, 5, 6, and beyond.

That's both criteria met: optimal substructure with overlapping subproblems. This is DP.

Step 2: Define the State

First attempt: dp[i] = length of LIS in nums[0..i]

Problem: If dp[4] = 2, what that subsequence ends with is unknown. Does it end with 3? With 5? The ending element is needed to decide if extending with nums[5] = 7 is possible.

Better: dp[i] = length of LIS that ends exactly at index i

Now the last element is known to be nums[i]. The check becomes: is nums[i] > nums[j] for some earlier j? If so, extension is possible.

Common Mistake
State Refinement Principle
If two subproblems produce the same state value but require different future decisions, your state definition is missing a variable. The first attempt above is exactly this situation: two subsequences of length 2 ending at different values look identical under dp[i] = "LIS length in nums[0..i]", but lead to different extension choices. Adding "ends at index i" separates them.

Step 3: Write the Recurrence

dp[i] = the longest increasing subsequence ending at i

For each previous index j where nums[j] < nums[i], the LIS ending at j could be extended by appending nums[i]. The goal is the longest such extension:

1dp[i] = 1 + max(dp[j] for all j < i where nums[j] < nums[i])

If no valid j exists (nums[i] is smaller than everything before it), dp[i] = 1: the element alone forms an increasing subsequence of length 1.

Step 4: Identify Base Cases

Every element by itself is an increasing subsequence of length 1. Initialize dp[i] = 1 for all i.

Step 5: Determine Fill Order

dp[i] depends on dp[j] for j < i. Fill left to right.

Step 6: Identify the Answer

The LIS doesn't have to end at the last index. It could end anywhere. So the answer is max(dp[i]) over all i.

Visualizing the Algorithm

This animation walks through every comparison. For each position i, the algorithm checks all previous positions j to find the longest increasing subsequence it can extend.

LIS: O(n²) Algorithm Walkthrough

Current (i)
-
Comparing (j)
-
Action
INIT
nums[]10[0]9[1]2[2]5[3]3[4]7[5]101[6]18[7]dp[]11111111

Longest Increasing Subsequence: Find the longest strictly increasing subsequence in [10, 9, 2, 5, 3, 7, 101, 18]. We'll use dp[i] = length of LIS ending at index i. Initialize all to 1 (each element alone is a valid subsequence).

The Code

LIS Solution
1def lengthOfLIS(nums):
2 if not nums:
3 return 0
4
5 n = len(nums)
6 dp = [1] * n # Every element is a subsequence of length 1
7
8 for i in range(1, n):
9 for j in range(i):
10 if nums[j] < nums[i]:
11 dp[i] = max(dp[i], dp[j] + 1)
12
13 return max(dp)
14
15# nums = [10, 9, 2, 5, 3, 7, 101, 18]
16# dp = [ 1, 1, 1, 2, 2, 3, 4, 4]
17# Answer: max(dp) = 4
18# One LIS: [2, 5, 7, 101] or [2, 3, 7, 101]
19# Both have length 4. DP tracks the length, not which path was taken.
  • Time: O(n²): for each of n positions, we check up to n previous positions.
  • Space: O(n): single dp array.

When DP Breaks

DP is not always the right approach. Recognizing when it does not apply saves time and avoids forcing a recurrence where none exists.

When greedy works. If the locally optimal choice is provably globally optimal, DP is unnecessary overhead. Activity Selection sorted by end time, Jump Game tracking maximum reachability, and Fractional Knapsack all have greedy proofs. The test: can a small counterexample be found where the greedy choice fails? If not, greedy is likely correct.

When the state space is too large. If describing a subproblem requires tracking too many dimensions (which items are used as a bitmask, exact board configuration, full sequence of moves), the table has exponentially many entries. DP with 2^n states is technically correct but practically no better than brute force. Look for ways to reduce the state: can some dimensions be dropped? Can the problem be decomposed?

When no recurrence exists. If the optimal choice at position i depends on future positions that have not been processed yet, a left-to-right recurrence cannot be written. Dungeon Game looks like it should fill top-left to bottom-right, but the minimum starting health depends on the worst cell along the entire path, which is unknown until the end is reached. The fix is reversing the fill direction, not abandoning DP.

When subproblems do not overlap. If the recursion tree has no repeated nodes, each subproblem is solved exactly once. Caching adds memory without saving time. This is Divide & Conquer. Merge Sort and Quick Sort split the array into independent halves that never appear as subproblems of each other.

Debugging DP

When a DP solution gives wrong answers, work through these questions in order. Most bugs fall into one of these categories.

Bug 1: Wrong Initialization

Why this breaks: When finding minimums, if you initialize dp to 0, every min() comparison picks 0 since nothing beats it. Your answer is always 0 regardless of the actual solution. For counting problems (like Coin Change II), dp[0]=1 because there is exactly one way to make amount 0: use no coins. Initialize other cells to 0 because there are initially 0 ways to make those amounts.

Prevention: Use the identity value for your operation. For min, it is infinity. For max, it is negative infinity. For addition/counting, it is 0. For multiplication, it is 1.

1# Wrong: initialized to 0
2dp = [0] * (amount + 1) # min() will always pick 0
3
4# Correct: initialized to infinity
5dp = [float('inf')] * (amount + 1)
6dp[0] = 0 # Base case: 0 coins for amount 0

Bug 2: Off-by-One Indexing

Why this breaks: In string DP, dp[i][j] represents "using the first i characters of s1 and first j characters of s2." This is 1-indexed because dp[0][...] means "using 0 characters" (the empty prefix). But strings are 0-indexed, so when you are at dp[i][j], the actual characters to compare are s1[i-1] and s2[j-1]. Using s1[i] compares the wrong character and produces garbage results.

Prevention: When writing your recurrence, explicitly annotate what dp[i][j] means. If it means "first i chars", then accessing the i-th character requires index i-1.

1# dp[i][j] uses first i chars of s1, first j chars of s2
2# To access actual characters: s1[i-1] and s2[j-1]
3if s1[i-1] == s2[j-1]: # NOT s1[i] == s2[j]
4 dp[i][j] = dp[i-1][j-1] + 1

Bug 3: Wrong Loop Bounds

Why this breaks: If your answer lives at dp[n][m] but your loop goes to range(n) instead of range(n+1), you never compute the final answer. Python's range(n) gives 0 to n-1, so range(1, n+1) is needed to include n. This is especially tricky when mixing 0-indexed and 1-indexed thinking.

Prevention: Before coding, write down: "My answer is at dp[?][?]" and verify your loops reach that cell. Trace through with a small example (n=2) to catch off-by-one errors early.

1# Make sure to include the final state
2for i in range(1, n + 1): # NOT range(n)
3 for j in range(1, m + 1): # Include m
4 ...

Bug 4: Forgetting Edge Cases

Why this breaks: Empty arrays cause index-out-of-bounds when you access nums[0]. Single-element arrays fail when your recurrence assumes at least 2 elements. Amount=0 in Coin Change should return 0 coins, but unhandled it might return infinity or -1.

Prevention: Before your main logic, handle edge cases explicitly: if not nums: return 0. For single elements, verify your loop handles i=0 correctly. Test with inputs: [], [5], [1,2], and your target boundary values.

Bug 5: State Missing Information

Why this breaks: If dp[i] cannot distinguish two situations that require different future decisions, the recurrence uses wrong values. For stock problems, dp[i] = "max profit through day i" cannot express whether a stock is currently held. The recurrence needs to know the current mode, but the state does not provide it. The fix is adding a dimension: dp[i][holding].

Prevention: Try writing the recurrence. If it requires information not in the state, add a dimension for that information.

Bug 6: Wrong Fill Order

Why this breaks: If dp[5] is computed before dp[3] and dp[4], the recurrence reads uninitialized values (typically 0) instead of computed answers. The result is silently wrong, not an error. The code runs, produces a number, and that number is garbage.

Prevention: Trace the dependency arrows from your recurrence. Every cell your recurrence reads must be filled before the cell being computed.

Bug 7: Variable Overwrite in Space Optimization

Why this breaks: In state machine DP (stock problems), updating can_buy before computing holding means holding reads today's can_buy instead of yesterday's. In 0/1 Knapsack, iterating capacity forwards lets dp[w - weight] use values already updated for the current item, allowing the same item to be used twice.

Prevention: Save yesterday's values before overwriting. For 0/1 Knapsack, iterate capacity backwards. For state machines, use temp variables for the previous day.

DP Time and Space Complexity

Time = (Number of unique states) x (Work per state)

Count the distinct inputs to the dp function to get the state count. Examine the recurrence to determine work per state: O(1) for constant-time comparisons (House Robber), O(n) when all previous positions are checked (LIS), O(n) when all split points are tried (Interval DP).

ProblemStatesWork/StateTotal Time
House RobbernO(1)O(n)
Climbing StairsnO(1)O(n)
0/1 Knapsackn x WO(1)O(nW)
LCSm x nO(1)O(mn)
Edit Distancem x nO(1)O(mn)
LIS (basic)nO(n)O(n²)
Matrix ChainO(n)O(n³)
Interval DPO(n)O(n³)

Space equals the state count before optimization. After optimization, it depends on how far back the recurrence looks: lookback-2 gives O(1), previous-row gives O(n), full table stays at O(n x m).

Practice Problems

Organized by pattern and difficulty. Each insight explains the reasoning, not just the formula.

Linear DP

ProblemDifficultyApproach
Climbing StairsEasyTo reach step n, the last move was from n-1 or n-2. Those are the only options, so add the ways to reach each.
House RobberMediumAt each house, the choice is binary: take it (skip previous) or skip it (keep previous best). The constraint forces looking 2 back when taking.
Maximum SubarrayMediumAt each position, decide: extend the current subarray or start fresh here. If the running sum is negative, starting fresh is always better.
Longest Increasing SubsequenceMediumState must fix ending position because extension requires knowing the last value. Answer requires scanning all dp values, not just dp[n].

String DP

ProblemDifficultyApproach
Longest Common SubsequenceMediumIf characters match, extend the diagonal (both strings advance). If not, try dropping one character from either string and take the better result.
Edit DistanceMediumThree operations = three choices per cell. Insert, delete, replace correspond to coming from left, above, or diagonal. Each adds 1 to the cost.
Longest Palindromic SubstringMediumBoth expand-around-center and 2D DP run in O(n²) time. Expand-around-center uses O(1) space vs O(n²) for the DP table. The DP approach asks: is s[i..j] a palindrome? Check ends and inner substring.
Regular Expression MatchingHardThe * operator is the trap. It matches zero or more of the preceding element. "Zero" means skip both pattern chars; "more" means stay at * and advance string.

Knapsack Family

ProblemDifficultyApproach
Coin ChangeMediumUnbounded: same coin can be used again. Iterate amount left-to-right so updated values (with this coin) can be reused immediately.
Coin Change IIMediumCounting combinations, not permutations. Loop coins outside, amounts inside to avoid counting [1,2] and [2,1] as different ways.
Partition Equal Subset SumMediumTransform to: can we select a subset summing to exactly half? 0/1 Knapsack where each number is used once. Iterate backwards.
Target SumMediumAssign + or - to each number. Equivalent to: partition into two groups where difference equals target. Math transforms it to standard subset sum.

Grid DP

ProblemDifficultyApproach
Unique PathsMediumEach cell is reachable from above or left only. No choice to make, just add the two predecessor counts.
Minimum Path SumMediumSame structure as Unique Paths but take min instead of sum, then add current cell cost. First row/column have only one predecessor.
Maximal SquareMediumA square of size k at (i,j) requires size k-1 squares at all three predecessors. The minimum of the three + 1 gives the answer.
Dungeon GameHardFill backwards from bottom-right because ending health determines starting requirement. Forward DP cannot determine how much health is needed upfront.

Tree DP

ProblemDifficultyApproach
House Robber IIIMediumReturn a tuple (rob_this, skip_this) from each subtree. Parent needs both values to decide its optimal choice based on the constraint.
Binary Tree Maximum Path SumHardAt each node, track "max path through this node" vs "max path ending here that parent can extend." Return the extendable one, update global with the through-path.
Diameter of Binary TreeMediumDiameter = left_depth + right_depth at some node. Return single-side depth to parent, update global with both-sides sum.

State Machine DP

ProblemDifficultyApproach
Best Time to Buy and Sell Stock with CooldownMediumTwo states: holding or not. Selling enters a cooldown day before buying is allowed again. Track both states at each day.
Best Time with Transaction FeeMediumSame two-state structure. Subtract fee when selling. The state machine has the same shape as cooldown, just different transition cost.
Best Time with K TransactionsHardAdd a transaction count dimension. State is (day, transactions_used, holding). 3D DP but can be reduced to 2D with careful ordering.

Reference Templates

Compact code shapes for each DP family. Use these for quick recovery, not first learning.

Linear DP

Use when each position depends on a fixed number of earlier positions.

1dp = [0] * n
2dp[0], dp[1] = base0, base1
3for i in range(2, n):
4 dp[i] = max(dp[i-1], dp[i-2] + nums[i])
5return dp[n-1] # or max(dp) if ending position varies

Grid DP

Use when movement constraints make each cell depend on already-computed neighbors.

1dp = [[0]*n for _ in range(m)]
2# Fill first row and first column as base cases
3for i in range(1, m):
4 for j in range(1, n):
5 dp[i][j] = f(dp[i-1][j], dp[i][j-1], grid[i][j])
6return dp[m-1][n-1]

String DP

Use when comparing prefixes of two strings with a 1-indexed table.

1dp = [[0]*(n+1) for _ in range(m+1)]
2# dp[0][j] and dp[i][0] are base cases (empty prefix)
3for i in range(1, m+1):
4 for j in range(1, n+1):
5 if s1[i-1] == s2[j-1]:
6 dp[i][j] = dp[i-1][j-1] + 1
7 else:
8 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
9return dp[m][n]

Interval DP

Use when the subproblem is a range and every split point must be tested.

1dp = [[0]*n for _ in range(n)]
2for length in range(2, n+1):
3 for i in range(n - length + 1):
4 j = i + length - 1
5 dp[i][j] = float('inf')
6 for k in range(i, j):
7 dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost(i,k,j))
8return dp[0][n-1]

State Machine DP

Use when legal actions depend on the current mode.

1state_a, state_b = base_a, base_b
2for i in range(1, n):
3 prev_a = state_a
4 state_a = max(state_a, state_b + transition_cost)
5 state_b = max(state_b, prev_a + transition_cost)
6return max(state_a, state_b)

Tree DP

Use when each node needs results from its children before returning to the parent.

1def dfs(node):
2 if not node: return (0, 0)
3 left = dfs(node.left)
4 right = dfs(node.right)
5 include = node.val + left[1] + right[1]
6 exclude = max(left) + max(right)
7 return (include, exclude)
8return max(dfs(root))

0/1 Knapsack (1D)

Use when each item can be used at most once. Iterate capacity backward.

1dp = [0] * (capacity + 1)
2for i in range(n):
3 for w in range(capacity, weights[i]-1, -1): # Backward
4 dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
5return dp[capacity]

Unbounded Knapsack (1D)

Use when each item can be reused unlimited times. Iterate capacity forward.

1dp = [float('inf')] * (amount + 1)
2dp[0] = 0
3for a in range(1, amount + 1): # Forward
4 for coin in coins:
5 if coin <= a and dp[a - coin] != float('inf'):
6 dp[a] = min(dp[a], dp[a - coin] + 1)
7return dp[amount] if dp[amount] != float('inf') else -1

Pattern Recap

Linear DP processes a sequence left to right. Each position depends on a fixed number of earlier positions (lookback-1 for Kadane, lookback-2 for House Robber, lookback-all for LIS). The lookback distance determines space optimization.

Grid DP fills a 2D table top-left to bottom-right because movement is constrained to right and down. Each cell combines the cell above and the cell to the left. First row and first column are base cases.

String DP compares all prefix pairs of two strings in a 1-indexed table. When characters match, the diagonal extends. When they do not match, the better neighbor is taken. The off-by-one between 1-indexed table and 0-indexed strings is the most common bug.

Interval DP solves ranges by trying every split point. Fill by increasing interval length so shorter sub-intervals are ready when needed. When choosing what to process first creates coupled subproblems, ask what to process last instead.

State Machine DP adds a mode dimension when legal actions depend on previous choices. Draw the state machine, label transitions, and the recurrence follows from the diagram.

Tree DP returns a tuple from each subtree because the parent's decision depends on which option the child took. Postorder traversal ensures children are solved before parents. No memoization is needed because each subtree is consumed exactly once.

0/1 Knapsack iterates capacity backwards in the 1D optimization to prevent using the same item twice. Unbounded Knapsack iterates forwards because reusing items is allowed.

Rebuilding the Pattern

When a DP problem feels unclear, start with the question the problem asks. "Find the minimum cost" means the state should store a cost. "Count the ways" means the state should store a count. "Is it possible" means the state should store a boolean. The type of answer determines the type of state.

Next, determine what changes between subproblems. A single position changing gives 1D. Two positions (one in each string) give 2D. A resource being consumed (capacity, budget, transaction count) adds another dimension. If deciding what the current step's choices are requires knowing a mode (holding a stock vs not holding), that mode becomes a state dimension too.

Try writing the recurrence with the state you have. If it needs information that the state does not provide, the state is incomplete. Add the missing information as a dimension. If the recurrence can be written, set base cases by translating dp[0] (or dp[0][0]) back to the original problem in plain language. "Ways to reach step 0" is 1. "Min coins for amount 0" is 0. "LCS with an empty string" is 0.

Verify the fill order by checking that every value the recurrence reads is already computed. Then check where the answer lives. If the state fixes something (ending position, current mode), the answer may require scanning all states or summing across modes, not just reading dp[n].

The pattern is recoverable from these decisions: what the state stores, what varies between subproblems, what choices exist at each state, how choices combine, what the base cases mean, what order to fill, and where the answer ends up.

Check Your Understanding

Use these questions to check whether you can reason through the pattern without looking at the template.

12 questions covering DP state design, recurrence bugs, family classification, and space optimization.

Practice this pattern

Apply the guide to complete interview problems with explanations and code.

Learn Dynamic Programming in a guided sequence

The Interview Course connects this pattern to its prerequisites, worked lessons, and progressively harder problems.

Explore the course