Partition Equal Subset Sum: 0/1 Knapsack in Disguise
Given a non-empty array of positive integers, can you split it into two subsets where both subsets have the same sum?
This is LeetCode 416: Partition Equal Subset Sum, and it looks like a partitioning problem on the surface. But the real trick is recognizing that it is actually a 0/1 knapsack problem in disguise. Once you see the reduction, the solution practically writes itself.
The key insight: reducing to knapsack
Here is the thought process. If you split an array into two subsets with equal sums, each subset must sum to exactly half the total. So the question becomes: can you find a subset of the array that sums to total / 2?
That is a classic 0/1 knapsack question. Each element is an "item" you either take or skip. The "knapsack capacity" is total / 2. You are not optimizing for value here, just asking a yes/no question: is the target sum reachable?
A few things fall out of this immediately:
- If the total sum is odd, return
Falseright away. You cannot split an odd number into two equal integers. - If any single element is bigger than
total / 2, returnFalse. That element cannot belong to either subset without exceeding the target. - Otherwise, build a DP table to check whether sum
total / 2is achievable.
This reduction is the entire problem. The DP itself is straightforward once you frame it correctly.
Starting with recursion
For each element, you have two choices: include it in the subset or skip it. If you include it, subtract its value from the remaining target. If you skip it, move on to the next element with the same target.
def can_partition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
def helper(i, remaining):
if remaining == 0:
return True
if i >= len(nums) or remaining < 0:
return False
# Take or skip
return helper(i + 1, remaining - nums[i]) or helper(i + 1, remaining)
return helper(0, target)
This is correct but exponential. Each element branches into two choices, giving O(2^n) time. For an array of 200 elements (the LeetCode constraint), that is not going to work.
The branching factor of 2 at every element is the hallmark of the 0/1 knapsack pattern. Each item is either in the knapsack or not. No partial amounts, no reuse. That "take or skip" decision at every step is what separates 0/1 knapsack from unbounded knapsack (like Coin Change, where you can reuse items).
Adding memoization (top-down DP)
The recursive solution recomputes the same (index, remaining) pairs over and over. Cache them.
def can_partition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
memo = {}
def helper(i, remaining):
if remaining == 0:
return True
if i >= len(nums) or remaining < 0:
return False
if (i, remaining) in memo:
return memo[(i, remaining)]
memo[(i, remaining)] = (
helper(i + 1, remaining - nums[i]) or
helper(i + 1, remaining)
)
return memo[(i, remaining)]
return helper(0, target)
Now each (i, remaining) pair is solved at most once. There are n possible values for i and target possible values for remaining, so time is O(n * target) and space is O(n * target) for the memo table. Much better.
Building the DP table (bottom-up)
The bottom-up version uses a 1D boolean array. dp[j] answers the question: "can we form sum j using some subset of the elements processed so far?"
The algorithm:
- Start with
dp[0] = True(sum 0 is always achievable with the empty subset). - For each number
numin the array, iteratejfromtargetdown tonum. Ifdp[j - num]isTrue, thendp[j]becomesTruetoo. - After processing all numbers, check
dp[target].
Why iterate backwards? Because each element can only be used once (this is 0/1 knapsack, not unbounded). If you iterated forwards, you could accidentally use the same element multiple times in a single pass. Going from right to left ensures that when you read dp[j - num], it reflects the state before including the current element.
Let's trace through nums = [1, 5, 11, 5], where total = 22 and target = 11:
Base case: dp[0] = True (sum 0 is always reachable)
Before processing any element, the only achievable sum is 0.
Process num = 1: we can now also reach sum 1
dp[0] was True, so dp[0 + 1] = dp[1] becomes True.
Process num = 5: reach sums 5 and 6
For each True cell j, mark dp[j + 5] as True. New sums: 5 and 6.
Process num = 11: reach sum 11. Target hit!
dp[0 + 11] = dp[11] becomes True. We have reached the target sum of 11.
Process num = 5 (second): more sums reachable, but dp[11] was already True
dp[11] is still True. The answer is True: we can partition into equal subsets.
The moment dp[11] flips to True, we know a valid partition exists. We do not even need to know which elements are in which subset. The boolean answer is enough.
def can_partition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
if dp[j - num]:
dp[j] = True
return dp[target]
Time: O(n * target) where n is the length of the array and target is total / 2.
Space: O(target). A single boolean array.
That backward inner loop is the only subtle part. Everything else is standard DP boilerplate.
Compare this to Coin Change, where the inner loop goes forward because you can reuse coins (unbounded knapsack). Here the inner loop goes backward because each number can only be used once (0/1 knapsack). That loop direction is the mechanical difference between bounded and unbounded knapsack DP.
Why 0/1 knapsack and not unbounded?
In Coin Change, you can use each denomination as many times as you want. That makes it unbounded knapsack. Here, each array element belongs to exactly one subset. You cannot put the same element in both. That is the 0/1 constraint: each item is either "in" (taken) or "out" (skipped), exactly once.
The structural difference in code is small but critical:
- Unbounded (Coin Change): inner loop goes from
cointotarget(left to right). Updated cells can be used again in the same pass, allowing reuse. - 0/1 (Partition Equal Subset Sum): inner loop goes from
targetdown tonum(right to left). Each cell is only updated using values from the previous pass, preventing reuse.
If you accidentally iterate forward in this problem, you will get wrong answers because the same number gets "used" multiple times.
Complexity analysis
| Approach | Time | Space |
|---|---|---|
| Naive recursion | O(2^n) | O(n) call stack |
| Memoized recursion | O(n * target) | O(n * target) |
| Bottom-up 1D DP | O(n * target) | O(target) |
Where target = sum(nums) / 2. For LeetCode constraints (n up to 200, values up to 100), target can be at most 10,000. So the DP table has at most 10,001 entries and you loop through it at most 200 times. That is 2 million operations, which runs in milliseconds.
Edge cases to watch for
- Odd total: return
Falseimmediately. No need to build any DP table. - Single element: an array with one number cannot be split. Return
False. - Element equals target: e.g.,
nums = [3, 3]. The first element makesdp[3]True, and you are done. - All identical elements: e.g.,
[4, 4, 4, 4]. Total is 16, target is 8. Works fine: pick any two elements. - Large single element exceeding target: e.g.,
nums = [1, 2, 5], total = 8, target = 4. Element 5 exceeds the target on its own, but the DP naturally handles this because the inner loop starts attargetand only goes down tonum.
The building blocks
This problem is built on take-or-skip DP, the same core decision pattern as House Robber. At every element, you decide: include this number in the target subset, or leave it out? The DP records the consequences of all possible combinations of those decisions.
The structure:
- State:
dp[j]= can we achieve sumjusing elements seen so far? - Transition: for each
num, setdp[j] = Trueifdp[j - num]was alreadyTrue - Loop direction: backward (right to left) to enforce the 0/1 constraint
This same building block appears in many other problems:
- Target Sum (LeetCode 494): same partition idea with a twist. Reduce to "find a subset that sums to
(total + target) / 2." - Last Stone Weight II (LeetCode 1049): minimize the remaining stone weight. Reduces to finding a subset sum as close to
total / 2as possible. Same DP. - 0/1 Knapsack (classic): the textbook version with weights and values. Same take-or-skip structure, but you track max value instead of reachability.
Once you recognize the 0/1 knapsack skeleton, these problems are all the same DP with different wrappers.
From understanding to recall
The reduction from "partition into equal subsets" to "find a subset summing to half" is the kind of insight that feels obvious once you see it, but is easy to blank on during an interview. Spaced repetition helps lock in these reductions so they fire automatically when you see a new problem.
The take-or-skip DP building block is one of roughly 60 reusable patterns that cover hundreds of LeetCode problems. Drilling the 0/1 knapsack pattern individually, writing the backward loop from scratch each time, and spacing your reps out over days and weeks is how you get from "I understand it" to "I can write it cold."
Related posts
- Coin Change - Unbounded knapsack DP, where the inner loop goes forward instead of backward. Great contrast with this problem.
- House Robber - The same take-or-skip decision at each element, applied to a linear optimization problem instead of a subset reachability problem.