Files
codetutor/backend/data/questions/partition-equal-subset-sum.yaml

239 lines
10 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
function_signature: "def can_partition(nums: list[int]) -> bool:"
test_cases:
visible:
- input: { nums: [1, 5, 11, 5] }
expected: true
- input: { nums: [1, 2, 3, 5] }
expected: false
- input: { nums: [1, 2, 5] }
expected: false
hidden:
- input: { nums: [1, 1] }
expected: true
- input: { nums: [2, 2, 1, 1] }
expected: true
- input: { nums: [100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 99, 97] }
expected: false
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`
&nbsp;
**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
&nbsp;
**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
&nbsp;
**Step 4: Return the result**
- Return `dp[target]` — whether we can achieve exactly half the total sum
&nbsp;
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.