218 lines
9.8 KiB
YAML
218 lines
9.8 KiB
YAML
title: Partition to K Equal Sum Subsets
|
|
slug: partition-to-k-equal-sum-subsets
|
|
difficulty: medium
|
|
leetcode_id: 698
|
|
leetcode_url: https://leetcode.com/problems/partition-to-k-equal-sum-subsets/
|
|
categories:
|
|
- arrays
|
|
- dynamic-programming
|
|
- recursion
|
|
patterns:
|
|
- backtracking
|
|
|
|
description: |
|
|
Given an integer array `nums` and an integer `k`, return `true` if it is possible to divide this array into `k` non-empty subsets whose sums are all equal.
|
|
|
|
constraints: |
|
|
- `1 <= k <= nums.length <= 16`
|
|
- `1 <= nums[i] <= 10^4`
|
|
- The frequency of each element is in the range `[1, 4]`
|
|
|
|
examples:
|
|
- input: "nums = [4,3,2,3,5,2,1], k = 4"
|
|
output: "true"
|
|
explanation: "It is possible to divide it into 4 subsets (5), (1,4), (2,3), (2,3) with equal sums of 5 each."
|
|
- input: "nums = [1,2,3,4], k = 3"
|
|
output: "false"
|
|
explanation: "The total sum is 10, which is not divisible by 3, so equal partition is impossible."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you have `k` empty buckets, and each bucket must hold exactly the same total weight. Your task is to distribute all the items (numbers) from the array into these buckets such that every bucket ends up with the same sum.
|
|
|
|
The first insight is mathematical: if we can partition the array into `k` equal-sum subsets, then each subset must have sum equal to `total_sum / k`. If the total sum isn't divisible by `k`, we can immediately return `false`.
|
|
|
|
Think of it like this: you're trying to fill `k` buckets one by one. For each number, you try placing it in a bucket. If a bucket would overflow (exceed the target sum), you skip it. If you successfully fill all buckets to exactly the target, you've found a valid partition.
|
|
|
|
The key constraint `nums.length <= 16` is a hint — this small size allows exponential-time solutions like backtracking. The challenge is to prune the search space efficiently to avoid exploring redundant paths.
|
|
|
|
approach: |
|
|
We solve this using **Backtracking with Pruning**:
|
|
|
|
**Step 1: Early validation**
|
|
|
|
- Calculate `total_sum` of all elements
|
|
- If `total_sum % k != 0`, return `false` immediately — equal partition is impossible
|
|
- Calculate `target = total_sum / k` — this is the sum each bucket must have
|
|
- If any single element exceeds `target`, return `false` — it can't fit in any bucket
|
|
|
|
|
|
|
|
**Step 2: Sort in descending order**
|
|
|
|
- Sort `nums` in descending order
|
|
- This crucial optimisation places larger numbers first, failing faster when a bucket can't fit
|
|
- Large numbers have fewer placement options, so trying them early prunes more branches
|
|
|
|
|
|
|
|
**Step 3: Backtracking with k buckets**
|
|
|
|
- Create an array `buckets` of size `k`, all initialised to `0`
|
|
- For each number (in descending order), try placing it in each bucket
|
|
- A number can go in bucket `i` if `buckets[i] + num <= target`
|
|
- If placement succeeds, recursively try to place the next number
|
|
- If all numbers are placed successfully, return `true`
|
|
- If no valid placement exists, backtrack (remove number from bucket) and try the next bucket
|
|
|
|
|
|
|
|
**Step 4: Skip duplicate buckets**
|
|
|
|
- If `buckets[i] == buckets[i-1]`, skip bucket `i` — placing the number there would lead to the same configuration we'll explore via bucket `i-1`
|
|
- This avoids redundant work when buckets have identical current sums
|
|
|
|
|
|
|
|
**Step 5: Return the result**
|
|
|
|
- If backtracking explores all options without success, return `false`
|
|
|
|
common_pitfalls:
|
|
- title: Missing the Divisibility Check
|
|
description: |
|
|
If `total_sum % k != 0`, it's mathematically impossible to partition into `k` equal subsets. Always check this first to avoid unnecessary computation.
|
|
|
|
For example, `nums = [1,2,3,4]` with `k = 3` has sum `10`, which isn't divisible by `3`.
|
|
wrong_approach: "Starting backtracking without checking divisibility"
|
|
correct_approach: "Return false immediately if total_sum % k != 0"
|
|
|
|
- title: Not Sorting in Descending Order
|
|
description: |
|
|
Without sorting, the backtracking explores many dead-end paths before failing. By placing larger numbers first, we fail faster when a number can't fit.
|
|
|
|
Consider `nums = [1,1,1,1,1,1,10]` with `k = 2` and `target = 8`. Without sorting, we might try many combinations of 1s before realising the `10` can never fit. Sorting puts `10` first, causing immediate failure.
|
|
wrong_approach: "Backtracking on unsorted array"
|
|
correct_approach: "Sort descending to prune earlier"
|
|
|
|
- title: Not Skipping Duplicate Bucket States
|
|
description: |
|
|
If two buckets have the same current sum, placing a number in either leads to equivalent configurations. Without this optimisation, the algorithm does redundant work.
|
|
|
|
For example, with `buckets = [3, 3, 0, 0]` and trying to place `2`, placing it in bucket 0 vs bucket 1 creates the same branching structure. Skip one to avoid duplication.
|
|
wrong_approach: "Trying every bucket regardless of current sums"
|
|
correct_approach: "Skip bucket i if buckets[i] == buckets[i-1]"
|
|
|
|
- title: Forgetting the Single Large Element Check
|
|
description: |
|
|
If any element is larger than `target`, it can never fit in any bucket. Check this after calculating the target to fail fast.
|
|
wrong_approach: "Letting backtracking discover this through exhaustion"
|
|
correct_approach: "Check max(nums) <= target before backtracking"
|
|
|
|
key_takeaways:
|
|
- "**Backtracking pattern**: When exploring combinations, try each option, recurse, then undo (backtrack) if it doesn't lead to a solution"
|
|
- "**Pruning is essential**: Sorting descending and skipping duplicate states transform an unusable algorithm into a practical one"
|
|
- "**Mathematical pre-checks**: Always validate constraints (divisibility, max element) before starting expensive computation"
|
|
- "**Small constraints hint at approach**: `n <= 16` suggests exponential algorithms like backtracking or bitmask DP are acceptable"
|
|
|
|
time_complexity: "O(k * 2^n). In the worst case, we explore all possible subsets for each of the k buckets. Pruning significantly reduces this in practice."
|
|
space_complexity: "O(n). We use O(n) for the recursion stack depth and O(k) for the buckets array."
|
|
|
|
solutions:
|
|
- approach_name: Backtracking with Pruning
|
|
is_optimal: true
|
|
code: |
|
|
def can_partition_k_subsets(nums: list[int], k: int) -> bool:
|
|
total = sum(nums)
|
|
|
|
# If total isn't divisible by k, equal partition is impossible
|
|
if total % k != 0:
|
|
return False
|
|
|
|
target = total // k
|
|
|
|
# If any element exceeds target, it can't fit in any bucket
|
|
if max(nums) > target:
|
|
return False
|
|
|
|
# Sort descending to fail faster (large numbers have fewer options)
|
|
nums.sort(reverse=True)
|
|
|
|
# k buckets, each starting empty
|
|
buckets = [0] * k
|
|
|
|
def backtrack(index: int) -> bool:
|
|
# All numbers placed successfully
|
|
if index == len(nums):
|
|
return True
|
|
|
|
num = nums[index]
|
|
|
|
for i in range(k):
|
|
# Skip duplicate bucket states to avoid redundant work
|
|
if i > 0 and buckets[i] == buckets[i - 1]:
|
|
continue
|
|
|
|
# Try placing num in bucket i
|
|
if buckets[i] + num <= target:
|
|
buckets[i] += num
|
|
|
|
# Recurse to place next number
|
|
if backtrack(index + 1):
|
|
return True
|
|
|
|
# Backtrack: remove num from bucket
|
|
buckets[i] -= num
|
|
|
|
# No valid placement found for this number
|
|
return False
|
|
|
|
return backtrack(0)
|
|
explanation: |
|
|
**Time Complexity:** O(k * 2^n) worst case — we may explore all subset combinations. Pruning makes this much faster in practice.
|
|
|
|
**Space Complexity:** O(n) — recursion stack depth plus O(k) for buckets array.
|
|
|
|
We try to fill k buckets by placing each number (largest first) into a valid bucket. Key optimisations: sorting descending to fail fast, skipping duplicate bucket states to avoid redundant paths, and early termination checks.
|
|
|
|
- approach_name: Bitmask Dynamic Programming
|
|
is_optimal: false
|
|
code: |
|
|
def can_partition_k_subsets(nums: list[int], k: int) -> bool:
|
|
total = sum(nums)
|
|
|
|
if total % k != 0:
|
|
return False
|
|
|
|
target = total // k
|
|
n = len(nums)
|
|
|
|
# dp[mask] = remaining sum needed for current bucket
|
|
# -1 means this state is unreachable
|
|
dp = [-1] * (1 << n)
|
|
dp[0] = 0 # Empty set has 0 sum
|
|
|
|
for mask in range(1 << n):
|
|
if dp[mask] == -1:
|
|
continue
|
|
|
|
for i in range(n):
|
|
# If element i is not yet used
|
|
if mask & (1 << i) == 0:
|
|
# Current bucket sum after adding nums[i]
|
|
new_sum = dp[mask] + nums[i]
|
|
|
|
if new_sum <= target:
|
|
new_mask = mask | (1 << i)
|
|
# Mod by target: when bucket fills, start new one
|
|
dp[new_mask] = new_sum % target
|
|
|
|
# All elements used, last bucket exactly filled
|
|
return dp[(1 << n) - 1] == 0
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) — we iterate through all 2^n subsets and for each check n elements.
|
|
|
|
**Space Complexity:** O(2^n) — the dp array stores state for each possible subset.
|
|
|
|
This approach uses bitmask DP where each bit represents whether an element is used. We track the "remaining space" in the current bucket. When a bucket fills (`sum == target`), we start fresh with the next bucket (achieved via `% target`). This guarantees we consider all valid partitions systematically.
|