Knapsack Variants and Subset Sum
Knapsack problems are notorious for a reason: the recurrences all look the same, and the differences live entirely in loop direction and loop nesting. Four one-line variations produce four different answers, and if you cannot derive which is which you will guess wrong under pressure.
This page is about deriving them. Two rules cover everything:
Loop direction decides whether an item can be reused. Descending over capacity means each item is used at most once (0/1). Ascending means each item can be used unlimited times (unbounded).
Loop nesting decides whether order matters. Items outer, capacity inner gives combinations (order-blind). Capacity outer, items inner gives permutations (order counted).
What you’ll learn
- Why a 1D array suffices, and what the descending loop is actually protecting.
- The 0/1 versus unbounded distinction, derived rather than memorised.
- Why LC 518 and LC 377 have the same recurrence and different loop nesting.
- The transform that turns “assign + and - signs” into a subset-sum problem.
- Three real LeetCode problems solved in the browser: 416, 494, 518.
The cue
From 2D to 1D
The honest starting point is a 2D table: dp[i][c]dp[i][c] is the answer using the first
ii items with capacity cc.
Every row depends only on the row above, so the table collapses to a single array. But collapsing introduces a hazard, and that hazard is the whole subject:
# 0/1 -- each item at most once: capacity DESCENDING
def can_reach_01(nums, target):
dp = [False] * (target + 1)
dp[0] = True
for n in nums:
for c in range(target, n - 1, -1): # descending
if dp[c - n]:
dp[c] = True
return dp[target]
# UNBOUNDED -- each item any number of times: capacity ASCENDING
def count_ways_unbounded(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins: # items OUTER
for a in range(coin, amount + 1): # ascending
dp[a] += dp[a - coin]
return dp[amount]
print(can_reach_01([1, 5, 11, 5], 11)) # True
print(count_ways_unbounded([1, 2, 5], 5)) # 4# 0/1 -- each item at most once: capacity DESCENDING
def can_reach_01(nums, target):
dp = [False] * (target + 1)
dp[0] = True
for n in nums:
for c in range(target, n - 1, -1): # descending
if dp[c - n]:
dp[c] = True
return dp[target]
# UNBOUNDED -- each item any number of times: capacity ASCENDING
def count_ways_unbounded(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins: # items OUTER
for a in range(coin, amount + 1): # ascending
dp[a] += dp[a - coin]
return dp[amount]
print(can_reach_01([1, 5, 11, 5], 11)) # True
print(count_ways_unbounded([1, 2, 5], 5)) # 4The inner loop’s lower bound (n - 1n - 1 or coincoin) simply avoids negative indices —
a capacity below the item’s weight cannot fit it at all.
Combinations versus permutations
This is the second rule, and the one that silently produces wrong answers because both versions run fine.
| Loop nesting | Counts | coins=[1,2]coins=[1,2], amount=3amount=3 | |
|---|---|---|---|
| Combinations (518) | items outer, capacity inner | order ignored | {1,1,1}{1,1,1}, {1,2}{1,2} = 2 |
| Permutations (377) | capacity outer, items inner | order counted | 1+1+11+1+1, 1+21+2, 2+12+1 = 3 |
The sign-assignment transform
LC 494 asks how many ways to put ++ or -- before each number so the total equals
targettarget. That looks unlike a knapsack until you split the numbers into the
positive set PP and the negated set NN:
Adding the two equations:
So the question becomes “how many subsets sum to that value?” — a standard 0/1 counting knapsack. Two feasibility checks fall straight out of the algebra:
- If
total + targettotal + targetis odd, no integer subset sum exists, so the answer is00. - If
abs(target) > totalabs(target) > total, the target is unreachable, so the answer is00.
| Variant | Loop direction | Loop nesting | Problem |
|---|---|---|---|
| 0/1: can we hit target? | descending | items outer | 416 |
| 0/1: count subsets | descending | items outer | 494 |
| 0/1: maximise value | descending | items outer | 474 |
| Unbounded: count combinations | ascending | items outer | 518 |
| Unbounded: count permutations | ascending | capacity outer | 377 |
| Unbounded: minimise count | ascending | either — minmin is order-blind | 322 · 279 |
Practice — real LeetCode problems
LC 416 — Partition Equal Subset Sum · Medium
Problem. Given an array of positive integers, decide whether it can be split into two subsets with equal sums.
Constraints. 1 <= len(nums) <= 2001 <= len(nums) <= 200, 1 <= nums[i] <= 1001 <= nums[i] <= 100.
Examples. [1,5,11,5][1,5,11,5] gives TrueTrue ([1,5,5][1,5,5] and [11][11]) ·
[1,2,3,5][1,2,3,5] gives FalseFalse · [1,1][1,1] gives TrueTrue
Editorial — approach, complexity, follow-ups
Two equal subsets means each sums to total / 2total / 2, so this is exactly subset
sum: can any subset reach that target?
Time , at most here. Space .
This is pseudo-polynomial: linear in the numeric value of the sum, not in the number of bits needed to write it. Subset sum is NP-complete in general, and this DP is only efficient because the constraints bound the sum. Saying so shows you know why the approach works here rather than always.
- Odd total is an instant
FalseFalse—[1,2,3,5][1,2,3,5]sums to 11. dp[0] = Truedp[0] = Trueis the empty subset; without it nothing is ever reachable.- Descending keeps it 0/1.
[2,2,3,5][2,2,3,5](total 12, target 6) is the discriminating case: no subset makes 6, but an ascending loop would reuse a22three times and wrongly returnTrueTrue.
A Python shortcut worth mentioning: a set of reachable sums,
reachable |= {r + n for r in reachable}reachable |= {r + n for r in reachable}, is shorter and often faster in practice.
Faster still is the bitset trick — bits |= bits << nbits |= bits << n, then test bit targettarget —
because Python’s big integers do the shifting in C.
Follow-ups you should expect: “Return the actual subsets?” — keep the 2D table
and walk backwards. “Minimise the difference between the two subsets (LC 1049)?” —
find the largest reachable sum at most total // 2total // 2; the answer is
total - 2 * besttotal - 2 * best. “Partition into kk equal subsets (LC 698)?” — much harder,
backtracking with pruning. “Why not greedy?” — taking the largest first fails on
[1,5,11,5][1,5,11,5].
LC 494 — Target Sum · Medium
Problem. Assign ++ or -- to each number in numsnums so the resulting
expression equals targettarget. Return the number of ways.
Constraints. 1 <= len(nums) <= 201 <= len(nums) <= 20, 0 <= nums[i] <= 10000 <= nums[i] <= 1000,
0 <= sum(nums) <= 10000 <= sum(nums) <= 1000, -1000 <= target <= 1000-1000 <= target <= 1000.
Examples. nums = [1,1,1,1,1], target = 3nums = [1,1,1,1,1], target = 3 gives 55 ·
nums = [1], target = 1nums = [1], target = 1 gives 11 · nums = [1], target = 2nums = [1], target = 2 gives 00
Editorial — approach, complexity, follow-ups
The transform is the whole solution. Splitting into positives PP and negatives NN
gives sum(P) - sum(N) = targetsum(P) - sum(N) = target and sum(P) + sum(N) = totalsum(P) + sum(N) = total, so
sum(P) = (total + target) / 2sum(P) = (total + target) / 2. Counting sign assignments becomes counting subsets
with that sum.
Time . Space .
Both guards come straight from the algebra:
(total + target) % 2(total + target) % 2— a non-integer subset sum is impossible.([1], 2)([1], 2)hits this:(1 + 2) / 2 = 1.5(1 + 2) / 2 = 1.5.abs(target) > totalabs(target) > total— unreachable even using every number with one sign. Without this guardsubsetsubsetgoes negative,[0] * (subset + 1)[0] * (subset + 1)produces an empty list, and the indexing fails.
The zero cases are the reason to trust the algebra:
([1,0], 1)([1,0], 1)gives22. Heresubset = 1subset = 1. Processing the00, the inner loop runsdp[s] += dp[s - 0]dp[s] += dp[s - 0], i.e.dp[s] += dp[s]dp[s] += dp[s], doubling every count. Correct:+1+0+1+0and+1-0+1-0.([0,0], 0)([0,0], 0)gives44. Two zeros, two sign choices each: . The doubling happens twice.
Follow-ups you should expect: “Do it with a memoised recursion instead?” —
dfs(index, running_sum)dfs(index, running_sum) with lru_cachelru_cache; states, and
arguably easier to derive under pressure, so it is worth offering. “Why the two
guards?” — explain them from the algebra. “Negative numbers in numsnums?” — the
constraints forbid it; with negatives the reachable range shifts and you would
offset the dp indices. “Reconstruct one valid assignment?” — keep the 2D table and
backtrack.
LC 518 — Coin Change II · Medium
Problem. Given coin denominations and an amountamount, return the number of
combinations that make up that amount. You have an infinite supply of each
coin, and combinations differing only in order count once.
Constraints. 1 <= len(coins) <= 3001 <= len(coins) <= 300, 1 <= coins[i] <= 50001 <= coins[i] <= 5000, all coins
distinct, 0 <= amount <= 50000 <= amount <= 5000.
Examples. amount = 5, coins = [1,2,5]amount = 5, coins = [1,2,5] gives 44 ·
amount = 3, coins = [2]amount = 3, coins = [2] gives 00 · amount = 0, coins = [7]amount = 0, coins = [7] gives 11
Editorial — approach, complexity, follow-ups
Both loop decisions matter here, and each encodes one requirement:
- Ascending amount —
dp[a - coin]dp[a - coin]has already been updated this round, so it may already include this coin. That is exactly the unbounded supply. - Coins outer — solutions are built in a fixed coin order, so
1+21+2and2+12+1are the same path. That is combinations, not permutations.
Time . Space .
(3, [1,2])(3, [1,2]) giving 2 is the discriminating case. The combinations are
{1,1,1}{1,1,1} and {1,2}{1,2}. Swap the loops — amount outer, coins inner — and you get
3, because 1+21+2 and 2+12+1 become distinct. That answer is correct for LC 377
and wrong here, and the code looks equally reasonable either way.
(0, [7])(0, [7]) giving 11 follows from dp[0] = 1dp[0] = 1: the empty selection is a valid way
to make zero. Easy to dismiss as a technicality, but it is also the base case the
whole recurrence builds on.
The last case, (500, [1,2,5])(500, [1,2,5]) giving 1270112701, confirms the arithmetic scales
rather than merely passing the tiny examples.
Follow-ups you should expect:
- “Count permutations instead (LC 377)?” Swap the loops: amount outer, coins inner. Same recurrence, same base case.
- “Minimum number of coins (LC 322)?” Change the recurrence to
dp[a] = min(dp[a], dp[a - coin] + 1)dp[a] = min(dp[a], dp[a - coin] + 1), withdp[0] = 0dp[0] = 0and the restfloat("inf")float("inf"). Loop order does not matter there, becauseminminis order-blind — a nice contrast worth pointing out. - “Each coin usable at most once?” Iterate the amount descending, making it 0/1.
- “Which coins were used?” The 1D array has discarded that; keep the 2D table.
- “Very large amounts?” The DP is pseudo-polynomial; for huge amounts with few coins, generating functions or matrix exponentiation are the theoretical routes.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 416 | Partition Equal Subset Sum | Medium | Boolean 0/1 subset-sum to total // 2total // 2 |
| 494 | Target Sum | Medium | Algebra turns signs into subset-sum; zeros double the count |
| 518 | Coin Change II | Medium | Coins outer + ascending = unbounded combinations |
| 377 | Combination Sum IV | Medium | Amount outer = permutations, despite the title |
| 322 | Coin Change | Medium | Minimisation, so loop order is irrelevant |
| 474 | Ones and Zeroes | Medium | 0/1 knapsack with two capacities — both loops descending |
| 1049 | Last Stone Weight II | Medium | Largest reachable sum at most total // 2total // 2; answer is total - 2 * besttotal - 2 * best |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Why descending for 0/1?” | Whether you derived it | Descending means dp[c - w]dp[c - w] has not been overwritten yet, so it still represents the previous row — item used once |
| “Why does loop order change the count?” | The subtlest point | Items outer fixes a build order (combinations); capacity outer tries every item last (permutations) |
| “Is this polynomial?” | Precision | Pseudo-polynomial — linear in the numeric target, exponential in its bit length; subset sum is NP-complete generally |
| “How do you handle zeros in 494?” | Care | The subset-sum transform handles them automatically: dp[s] += dp[s]dp[s] += dp[s] doubles the count |
| “Recover the chosen items?” | Limits of the optimisation | The 1D array cannot; keep the 2D table and walk back |
| “Two capacities (LC 474)?” | Generalisation | A 2D dp over both budgets, with both loops descending |
| “Does loop order matter for LC 322?” | Depth | No — minmin is order-blind, unlike counting |
Edge-case checklist
- Odd total (LC 416) — instant
FalseFalse. - Single element —
[1][1]cannot be partitioned. amount = 0amount = 0(LC 518) — exactly one way (take nothing); testsdp[0] = 1dp[0] = 1.- Target unreachable —
(3, [2])(3, [2])gives00. abs(target) > totalabs(target) > total(LC 494) — guard before computingsubsetsubset, or the array size goes negative.(total + target)(total + target)odd (LC 494) — return00.- Zeros in the array (LC 494) — each doubles the count;
([0,0], 0)([0,0], 0)gives44. - Coin larger than the amount — the inner loop’s lower bound skips it safely.
- All elements equal —
[100,100][100,100]partitions;[1,1][1,1]too. - Large amounts —
(500, [1,2,5])(500, [1,2,5])checks the arithmetic scales.
Recap
- The 2D knapsack table collapses to 1D because each row depends only on the row above — and collapsing is what makes loop direction significant.
- Loop direction decides reuse. Descending over capacity gives 0/1 (each item once); ascending gives unbounded. Derive it from “has this index been overwritten this round?“.
- Loop nesting decides ordering. Items outer gives combinations; capacity outer gives permutations. LC 518 and LC 377 differ only in this, and LC 377’s title says “combination” while it counts permutations.
- Loop order is irrelevant for minimisation (LC 322), because
minminis order-blind. Only counting cares. dp[0]dp[0]is the base case everything rests on —TrueTruefor reachability,11for counting,00for minimisation.- Algebra beats case analysis. The
sum(P) = (total + target) / 2sum(P) = (total + target) / 2transform turns sign assignment into subset sum and handles zeros for free. - These DPs are pseudo-polynomial — efficient only because the constraints bound the target.
Next: Classic DP — LIS, LCS and Edit Distance — the sequence-alignment family, where the state is a pair of indices rather than a capacity.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
