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

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.

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
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 - 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 nestingCountscoins=[1,2]coins=[1,2], amount=3amount=3
Combinations (518)items outer, capacity innerorder ignored{1,1,1}{1,1,1}, {1,2}{1,2} = 2
Permutations (377)capacity outer, items innerorder counted1+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:

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 + targettotal + target is odd, no integer subset sum exists, so the answer is 00.
  • If abs(target) > totalabs(target) > total, the target is unreachable, so the answer is 00.
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 — minmin is order-blind322 · 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 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 FalseFalse[1,2,3,5][1,2,3,5] sums to 11.
  • dp[0] = Truedp[0] = True is 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 a 22 three times and wrongly return TrueTrue.

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 O(nsubset)O(n \cdot \text{subset}). Space O(subset)O(\text{subset}).

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 guard subsetsubset goes 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) gives 22. Here subset = 1subset = 1. Processing the 00, the inner loop runs dp[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+0 and +1-0+1-0.
  • ([0,0], 0)([0,0], 0) gives 44. 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)dfs(index, running_sum) with lru_cachelru_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 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 amountdp[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+2 and 2+12+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])(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), with dp[0] = 0dp[0] = 0 and the rest float("inf")float("inf"). Loop order does not matter there, because minmin 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.

LeetCode problem set

#ProblemDifficultyThe twist
416Partition Equal Subset SumMediumBoolean 0/1 subset-sum to total // 2total // 2
494Target SumMediumAlgebra turns signs into subset-sum; zeros double the count
518Coin Change IIMediumCoins outer + ascending = unbounded combinations
377Combination Sum IVMediumAmount outer = permutations, despite the title
322Coin ChangeMediumMinimisation, so loop order is irrelevant
474Ones and ZeroesMedium0/1 knapsack with two capacities — both loops descending
1049Last Stone Weight IIMediumLargest reachable sum at most total // 2total // 2; answer is total - 2 * besttotal - 2 * best

Interview follow-ups

They askWhat they’re checkingThe answer
“Why descending for 0/1?”Whether you derived itDescending 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 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]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 — 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); tests dp[0] = 1dp[0] = 1.
  • Target unreachable(3, [2])(3, [2]) gives 00.
  • abs(target) > totalabs(target) > total (LC 494) — guard before computing subsetsubset, or the array size goes negative.
  • (total + target)(total + target) odd (LC 494) — return 00.
  • Zeros in the array (LC 494) — each doubles the count; ([0,0], 0)([0,0], 0) gives 44.
  • 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 minmin is order-blind. Only counting cares.
  • dp[0]dp[0] is the base case everything rests on — TrueTrue for reachability, 11 for counting, 00 for minimisation.
  • Algebra beats case analysis. The sum(P) = (total + target) / 2sum(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.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did