Skip to content

Two Dimensional DP and Knapsack

Some DP states need two dimensions to describe them — not just “which index,” but “which index, and how much capacity/budget/count is left.” The knapsack family is the canonical example, and once you can read its 2D table, subset-sum and partition problems fall out of the same recurrence with a small twist.

What you’ll learn

  • The 0/1 knapsack recurrence: for each item, take it or leave it.
  • How to read the 2D table dp[i][w]dp[i][w] — “best value using the first ii items with capacity ww.”
  • Unbounded knapsack: the same table, but items can be reused.
  • Subset sum / Partition Equal Subset Sum: knapsack with a boolean instead of a max.
  • Space optimization: collapsing the 2D table to a single rolling 1D array.

0/1 Knapsack

Given item weights, values, and a capacity WW, maximize total value without exceeding the capacity — each item can be used at most once. State: dp[i][w]dp[i][w] = best value achievable using the first ii items with capacity ww. For each item, either skip it (dp[i-1][w]dp[i-1][w]) or take it (if it fits) and add its value to the best from the remaining capacity.

dp[i][w]={dp[i1][w]wti>wmax(dp[i1][w], dp[i1][wwti]+vali)wtiwdp[i][w] = \begin{cases} dp[i-1][w] & wt_i > w \\ \max\big(dp[i-1][w],\ dp[i-1][w - wt_i] + val_i\big) & wt_i \le w \end{cases}
knapsack_01.py
def knapsack_01(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        wt, val = weights[i - 1], values[i - 1]
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]                    # option 1: skip item i
            if wt <= w:
                take = dp[i - 1][w - wt] + val          # option 2: take item i
                dp[i][w] = max(dp[i][w], take)
 
    return dp[n][capacity]
 
 
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
print(knapsack_01(weights, values, 7))   # expect 9  (items of weight 3 + 4 -> value 4 + 5)
knapsack_01.py
def knapsack_01(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        wt, val = weights[i - 1], values[i - 1]
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]                    # option 1: skip item i
            if wt <= w:
                take = dp[i - 1][w - wt] + val          # option 2: take item i
                dp[i][w] = max(dp[i][w], take)
 
    return dp[n][capacity]
 
 
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
print(knapsack_01(weights, values, 7))   # expect 9  (items of weight 3 + 4 -> value 4 + 5)

Reading the table: row ii is “using only the first ii items,” column ww is “with this much capacity.” dp[i][w]dp[i][w] always equals at least dp[i-1][w]dp[i-1][w] (taking item ii can only help, never hurt), so the table grows monotonically down each column. The final answer sits in the bottom-right corner: all items considered, full capacity available.

Unbounded Knapsack

Same setup, but now each item can be used any number of times (think: coin change, but maximizing value instead of counting ways). The only change from 0/1: when you take an item, you stay on row ii (not i - 1i - 1), because item ii is still available to take again.

dp[i][w]=max(dp[i1][w], dp[i][wwti]+vali)dp[i][w] = \max\big(dp[i-1][w],\ dp[i][w - wt_i] + val_i\big)
knapsack_unbounded.py
def knapsack_unbounded(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        wt, val = weights[i - 1], values[i - 1]
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]                    # option 1: skip item i entirely
            if wt <= w:
                take = dp[i][w - wt] + val              # option 2: reuse item i again (stay on row i)
                dp[i][w] = max(dp[i][w], take)
 
    return dp[n][capacity]
 
 
weights = [2, 3, 4]
values = [3, 4, 5]
print(knapsack_unbounded(weights, values, 7))   # expect 10  (two of weight 2 + one of weight 3 -> 3+3+4)
knapsack_unbounded.py
def knapsack_unbounded(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        wt, val = weights[i - 1], values[i - 1]
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]                    # option 1: skip item i entirely
            if wt <= w:
                take = dp[i][w - wt] + val              # option 2: reuse item i again (stay on row i)
                dp[i][w] = max(dp[i][w], take)
 
    return dp[n][capacity]
 
 
weights = [2, 3, 4]
values = [3, 4, 5]
print(knapsack_unbounded(weights, values, 7))   # expect 10  (two of weight 2 + one of weight 3 -> 3+3+4)

Subset Sum / Partition Equal Subset Sum

Can a subset of the array sum to exactly some targettarget? This is knapsack with values dropped entirely — dp[i][s]dp[i][s] is now a boolean: “can the first ii numbers reach sum ss?” Partition Equal Subset Sum asks whether the whole array can be split into two equal-sum halves, which is just subset sum with target = total_sum / 2target = total_sum / 2.

dp[i][s]=dp[i1][s]  (snumi  dp[i1][snumi])dp[i][s] = dp[i-1][s] \ \lor\ \big(s \ge num_i \ \land\ dp[i-1][s - num_i]\big)
partition_equal_subset_sum.py
def can_partition(nums):
    total = sum(nums)
    if total % 2 != 0:
        return False                 # an odd total can never split into two equal halves
    target = total // 2
 
    n = len(nums)
    dp = [[False] * (target + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = True               # sum 0 is always reachable: take nothing
 
    for i in range(1, n + 1):
        num = nums[i - 1]
        for s in range(target + 1):
            dp[i][s] = dp[i - 1][s]                        # skip nums[i-1]
            if num <= s:
                dp[i][s] = dp[i][s] or dp[i - 1][s - num]   # or use it
 
    return dp[n][target]
 
 
print(can_partition([1, 5, 11, 5]))   # expect True   ({1, 5, 5} and {11})
print(can_partition([1, 2, 3, 5]))    # expect False  (no equal split exists)
partition_equal_subset_sum.py
def can_partition(nums):
    total = sum(nums)
    if total % 2 != 0:
        return False                 # an odd total can never split into two equal halves
    target = total // 2
 
    n = len(nums)
    dp = [[False] * (target + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = True               # sum 0 is always reachable: take nothing
 
    for i in range(1, n + 1):
        num = nums[i - 1]
        for s in range(target + 1):
            dp[i][s] = dp[i - 1][s]                        # skip nums[i-1]
            if num <= s:
                dp[i][s] = dp[i][s] or dp[i - 1][s - num]   # or use it
 
    return dp[n][target]
 
 
print(can_partition([1, 5, 11, 5]))   # expect True   ({1, 5, 5} and {11})
print(can_partition([1, 2, 3, 5]))    # expect False  (no equal split exists)

Watching the table fill

sketch 0/1 knapsack table filling row by row p5.js
Each cell only reads the row above it (skip) and a cell to the upper-left within reach (take) -- never a cell in a row below.

Practice — real LeetCode problems

Three knapsacks with three different flavours: unbounded (reuse allowed), 0/1 with two capacities at once, and a minimisation that only becomes a knapsack after you rewrite the question.

LC 322 — Coin Change · Medium

Problem. Given coin denominations and an amountamount, return the fewest coins that sum to exactly amountamount, or -1-1 if it is impossible. You have unlimited coins of each denomination.

Constraints. 1 <= len(coins) <= 121 <= len(coins) <= 12, 1 <= coins[i] <= 2**31 - 11 <= coins[i] <= 2**31 - 1, 0 <= amount <= 10**40 <= amount <= 10**4.

Examples. coins = [1,2,5], amount = 11coins = [1,2,5], amount = 11 gives 33 (5+5+1) · coins = [2], amount = 3coins = [2], amount = 3 gives -1-1 · amount = 0amount = 0 gives 00

Editorial · approach, complexity, follow-ups

Unbounded knapsack, minimising count. The direction of the capacity loop is the whole distinction:

  • Ascending capacity: dp[a - c]dp[a - c] may already include coin cc, so coins are reusable — unbounded.
  • Descending capacity: dp[a - c]dp[a - c] is from the previous item’s row, so each coin is used at most once — 0/1.

Time O(amount×len(coins))O(\text{amount} \times \text{len(coins)}). Space O(amount)O(\text{amount}).

  • amount = 0amount = 0 returns 0, and dp = [0]dp = [0] handles it with no loop iterations.
  • Greedy largest-first fails. coins = [1,3,4], amount = 6coins = [1,3,4], amount = 6 greedily takes 4+1+1 = 3 coins; the answer is 3+3 = 2. Greedy is only correct for canonical systems, and real currency happens to be one — which is why the intuition is so sticky and so wrong here.
  • Unreachable amounts must return -1-1, not infinity. [2][2] with amount = 3amount = 3 is the check.
  • Huge denominations are harmless: the c <= ac <= a guard skips any coin bigger than the current amount, so 2**31 - 12**31 - 1 never indexes out of range.

Follow-ups you should expect: “Which coins?” — store the coin that improved each dp[a]dp[a] and walk back from amountamount. “Count the combinations instead (LC 518)?” — loop coins outside and amounts inside, and sum; the loop order is what stops permutations being double-counted. “Count permutations (LC 377)?” — swap those loops. “At most kk of each coin?” — bounded knapsack, solved with binary splitting of the counts. “Amount up to 10910^9?” — the DP dies and it becomes a number-theory problem.

LC 474 — Ones and Zeroes · Medium

Problem. Given an array of binary strings and budgets mm (zeros) and nn (ones), return the size of the largest subset of strings whose total zero count is at most mm and total one count is at most nn.

Constraints. 1 <= len(strs) <= 6001 <= len(strs) <= 600, 1 <= len(strs[i]) <= 1001 <= len(strs[i]) <= 100, 1 <= m, n <= 1001 <= m, n <= 100.

Examples. strs = ["10","0001","111001","1","0"], m = 5, n = 3strs = ["10","0001","111001","1","0"], m = 5, n = 3 gives 44 (the subset ["10","0001","1","0"]["10","0001","1","0"]) · strs = ["10","0","1"], m = 1, n = 1strs = ["10","0","1"], m = 1, n = 1 gives 22

Editorial · approach, complexity, follow-ups

The tell is “at most mm zeros and at most nn ones”: two independent resources, so the capacity is a pair. Everything else is standard 0/1 knapsack with every item’s value equal to 1.

Time O(len(strs)×m×n)O(\text{len(strs)} \times m \times n) — about 600×100×100=6×106600 \times 100 \times 100 = 6 \times 10^6 here. Space O(mn)O(mn).

  • Both loops must descend. If either ascends, a string can be reused inside its own pass and you overcount. The given examples will not catch the bug — both loop directions answer [4, 2, 2][4, 2, 2]. The smallest input that does is ["0"]["0"] with m = 2, n = 1m = 2, n = 1: the answer is 1, and an ascending loop says 2, having spent the single string twice.
  • The item loop stays outermost. Rolling a 2D array in place only works because each string is fully processed before the next begins.
  • A string that fits neither budget is skipped automatically: `range(m, zeros
    • 1, -1)is empty whenis empty whenzeros > m`, so no guard is needed.
  • mm or nn equal to 0 is outside LeetCode’s constraints, but the code still handles it — the grid is a single row or column and only free items would fit.

Follow-ups you should expect: “Which strings?” — keep the full 3D table dp[k][i][j]dp[k][i][j] and backtrack, or store parents. “Maximise total length instead of count?” — the item value becomes len(s)len(s); nothing else changes. “Three resources?” — a third descending loop, and the constant factor stops being friendly. “Can this be greedy by shortest string first?” — no; a short string may eat the one zero a much better pair needed.

LC 1049 — Last Stone Weight II · Medium

Problem. Repeatedly smash two stones xx and yy: both are destroyed and, if x != yx != y, a stone of weight |x - y||x - y| is added. Return the smallest possible weight of the last remaining stone (0 if none remains).

Constraints. 1 <= len(stones) <= 301 <= len(stones) <= 30, 1 <= stones[i] <= 1001 <= stones[i] <= 100.

Examples. [2,7,4,1,8,1][2,7,4,1,8,1] gives 11 · [31,26,33,21,40][31,26,33,21,40] gives 55

Editorial · approach, complexity, follow-ups

The reduction is the interview. Once you see that every schedule assigns each stone a ++ or a --, the problem reads: split the stones into groups AA and BB minimising AB|{\sum A} - {\sum B}|. Writing A=b\sum A = b and B=totalb\sum B = \text{total} - b, the difference is total2b\text{total} - 2b, minimised by the largest btotal/2b \le \lfloor \text{total}/2 \rfloor you can actually reach. That is subset sum.

Why the reduction holds, in both directions: any schedule expands into a signed sum by induction on the smashes, and conversely any sign assignment is realisable — smash within each group down to one stone each, then smash those two together. The total - 2btotal - 2b form with b <= total // 2b <= total // 2 also guarantees the result is non-negative, so no absolute value is needed.

Time O(n×total)O(n \times \text{total}), at most 30×300030 \times 3000. Space O(total)O(\text{total}).

  • total // 2total // 2 floors, which is what you want: for an odd total the two groups cannot tie and the best difference is 1.
  • [1,1][1,1] gives 0 — the two stones annihilate. [1][1] gives 1 — nothing to smash, so total - 2 * 0total - 2 * 0.
  • The r + s <= targetr + s <= target filter is what keeps the set small and is also why max(reachable)max(reachable) is the right pick; without the cap you would need the value nearest to half from below.
  • Building a new set inside the comprehension is the set-based equivalent of the descending loop: you must not iterate a set you are mutating, and you must not let stone ss be added twice.

Follow-ups you should expect: “How is this different from LC 1046, Last Stone Weight?” — that one fixes the schedule as always-smash-the-two-largest, so it is a heap problem, not DP. Knowing why the greedy is correct there and wrong here is the point. “Return the two groups?” — track predecessors, or use a boolean table instead of a set. “Bitset trick?” — bits |= bits << sbits |= bits << s, then scan down from targettarget for the highest set bit; Python’s big ints make this very fast. “All stones equal?” — the answer is 0 for an even count, stones[0]stones[0] for an odd one.

LeetCode problem set

#ProblemDifficultyThe twist
416Partition Equal Subset SumMediumSubset sum with target = total / 2target = total / 2, as above
494Target SumMediumAssign ++/-- signs to reach a target; reframes into a subset-sum problem once you do the algebra on positive vs. negative subsets
0/1 Knapsack (classic) (classic)The exact template above (GeeksforGeeks / interview-style, not on LeetCode directly)
474Ones and ZeroesMediumKnapsack with two capacity dimensions (count of 0s and count of 1s) instead of one
1049Last Stone Weight IIMediumReframes into “find the subset closest to half the total,” minimizing the leftover difference

Recap

  • 2D DP states show up when the answer depends on two things changing together — here, “how many items considered” and “how much capacity remains.”
  • 0/1 knapsack: each item taken at most once — the “take” branch reads the row above (dp[i-1][...]dp[i-1][...]).
  • Unbounded knapsack: items reusable — the “take” branch reads the same row (dp[i][...]dp[i][...]), since the item can be picked again.
  • Subset sum / Partition Equal Subset Sum: knapsack with a boolean instead of a max — “is this sum reachable?” instead of “what’s the best value?”
  • A 2D table that only ever reads the row directly above (or the same row) collapses to a rolling 1D array — iterate capacity high-to-low for 0/1 (each item once), low-to-high for unbounded (items reusable).

That covers the “index-based” core of dynamic programming: spotting DP from a recursion tree, solving it with a 1D state, and scaling up to a two-dimensional state with knapsack. The state doesn’t have to stop at an index, though.

Next: Classic DP: LIS, LCS, and Edit Distance — three more foundational recurrences, this time over sequences.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did