Skip to content

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).

  • 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 2D table is the honest starting point, so it is worth watching one get filled before collapsing it. Each cell asks the same question — take this item, or skip it? — and the arrows show which two cells the answer comes from.

dp0/1 knapsack: every cell is max(skip = above, take = above-and-left-by-w)capacity 7, four items
rows: items consideredcols: capacity
01234567w1v1w3v4w4v5w5v700000000
base caseRow 0 means "no items available", so every capacity yields value 0. Each later row adds exactly one item to the pool — the row index is *how many items you are allowed to consider*, not which item you took.
1/38

Look at where each cell reads from: dp[i-1][c] directly above (skip the item) and dp[i-1][c - w] up and to the left (take it). Both sources are in the PREVIOUS row -- which is what the one-array version has to preserve, and why it iterates capacity descending.

The honest starting point is a 2D table: dp[i][c] is the answer using the first i items with capacity c.

dp[i][c]=max(dp[i1][c], dp[i1][cwi]+vi)dp[i][c] = \max\big(dp[i-1][c],\ dp[i-1][c - w_i] + v_i\big)

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:

knapsack_directions.py
# 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

The inner loop’s lower bound (n - 1 or coin) simply avoids negative indices — a capacity below the item’s weight cannot fit it at all.

This is the second rule, and the one that silently produces wrong answers because both versions run fine.

Loop nestingCountscoins=[1,2], amount=3
Combinations (518)items outer, capacity innerorder ignored{1,1,1}, {1,2} = 2
Permutations (377)capacity outer, items innerorder counted1+1+1, 1+2, 2+1 = 3

LC 494 asks how many ways to put + or - before each number so the total equals target. That looks unlike a knapsack until you split the numbers into the positive set P and the negated set N:

sum(P)sum(N)=target,sum(P)+sum(N)=total\text{sum}(P) - \text{sum}(N) = \text{target}, \qquad \text{sum}(P) + \text{sum}(N) = \text{total}

Adding the two equations:

sum(P)=total+target2\text{sum}(P) = \frac{\text{total} + \text{target}}{2}

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 + target is odd, no integer subset sum exists, so the answer is 0.
  • If abs(target) > total, the target is unreachable, so the answer is 0.
VariantLoop directionLoop nestingProblem
0/1: can we hit target?descendingitems outer416
0/1: count subsetsdescendingitems outer494
0/1: maximise valuedescendingitems outer474
Unbounded: count combinationsascendingitems outer518
Unbounded: count permutationsascendingcapacity outer377
Unbounded: minimise countascendingeither — min is order-blind322 · 279

Subset sum (LC 416’s core) — nums = [1, 5, 11, 5], target = 11, capacity descending. dp[c] means “some subset reaches exactly c”. Written as 1/0 for readability:

after itemdp[0..11]newly reachable
— (init)1 0 0 0 0 0 0 0 0 0 0 0only 0, from the empty subset
11 1 0 0 0 0 0 0 0 0 0 01
51 1 0 0 0 1 1 0 0 0 0 05, 6
111 1 0 0 0 1 1 0 0 0 0 111 → the answer is already True
51 1 0 0 0 1 1 0 0 0 1 110 (= 5 + 5, using both fives)
  • Each item adds only offsets of what was already reachable. After 5, index 6 lights up because index 1 was set — that is dp[c] |= dp[c - n], and it is why this is one array rather than a set of sums.
  • The second 5 is a distinct item, and 10 proves it. Index 10 becomes reachable only on the last pass, using both fives. Deduplicating the input would be wrong here.
  • Nothing is ever cleared. Once a capacity is reachable it stays reachable, so the array is monotone in time. That is what makes the boolean version safe to short-circuit on dp[target].

The direction bug, in the smallest case that shows it — nums = [2], target = 4:

loop directiontracedp[4]means
descending 42dp[4] |= dp[2] (still False), then dp[2] |= dp[0]TrueFalsecorrect 0/1: one 2 cannot make 4
ascending 24dp[2] |= dp[0]True, then dp[4] |= dp[2]TrueTrueunbounded: the 2 was reused

One character of loop direction, two different problems. The rule to derive rather than memorise: when you read dp[c - w], has this round already written it? Descending says no (so it is the previous row, item unused); ascending says yes (so the item may already be in there).

Combinations versus permutations — coins = [1, 2], amount = 3:

nestingdp[0..3]dp[3]
items outer (LC 518)1 1 2 22{1,1,1}, {1,2}
capacity outer (LC 377)1 1 2 331+1+1, 1+2, 2+1

Same recurrence, same dp[0] = 1, same ascending inner loop. Only the two for lines swap, and dp[2] is even identical in both — the divergence appears only at 3, which is why a small test case can pass with the wrong nesting.

Let n be the number of items and C the capacity or target.

VariantTimeSpace
2D table (any variant)O(nC)O(nC)O(nC)O(nC)
1D rolling arrayO(nC)O(nC)O(C)O(C)
Subset sum / partition (LC 416)O(nC)O(nC)O(C)O(C) — one boolean array
Coin change, min coins (LC 322)O(nC)O(nC)O(C)O(C)
Count combinations / permutations (518, 377)O(nC)O(nC)O(C)O(C)
Target sum via sign transform (LC 494)O(nS+T2)O(n \cdot \frac{S + T}{2})O(S)O(S)
Bitset subset sumO(nC/64)O(nC / 64)O(C/64)O(C / 64)
ProblemWhich knapsackThe one thing that changes
LC 416 Partition Equal Subset Sum0/1, booleanodd total → immediately False; target is total // 2
LC 494 Target Sum0/1, countingthe sign transform: find subsets summing to (total + target) // 2; reject if that is odd or negative
LC 474 Ones and Zeroes0/1, two capacitiesdp[m][n], both dimensions descending
LC 322 Coin Change (min coins)unbounded, minimisingdp[a] = min(dp[a], dp[a-c] + 1), init inf, dp[0] = 0
LC 518 Coin Change IIunbounded, counting combinationsitems outer
LC 377 Combination Sum IVunbounded, counting permutationscapacity outer — despite the problem’s name
LC 279 Perfect Squaresunbounded, minimisingthe items are 1,4,9,1, 4, 9, \dots generated, not given
LC 1049 Last Stone Weight II0/1, maximisingminimise the difference = get as close to total // 2 as possible
Bounded knapsack (k copies)between the twobinary-split each item into 1,2,4,1, 2, 4, \dots copies, then run 0/1
Maximise value, not reachability0/1 with valuesdp[c] = max(dp[c], dp[c-w] + v) — same directions apply
Which items were chosenanykeep the 2D table and walk backwards, or store a parent per cell; the 1D array throws that information away

LC 416 — Partition Equal Subset Sum · Medium

Section titled “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) <= 200, 1 <= nums[i] <= 100.

Examples. [1,5,11,5] gives True ([1,5,5] and [11]) · [1,2,3,5] gives False · [1,1] gives True

Editorial — approach, complexity, follow-ups

Two equal subsets means each sums to total / 2, so this is exactly subset sum: can any subset reach that target?

Time O(ntarget)O(n \cdot \text{target}), at most 200×10000200 \times 10000 here. Space O(target)O(\text{target}).

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 False[1,2,3,5] sums to 11.
  • dp[0] = True is the empty subset; without it nothing is ever reachable.
  • Descending keeps it 0/1. [2,2,3,5] (total 12, target 6) is the discriminating case: no subset makes 6, but an ascending loop would reuse a 2 three times and wrongly return True.

A Python shortcut worth mentioning: a set of reachable sums, reachable |= {r + n for r in reachable}, is shorter and often faster in practice. Faster still is the bitset trick — bits |= bits << n, then test bit target — 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 // 2; the answer is total - 2 * best. “Partition into k equal subsets (LC 698)?” — much harder, backtracking with pruning. “Why not greedy?” — taking the largest first fails on [1,5,11,5].

Problem. Assign + or - to each number in nums so the resulting expression equals target. Return the number of ways.

Constraints. 1 <= len(nums) <= 20, 0 <= nums[i] <= 1000, 0 <= sum(nums) <= 1000, -1000 <= target <= 1000.

Examples. nums = [1,1,1,1,1], target = 3 gives 5 · nums = [1], target = 1 gives 1 · nums = [1], target = 2 gives 0

Editorial — approach, complexity, follow-ups

The transform is the whole solution. Splitting into positives P and negatives N gives sum(P) - sum(N) = target and sum(P) + sum(N) = total, so sum(P) = (total + target) / 2. Counting sign assignments becomes counting subsets with that sum.

Time O(nsubset)O(n \cdot \text{subset}). Space O(subset)O(\text{subset}).

Both guards come straight from the algebra:

  • (total + target) % 2 — a non-integer subset sum is impossible. ([1], 2) hits this: (1 + 2) / 2 = 1.5.
  • abs(target) > total — unreachable even using every number with one sign. Without this guard subset goes negative, [0] * (subset + 1) produces an empty list, and the indexing fails.

The zero cases are the reason to trust the algebra:

  • ([1,0], 1) gives 2. Here subset = 1. Processing the 0, the inner loop runs dp[s] += dp[s - 0], i.e. dp[s] += dp[s], doubling every count. Correct: +1+0 and +1-0.
  • ([0,0], 0) gives 4. Two zeros, two sign choices each: 22=42^2 = 4. The doubling happens twice.

Follow-ups you should expect: “Do it with a memoised recursion instead?” — dfs(index, running_sum) with lru_cache; O(nsum)O(n \cdot \text{sum}) 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 nums?” — 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.

Problem. Given coin denominations and an amount, 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) <= 300, 1 <= coins[i] <= 5000, all coins distinct, 0 <= amount <= 5000.

Examples. amount = 5, coins = [1,2,5] gives 4 · amount = 3, coins = [2] gives 0 · amount = 0, coins = [7] gives 1

Editorial — approach, complexity, follow-ups

Both loop decisions matter here, and each encodes one requirement:

  • Ascending amountdp[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+2 and 2+1 are the same path. That is combinations, not permutations.

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

(3, [1,2]) giving 2 is the discriminating case. The combinations are {1,1,1} and {1,2}. Swap the loops — amount outer, coins inner — and you get 3, because 1+2 and 2+1 become distinct. That answer is correct for LC 377 and wrong here, and the code looks equally reasonable either way.

(0, [7]) giving 1 follows from dp[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]) giving 12701, 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), with dp[0] = 0 and the rest float("inf"). Loop order does not matter there, because min is 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.

Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.

7 problems
0 easy7 medium0 hard

Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.

  • 322Coin ChangemediumMinimisation, so loop order is irrelevantNeetCode 150Blind 75LeetCode Top Interview 150amazongooglemicrosoftbytedance
  • 377Combination Sum IVmediumAmount outer = **permutations**, despite the titleBlind 75
  • 416Partition Equal Subset SummediumBoolean 0/1 subset-sum to `total // 2`NeetCode 150
  • 474Ones and Zeroesmedium0/1 knapsack with **two** capacities -- both loops descending
  • 494Target SummediumAlgebra turns signs into subset-sum; zeros double the countNeetCode 150
  • 518Coin Change IImediumCoins outer + ascending = unbounded **combinations**NeetCode 150
  • 1049Last Stone Weight IImediumLargest reachable sum at most `total // 2`; answer is `total - 2 * best`
They askWhat they’re checkingThe answer
“Why descending for 0/1?”Whether you derived itDescending means 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 pointItems outer fixes a build order (combinations); capacity outer tries every item last (permutations)
“Is this polynomial?”PrecisionPseudo-polynomial — linear in the numeric target, exponential in its bit length; subset sum is NP-complete generally
“How do you handle zeros in 494?”CareThe subset-sum transform handles them automatically: dp[s] += dp[s] doubles the count
“Recover the chosen items?”Limits of the optimisationThe 1D array cannot; keep the 2D table and walk back
“Two capacities (LC 474)?”GeneralisationA 2D dp over both budgets, with both loops descending
“Does loop order matter for LC 322?”DepthNo — min is order-blind, unlike counting
  • Odd total (LC 416) — instant False.
  • Single element[1] cannot be partitioned.
  • amount = 0 (LC 518) — exactly one way (take nothing); tests dp[0] = 1.
  • Target unreachable(3, [2]) gives 0.
  • abs(target) > total (LC 494) — guard before computing subset, or the array size goes negative.
  • (total + target) odd (LC 494) — return 0.
  • Zeros in the array (LC 494) — each doubles the count; ([0,0], 0) gives 4.
  • Coin larger than the amount — the inner loop’s lower bound skips it safely.
  • All elements equal[100,100] partitions; [1,1] too.
  • Large amounts(500, [1,2,5]) checks the arithmetic scales.
pch.quizTag Knapsack variants — self-check
  1. In the one-array 0/1 knapsack, why must the capacity loop run descending?

    pch.quizShowAnswer

    B — So that when you read dp[c - w], that index has not been overwritten this round — it still represents the previous row, so the item is used at most once — Derive it from 'has this index been written yet this round?' rather than memorising. Ascending makes dp[c - w] already include the current item, which is exactly the unbounded knapsack.

  2. `nums = [2]`, `target = 4`. What do the two loop directions give?

    pch.quizShowAnswer

    B — Descending gives False (correct 0/1 — one 2 cannot make 4); ascending gives True, because the single 2 gets reused — This is the smallest input that separates the two, and worth keeping as a test. On [1,5,11,5] both directions happen to answer True, which is why the bug hides.

  3. LC 518 counts combinations, LC 377 counts permutations. What is the difference in code?

    pch.quizShowAnswer

    B — Only the loop nesting: items outer counts combinations; capacity outer counts permutations, because at each amount you try every coin as the last one added — Same recurrence, same dp[0] = 1, same ascending inner loop. On coins [1,2] and amount 3 the answers are 2 versus 3 — but dp[2] is identical in both, so a small test can pass with the wrong nesting.

  4. How does LC 494 (put + or − before each number to hit `target`) become a knapsack?

    pch.quizShowAnswer

    B — Split into the positive set P and negated set N: P − N = target and P + N = total, so P = (total + target) / 2 — count 0/1 subsets summing to that, rejecting non-integer or negative results — The parity and range guards are half the marks: if total + target is odd, or the required sum is negative, the answer is 0 with no DP at all.

  5. The interviewer says the target is now 10^12. What happens to your O(nC) solution?

    pch.quizShowAnswer

    B — It dies — O(nC) is pseudo-polynomial, i.e. polynomial in the value of C rather than its digit count. Knapsack is NP-hard; you would need meet-in-the-middle O(2^(n/2)), branch and bound, or an approximation — Recognising that these DPs are efficient only because interview constraints keep C small is the difference between reciting the recurrence and understanding it.

  6. The follow-up asks WHICH items were selected, not just the best value. What changes?

    pch.quizShowAnswer

    B — The 1D array has thrown that information away; keep the 2D table and walk backwards (or store a parent per cell), trading O(C) space back up to O(nC) — The space optimisation is not free: collapsing rows destroys the decision history. Naming that trade-off unprompted is what the question is testing.

  • Cue — pick a subset of items to hit or maximise against a capacity: subset sum, partition, coin change, target sum, “can these be split evenly”.
  • 2D honest formdp[i][c] = max(dp[i-1][c], dp[i-1][c-w] + v). Both sources are in the previous row; that is what the 1D collapse must preserve.
  • 0/1 → capacity DESCENDING. Unbounded → capacity ASCENDING. Derive it from “has this index been written this round?”
  • Combinations → items outer. Permutations → capacity outer. LC 518 vs LC 377, and LC 377’s name lies.
  • Booleansdp[0] = True, dp[c] |= dp[c-n]. Countingdp[0] = 1, dp[a] += dp[a-c]. Minimisingdp[0] = 0, rest inf.
  • Sign transform (LC 494) — target subset is (total + target) // 2; reject odd or negative.
  • CostO(nC)O(nC) time, O(C)O(C) space, pseudo-polynomial: fine for C105C \le 10^5, hopeless for 101210^{12}.
  • Recovering the chosen items needs the 2D table back.
  • 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 min is order-blind. Only counting cares.
  • dp[0] is the base case everything rests on — True for reachability, 1 for counting, 0 for minimisation.
  • Algebra beats case analysis. The sum(P) = (total + target) / 2 transform 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.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading