Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dynamic programming II: grids and strings

Two-dimensional dynamic programming is the same method as the previous chapter with a state that needs two indices instead of one. Nothing else changes: you define the state in a sentence, write the transition, find the base case, and choose an iteration order that fills every cell before it is read. The only new work is that the order is now a choice in two directions rather than one.

The load-bearing claim of this chapter is about the two-string problems, and it is worth learning as a sentence. Almost every problem about two strings has the same shape: dp[i][j] is the answer for the first i characters of one string and the first j characters of the other, and the transition splits on whether the current characters match. On a match you take the diagonal cell, because both characters are consumed together. On a mismatch you combine the cell above and the cell to the left, because one character must be dropped and you do not yet know which. Once you see that, longest common subsequence, edit distance and distinct subsequences stop being three problems. They are one table with three different combination rules, and the section below draws it three ways.

Grid problems are the easy half. The cell is the state, the moves in the statement are the transition, and the sweep order is forced by the moves.

Recognising it from the phrasing

The interviewer saysThe stateThe fill order
“two strings, compare them”dp[i][j] over both prefixesrows then columns
“a grid, move right or down”dp[r][c] from the top and the lefttop to bottom, left to right
“a grid with obstacles”the same, blocked cells forced to zerotop to bottom, left to right
“a knapsack with a capacity”dp[item][capacity]items outer, capacity either way
“an interval”, “a range i to jdp[i][j] over the rangeby increasing length
“with a cooldown”, “you may hold one”dp[i][state], a small state machineleft to right
“the longest path in a grid”memoised DFS from each cellno sweep order exists
“count the ways through a grid”dp[r][c], sumstop to bottom, left to right

Ask two questions before writing anything. First: what do the two indices mean? Say it as one sentence with both indices named, for example “the edit distance between the first i characters of a and the first j characters of b”. Second, and this is the question people skip: in what order must the table be filled so that every value I read is already written? For prefix problems the answer is left to right and top to bottom, because each cell reads the cell above, the cell to the left and the diagonal. For interval problems the answer is by increasing interval length, because dp[i][j] reads shorter intervals inside itself, and those are neither above nor to the left in any simple sweep. That is the order people get wrong, and it fails silently by reading zeros. Say the fill order out loud before you write the loops and you will avoid the most common two-dimensional DP bug.

The templates

Template 1 — the two-string prefix table. Use whenever the input is two sequences. The skeleton is always these two nested loops with a match branch and a mismatch branch; only the two branch bodies change between problems.

def longest_common_subsequence(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]    ## dp[i][j]: first i of a, first j of b
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1   ## MATCH: take the diagonal and add one
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])   ## MISMATCH: drop one character
    return dp[n][m]

## tests

assert longest_common_subsequence("abcde", "ace") == 3
assert longest_common_subsequence("abc", "abc") == 3
assert longest_common_subsequence("abc", "def") == 0
assert longest_common_subsequence("", "abc") == 0
print(longest_common_subsequence("abcde", "ace"))
3

Template 2 — the grid sweep. Use when the moves are right and down. The answer is recorded in the bottom-right cell, and the two edges are the base cases because they have only one incoming direction.

def min_path_sum(grid):
    rows, cols = len(grid), len(grid[0])
    dp = [[0] * cols for _ in range(rows)]        ## dp[r][c]: best cost to reach (r, c)
    for r in range(rows):                         ## top to bottom, left to right
        for c in range(cols):
            if r == 0 and c == 0:
                dp[r][c] = grid[r][c]             ## base case: the start cell
            elif r == 0:
                dp[r][c] = dp[r][c - 1] + grid[r][c]
            elif c == 0:
                dp[r][c] = dp[r - 1][c] + grid[r][c]
            else:
                dp[r][c] = min(dp[r - 1][c], dp[r][c - 1]) + grid[r][c]
    return dp[rows - 1][cols - 1]

## tests

assert min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]) == 7
assert min_path_sum([[1, 2, 3], [4, 5, 6]]) == 12
assert min_path_sum([[7]]) == 7
print(min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]))
7

Template 3 — interval DP, filled by increasing length. Use when the state is a range of one sequence. The outer loop is the length, never the index, and the answer sits at dp[0][n-1].

def longest_palindromic_subsequence(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]              ## dp[i][j]: answer for s[i..j] inclusive
    for i in range(n):
        dp[i][i] = 1                              ## base case: every single character
    for length in range(2, n + 1):                ## BY INCREASING LENGTH, not by index
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2   ## shorter interval, already computed
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    return dp[0][n - 1] if n else 0

## tests

assert longest_palindromic_subsequence("bbbab") == 4
assert longest_palindromic_subsequence("cbbd") == 2
assert longest_palindromic_subsequence("") == 0
print(longest_palindromic_subsequence("bbbab"))
4

Template 4 — memoised DFS on a grid. Use when no sweep order exists, because the moves can go in all four directions. The recursion supplies the order and the dictionary supplies the memoisation.

def longest_increasing_path(matrix):
    if not matrix:
        return 0
    rows, cols = len(matrix), len(matrix[0])
    memo = {}
    def best(r, c):                               ## longest strictly increasing path FROM (r, c)
        if (r, c) in memo:
            return memo[(r, c)]
        length = 1
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and matrix[nr][nc] > matrix[r][c]:
                length = max(length, 1 + best(nr, nc))
        memo[(r, c)] = length
        return length
    return max(best(r, c) for r in range(rows) for c in range(cols))

## tests

assert longest_increasing_path([[9, 9, 4], [6, 6, 8], [2, 1, 1]]) == 4
assert longest_increasing_path([[3, 4, 5], [3, 2, 6], [2, 2, 1]]) == 4
assert longest_increasing_path([[1]]) == 1
print(longest_increasing_path([[9, 9, 4], [6, 6, 8], [2, 1, 1]]))
4

Templates 1, 2 and 3 are all bottom-up tables and differ only in the fill order. Template 4 exists because some problems have no fill order at all: a path in a grid may go up, down, left or right, so no row-by-row sweep can guarantee that a neighbour is ready. The values strictly increase along a path, so the recursion cannot cycle, and that is exactly the property that licenses the memo.

The two-string table, drawn three ways

Take a = "abc" and b = "abd" under longest common subsequence. The table has one extra row and one extra column for the empty prefixes, and the whole of row 0 and column 0 is zero, because the longest common subsequence with an empty string is empty.

(empty)abd
(empty)0000
a0111
b0122
c0122

Read two cells to see the rule. At row a, column a, the characters match, so the value is the diagonal cell above and to the left, which is 0, plus one: 1. At row c, column d, the characters differ, so the value is the larger of the cell above, 2, and the cell to the left, 2, which is 2. Every cell in the table is one of those two moves. The answer is the bottom-right cell, 2, which is the length of "ab".

Now change one rule at a time and you get the other two classics.

Edit distance uses the same table with three neighbours instead of two. The base row and column are no longer zero: turning the first i characters into an empty string costs i deletions, so dp[i][0] = i and dp[0][j] = j. On a match the cost is the diagonal unchanged, because no operation is needed. On a mismatch you pay one and take the cheapest of the diagonal, which is a replacement, the cell above, which is a deletion, and the cell to the left, which is an insertion. That is the only difference: two neighbours become three, and max becomes 1 + min.

Distinct subsequences uses the same table with a different combination rule. It counts, so it sums instead of taking a maximum. dp[i][0] = 1, because there is exactly one way to match an empty target: take nothing. The cell above is always available, because you may always skip s[i-1]. When the characters match you also have the option of using s[i-1] to cover t[j-1], which is the diagonal cell, and since these are two disjoint groups of ways, you add them rather than choosing between them.

def lcs_table(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1              ## diagonal + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])   ## max of up and left
    return dp

def edit_distance(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = i                                         ## delete every character
    for j in range(m + 1):
        dp[0][j] = j                                         ## insert every character
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]                  ## free: no operation
            else:                                            ## replace, delete, insert
                dp[i][j] = 1 + min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1])
    return dp[n][m]

def distinct_subsequences(s, t):
    n, m = len(s), len(t)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = 1                                         ## one way to match the empty t
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = dp[i - 1][j]                          ## always: skip s[i-1]
            if s[i - 1] == t[j - 1]:
                dp[i][j] += dp[i - 1][j - 1]                 ## SUM, not max: also use it
    return dp[n][m]

## tests

table = lcs_table("abc", "abd")
assert table[3][3] == 2
assert edit_distance("abc", "abd") == 1
assert edit_distance("horse", "ros") == 3
assert distinct_subsequences("rabbbit", "rabbit") == 3
assert distinct_subsequences("babgbag", "bag") == 5
for row in table:
    print(row)
[0, 0, 0, 0]
[0, 1, 1, 1]
[0, 1, 2, 2]
[0, 1, 2, 2]

The printed table is the same one drawn above. Three problems, one grid of cells, three rules for filling it. When a two-string problem you have not seen arrives, draw this four-by-four table on the whiteboard first and ask only two questions: what goes in row 0 and column 0, and what happens on a match. The rest of the solution follows.

The problems

P1. Unique Paths — count the paths from the top-left to the bottom-right moving only right or down

Which template. Template 2, counting, reduced to a single row. The trick. Every path into a cell arrives from above or from the left, so dp[r][c] is the sum of those two, and the whole top row and left column are 1. A single row suffices, because updating row[c] += row[c-1] in place reads the old value of row[c], which is the cell above, and the new value of row[c-1], which is the cell to the left. The cross-check uses the closed form: a path is a choice of which of the r+c-2 moves go down.

from math import comb

def unique_paths(rows, cols):
    row = [1] * cols                              ## dp[0][c] = 1: only one way along the top
    for _ in range(1, rows):
        for c in range(1, cols):
            row[c] += row[c - 1]                  ## from above (old row[c]) plus from the left
    return row[cols - 1]

## tests

assert unique_paths(3, 7) == 28
assert unique_paths(3, 2) == 3
assert unique_paths(1, 1) == 1
for r in range(1, 9):                             ## cross-check against the binomial formula
    for c in range(1, 9):
        assert unique_paths(r, c) == comb(r + c - 2, r - 1)
print(unique_paths(3, 7), unique_paths(3, 2), "binomial formula agrees")
28 3 binomial formula agrees

Complexity. time, space.

P2. Unique Paths II — the same count when some cells are blocked

Which template. Template 2, with a forced zero. The trick. A blocked cell is reachable in zero ways, so set it to 0 and let the rest of the sweep run unchanged. That single line is the whole difference from P1, and it works because the recurrence sums incoming counts: a zero contributes nothing downstream. Check the start cell itself, since a blocked start makes the answer 0.

def unique_paths_with_obstacles(grid):
    rows, cols = len(grid), len(grid[0])
    dp = [[0] * cols for _ in range(rows)]
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1:
                dp[r][c] = 0                      ## a blocked cell is reachable in zero ways
            elif r == 0 and c == 0:
                dp[r][c] = 1
            else:
                above = dp[r - 1][c] if r > 0 else 0
                left = dp[r][c - 1] if c > 0 else 0
                dp[r][c] = above + left
    return dp[rows - 1][cols - 1]

## tests

assert unique_paths_with_obstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]]) == 2
assert unique_paths_with_obstacles([[0, 1], [0, 0]]) == 1
assert unique_paths_with_obstacles([[1]]) == 0
assert unique_paths_with_obstacles([[0, 0], [1, 1], [0, 0]]) == 0
print(unique_paths_with_obstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]]),
      unique_paths_with_obstacles([[0, 0], [1, 1], [0, 0]]))
2 0

Complexity. time, space, or with a single row.

P3. Minimum Path Sum — the cheapest path from the top-left to the bottom-right, moving right or down

Which template. Template 2, minimisation. The trick. Identical to P1 with min in place of +, plus the cost of the current cell. The edges are the only fiddly part: the top row can only come from the left and the left column can only come from above, so write those two cases explicitly rather than guarding with sentinels.

def min_path_sum(grid):
    rows, cols = len(grid), len(grid[0])
    dp = [[0] * cols for _ in range(rows)]        ## dp[r][c] = cheapest cost to reach (r, c)
    for r in range(rows):
        for c in range(cols):
            if r == 0 and c == 0:
                dp[r][c] = grid[r][c]
            elif r == 0:
                dp[r][c] = dp[r][c - 1] + grid[r][c]
            elif c == 0:
                dp[r][c] = dp[r - 1][c] + grid[r][c]
            else:
                dp[r][c] = min(dp[r - 1][c], dp[r][c - 1]) + grid[r][c]
    return dp[rows - 1][cols - 1]

def min_path_sum_brute(grid):                     ## check: recursion over every path
    rows, cols = len(grid), len(grid[0])
    def go(r, c):
        if r == rows - 1 and c == cols - 1:
            return grid[r][c]
        options = []
        if r + 1 < rows:
            options.append(go(r + 1, c))
        if c + 1 < cols:
            options.append(go(r, c + 1))
        return grid[r][c] + min(options)
    return go(0, 0)

## tests

assert min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]) == 7
assert min_path_sum([[1, 2, 3], [4, 5, 6]]) == 12
grids = [[[1, 3, 1], [1, 5, 1], [4, 2, 1]], [[1, 2, 3], [4, 5, 6]], [[7]],
         [[5, 9, 2, 4], [1, 1, 8, 3], [6, 2, 2, 7]]]
for g in grids:                                   ## cross-check against every path
    assert min_path_sum(g) == min_path_sum_brute(g)
print(min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]), "brute force agrees")
7 brute force agrees

Complexity. time, space.

P4. Longest Common Subsequence — the longest subsequence present in both strings

Which template. Template 1, the reference instance of it. The trick. On a match, both characters are consumed together, so the value is the diagonal plus one and there is nothing to decide. On a mismatch, at least one of the two characters is not in the answer, but you do not know which, so you try both by taking the maximum of the cell above and the cell to the left. The cross-check enumerates every subsequence of the first string.

def longest_common_subsequence(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[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[n][m]

def lcs_brute(a, b):                              ## check: every subsequence of a
    n, best = len(a), 0
    for mask in range(1 << n):
        pick = "".join(a[i] for i in range(n) if mask >> i & 1)
        j = 0
        for ch in b:                              ## is pick a subsequence of b?
            if j < len(pick) and pick[j] == ch:
                j += 1
        if j == len(pick):
            best = max(best, len(pick))
    return best

## tests

assert longest_common_subsequence("abcde", "ace") == 3
assert longest_common_subsequence("abc", "def") == 0
assert longest_common_subsequence("", "abc") == 0
pairs = [("abcde", "ace"), ("abc", "def"), ("bsbininm", "jmjkbkjkv"), ("oxcpqrsvwf", "shmtulqrypy")]
for a, b in pairs:                                ## cross-check against every subsequence
    assert longest_common_subsequence(a, b) == lcs_brute(a, b)
print(longest_common_subsequence("abcde", "ace"), "brute force agrees")
3 brute force agrees

Complexity. time, space, reducible to with two rows.

P5. Edit Distance — the fewest insertions, deletions and replacements turning a into b

Which template. Template 1 with three neighbours. The trick. Name each neighbour as an operation while you write it and the recurrence stops being symbols. The diagonal is a replacement, the cell above is a deletion from a, and the cell to the left is an insertion into a. On a match you move diagonally for free. The base row and column are not zero: they are i and j, the cost of deleting or inserting everything.

def min_distance(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = i                              ## delete i characters
    for j in range(m + 1):
        dp[0][j] = j                              ## insert j characters
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]       ## free
            else:                                 ## replace / delete / insert
                dp[i][j] = 1 + min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1])
    return dp[n][m]

def min_distance_brute(a, b):                     ## check: plain recursion, no table
    def go(i, j):
        if i == 0:
            return j
        if j == 0:
            return i
        if a[i - 1] == b[j - 1]:
            return go(i - 1, j - 1)
        return 1 + min(go(i - 1, j - 1), go(i - 1, j), go(i, j - 1))
    return go(len(a), len(b))

## tests

assert min_distance("horse", "ros") == 3
assert min_distance("intention", "execution") == 5
assert min_distance("", "abc") == 3
for a, b in [("horse", "ros"), ("abc", "abd"), ("", "abc"), ("sunday", "saturday")]:
    assert min_distance(a, b) == min_distance_brute(a, b)
print(min_distance("horse", "ros"), min_distance("intention", "execution"),
      "brute force agrees")
3 5 brute force agrees

Complexity. time, space.

P6. Distinct Subsequences — count the subsequences of s that equal t

Which template. Template 1, counting, so the branches add rather than choose. The trick. Two options exist at every cell and they are disjoint. You may always skip s[i-1], which is the cell directly above. When s[i-1] == t[j-1] you may additionally match it against t[j-1], which is the diagonal. Since no arrangement is counted in both groups, the value is their sum. The base column is 1, not 0: taking nothing is one valid way to build the empty target, and setting it to 0 makes the entire table zero.

def num_distinct(s, t):
    n, m = len(s), len(t)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = 1                              ## one way to match the empty t: take nothing
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = dp[i - 1][j]               ## always available: skip s[i-1]
            if s[i - 1] == t[j - 1]:
                dp[i][j] += dp[i - 1][j - 1]      ## SUM, because both choices are counted
    return dp[n][m]

def num_distinct_brute(s, t):                     ## check: every subset of positions in s
    n, total = len(s), 0
    for mask in range(1 << n):
        if "".join(s[i] for i in range(n) if mask >> i & 1) == t:
            total += 1
    return total

## tests

assert num_distinct("rabbbit", "rabbit") == 3
assert num_distinct("babgbag", "bag") == 5
assert num_distinct("abc", "") == 1
for s, t in [("rabbbit", "rabbit"), ("babgbag", "bag"), ("abc", ""), ("aaaa", "aa"), ("abc", "d")]:
    assert num_distinct(s, t) == num_distinct_brute(s, t)
print(num_distinct("rabbbit", "rabbit"), num_distinct("babgbag", "bag"), "brute force agrees")
3 5 brute force agrees

Complexity. time, space, reducible to one row scanned right to left.

P7. Interleaving String — can s3 be formed by interleaving s1 and s2 keeping both orders

Which template. Template 1, boolean, over the prefixes of s1 and s2. The trick. The third string does not need a third index. If you have used i characters of s1 and j of s2, you have used exactly i + j characters of s3, so the position in s3 is determined. That collapse from three indices to two is the entire insight. The first check is on lengths: if they do not add up, return False immediately.

def is_interleave(s1, s2, s3):
    n, m = len(s1), len(s2)
    if n + m != len(s3):
        return False                              ## the lengths must add up
    dp = [[False] * (m + 1) for _ in range(n + 1)]
    dp[0][0] = True                               ## dp[i][j]: s1[:i] and s2[:j] make s3[:i+j]
    for i in range(n + 1):
        for j in range(m + 1):
            if i > 0 and dp[i - 1][j] and s1[i - 1] == s3[i + j - 1]:
                dp[i][j] = True                   ## the last character came from s1
            if j > 0 and dp[i][j - 1] and s2[j - 1] == s3[i + j - 1]:
                dp[i][j] = True                   ## the last character came from s2
    return dp[n][m]

def is_interleave_brute(s1, s2, s3):              ## check: recursion over both sources
    def go(i, j):
        if i + j == len(s3):
            return i == len(s1) and j == len(s2)
        if i < len(s1) and s1[i] == s3[i + j] and go(i + 1, j):
            return True
        return j < len(s2) and s2[j] == s3[i + j] and go(i, j + 1)
    return len(s1) + len(s2) == len(s3) and go(0, 0)

## tests

assert is_interleave("aabcc", "dbbca", "aadbbcbcac") is True
assert is_interleave("aabcc", "dbbca", "aadbbbaccc") is False
assert is_interleave("", "", "") is True
cases = [("aabcc", "dbbca", "aadbbcbcac"), ("aabcc", "dbbca", "aadbbbaccc"), ("", "", ""),
         ("a", "b", "ab"), ("ab", "ab", "abab"), ("a", "", "a")]
for s1, s2, s3 in cases:
    assert is_interleave(s1, s2, s3) == is_interleave_brute(s1, s2, s3)
print(is_interleave("aabcc", "dbbca", "aadbbcbcac"),
      is_interleave("aabcc", "dbbca", "aadbbbaccc"), "brute force agrees")
True False brute force agrees

Complexity. time, space.

P8. Longest Palindromic Subsequence — the longest subsequence of s that reads the same both ways

Which template. Template 1, applied to s and its reverse. The trick is worth naming. The trick. A palindromic subsequence of s is a subsequence that also appears in s reversed, so the answer is LCS(s, reversed(s)). This turns a new problem into one you have already solved, and naming the reduction in one sentence is a strong signal in an interview. The interval DP of template 3 computes the same number directly, and the cross-check confirms the two agree.

def longest_palindrome_subseq(s):
    a, b = s, s[::-1]                             ## the trick: LCS of s with its reverse
    n = len(s)
    dp = [[0] * (n + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[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[n][n]

def lps_interval(s):                              ## check: the direct interval DP
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n):
        dp[i][i] = 1
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = (dp[i + 1][j - 1] if length > 2 else 0) + 2
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    return dp[0][n - 1] if n else 0

## tests

assert longest_palindrome_subseq("bbbab") == 4
assert longest_palindrome_subseq("cbbd") == 2
assert longest_palindrome_subseq("") == 0
for s in ["bbbab", "cbbd", "", "a", "agbdba", "character", "abcdefg"]:
    assert longest_palindrome_subseq(s) == lps_interval(s)
print(longest_palindrome_subseq("bbbab"), longest_palindrome_subseq("character"),
      "interval DP agrees")
4 5 interval DP agrees

Complexity. time, space for either form.

P9. Regular Expression Matching — does the pattern with . and * match the whole string

Which template. Template 1, boolean, and the hardest classic here. Work it slowly. The trick. A * is never alone: it always binds to the character before it, so the unit is the pair p[j-2] p[j-1]. That pair has exactly two behaviours. Use it zero times, which skips two pattern characters and gives dp[i][j-2]. Or use it one more time, which is legal only when the preceding pattern character matches s[i-1], and which consumes one text character while leaving the pattern where it is, giving dp[i-1][j]. Write those two lines and the rest is the ordinary match branch. The row-0 initialisation matters as much: a pattern like "a*b*" matches the empty string, so dp[0][j] = dp[0][j-2] whenever p[j-1] is a star.

def is_match(s, p):
    n, m = len(s), len(p)
    dp = [[False] * (m + 1) for _ in range(n + 1)]
    dp[0][0] = True                               ## empty pattern matches empty text
    for j in range(2, m + 1):
        if p[j - 1] == "*":
            dp[0][j] = dp[0][j - 2]               ## "a*" can vanish
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if p[j - 1] == "*":
                dp[i][j] = dp[i][j - 2]           ## use the pair ZERO times
                if p[j - 2] == s[i - 1] or p[j - 2] == ".":
                    dp[i][j] = dp[i][j] or dp[i - 1][j]   ## or ONE MORE time
            elif p[j - 1] == "." or p[j - 1] == s[i - 1]:
                dp[i][j] = dp[i - 1][j - 1]       ## one character each, consumed together
    return dp[n][m]

def is_match_brute(s, p):                         ## check: plain recursion, no table
    def go(i, j):
        if j == len(p):
            return i == len(s)
        first = i < len(s) and p[j] in (s[i], ".")
        if j + 1 < len(p) and p[j + 1] == "*":
            return go(i, j + 2) or (first and go(i + 1, j))
        return first and go(i + 1, j + 1)
    return go(0, 0)

## tests

assert is_match("aa", "a") is False
assert is_match("aa", "a*") is True
assert is_match("ab", ".*") is True
assert is_match("mississippi", "mis*is*p*.") is False
cases = [("aa", "a"), ("aa", "a*"), ("ab", ".*"), ("aab", "c*a*b"), ("mississippi", "mis*is*p*."),
         ("", "a*"), ("", ".*"), ("abc", "a.c"), ("aaa", "a*a"), ("bbbba", ".*a*a")]
for s, p in cases:                                ## cross-check against the recursion
    assert is_match(s, p) == is_match_brute(s, p)
print(is_match("aa", "a*"), is_match("mississippi", "mis*is*p*."), "brute force agrees")
True False brute force agrees

Complexity. time, space.

P10. Wildcard Matching — the same question when * matches any sequence and ? any single character

Which template. Template 1 again, and it is simpler than P9. Say why. The trick. Here * stands alone, so there is no pair to look back at. It matches the empty sequence, which is dp[i][j-1], or it absorbs one more text character and stays available, which is dp[i-1][j]. That is the whole rule. Compare it aloud with P9: the star in a regular expression binds to the previous character and therefore skips two pattern positions; the wildcard star binds to nothing and skips one.

def is_match_wildcard(s, p):
    n, m = len(s), len(p)
    dp = [[False] * (m + 1) for _ in range(n + 1)]
    dp[0][0] = True
    for j in range(1, m + 1):
        if p[j - 1] == "*":
            dp[0][j] = dp[0][j - 1]               ## a leading run of stars matches nothing
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if p[j - 1] == "*":                   ## star alone: empty, or eat one character
                dp[i][j] = dp[i][j - 1] or dp[i - 1][j]
            elif p[j - 1] == "?" or p[j - 1] == s[i - 1]:
                dp[i][j] = dp[i - 1][j - 1]
    return dp[n][m]

def wildcard_brute(s, p):                         ## check: plain recursion
    def go(i, j):
        if j == len(p):
            return i == len(s)
        if p[j] == "*":
            return go(i, j + 1) or (i < len(s) and go(i + 1, j))
        if i < len(s) and (p[j] == "?" or p[j] == s[i]):
            return go(i + 1, j + 1)
        return False
    return go(0, 0)

## tests

assert is_match_wildcard("aa", "a") is False
assert is_match_wildcard("aa", "*") is True
assert is_match_wildcard("cb", "?a") is False
assert is_match_wildcard("adceb", "*a*b") is True
cases = [("aa", "a"), ("aa", "*"), ("cb", "?a"), ("adceb", "*a*b"), ("acdcb", "a*c?b"),
         ("", "***"), ("", "?"), ("abc", "a*c")]
for s, p in cases:
    assert is_match_wildcard(s, p) == wildcard_brute(s, p)
print(is_match_wildcard("adceb", "*a*b"), is_match_wildcard("acdcb", "a*c?b"),
      "brute force agrees")
True False brute force agrees

Complexity. time, space.

P11. Target Sum — count the ways to put a plus or minus before each number to reach target

Which template. A 0/1 knapsack in disguise, reduced to one row. The trick. Split the numbers into the set P that gets a plus and the set M that gets a minus. Then sum(P) - sum(M) = target and sum(P) + sum(M) = total, so sum(P) = (total + target) / 2. The problem becomes: how many subsets sum to that number. If the division is not exact, or the target exceeds the total in absolute value, the answer is 0. Each number is used once, so the capacity loop runs descending.

def find_target_sum_ways(nums, target):
    total = sum(nums)
    if (total + target) % 2 or abs(target) > total:
        return 0
    capacity = (total + target) // 2              ## the subset given a plus sign
    dp = [0] * (capacity + 1)
    dp[0] = 1
    for x in nums:                                ## 0/1 knapsack: capacity DESCENDING
        for c in range(capacity, x - 1, -1):
            dp[c] += dp[c - x]
    return dp[capacity]

def target_sum_brute(nums, target):               ## check: every assignment of signs
    n, total = len(nums), 0
    for mask in range(1 << n):
        value = sum(nums[i] if mask >> i & 1 else -nums[i] for i in range(n))
        total += value == target
    return total

## tests

assert find_target_sum_ways([1, 1, 1, 1, 1], 3) == 5
assert find_target_sum_ways([1], 1) == 1
assert find_target_sum_ways([1], 2) == 0
cases = [([1, 1, 1, 1, 1], 3), ([1], 1), ([1], 2), ([0, 0, 1], 1), ([2, 3, 5, 1], 1)]
for nums, target in cases:                        ## cross-check against every sign assignment
    assert find_target_sum_ways(nums, target) == target_sum_brute(nums, target)
print(find_target_sum_ways([1, 1, 1, 1, 1], 3), find_target_sum_ways([0, 0, 1], 1),
      "brute force agrees")
5 4 brute force agrees

Complexity. time, space. Zeros count twice, once with each sign, which the case [0, 0, 1] checks.

P12. Coin Change II — count the combinations of coins summing to amount, as a full table first

Which template. The dp[item][capacity] table, then its one-row reduction. The trick. Writing the two-dimensional version first removes the mystery from the loop order in the one-row version. dp[i][a] means “using only the first i coin types, the number of ways to make a”. Skipping coin i reads the previous row. Using coin i reads the same row at a - coins[i-1], and reading the same row is exactly what permits unlimited reuse. Collapse the rows and that same-row read becomes the ascending capacity loop. The item axis is what forbids counting 1+2 and 2+1 separately, because a row is only ever entered once per coin type.

def change_2d(amount, coins):
    k = len(coins)
    dp = [[0] * (amount + 1) for _ in range(k + 1)]
    for i in range(k + 1):
        dp[i][0] = 1                              ## one way to make 0 with any coin prefix
    for i in range(1, k + 1):
        for a in range(amount + 1):
            dp[i][a] = dp[i - 1][a]               ## do not use coin i at all
            if coins[i - 1] <= a:
                dp[i][a] += dp[i][a - coins[i - 1]]   ## use it again: SAME row, unlimited
    return dp[k][amount]

def change_1d(amount, coins):                     ## the space-reduced form of the same table
    dp = [0] * (amount + 1)
    dp[0] = 1
    for c in coins:
        for a in range(c, amount + 1):
            dp[a] += dp[a - c]
    return dp[amount]

## tests

assert change_2d(5, [1, 2, 5]) == 4
assert change_2d(3, [2]) == 0
assert change_2d(10, [10]) == 1
for amount, coins in [(5, [1, 2, 5]), (11, [1, 2, 5]), (9, [2, 3, 4]), (0, [7]), (100, [1, 5, 10])]:
    assert change_2d(amount, coins) == change_1d(amount, coins)
print(change_2d(5, [1, 2, 5]), change_2d(100, [1, 5, 10]), "the one-row form agrees")
4 121 the one-row form agrees

Complexity. time; space for the table and after the reduction.

P13. 0/1 Knapsack — maximum value of items fitting in a capacity, each item used at most once

Which template. The dp[item][capacity] table. Every named knapsack problem is a costume for this. The trick. Write the table form once, by hand, and the disguises become obvious: Partition Equal Subset Sum is this with all values equal to the weights and a boolean answer, and Target Sum is this with a shifted capacity. The take branch reads dp[i-1][...], the previous row, which is what enforces “each item at most once”. Collapsing to one row therefore needs the capacity loop to run descending, so that dp[c - w] still holds a value from before this item was offered.

def knapsack_2d(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for c in range(capacity + 1):
            dp[i][c] = dp[i - 1][c]               ## skip item i
            if weights[i - 1] <= c:               ## take it: read the PREVIOUS row
                dp[i][c] = max(dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1])
    return dp[n][capacity]

def knapsack_1d(weights, values, capacity):       ## the same table, one row, capacity descending
    dp = [0] * (capacity + 1)
    for w, v in zip(weights, values):
        for c in range(capacity, w - 1, -1):
            dp[c] = max(dp[c], dp[c - w] + v)
    return dp[capacity]

def knapsack_brute(weights, values, capacity):    ## check: every subset of items
    n, best = len(weights), 0
    for mask in range(1 << n):
        w = sum(weights[i] for i in range(n) if mask >> i & 1)
        if w <= capacity:
            best = max(best, sum(values[i] for i in range(n) if mask >> i & 1))
    return best

## tests

assert knapsack_2d([1, 3, 4, 5], [1, 4, 5, 7], 7) == 9
assert knapsack_1d([1, 3, 4, 5], [1, 4, 5, 7], 7) == 9
cases = [([1, 3, 4, 5], [1, 4, 5, 7], 7), ([2, 2, 3], [3, 3, 5], 4), ([5], [10], 4),
         ([1, 2, 3, 4, 5], [5, 4, 3, 2, 1], 6)]
for w, v, c in cases:                             ## cross-check both forms against every subset
    assert knapsack_2d(w, v, c) == knapsack_1d(w, v, c) == knapsack_brute(w, v, c)
print(knapsack_2d([1, 3, 4, 5], [1, 4, 5, 7], 7), "one-row form and brute force agree")
9 one-row form and brute force agree

Complexity. time; or space.

P14. Best Time to Buy and Sell Stock with Cooldown, and with a Transaction Fee — two state machines

Which template. dp[i][state], with the states carried as plain variables. The trick. Draw the machine before writing code. The cooldown version has three states: HOLD, SOLD on this day, and REST. You may buy only from REST, because SOLD is the cooldown day, and SOLD becomes REST on the following day. That single missing edge, SOLD to HOLD, is the cooldown. The fee version needs no third state: it is the ordinary two-state machine with the fee subtracted once per sale. Update all states in one tuple assignment so that each reads the previous day’s values.

def max_profit_cooldown(prices):
    hold, sold, rest = float("-inf"), float("-inf"), 0
    for price in prices:
        hold, sold, rest = (max(hold, rest - price),   ## buy only from REST, never from SOLD
                            hold + price,              ## selling today puts you in SOLD
                            max(rest, sold))           ## SOLD becomes REST after one day
    return max(sold, rest)

def max_profit_fee(prices, fee):
    hold, free = float("-inf"), 0                 ## the same machine, no cooldown state
    for price in prices:
        hold, free = max(hold, free - price), max(free, hold + price - fee)
    return free

def brute_cooldown(prices):                       ## check: recursion over buy/sell/wait
    def go(i, holding, blocked):
        if i == len(prices):
            return 0
        best = go(i + 1, holding, False)          ## wait
        if holding:
            best = max(best, prices[i] + go(i + 1, False, True))
        elif not blocked:
            best = max(best, -prices[i] + go(i + 1, True, False))
        return best
    return go(0, False, False)

## tests

assert max_profit_cooldown([1, 2, 3, 0, 2]) == 3
assert max_profit_cooldown([1]) == 0
assert max_profit_fee([1, 3, 2, 8, 4, 9], 2) == 8
assert max_profit_fee([1, 3, 7, 5, 10, 3], 3) == 6
for a in [[1, 2, 3, 0, 2], [1], [6, 1, 3, 2, 4, 7], [2, 1, 4], [1, 4, 2, 7]]:
    assert max_profit_cooldown(a) == brute_cooldown(a)
print(max_profit_cooldown([1, 2, 3, 0, 2]), max_profit_fee([1, 3, 2, 8, 4, 9], 2),
      "brute force agrees on the cooldown machine")
3 8 brute force agrees on the cooldown machine

Complexity. time, space for both.

P15. Longest Increasing Path in a Matrix — the longest strictly increasing path moving in four directions

Which template. Template 4, memoised DFS, because no sweep order exists. The trick. State why the memo is safe: the path values strictly increase, so the recursion follows a directed acyclic graph and can never revisit a cell within one call. That is what allows a plain dictionary with no visited set. Define the state as the longest path starting at a cell rather than ending at it, so the recursion moves forwards and the answer is the maximum over all cells.

def longest_increasing_path(matrix):
    if not matrix or not matrix[0]:
        return 0
    rows, cols = len(matrix), len(matrix[0])
    memo = {}
    def best(r, c):                               ## longest increasing path STARTING at (r, c)
        if (r, c) in memo:
            return memo[(r, c)]
        length = 1
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and matrix[nr][nc] > matrix[r][c]:
                length = max(length, 1 + best(nr, nc))
        memo[(r, c)] = length                     ## no cycles: values strictly increase
        return length
    return max(best(r, c) for r in range(rows) for c in range(cols))

def brute_path(matrix):                           ## check: the same search with no memo
    rows, cols = len(matrix), len(matrix[0])
    def go(r, c):
        length = 1
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and matrix[nr][nc] > matrix[r][c]:
                length = max(length, 1 + go(nr, nc))
        return length
    return max(go(r, c) for r in range(rows) for c in range(cols))

## tests

assert longest_increasing_path([[9, 9, 4], [6, 6, 8], [2, 1, 1]]) == 4
assert longest_increasing_path([[3, 4, 5], [3, 2, 6], [2, 2, 1]]) == 4
assert longest_increasing_path([[1]]) == 1
grids = [[[9, 9, 4], [6, 6, 8], [2, 1, 1]], [[3, 4, 5], [3, 2, 6], [2, 2, 1]], [[1]],
         [[1, 2, 3], [6, 5, 4], [7, 8, 9]]]
for g in grids:                                   ## cross-check against the unmemoised search
    assert longest_increasing_path(g) == brute_path(g)
print(longest_increasing_path([[9, 9, 4], [6, 6, 8], [2, 1, 1]]),
      longest_increasing_path([[1, 2, 3], [6, 5, 4], [7, 8, 9]]), "unmemoised search agrees")
4 9 unmemoised search agrees

Complexity. time, because each cell is computed once, and space.

P16. Maximal Square — the area of the largest square of 1s in a binary matrix

Which template. Template 2, with the state ending at a cell. The trick. Let dp[r][c] be the side of the largest square whose bottom-right corner is at (r, c). A square of side k+1 ending here requires squares of side at least k ending at the cell above, the cell to the left and the cell diagonally up-left, so the side is one plus the minimum of those three. The minimum is the whole idea: the smallest of the three neighbours is the binding constraint. Return the best side squared, not the best side.

def maximal_square(matrix):
    rows, cols = len(matrix), len(matrix[0])
    dp = [[0] * cols for _ in range(rows)]        ## dp[r][c] = side of the square ENDING here
    best = 0
    for r in range(rows):
        for c in range(cols):
            if matrix[r][c] == 1:
                if r == 0 or c == 0:
                    dp[r][c] = 1
                else:                             ## three neighbours limit the square
                    dp[r][c] = 1 + min(dp[r - 1][c], dp[r][c - 1], dp[r - 1][c - 1])
                best = max(best, dp[r][c])
    return best * best

def maximal_square_brute(matrix):                 ## check: try every square directly
    rows, cols, best = len(matrix), len(matrix[0]), 0
    for r in range(rows):
        for c in range(cols):
            side = 1
            while r + side <= rows and c + side <= cols:
                if all(matrix[i][j] == 1 for i in range(r, r + side)
                       for j in range(c, c + side)):
                    best = max(best, side)
                side += 1
    return best * best

## tests

grid = [[1, 0, 1, 0, 0], [1, 0, 1, 1, 1], [1, 1, 1, 1, 1], [1, 0, 0, 1, 0]]
assert maximal_square(grid) == 4
assert maximal_square([[0, 1], [1, 0]]) == 1
assert maximal_square([[0]]) == 0
for g in [grid, [[0, 1], [1, 0]], [[0]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]]]:
    assert maximal_square(g) == maximal_square_brute(g)
print(maximal_square(grid), maximal_square([[1, 1, 1], [1, 1, 1], [1, 1, 1]]),
      "brute force agrees")
4 9 brute force agrees

Complexity. time, space, reducible to one row.

P17. Burst Balloons — burst every balloon, earning the product of a balloon and its two live neighbours

Which template. Template 3, interval DP, and the reframing is the entire problem. The trick. Do not ask which balloon is burst first. That choice splits the row into two halves that are not independent, because the halves become neighbours once the middle balloon is gone. Ask which balloon is burst last inside the open range (i, j). When balloon last is burst last, everything strictly inside (i, last) and (last, j) is already gone, so at that moment the live neighbours of last are exactly the walls i and j. The gain is therefore balloons[i] * balloons[last] * balloons[j], a fixed number, and the two sub-ranges are genuinely independent. Pad the array with a 1 at each end so the walls always exist.

def max_coins(nums):
    balloons = [1] + list(nums) + [1]             ## virtual 1s at both ends
    n = len(balloons)
    dp = [[0] * n for _ in range(n)]              ## dp[i][j] = best over the OPEN range (i, j)
    for length in range(2, n):                    ## by increasing gap between the walls
        for i in range(n - length):
            j = i + length
            for last in range(i + 1, j):          ## which balloon is burst LAST inside (i, j)
                gain = balloons[i] * balloons[last] * balloons[j]
                dp[i][j] = max(dp[i][j], dp[i][last] + gain + dp[last][j])
    return dp[0][n - 1]

def max_coins_brute(nums):                        ## check: burst in every possible order
    def go(rest):
        if not rest:
            return 0
        best = 0
        for k in range(len(rest)):
            left = rest[k - 1] if k > 0 else 1
            right = rest[k + 1] if k + 1 < len(rest) else 1
            best = max(best, left * rest[k] * right + go(rest[:k] + rest[k + 1:]))
        return best
    return go(tuple(nums))

## tests

assert max_coins([3, 1, 5, 8]) == 167
assert max_coins([1, 5]) == 10
assert max_coins([]) == 0
for a in [[3, 1, 5, 8], [1, 5], [], [7], [2, 4, 3, 5], [9, 76, 64]]:
    assert max_coins(a) == max_coins_brute(a)
print(max_coins([3, 1, 5, 8]), max_coins([9, 76, 64]), "brute force agrees")
167 44416 brute force agrees

Complexity. time — a length loop, a start loop and a split loop — and space.

P18. Palindrome Partitioning II — the fewest cuts so that every piece of s is a palindrome

Which template. An interval table for the palindrome test, then a one-dimensional cut table on top. The trick. Two tables, computed in the right order. First is_pal[i][j], filled by increasing length, because s[i..j] is a palindrome when the ends match and the inside already is. Then cuts[i], the fewest cuts for the first i characters: try every last piece s[j..i-1] that is a palindrome and take cuts[j] + 1. Set cuts[0] = -1, not 0, so that a whole string which is itself a palindrome costs zero cuts — with cuts[0] = 0 the answer for "aa" comes out as 1.

def min_cut(s):
    n = len(s)
    is_pal = [[False] * n for _ in range(n)]
    for length in range(1, n + 1):                ## intervals by increasing length
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and (length <= 2 or is_pal[i + 1][j - 1]):
                is_pal[i][j] = True
    cuts = [0] * (n + 1)
    cuts[0] = -1                                  ## -1 so a whole-string palindrome costs 0 cuts
    for i in range(1, n + 1):
        cuts[i] = i - 1                           ## worst case: cut before every character
        for j in range(i):
            if is_pal[j][i - 1]:                  ## s[j..i-1] is a palindrome piece
                cuts[i] = min(cuts[i], cuts[j] + 1)
    return cuts[n] if n else 0

def min_cut_brute(s):                             ## check: recursion over every first piece
    def go(i):
        if i == len(s):
            return 0
        best = len(s)
        for j in range(i + 1, len(s) + 1):
            piece = s[i:j]
            if piece == piece[::-1]:
                best = min(best, 1 + go(j))
        return best
    return max(go(0) - 1, 0)

## tests

assert min_cut("aab") == 1
assert min_cut("a") == 0
assert min_cut("ab") == 1
assert min_cut("") == 0
for s in ["aab", "a", "ab", "abacdc", "cabababcbc", "leet", "aaaa"]:
    assert min_cut(s) == min_cut_brute(s)
print(min_cut("aab"), min_cut("cabababcbc"), "brute force agrees")
1 3 brute force agrees

Complexity. time, space.

Tricks and tips

Say both indices out loud, then say the fill order.dp[i][j] is the edit distance between the first i characters of a and the first j of b, filled top to bottom and left to right.” Two sentences, ten seconds, and they prevent the two failures that matter: a state that does not determine its own subproblems, and a loop order that reads unwritten cells.

Prefix tables need the extra row and column. Index 0 means the empty prefix, so the table is (n+1) by (m+1) and the character at table position i is a[i-1]. Sizing the table n by m forces awkward special cases everywhere and is the most common structural mistake in this family.

The base row and column carry the whole problem type. Zero for longest common subsequence, i and j for edit distance, 1 for distinct subsequences. Fill them before the double loop and check one value by hand. If the whole table comes out as zeros, the base is wrong, not the recurrence.

Interval problems iterate by length. dp[i][j] for a range reads intervals strictly inside itself, and no row-by-row sweep guarantees those are ready. Write for length in range(2, n+1) and derive j from i and length. An index-by-index loop compiles, runs, and reads zeros.

When the order is impossible, memoise instead. Four-directional grid movement has no sweep order at all, so recursion plus a dictionary is not a shortcut, it is the correct tool. Check that the recursion cannot cycle — in Longest Increasing Path the strictly increasing values guarantee it — and say that out loud, because it is the part an interviewer will probe.

Interval problems often invert the choice. Burst Balloons is unsolvable while you think about the first balloon and easy once you think about the last, because “last” is what makes the two sides independent. Whenever splitting on the first move leaves the halves coupled, try splitting on the last one.

Reduce two rows to one only after the table works. A two-string table needs the previous row and the current row, so two rows suffice. Collapsing to one row is possible for the counting variants but requires scanning in the direction that preserves the value you still need — right to left for distinct subsequences, descending capacity for 0/1 knapsack. Get the full table correct first, then reduce, and be ready to say that the reduction costs you the ability to reconstruct the answer.

A third string does not always need a third index. In Interleaving String the position in s3 is i + j. Look for that collapse whenever a problem seems to need three indices; usually one of them is a function of the other two.

The bugs that cost the round

Filling an interval table in index order. for i then for j reads dp[i+1][j-1], a shorter interval that has not been computed, and gets 0. The code runs and the answer is quietly too small. Loop over the length.

The wrong table size. A prefix table must be (n+1) by (m+1). Using n by m and then indexing a[i] instead of a[i-1] is a mismatch that produces plausible values on short inputs.

Base cases left at zero. Distinct Subsequences with dp[i][0] = 0 returns 0 for everything, and Edit Distance without the i and j edges returns nonsense for any pair where one string is a prefix of the other.

Off-by-one in Palindrome Partitioning II. cuts[0] must be -1, because the recurrence counts pieces and the answer counts cuts. With 0 the answer for a whole-string palindrome comes out one too high, and every sample where the answer is not zero still passes.

Updating a state machine sequentially. In the cooldown machine, computing hold first and then using the new hold to compute sold sells a share bought on the same day. Assign all three states in one tuple assignment from the previous day’s values.

Confusing the two kinds of star. In a regular expression the star binds to the preceding character, so skipping the pair means j - 2. In wildcard matching the star stands alone, so skipping it means j - 1. Writing j - 2 in the wildcard solution reads a pattern position that does not belong to the star and matches the wrong strings.

Returning the wrong quantity in Maximal Square. The table holds the side, and the question asks for the area. Square the best side once at the end.

Done when

  • Given a two-string problem, you can draw the (n+1) by (m+1) table, state what row 0 and column 0 hold, and state the match and mismatch branches, before writing code.
  • You can convert longest common subsequence into edit distance and into distinct subsequences by changing only the base row, the neighbours used, and the combination rule.
  • You can say the fill order for a prefix table, a grid sweep and an interval table, and explain why an interval table must go by increasing length.
  • You can recognise when no sweep order exists and reach for a memoised depth-first search instead, and justify why the recursion cannot cycle.