DP Patterns: Defining the State, Choosing Loop Order, and Recognizing Knapsack, LCS, and LIS

Key takeaways

Most DP problems reuse a handful of state shapes: a prefix, a grid, two string prefixes, a capacity. Recognizing the shape is half the work; the other half is the details that decide correctness, such as loop direction in knapsack and loop order in counting problems.

Introduction

Most DP problems are variations of a few patterns. Knowing the patterns does not make new problems easy, but it does give you a short list of state shapes to try first.

Every DP solution is built from the same three decisions, and the patterns below are really just common answers to them:

  1. State: what does dp[i] (or dp[i][j]) mean, in one precise sentence? “The number of ways to reach step i.” “The best value using the first i items with capacity w.” If you cannot state it in a sentence, the recurrence will be wrong.
  2. Transition: how is one state computed from smaller ones? This usually comes from asking “what was the last decision?” (last step was 1 or 2 stairs; last item was taken or not; last characters matched or not).
  3. Order and base cases: in what order must states be filled so that every state’s inputs are ready, and what are the smallest states whose values you know directly?

Wrong DP solutions almost always fail at the first decision, not the code. A vague state like “dp[i] is the answer for i” leaves it unclear whether the i-th element is included or merely available, and those two readings produce different recurrences. The LIS section below is a good example: dp[i] there means “the longest increasing subsequence that ends at index i”, and that “ends at” is what makes the recurrence possible.


1D DP Patterns

In 1D DP, dp[i] typically means “optimal value from first to ith element” or “optimal value for subproblem of size i”. The two examples below are representative patterns that fill the next cell using only previous cells.

Pattern 1: Using Previous Values

def climb_stairs(n):
    """
    Climbing stairs (1 or 2 steps)
    dp[i] = dp[i-1] + dp[i-2]
    """
    if n <= 2:
        return n
    
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    
    return dp[n]
print(climb_stairs(5))  # 8

Pattern 2: Max/Min Selection

def rob(houses):
    """
    House Robber (cannot rob adjacent houses)
    dp[i] = max(dp[i-1], dp[i-2] + houses[i])
    """
    if not houses:
        return 0
    if len(houses) == 1:
        return houses[0]
    
    dp = [0] * len(houses)
    dp[0] = houses[0]
    dp[1] = max(houses[0], houses[1])
    
    for i in range(2, len(houses)):
        dp[i] = max(dp[i-1], dp[i-2] + houses[i])
    
    return dp[-1]
# Test
print(rob([2, 7, 9, 3, 1]))  # 12 (2+9+1)

Both examples only look back one or two steps, so the full array is unnecessary: two variables (prev2, prev1) are enough, reducing memory from O(n) to O(1). Keep the array while you develop and debug a solution, though, because printing dp is the easiest way to check the recurrence by hand on a small input. Optimize the space only after the answers are right.

Note that House Robber’s state is “the best total using houses 0..i, whether or not house i is robbed”. That is why dp[i-1] is a valid option: skipping house i simply carries over the previous best. An alternative state, “the best total ending with robbing house i”, also works but needs a different recurrence (houses[i] + max(dp[0..i-2])). Neither is wrong, but mixing the two meanings in one solution is a classic bug.


2D DP Patterns

Pattern 1: Grid Paths

def unique_paths(m, n):
    """
    Number of paths in m×n grid
    dp[i][j] = dp[i-1][j] + dp[i][j-1]
    """
    dp = [[1] * n for _ in range(m)]
    
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]
print(unique_paths(3, 7))  # 28

Pattern 2: LCS (Longest Common Subsequence)

def lcs(s1, s2):
    """
    Longest Common Subsequence
    "ABCDGH", "AEDFHR" → "ADH" (length 3)
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    return dp[m][n]
# Test
print(lcs("ABCDGH", "AEDFHR"))  # 3

LCS Backtracking:

def lcs_with_string(s1, s2):
    """
    Return LCS string, not just length
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Build DP table
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    # Backtrack to find string
    result = []
    i, j = m, n
    
    while i > 0 and j > 0:
        if s1[i-1] == s2[j-1]:
            result.append(s1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    
    return ''.join(reversed(result))
# Test
print(lcs_with_string("ABCDGH", "AEDFHR"))  # "ADH"

The LCS table uses one extra row and column: dp[i][j] is the LCS length of the first i characters of s1 and the first j characters of s2, so dp[0][*] and dp[*][0] represent empty prefixes and are 0. This “prefix length” convention removes all special cases at the edges, which is why s1[i-1] appears in the code. The same convention appears in edit distance below, and it is worth adopting as a default for any DP over two sequences.

The reconstruction step walks back from dp[m][n] and retraces which choice produced each value. That only works because the whole table is kept. If you reduce LCS to two rows to save memory, you can still compute the length but can no longer recover the string (without more advanced techniques such as Hirschberg’s algorithm). When a problem asks for the actual sequence, not just its length, plan for the full table. Also, when there are several LCSs of the same length, the tie-breaking in the elif decides which one you get. Tests that compare against one specific string can fail even though the answer is valid.


Knapsack Problem Patterns

0-1 Knapsack

def knapsack_01(weights, values, capacity):
    """
    Each item 0 or 1 time
    """
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    for i in range(1, n + 1):
        for w in range(capacity + 1):
            # Don't take
            dp[i][w] = dp[i-1][w]
            
            # Take
            if weights[i-1] <= w:
                dp[i][w] = max(
                    dp[i][w],
                    dp[i-1][w - weights[i-1]] + values[i-1]
                )
    
    return dp[n][capacity]
# Test
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print(knapsack_01(weights, values, capacity))  # 10

Space Optimized (1D):

def knapsack_01_optimized(weights, values, capacity):
    """
    O(capacity) space
    """
    dp = [0] * (capacity + 1)
    
    for i in range(len(weights)):
        # Iterate backwards to prevent overwriting
        for w in range(capacity, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    
    return dp[capacity]

The backward loop is the entire difference between 0-1 knapsack and unbounded knapsack, and it is the most commonly misunderstood line in DP code. In the 1D version, dp[w] holds values from the previous item’s row until it is overwritten. The update reads dp[w - weights[i]], a smaller index. If w goes from high to low, that smaller index has not been updated yet for item i, so it still means “best without item i”, and item i is used at most once. If w went from low to high, dp[w - weights[i]] might already include item i, and adding it again would use the same item twice. Loop direction is literally how the code encodes “each item once”.

Keep in mind that knapsack DP is O(n × capacity). That is pseudo-polynomial: fast when capacity is a small integer, but useless when weights are large (a capacity of 10⁹) or not integers. In those cases, the problem needs a different approach, such as DP over values instead of weights, meet-in-the-middle, or an approximation.

Unbounded Knapsack

def knapsack_unbounded(weights, values, capacity):
    """
    Each item unlimited times
    """
    dp = [0] * (capacity + 1)
    
    for w in range(capacity + 1):
        for i in range(len(weights)):
            if weights[i] <= w:
                dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    
    return dp[capacity]
# Test
weights = [1, 3, 4]
values = [15, 50, 60]
capacity = 8
print(knapsack_unbounded(weights, values, capacity))  # 130 (3+3+1+1 → 50+50+15+15)

Here the forward direction is exactly what you want: dp[w - weights[i]] may already include item i, which is allowed because items can repeat.

Loop order in counting problems: combinations vs permutations

For maximizing a value, the order of the two loops in unbounded knapsack does not matter. For counting ways, it changes the answer:

def count_combinations(coins, amount):
    # coins in the OUTER loop: each multiset counted once
    dp = [1] + [0] * amount
    for c in coins:
        for a in range(c, amount + 1):
            dp[a] += dp[a - c]
    return dp[amount]

def count_permutations(coins, amount):
    # amount in the OUTER loop: every ordering counted separately
    dp = [1] + [0] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a:
                dp[a] += dp[a - c]
    return dp[amount]

print(count_combinations([1, 2], 3))   # 2: {1,1,1}, {1,2}
print(count_permutations([1, 2], 3))   # 3: 1+1+1, 1+2, 2+1

With coins in the outer loop, coins are added in a fixed order, so 1+2 and 2+1 are built only one way. With the amount in the outer loop, every coin can be the “last” one at every step, so each ordering is counted. LeetCode 518 (Coin Change II) wants the first; LeetCode 377 (Combination Sum IV) wants the second. The two solutions differ only in which loop is outside.

This is the DP bug I find most instructive, because both versions look equally reasonable, both pass the problem’s small examples if the example has only one arrangement, and the difference only shows up on inputs where order could matter. When I write a counting DP, I now ask explicitly whether 1+2 and 2+1 are the same answer, before choosing the loop order.


LIS (Longest Increasing Subsequence)

O(n²) Solution

def lis_n2(arr):
    """
    Longest Increasing Subsequence
    [10, 9, 2, 5, 3, 7, 101, 18] → 4 ([2,3,7,18])
    """
    n = len(arr)
    dp = [1] * n
    
    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    
    return max(dp)
# Test
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(lis_n2(arr))  # 4

O(n log n) Solution

import bisect
def lis_nlogn(arr):
    """
    Using binary search
    """
    dp = []
    
    for num in arr:
        pos = bisect.bisect_left(dp, num)
        
        if pos == len(dp):
            dp.append(num)
        else:
            dp[pos] = num
    
    return len(dp)
# Test
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(lis_nlogn(arr))  # 4

How O(n log n) Works:

arr = [10, 9, 2, 5, 3, 7, 101, 18]
Step 1: num=10, dp=[10]
Step 2: num=9,  dp=[9]     (replace 10)
Step 3: num=2,  dp=[2]     (replace 9)
Step 4: num=5,  dp=[2,5]   (append)
Step 5: num=3,  dp=[2,3]   (replace 5)
Step 6: num=7,  dp=[2,3,7] (append)
Step 7: num=101,dp=[2,3,7,101] (append)
Step 8: num=18, dp=[2,3,7,18]  (replace 101)
Length = 4

The array in the O(n log n) version is usually named dp, but it has a different meaning from the O(n²) table: tails[k] is the smallest possible last element of any increasing subsequence of length k + 1 seen so far. Keeping tails as small as possible leaves the most room for future elements to extend them. Because the array is always sorted, binary search finds where each new number belongs.

A common misconception is that this array is the longest increasing subsequence. It is not. Its length is correct, but its contents mix elements from different subsequences. For [3, 4, 1], it ends as [1, 4], which is not a subsequence of the input at all (1 comes after 4). To reconstruct an actual LIS, also store, for each element, the index of its predecessor at the moment it was placed, and walk back from the last element. And note the choice of bisect_left: it gives a strictly increasing subsequence. Using bisect_right instead gives the longest non-decreasing one, which is a different problem that shows up in variants such as “minimum removals to make an array sorted”.


Edit Distance Pattern

def edit_distance(s1, s2):
    """
    Minimum operations to convert s1 to s2
    Operations: insert, delete, replace
    
    "horse", "ros" → 3
    (replace h→r, delete o, delete e)
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Initial values
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    
    # Fill table
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(
                    dp[i-1][j] + 1,    # delete
                    dp[i][j-1] + 1,    # insert
                    dp[i-1][j-1] + 1   # replace
                ) 
    
    return dp[m][n]
# Test
print(edit_distance("horse", "ros"))  # 3

The three options in the min correspond to the three operations, and it helps to read them as prefix changes. dp[i-1][j] + 1: delete s1[i-1], then convert the rest. dp[i][j-1] + 1: convert s1[:i] into s2[:j-1], then insert s2[j-1]. dp[i-1][j-1] + 1: replace one character with the other. The base cases say that converting a prefix of length i to the empty string takes i deletions, and the reverse takes j insertions. This is the Levenshtein distance, used in spell checkers, fuzzy search, and DNA alignment, often with different costs per operation, which only changes the + 1 terms.


Recognizing and Debugging DP

From problem statement to pattern

A few phrases in a problem statement point strongly to a pattern. “Number of ways” or “minimum cost to reach” position n: 1D DP over positions. “Two strings” or “two sequences”: a 2D table over prefixes (LCS, edit distance). “Capacity”, “budget”, or “exact sum” with items: knapsack. “Longest increasing” or “chain” of pairs: LIS. When the problem involves a choice that depends on what you did before (holding a stock, cooldown), add that as an extra state dimension, which is the state machine pattern in section 7.

It is also worth checking whether DP is needed at all. If choosing the locally best option at each step can be proven optimal, a greedy algorithm is simpler and faster (see Greedy). The classic counterexample is coin change with coins {1, 3, 4} and amount 6: greedy takes 4+1+1 (3 coins), DP finds 3+3 (2 coins).

Debugging a wrong DP

When a DP gives wrong answers, print the table for the smallest failing input and fill a few cells by hand, using the one-sentence meaning of the state. The first cell where your hand calculation and the table disagree points to the bug, which is almost always a base case, an off-by-one in the prefix convention, or a loop that runs in the wrong direction. Comparing against a brute-force solution on random small inputs finds these quickly, and is how I would test any DP before trusting it on large inputs.

Common Mistakes

# ❌ Wrong: Forgot initial values
dp = [0] * n
# dp[0], dp[1] not set!
# ✅ Correct: Set initial values
dp = [0] * n
dp[0] = 1
dp[1] = 1
# ❌ Wrong: Index out of bounds
for i in range(n):
    dp[i] = dp[i-1] + dp[i-2]  # i=0, i=1 error!
# ✅ Correct: Start from valid index
for i in range(2, n):
    dp[i] = dp[i-1] + dp[i-2]

Advanced Patterns

Partition DP

def word_break(s, word_dict):
    """
    Check if string can be segmented into dictionary words
    s = "leetcode", wordDict = ["leet", "code"] → True
    """
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_dict:
                dp[i] = True
                break
    
    return dp[n]
# Test
s = "leetcode"
word_dict = {"leet", "code"}
print(word_break(s, word_dict))  # True

State Machine DP

def max_profit_cooldown(prices):
    """
    Stock trading with cooldown
    States: hold, sold, rest
    """
    if not prices:
        return 0
    
    n = len(prices)
    hold = [0] * n
    sold = [0] * n
    rest = [0] * n
    
    hold[0] = -prices[0]
    
    for i in range(1, n):
        hold[i] = max(hold[i-1], rest[i-1] - prices[i])
        sold[i] = hold[i-1] + prices[i]
        rest[i] = max(rest[i-1], sold[i-1])
    
    return max(sold[-1], rest[-1])
# Test
prices = [1, 2, 3, 0, 2]
print(max_profit_cooldown(prices))  # 3

word_break shows partition DP: dp[i] is True when the first i characters can be split into words, and the transition tries every possible last word s[j:i]. It is O(n²) substring checks. Limiting j to the length of the longest dictionary word makes it much faster in practice.

The stock problem shows why an extra state dimension is often the cleanest way to handle rules: instead of one dp[i], keep one value per situation you can be in on day i (holding a share, just sold, resting). Each rule of the problem becomes a transition between situations: you can only buy from rest (which enforces the cooldown), and you can only sell from hold. Drawing the three states with arrows before writing code makes the recurrences almost mechanical.


Matching a problem to a DP pattern

PatternRecurrenceExample
1D Previousdp[i] = f(dp[i-1])Climbing stairs
1D Max/Mindp[i] = max(...)House robber
2D Griddp[i][j] = f(dp[i-1][j], dp[i][j-1])Unique paths
2D Stringdp[i][j] = f(s1[i], s2[j])LCS, Edit distance
Knapsackdp[i][w] = max(take, don't take)0-1 Knapsack

The problem statement usually names the pattern. One sequence and a choice at each position points to 1D. Two strings or a grid points to 2D indexed by position in each. A capacity, budget or target sum with items to pick points to knapsack, and “each item at most once” versus “unlimited” decides whether the 1D loop runs downward or upward. “Subsequence” allows skipping elements and “subarray” or “substring” does not, which changes whether dp[i] means “best ending exactly at i” or “best among the first i”.

When none of these fits, the state probably needs another dimension, for example the last element chosen or whether you are currently holding a stock.


Baekjoon

LeetCode

  • LeetCode 70: Climbing Stairs
  • LeetCode 198: House Robber
  • LeetCode 62: Unique Paths
  • LeetCode 1143: Longest Common Subsequence
  • LeetCode 72: Edit Distance
  • LeetCode 300: Longest Increasing Subsequence

Programmers