Knapsack Problem Variants
Master the 0/1, unbounded, and fractional knapsack variants with DP solutions and optimization techniques for resource allocation problems.
Knapsack Problem Variants
The knapsack problem asks: given items with weights and values, maximize total value while respecting a weight capacity. This sounds simple, but it shows up everywhere — budget allocation, cargo loading, portfolio selection, job scheduling. 0/1 knapsack is NP-hard, but DP gives you pseudo-polynomial O(nW) solutions that work well in practice when weights aren’t enormous.
Three variants matter: 0/1 knapsack (discrete items, take-or-leave decisions), unbounded knapsack (each item can be taken unlimited times), and fractional knapsack (items can be divided, solvable greedily). Interview problems routinely mix these variants or add constraints like “exactly fill the capacity” or “minimize wasted space.” The state transition is the key: for 0/1, you decide whether to include each item based on what you already computed for smaller capacities; for unbounded, you can reuse items.
Introduction
The knapsack problem asks: given a set of items with weights and values, maximize total value while respecting a weight capacity. This deceptively simple optimization underlies real-world resource allocation—from budget allocation and cargo shipping to portfolio selection and job scheduling. Despite being NP-hard for 0/1 knapsack, dynamic programming provides pseudo-polynomial O(nW) solutions that work well in practice when weights aren’t enormously large.
Three variants form the core vocabulary: 0/1 knapsack (discrete items, take-or-leave decisions), unbounded knapsack (each item can be taken unlimited times), and fractional knapsack (items can be divided, solvable greedily). Interview problems routinely mix these variants or add constraints like “exactly fill the capacity” or “minimize wasted space.” The state transition is the key: for 0/1, you decide whether to include each item based on what you already computed for smaller capacities; for unbounded, you can reuse items.
This guide covers implementation patterns for all three variants, the space optimization from O(nW) to O(W), and how to recognize when a problem is knapsack in disguise. You’ll learn the difference between the “complete” knapsack and “0/1 knapsack” formulations, how to handle multi-dimensional constraints, and practical tips for when the pseudo-polynomial DP is too slow (approximation schemes or integer programming for large weights).
When to Use
Knapsack DP applies when:
- Resource allocation with constraints — Budget limits, weight limits, capacity limits
- Selection with trade-offs — Every choice has a cost and benefit
- Cargo loading problems — Trucks, ships, containers with weight limits
- Investment selection — Choosing investments with capital constraints
- Task scheduling — Selecting tasks with time/resource requirements
When Not to Use
- Fractional items and unbounded choice — Use greedy (sort by value/weight ratio)
- Massive weight capacities — O(nW) becomes impractical; use approximation algorithms
- Truly continuous values — When decisions aren’t discrete, LP relaxation works better
Architecture: DP State Transitions
graph TD
A["dp[i][w] = max value using first i items with weight limit w"] --> B{"Item i taken?"}
B -->|No| C["dp[i-1][w]"]
B -->|Yes| D["dp[i-1][w-weight[i]] + value[i]"]
A2[Unbounded: unlimited copies] --> B2{"Item i taken?"}
B2 -->|No| C2["dp[i-1][w]"]
B2 -->|Yes| D2["dp[i][w-weight[i]] + value[i]"]
Implementation
0/1 Knapsack (Standard)
def knapsack_01(weights, values, capacity):
"""
0/1 knapsack: each item taken at most once.
Time: O(n * capacity), Space: O(capacity)
"""
n = len(weights)
# Space-optimized: only need previous row
dp = [0] * (capacity + 1)
for i in range(n):
# Traverse backwards to avoid using updated values
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
Unbounded Knapsack
def knapsack_unbounded(weights, values, capacity):
"""
Unbounded knapsack: unlimited copies of each item.
Time: O(n * capacity), Space: O(capacity)
"""
dp = [0] * (capacity + 1)
for w in range(capacity + 1):
for i in range(len(weights)):
if weights[i] <= w:
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
Complete (Unbounded) with Item Tracking
def knapsack_with_reconstruction(weights, values, capacity):
"""
0/1 knapsack with item selection reconstruction.
"""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w]
if weights[i - 1] <= w:
dp[i][w] = max(dp[i][w], dp[i - 1][w - weights[i - 1]] + values[i - 1])
# Reconstruct solution
selected = []
w = capacity
for i in range(n, 0, -1):
if dp[i][w] != dp[i - 1][w]:
selected.append(i - 1)
w -= weights[i - 1]
return dp[n][capacity], selected
Multi-Dimensional Knapsack
def knapsack_2d(weights, costs, values, max_weight, max_cost):
"""
0/1 knapsack with two constraints (weight and cost).
Time: O(n * max_weight * max_cost)
"""
dp = [[0] * (max_cost + 1) for _ in range(max_weight + 1)]
for i in range(len(values)):
for w in range(max_weight, weights[i] - 1, -1):
for c in range(max_cost, costs[i] - 1, -1):
dp[w][c] = max(dp[w][c], dp[w - weights[i]][c - costs[i]] + values[i])
return dp[max_weight][max_cost]
Common Pitfalls / Anti-Patterns
- Forgetting backward traversal in 0/1 — Forward traversal uses updated values (items counted twice)
- Mutable default arguments — Never use
dp={}in function signatures - Confusing unbounded with 0/1 — Unbounded traverses forward, uses dp[w - weight[i]] (same row)
- Integer overflow — Use appropriate numeric types for large capacity values
Real-world Failure Scenarios
| Scenario | What Went Wrong | Lesson Learned |
|---|---|---|
| E-commerce inventory system | O(nW) DP with capacity 1M caused OOM on 4GB server | Weight ranges often create sparse solution spaces; consider memoization or meet-in-the-middle |
| Container loading optimization | NP-hard 0/1 approximated with greedy, missing 23% value | When precision matters, use branch-and-bound or ILP solvers for small item counts |
| Portfolio selection with transaction costs | Simple knapsack ignored fixed costs per transaction | Add transaction cost as separate dimension or post-process to group small items |
| Job scheduling with deadlines | Treated as 0/1 but jobs had soft dependencies | Precedence constraints require DAG-based DP, not standard knapsack |
Trade-off Analysis
| Variant | Time Complexity | Space Complexity | Optimality | Use When |
|---|---|---|---|---|
| 0/1 Knapsack (2D table) | O(nW) | O(nW) | Always optimal | Need to reconstruct selected items |
| 0/1 Knapsack (1D optimized) | O(nW) | O(W) | Always optimal | Only need max value, W is manageable |
| Unbounded | O(nW) | O(W) | Always optimal | Infinite supply of items |
| Fractional | O(n log n) | O(1) | Always optimal | Items divisible, greedy works |
| Multi-Dimensional 0/1 | O(nW*C) | O(W*C) | Always optimal | Two+ constraints (weight + cost + …) |
| Approximate (PTAS) | O(n^(1/ε)) | O(n) | (1-ε)-optimal | Large W, need provable bounds |
| Greedy Approximate | O(n log n) | O(1) | No guarantee | Fast, acceptable to miss optimal |
When to choose approximation: If W > 10^7 and n > 500, O(nW) becomes impractical. Greedy gives ~50% of optimal in practice. PTAS gives (1-ε) guarantee with O(n^(1/ε)) time.
Quick Recap Checklist
- 0/1 knapsack: traverse backwards, use previous row only
- Unbounded: traverse forward, can use current row (infinite supply)
- State: dp[i][w] = max value using first i items with weight limit w
- Base case: dp[0][w] = 0 (no items) and dp[i][0] = 0 (no capacity)
- Time complexity O(n × capacity) — pseudo-polynomial
Interview Questions
Backward traversal is the fix for a subtle bug: when you update dp[w] going forward, the item i you're processing could get counted again in the same iteration. Going backward, dp[w - weight[i]] is guaranteed to still hold the value from item i-1, so you never double-count.
The 1D optimization works because dp[w] only ever reads from dp[w - weight[i]]. After you update dp[w] for item i, you never need the old dp[w] again in the same item's iteration—it's consumed. Backward traversal ensures that when you read dp[w - weight[i]], you haven't overwritten it yet with item i's contribution. This collapses the 2D table to a single row without losing correctness.
Fractional knapsack has the greedy-choice property: picking the item with the highest value-to-weight ratio always leads to an optimal solution. You can cut it, so filling your capacity with the best ratio items first never backfires. 0/1 knapsack doesn't have this property—grabbing a high-ratio item might prevent you from taking several medium-ratio items that together are worth more. This missing optimal substructure is exactly what makes 0/1 NP-hard.
Multi-dimensional knapsack adds a second constraint (volume, cost, time—whatever matters for your problem). You expand the DP table to track both: dp[w][v] holds the max value achievable with weight w and volume v. The transition checks both limits before adding an item. Complexity multiplies: O(n × maxWeight × maxVolume). This shows up in container loading, vehicle routing with fuel constraints, and portfolio selection with transaction costs.
Split the items in half—call them left and right. Enumerate every subset sum for each half (2^(n/2) each, which is manageable up to about n=40). Then combine them: for each subset sum from the left, find the best matching sum from the right that fits within the capacity. This turns O(2^n) brute force into O(2^(n/2)), which is the difference between waiting forever and getting an answer before your interview ends.
Bounded knapsack specifies how many copies of each item you can take (e.g., item i has quantity limit m[i]).
The state transition adds a third dimension: dp[i][w] = max(dp[i-1][w], dp[i-1][w - k*weight[i]] + k*value[i])
for k = 1 to min(m[i], w/weight[i]). A common optimization uses binary decomposition: split each item into
powers-of-two bundles (1, 2, 4, ..., remainder) and treat each bundle as a separate 0/1 item, reducing bounded to
O(n log max_quantity) items.
Subset sum is a special case of 0/1 knapsack where all values equal weights (or values are the targets themselves).
The recurrence dp[w] = dp[w] or dp[w - num] mirrors knapsack with unit values. Conversely, any 0/1 knapsack
can be reduced to subset sum by treating (value - weight) pairs as numbers and looking for a target sum, though the
transformation loses the optimization objective. Subset sum being NP-complete confirms 0/1 knapsack is NP-hard.
The 2D table dp[i][w] is necessary when you need to reconstruct which items were selected.
The space-optimized version discards which items contributed to each state. Also, the 2D version can be easier to
debug since each row represents a clear "first i items" boundary. Use 1D only when you need the final maximum value
and don't care about the actual selection.
Base cases: dp[0][w] = 0 (zero items can produce zero value) and dp[i][0] = 0 (zero capacity
holds zero value). These anchor the recurrence. All other cells start at 0 because we're maximizing and the minimum
achievable value is 0 (no items selected). Incorrect initialization—like starting at negative infinity—would corrupt
the recurrence. The base case also reveals why negative weights require special handling: they break the natural
ordering of capacity.
Use a 2D DP table where dp[i][v] = minimum weight needed to achieve at least value v using first i items.
Traverse values in ascending order and stop when no feasible solution exists (weight exceeds capacity). Alternatively,
use Pareto optimality: track all non-dominated pairs (value, weight) and prune any solution strictly worse in both
dimensions. This produces a Pareto frontier rather than a single solution.
Tabulation builds the DP table iteratively from smaller subproblems up to the full solution—fast but processes all
states even if unused. Memoization (top-down) recursively computes only states actually reached, lazily filling
the table. Memoization avoids wasted work on sparse state spaces but has recursion overhead. For knapsack, tabulation
is usually faster due to no function call overhead; memoization shines when the reachable state space is much smaller
than the full n x W grid.
Use arbitrary-precision integers (Python's int handles this natively). In languages like C++ or Java,
use BigInteger or BigDecimal. Alternatively, normalize values: if weights are large but
limited in range, use dictionary-based sparse representation (hash maps instead of arrays) to store only reachable
states. A third approach: scale weights to smaller units or use modular arithmetic if the problem structure permits.
No greedy algorithm guarantees the optimal 0/1 knapsack solution. A common heuristic sorts by value-to-weight
ratio (v_i/w_i) and picks items until capacity is full—it runs in O(n log n) but can be arbitrarily
far from optimal. Acceptable when: (1) speed matters more than precision, (2) items are small and uniformly
distributed, (3) you need a quick bound for branch-and-bound. The greedy solution also serves as a warm start
for iterative improvement.
Negative weights break standard DP assumptions because adding a negative weight could increase remaining capacity, creating cycles. Solutions: (1) Shift all weights to positive by adding a constant, then adjust capacity; (2) Use unconstrained DP with a hash map keyed by actual weight sum, tracking max value for each reachable weight; (3) If only some weights are negative, treat them separately—negative-weight items should always be taken if valuable (unbounded effect) and require special handling in the recurrence. The problem becomes strongly NP-hard even with a single negative weight.
Branch and bound explores the item decision tree but prunes branches that cannot beat the current best solution. A bound is computed using the fractional knapsack relaxation (upper bound = current value + remaining capacity × best value-per-weight among undecided items). The algorithm: (1) Sort items by density descending; (2) Recursively decide to include/exclude each item; (3) Bound: if fractional upper bound ≤ best known integer solution, prune. For n=30-50 items this can solve problems that brute force O(2^n) cannot, though worst case is still exponential.
Multiple knapsack assigns each item to at most one of K knapsacks with individual capacities W_1...W_k. The
straightforward approach adds a dimension: dp[i][w1][w2]...[wk]—exponential in K, quickly intractable.
Practical approaches: (1) Merge knapsacks into one super-capacity if items are fungible; (2) Use integer linear
programming (ILP) solvers for exact solutions; (3) Apply greedy decomposition—assign items to the knapsack with
most remaining capacity—then locally optimize with exchanges between knapsacks.
O(nW) is not polynomial in the input size because W is represented in binary, requiring log(W) bits.
A truly polynomial algorithm would be O(n^c) where c is constant. For W = 2^100 (astronomical), even n=1 gives
2^100 operations. This matters for cryptography (large key spaces) and any problem where capacity grows exponentially
with input encoding. Mitigation: approximation schemes (PTAS), integer linear programming, or heuristic methods
when W is large relative to n.
Dynamic capacity arises in problems like budget cycles or cargo with refueling stops. Two approaches: (1) Expand
the state to include time/capacity dimension: dp[i][w][t] where t is the time step and capacity varies
deterministically (e.g., decays or resets); (2) Treat each time period as a separate knapsack subproblem and use
the solution as input for the next period. If capacity changes stochastically, use Markov decision process (MDP)
formulation with value iteration. The complexity grows multiplicatively with time steps.
Both guarantee a (1-ε) optimal solution. PTAS runs in O(n^(1/ε))—polynomial in n but exponential in 1/ε. FPTAS runs in O(n^3 / ε) or O(n^2 / ε)—truly polynomial in both n and 1/ε. The FPTAS for knapsack works by scaling values: divide all values by a factor that rounds them to O(n/ε) distinct levels, then solve the rounded instance with DP. The rounding introduces at most (1-ε) error. FPTAS is preferred when you need a provable bound with scalable precision.
Item dependencies form a constraint graph. Solutions: (1) If dependencies form a DAG, perform topological sort and use grouped DP where each strongly connected component is collapsed into a "composite item" with all valid internal combinations precomputed; (2) Add an "include flag" for each dependency pair: treat (A, B) as a combined item with weight = w_A + w_B and value = v_A + v_B; (3) Use the inclusion-exclusion principle for OR constraints where taking B requires either A or C (A ∪ C). For complex dependency graphs, consider ILP formulation which natively expresses logical constraints.
Further Reading
- Dynamic Programming: Introduction — Core DP patterns that underlie knapsack solutions
- Greedy Algorithms — When greedy works and why fractional knapsack is solvable greedily
- MIT Knapsack Lecture Notes — Deep dive into DP theory
- Stanford CS161 — Practical problem-solving with knapsack variants
Conclusion
You should now be able to recognize knapsack patterns in interview problems and pick the right variant. For 0/1 knapsack, remember backward traversal to avoid double-counting items. For unbounded, forward traversal lets you reuse the same item. When you see a greedy choice opportunity (fractional items), sort by value-to-weight ratio and grab what fits. If you need to reconstruct which items were selected, keep the full DP table. For more on dynamic programming patterns, see Dynamic Programming: Introduction.
Category
Related Posts
Memoization vs Tabulation: Two Faces of Dynamic Programming
Understand the two fundamental DP approaches—top-down with memoization and bottom-up with tabulation—plus hybrid techniques like the/m on the fly.
1D Dynamic Programming Problems: Classic Patterns
Learn to solve common 1D dynamic programming problems including climbing stairs, house robber, and coin change with optimized space solutions.
2D Dynamic Programming: Matrix Chain and Beyond
Master 2D DP problems with two state variables for string manipulation, matrix chain multiplication, and optimal game strategies.