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 firstiiitems with capacityww.” - 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.
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)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.
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)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.
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)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
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 coincc, 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 . Space .
amount = 0amount = 0returns 0, anddp = [0]dp = [0]handles it with no loop iterations.- Greedy largest-first fails.
coins = [1,3,4], amount = 6coins = [1,3,4], amount = 6greedily 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]withamount = 3amount = 3is the check. - Huge denominations are harmless: the
c <= ac <= aguard skips any coin bigger than the current amount, so2**31 - 12**31 - 1never 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 ?” — 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 — about here. Space .
- 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"]withm = 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.
- 1, -1)
mmornnequal 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 and
minimising . Writing and
, the difference is , minimised by
the largest 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 , at most . Space .
total // 2total // 2floors, 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, sototal - 2 * 0total - 2 * 0.- The
r + s <= targetr + s <= targetfilter is what keeps the set small and is also whymax(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
ssbe 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
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 416 | Partition Equal Subset Sum | Medium | Subset sum with target = total / 2target = total / 2, as above |
| 494 | Target Sum | Medium | Assign ++/-- 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) |
| 474 | Ones and Zeroes | Medium | Knapsack with two capacity dimensions (count of 0s and count of 1s) instead of one |
| 1049 | Last Stone Weight II | Medium | Reframes 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 coffeeWas this page helpful?
Let us know how we did
