Medium#4168 min readDynamic Programming2D DPKnapsackLeetCode

Partition Equal Subset Sum

1D knapsack with descending inner loop

0/1 Knapsack. dp[j] = can we make sum j? Space-optimize to 1D.

Published Apr 17, 2026

Problem

Given an integer array nums, return true if the array can be split into two subsets with equal sum.

Input
nums = [1, 5, 11, 5]
Output
true
Explanation
[1, 5, 5] and [11] both sum to 11.
Input
nums = [1, 2, 3, 5]
Output
false
Explanation
Total is 11 — odd, so no equal split.
Input
nums = [1, 1]
Output
true
Explanation
[1] and [1].

Intuition

"Split into two equal-sum subsets" has a quick reframe: the total must be even (call it 2T), and we need a subset summing to T. The other subset automatically sums to T by subtraction.

Now we have the subset sum problem: can we pick a subset of nums summing to T? That's a classic 0/1 knapsack — each item is either in or out, and we're asking a yes/no reachability question.

dp[i][j] = "using the first i items, can we sum to j?" Each item either joins the subset (dp[i - 1][j - nums[i - 1]]) or skips (dp[i - 1][j]). Take the OR.

dp[i][j] = dp[i - 1][j] || (j >= nums[i-1] && dp[i - 1][j - nums[i-1]])

The 1D collapse is the one space optimization everyone gets wrong once. Iterating j left-to-right would let the same item get counted twice (a fresh update at dp[j - x] would feed back into dp[j] in the same pass). Iterate j descending from T down to x so each item's effect propagates only forward in time. This is the hallmark move of 0/1 knapsack, and not just a trick — it's why it's called 0/1.

Approach

2D Boolean Knapsack

TimeO(n · T)
SpaceO(n · T)

Literal translation. T = sum / 2. Base row dp[0][0] = true (we can hit zero sum with zero items); everything else false. Each subsequent row considers including or excluding that row's item.

impl Solution {
    pub fn can_partition(nums: Vec<i32>) -> bool {
        let sum: i32 = nums.iter().sum();
        if sum % 2 != 0 {
            return false;
        }
        let target = (sum / 2) as usize;
        let n = nums.len();
        let mut dp = vec![vec![false; target + 1]; n + 1];
        dp[0][0] = true;
        for i in 1..=n {
            let x = nums[i - 1] as usize;
            for j in 0..=target {
                dp[i][j] = dp[i - 1][j] || (j >= x && dp[i - 1][j - x]);
            }
        }
        dp[n][target]
    }
}
Rust · runnable

1D Rolling Knapsack

TimeO(n · T)
SpaceO(T)
Recommended

Single dp: Vec<bool> of length target + 1. For each item, iterate j descending so updates to dp[j] don't see the just-updated dp[j - x]. Early-exit as soon as dp[target] flips to true.

impl Solution {
    pub fn can_partition(nums: Vec<i32>) -> bool {
        let sum: i32 = nums.iter().sum();
        if sum % 2 != 0 {
            return false;
        }
        let target = (sum / 2) as usize;
        let mut dp = vec![false; target + 1];
        dp[0] = true;
        for &x in &nums {
            let x = x as usize;
            if x > target {
                continue;
            }
            // Descending — each item applied at most once per row.
            for j in (x..=target).rev() {
                if dp[j - x] {
                    dp[j] = true;
                }
            }
            if dp[target] {
                return true;
            }
        }
        dp[target]
    }
}
Rust · runnable

For LC's tight constraints (sum 20000), a u128 bitset gives another ~32× speedup: dp |= dp << x. Same DP, expressed in machine words. Overkill for the given limits; worth knowing.

Trace

1D rolling on nums = [1, 5, 11, 5], target 11:

itemdp beforedp after
1[T,F,F,F,F,F,F,F,F,F,F,F][T,T,F,F,F,F,F,F,F,F,F,F]
5[T,T,F,F,F,F,F,F,F,F,F,F][T,T,F,F,F,T,T,F,F,F,F,F]
11[T,T,F,F,F,T,T,F,F,F,F,F][T,T,F,F,F,T,T,F,F,F,F,T]
5[T,T,F,F,F,T,T,F,F,F,F,T][T,T,F,F,F,T,T,F,F,F,T,T]

dp[11] = true. ✓ (Found after inserting the 11; we can stop there.)

Edge cases

  • Odd total — early return false. A sum split into two equal halves must be even.
  • Single element — always false (can't split one element into two non-empty halves).
  • All zerostrue. Two empty subsets both sum to zero.
  • Any x > target — skip it. Including a too-large value can never help reach target, and the unconditional dp[j - x] lookup would underflow.
ℹ️
Note

Classic bug: iterating j ascending in the 1D form gives the unbounded knapsack answer instead. That counts each item arbitrarily many times — wrong for subset sum but correct for problems like #322 Coin Change where repetition is allowed. The direction of iteration encodes whether items can be reused.

Takeaway

Partition Equal Subset Sum is the 0/1 knapsack in disguise. Recognize the pattern: "pick a subset that satisfies a constraint" → subset-sum reachability DP.

The two DP skills this problem teaches are worth way more than the problem itself:

  1. Reframe composite questions. "Split into two equal halves" became "hit target sum / 2" in one step of arithmetic.
  2. Loop direction encodes item multiplicity. Descending j = 0/1 (each item once). Ascending j = unbounded (each item any number of times).

You'll use both in #494 Target Sum (another subset-sum reframe), #322 Coin Change, and #518 Coin Change II (ordering matters).

More in 2D DP