221 lines
9.4 KiB
YAML
221 lines
9.4 KiB
YAML
title: Partition Equal Subset Sum
|
||
slug: partition-equal-subset-sum
|
||
difficulty: medium
|
||
leetcode_id: 416
|
||
leetcode_url: https://leetcode.com/problems/partition-equal-subset-sum/
|
||
categories:
|
||
- arrays
|
||
- dynamic-programming
|
||
patterns:
|
||
- dynamic-programming
|
||
|
||
description: |
|
||
Given an integer array `nums`, return `true` *if you can partition the array into two subsets such that the sum of the elements in both subsets is equal* or `false` *otherwise*.
|
||
|
||
constraints: |
|
||
- `1 <= nums.length <= 200`
|
||
- `1 <= nums[i] <= 100`
|
||
|
||
examples:
|
||
- input: "nums = [1,5,11,5]"
|
||
output: "true"
|
||
explanation: "The array can be partitioned as [1, 5, 5] and [11]."
|
||
- input: "nums = [1,2,3,5]"
|
||
output: "false"
|
||
explanation: "The array cannot be partitioned into equal sum subsets."
|
||
|
||
explanation:
|
||
intuition: |
|
||
At first glance, this looks like a partitioning problem where we need to divide an array into two groups. But here's the key insight: if we can partition the array into two subsets with **equal sums**, then each subset must sum to exactly **half of the total array sum**.
|
||
|
||
Think of it like balancing a scale. If the total weight is 22, each side needs exactly 11 to balance. This transforms our problem: instead of finding two equal subsets, we just need to find **one subset that sums to `total_sum / 2`**. The remaining elements automatically form the other subset.
|
||
|
||
This is a classic **0/1 Knapsack** problem in disguise. Imagine you have a knapsack with capacity `total_sum / 2`. Each number in the array is an item you can either include or exclude. Can you fill the knapsack exactly?
|
||
|
||
Two immediate observations help us prune:
|
||
- If the total sum is **odd**, it's impossible to split into two equal integer sums — return `false` immediately
|
||
- If any single element exceeds `total_sum / 2`, it can't fit in either subset — return `false`
|
||
|
||
approach: |
|
||
We use **Dynamic Programming** with a boolean array to track achievable sums.
|
||
|
||
**Step 1: Calculate the target**
|
||
|
||
- Compute `total_sum` of all elements
|
||
- If `total_sum` is odd, return `false` immediately (can't split evenly)
|
||
- Set `target = total_sum // 2`
|
||
|
||
|
||
|
||
**Step 2: Initialise the DP array**
|
||
|
||
- Create a boolean array `dp` of size `target + 1`
|
||
- `dp[i]` represents: "Can we achieve sum `i` using some subset of numbers seen so far?"
|
||
- Set `dp[0] = True` — we can always achieve sum 0 by selecting nothing
|
||
|
||
|
||
|
||
**Step 3: Process each number**
|
||
|
||
- For each `num` in the array, iterate **backwards** from `target` down to `num`
|
||
- For each sum `j`, if `dp[j - num]` is `True`, then `dp[j]` becomes `True`
|
||
- We iterate backwards to avoid using the same number twice in one iteration
|
||
|
||
|
||
|
||
**Step 4: Return the result**
|
||
|
||
- Return `dp[target]` — whether we can achieve exactly half the total sum
|
||
|
||
|
||
|
||
The backward iteration is crucial: if we went forward, adding a number could affect later calculations in the same pass, effectively "using" the number multiple times.
|
||
|
||
common_pitfalls:
|
||
- title: Forward Iteration Leads to Reusing Elements
|
||
description: |
|
||
A subtle but critical bug occurs if you iterate forward through sums instead of backward.
|
||
|
||
Consider `nums = [1, 2]` with `target = 3`. If we process `1` going forward:
|
||
- `dp[1] = True` (we can make 1)
|
||
- Then checking `dp[2]`: `dp[2 - 1] = dp[1] = True`, so `dp[2] = True`
|
||
|
||
But wait — we've effectively used `1` twice! The backward iteration prevents this by ensuring we only consider sums achievable *before* adding the current number.
|
||
wrong_approach: "Forward iteration: for j in range(num, target + 1)"
|
||
correct_approach: "Backward iteration: for j in range(target, num - 1, -1)"
|
||
|
||
- title: Forgetting the Odd Sum Early Exit
|
||
description: |
|
||
If the total sum is odd (e.g., 11), there's no way to split it into two equal integer parts. Without this check, the algorithm would waste time computing a DP table for an impossible target.
|
||
|
||
Always check `if total_sum % 2 != 0: return False` before proceeding.
|
||
wrong_approach: "Skipping the odd check and computing DP anyway"
|
||
correct_approach: "Return False immediately when total_sum is odd"
|
||
|
||
- title: Not Handling Large Single Elements
|
||
description: |
|
||
If any element exceeds `target`, it cannot be part of either subset that sums to `target`. While the DP naturally handles this (we skip sums less than `num`), an explicit check can provide an early exit.
|
||
|
||
For `nums = [1, 2, 100]` with `total_sum = 103`, this is odd so we'd exit anyway. But for `nums = [1, 100, 1]` with `total_sum = 102` and `target = 51`, the `100` makes partitioning impossible.
|
||
|
||
key_takeaways:
|
||
- "**Subset sum is 0/1 Knapsack**: Recognise that finding a subset with a target sum is equivalent to the classic knapsack problem"
|
||
- "**Transform the problem**: Instead of finding two equal subsets, find one subset summing to half the total — much simpler"
|
||
- "**Backward DP iteration**: When each element can only be used once, iterate backwards to prevent double-counting"
|
||
- "**Early pruning matters**: Odd total sums are impossible; check this first for an O(1) exit in many cases"
|
||
|
||
time_complexity: "O(n × target) where `n` is the array length and `target = sum(nums) / 2`. We process each number once and update up to `target` sums."
|
||
space_complexity: "O(target). We use a 1D DP array of size `target + 1`. This can be up to O(n × max_element / 2) = O(n × 50) = O(n × 50) in the worst case given the constraints."
|
||
|
||
solutions:
|
||
- approach_name: 1D Dynamic Programming
|
||
is_optimal: true
|
||
code: |
|
||
def can_partition(nums: list[int]) -> bool:
|
||
total_sum = sum(nums)
|
||
|
||
# Odd sum can't be split into two equal parts
|
||
if total_sum % 2 != 0:
|
||
return False
|
||
|
||
target = total_sum // 2
|
||
|
||
# dp[i] = True if we can achieve sum i with some subset
|
||
dp = [False] * (target + 1)
|
||
dp[0] = True # Sum of 0 is always achievable (empty subset)
|
||
|
||
for num in nums:
|
||
# Iterate backwards to avoid using same num twice
|
||
for j in range(target, num - 1, -1):
|
||
# If we could make (j - num), we can now make j
|
||
if dp[j - num]:
|
||
dp[j] = True
|
||
|
||
# Early exit if we've found the target
|
||
if dp[target]:
|
||
return True
|
||
|
||
return dp[target]
|
||
explanation: |
|
||
**Time Complexity:** O(n × target) — For each of the n numbers, we potentially update target sums.
|
||
|
||
**Space Complexity:** O(target) — Single array of size target + 1.
|
||
|
||
This optimised 1D approach uses the insight that we only need the previous row's values, and by iterating backwards, we can update in place without a second array.
|
||
|
||
- approach_name: 2D Dynamic Programming
|
||
is_optimal: false
|
||
code: |
|
||
def can_partition(nums: list[int]) -> bool:
|
||
total_sum = sum(nums)
|
||
|
||
# Odd sum can't be split into two equal parts
|
||
if total_sum % 2 != 0:
|
||
return False
|
||
|
||
target = total_sum // 2
|
||
n = len(nums)
|
||
|
||
# dp[i][j] = True if first i elements can sum to j
|
||
dp = [[False] * (target + 1) for _ in range(n + 1)]
|
||
|
||
# Base case: sum 0 is achievable with any number of elements
|
||
for i in range(n + 1):
|
||
dp[i][0] = True
|
||
|
||
for i in range(1, n + 1):
|
||
num = nums[i - 1]
|
||
for j in range(1, target + 1):
|
||
# Don't include current number
|
||
dp[i][j] = dp[i - 1][j]
|
||
|
||
# Include current number if it fits
|
||
if j >= num:
|
||
dp[i][j] = dp[i][j] or dp[i - 1][j - num]
|
||
|
||
return dp[n][target]
|
||
explanation: |
|
||
**Time Complexity:** O(n × target) — Same as 1D approach.
|
||
|
||
**Space Complexity:** O(n × target) — Full 2D table.
|
||
|
||
This classic 2D DP makes the state transitions clearer: for each element, we either include it (look at `dp[i-1][j-num]`) or exclude it (look at `dp[i-1][j]`). While easier to understand, it uses more memory than necessary.
|
||
|
||
- approach_name: Recursive with Memoization
|
||
is_optimal: false
|
||
code: |
|
||
def can_partition(nums: list[int]) -> bool:
|
||
total_sum = sum(nums)
|
||
|
||
if total_sum % 2 != 0:
|
||
return False
|
||
|
||
target = total_sum // 2
|
||
memo = {}
|
||
|
||
def dp(index: int, remaining: int) -> bool:
|
||
# Base cases
|
||
if remaining == 0:
|
||
return True
|
||
if index >= len(nums) or remaining < 0:
|
||
return False
|
||
|
||
# Check memo
|
||
if (index, remaining) in memo:
|
||
return memo[(index, remaining)]
|
||
|
||
# Try including or excluding current element
|
||
result = (dp(index + 1, remaining - nums[index]) or
|
||
dp(index + 1, remaining))
|
||
|
||
memo[(index, remaining)] = result
|
||
return result
|
||
|
||
return dp(0, target)
|
||
explanation: |
|
||
**Time Complexity:** O(n × target) — Each unique (index, remaining) state computed once.
|
||
|
||
**Space Complexity:** O(n × target) for memoization + O(n) recursion stack.
|
||
|
||
This top-down approach may be more intuitive for those who think recursively. At each index, we branch: either include the current number (subtract from remaining) or skip it. Memoization prevents recomputation of identical subproblems.
|