0/1 Knapsack: Recursion to Single-Array Space-Optimized Approach
1. Why Do We Need This? (Problem Statement & Status Quo)
Imagine a thief entering a house to steal items. There are items available, and each item has a specific weight and a specific financial value. The thief carries a knapsack (bag) that has a strict weight capacity limit, say . The thief cannot carry everything; the total weight of the selected items must not exceed the bag's weight capacity.
The goal of the 0/1 Knapsack problem is to choose a subset of items such that:
- The total weight of the chosen items is .
- The total value of the chosen items is maximized.
Concrete Example Setup
- Items ():
- Item 0:
- Item 1:
- Item 2:
- Bag Capacity ():
If the thief picks Item 0 (weight 3, value 30) and Item 2 (weight 5, value 60):
- Total Weight: (fits the capacity of 8)
- Total Value:
Can we pick Item 1 and Item 2?
- Total Weight: (exceeds bag capacity 8, so invalid).
2. Core Mental Model & Why Greedy Fails
Real-Life Analogy: The Uneducated Thief
A common initial instinct is to use a Greedy Approach: sort items by value-to-weight ratio or pick the most expensive item first, then take the next most expensive, and so on, until the bag is full.
However, Greedy fails here because of a lack of uniformity.
Counter-Example:
Bag Capacity (W) = 6
Item 0: Weight = 5, Value = 60 (Most expensive)
Item 1: Weight = 3, Value = 40
Item 2: Weight = 2, Value = 30
- Greedy Choice: The thief greedily picks Item 0 (Value = 60). The remaining bag capacity becomes . The thief cannot fit any other items. Total value = 60.
- Optimal Dynamic Programming Choice: The thief picks Item 1 (Value = 40, Weight = 3) and Item 2 (Value = 30, Weight = 2). Total weight = (). Total value = .
Because future combinations can yield a higher cumulative value, greedy strategies collapse. We must try all combinations to find the maximal value, which leads us to Recursion and Dynamic Programming.
3. Step 1: Recursive Formulation (Try All Combinations)
To design our recursive function f(index, w), we follow standard dynamic programming on subsequences rules:
-
Express in terms of indices and parameters:
index: The current item we are considering (ranging from down to ).w: The remaining weight capacity of the bag.- Meaning of
f(index, w): The maximum value you can achieve considering items from index toindexwith a remaining bag weight ofw.
-
Explore all possibilities (Pick & Non-Pick):
- Not Take (Exclude): Do not include the current item. Move to
index - 1with the same bag weightw. Value added =0. - Take (Include): You can only take an item if its weight is less than or equal to the remaining bag weight (
wt[index] <= w). If valid, you addval[index]and move toindex - 1with a reduced bag weight:w - wt[index].
- Not Take (Exclude): Do not include the current item. Move to
-
Take the Maximum: Return the maximum value between taking and not taking.
Base Cases for Recursion
When we reach index == 0 (only one item left):
- If the remaining bag weight
wis greater than or equal towt[0], we can steal it and returnval[0]. - Otherwise, we cannot fit it, so we return
0.
4. Step 2: Memoization (Top-Down DP)
Because recursion recomputes overlapping subproblems, time complexity explodes to . We eliminate redundant work by caching states in a 2D DP table of size [n][max_weight + 1] initialized to -1.
Memoized Code Structure (C++)
int f(int index, int w, vector<int>& wt, vector<int>& val, vector<vector<int>>& dp) {
if (index == 0) {
if (wt[0] <= w) return val[0];
return 0;
}
if (dp[index][w] != -1) return dp[index][w];
int notTake = 0 + f(index - 1, w, wt, val, dp);
int take = INT_MIN;
if (wt[index] <= w) {
take = val[index] + f(index - 1, w - wt[index], wt, val, dp);
}
return dp[index][w] = max(notTake, take);
}
- Time Complexity:
- Space Complexity: (DP Table) + (Recursive Stack Space)
5. Step 3: Tabulation (Bottom-Up DP)
We convert top-down memoization into bottom-up tabulation to eliminate recursion stack space.
Tabulation Logic
- Base Case Translation: For
index = 0, any bag weight can store the item, yielding a value ofval[0]. All other weights yield0. - Nested Loops:
- Outer loop
indexfrom1ton - 1. - Inner loop
wfrom0tomax_weight.
- Outer loop
- Recurrence Copy: Directly copy the pick/non-pick logic into the table.
Tabulation Code (C++)
int knapsack(vector<int>& wt, vector<int>& val, int n, int maxWeight) {
vector<vector<int>> dp(n, vector<int>(maxWeight + 1, 0));
// Base case: for index 0
for (int w = wt[0]; w <= maxWeight; w++) {
dp[0][w] = val[0];
}
for (int index = 1; index < n; index++) {
for (int w = 0; w <= maxWeight; w++) {
int notTake = dp[index - 1][w];
int take = INT_MIN;
if (wt[index] <= w) {
take = val[index] + dp[index - 1][w - wt[index]];
}
dp[index][w] = max(notTake, take);
}
}
return dp[n - 1][maxWeight];
}
6. Step 4: Two-Row Space Optimization
Notice that to compute any row index, we only ever require values from the immediate previous row (index - 1). We do not need the entire history of all previous rows.
Thus, we can reduce our space complexity from down to by maintaining just two rows: prev and curr.
int knapsackTwoRow(vector<int>& wt, vector<int>& val, int n, int maxWeight) {
vector<int> prev(maxWeight + 1, 0), curr(maxWeight + 1, 0);
for (int w = wt[0]; w <= maxWeight; w++) {
prev[w] = val[0];
}
for (int index = 1; index < n; index++) {
for (int w = 0; w <= maxWeight; w++) {
int notTake = prev[w];
int take = INT_MIN;
if (wt[index] <= w) {
take = val[index] + prev[w - wt[index]];
}
curr[w] = max(notTake, take);
}
prev = curr;
}
return prev[maxWeight];
}
7. Step 5: Masterclass — Single-Array Space Optimization ()
Can we optimize this further from two rows down to just a single array ( space)?
The Problem with Left-to-Right Iteration
If we try to use a single array and iterate bag weight from 0 up to maxWeight:
- When calculating
curr[w], we needprev[w - wt[index]](a value from the left side of the array). - If we overwrite our single array from left to right, we destroy the older values of the previous row that our current calculations depend on.
The Right-to-Left Traversal Insight
To safely update a single array in place without losing necessary state from the "previous row", we must iterate the inner weight loop backwards from maxWeight down to 0.
Why does this work?
When we compute values moving from right to left, any lookup to w - wt[index] refers to an element on the left side. Since we haven't modified the left side yet in the current row iteration, that left-side value still holds the exact data from the previous row!
Single-Array Space-Optimized Code (C++)
int knapsackSingleArray(vector<int>& wt, vector<int>& val, int n, int maxWeight) {
vector<int> prev(maxWeight + 1, 0);
// Base case: for index 0
for (int w = wt[0]; w <= maxWeight; w++) {
prev[w] = val[0];
}
for (int index = 1; index < n; index++) {
// Iterate backwards from maxWeight down to 0
for (int w = maxWeight; w >= 0; w--) {
int notTake = prev[w];
int take = INT_MIN;
if (wt[index] <= w) {
take = val[index] + prev[w - wt[index]];
}
prev[w] = max(notTake, take);
}
}
return prev[maxWeight];
}
8. Summary & Comparison Table
Summary and Key Takeaways
- Greedy Fails: Greedy strategies fail in 0/1 Knapsack due to a lack of value-to-weight uniformity, requiring exhaustive combination checks via Dynamic Programming.
- Subsequence Pattern: The problem models naturally as DP on subsequences using binary choices: Take or Not Take.
- State Representation: Defined by two parameters:
index(current item being evaluated) andw(remaining bag weight capacity). - Base Case Design: Evaluated cleanly at the boundary (
index == 0) by checking if the single remaining item fits within the remaining weight capacity. - Space Reduction Journey: Space complexity evolves effortlessly from down to by keeping two rows.
- The Single-Array Trick: By reversing the inner weight loop direction from
maxWeightdown to0, we eliminate the second row entirely and achieve optimal space complexity without corrupting dependency states.