Intermediate

Subset Problems

Subset problems ask you to enumerate all possible subsets of a collection, often with constraints like a target sum or no duplicates. The key insight is that at each element you make a binary decision: include it or skip it. This creates a binary decision tree with 2n leaves.

💡
Subset vs Combination: Subsets collect results at every node of the decision tree. Combinations only collect at leaf nodes where the path has exactly k elements.

Problem 1: Subsets (LC 78)

Problem: Given an integer array of unique elements, return all possible subsets (the power set).

Example: nums = [1, 2, 3][[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]

Brute Force: Bit Manipulation

def subsets_brute(nums: list[int]) -> list[list[int]]:
    """Generate all subsets using bitmask enumeration.

    For n elements, iterate from 0 to 2^n - 1.
    Each bit position represents include/exclude.

    Time: O(2^n * n), Space: O(2^n * n)
    Works but not extensible to constraint-based variants.
    """
    n = len(nums)
    result = []

    for mask in range(1 << n):  # 0 to 2^n - 1
        subset = []
        for i in range(n):
            if mask & (1 << i):
                subset.append(nums[i])
        result.append(subset)

    return result

Optimal: Backtracking (Collect at Every Node)

def subsets(nums: list[int]) -> list[list[int]]:
    """Generate all subsets via backtracking.

    Key difference from combinations: we add path to result
    at EVERY recursion level, not just when path reaches a
    specific length.

    Time: O(2^n * n) - 2^n subsets, O(n) to copy each
    Space: O(n) recursion depth
    """
    result = []

    def backtrack(start, path):
        result.append(path[:])  # collect at every node

        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

# subsets([1,2,3]) -> [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Problem 2: Subsets II (LC 90)

Problem: Given an integer array that may contain duplicates, return all unique subsets.

Example: nums = [1, 2, 2][[], [1], [1,2], [1,2,2], [2], [2,2]]

Brute Force: Generate All, Deduplicate

def subsets_with_dup_brute(nums: list[int]) -> list[list[int]]:
    """Generate all subsets and remove duplicates using a set.

    Time: O(2^n * n * log(n)), Space: O(2^n * n)
    Wasteful: generates duplicates then filters them.
    """
    result_set = set()

    def backtrack(start, path):
        result_set.add(tuple(sorted(path)))
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return [list(s) for s in result_set]

Optimal: Sort + Skip Duplicates

def subsets_with_dup(nums: list[int]) -> list[list[int]]:
    """Unique subsets via backtracking with duplicate skipping.

    Sort the array first. Then at each recursion level, skip
    an element if it equals the previous element at the same level.
    This prevents generating the same subset via different orderings.

    Time: O(2^n * n), Space: O(n)
    """
    nums.sort()
    result = []

    def backtrack(start, path):
        result.append(path[:])

        for i in range(start, len(nums)):
            # Skip duplicate at the same recursion level
            if i > start and nums[i] == nums[i - 1]:
                continue
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

# subsets_with_dup([1,2,2]) -> [[], [1], [1,2], [1,2,2], [2], [2,2]]

Problem 3: Combination Sum II (LC 40)

Problem: Given a collection of candidate numbers (may contain duplicates) and a target, find all unique combinations where chosen numbers sum to target. Each number may be used only once.

Example: candidates = [10, 1, 2, 7, 6, 1, 5], target = 8[[1,1,6], [1,2,5], [1,7], [2,6]]

Brute Force: Generate All Subsets, Filter by Sum

def combination_sum2_brute(candidates: list[int], target: int) -> list[list[int]]:
    """Generate all subsets and filter by target sum.

    Time: O(2^n * n), Space: O(2^n)
    Explores many branches that exceed the target.
    """
    result = set()

    def backtrack(start, path, total):
        if total == target:
            result.add(tuple(sorted(path)))
            return
        if total > target:
            return
        for i in range(start, len(candidates)):
            backtrack(i + 1, path + [candidates[i]], total + candidates[i])

    backtrack(0, [], 0)
    return [list(c) for c in result]

Optimal: Sort + Prune + Skip Duplicates

def combination_sum2(candidates: list[int], target: int) -> list[list[int]]:
    """Find unique combinations summing to target (no reuse).

    Three optimizations:
    1. Sort to enable pruning and duplicate detection
    2. Break early when candidate exceeds remaining sum
    3. Skip duplicates at the same recursion level

    Time: O(2^n) worst case, much faster with pruning
    Space: O(n) recursion depth
    """
    candidates.sort()
    result = []

    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(path[:])
            return

        for i in range(start, len(candidates)):
            # Pruning: sorted array, so all future candidates too large
            if candidates[i] > remaining:
                break
            # Skip duplicates at same level
            if i > start and candidates[i] == candidates[i - 1]:
                continue

            path.append(candidates[i])
            backtrack(i + 1, path, remaining - candidates[i])
            path.pop()

    backtrack(0, [], target)
    return result

# combination_sum2([10,1,2,7,6,1,5], 8) -> [[1,1,6], [1,2,5], [1,7], [2,6]]

Problem 4: Target Sum / Subset Sum (LC 494 variant)

Problem: Given an array of non-negative integers and a target, determine if there exists a subset whose elements sum to the target.

Example: nums = [3, 34, 4, 12, 5, 2], target = 9True (subset [4, 5])

Brute Force: Check All 2n Subsets

def subset_sum_brute(nums: list[int], target: int) -> bool:
    """Check all subsets for target sum via recursion.

    At each element, include it or exclude it.

    Time: O(2^n), Space: O(n) recursion depth
    """
    def helper(idx, remaining):
        if remaining == 0:
            return True
        if idx >= len(nums) or remaining < 0:
            return False
        # Include nums[idx] OR exclude it
        return (helper(idx + 1, remaining - nums[idx]) or
                helper(idx + 1, remaining))

    return helper(0, target)

Optimal: Backtracking + Memoization (DP)

def subset_sum(nums: list[int], target: int) -> bool:
    """Subset sum with memoization (top-down DP).

    Cache (index, remaining) pairs to avoid recomputation.
    This converts O(2^n) to O(n * target) pseudo-polynomial.

    Time: O(n * target), Space: O(n * target)
    """
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def helper(idx, remaining):
        if remaining == 0:
            return True
        if idx >= len(nums) or remaining < 0:
            return False
        return (helper(idx + 1, remaining - nums[idx]) or
                helper(idx + 1, remaining))

    return helper(0, target)

# subset_sum([3, 34, 4, 12, 5, 2], 9) -> True
ML Context: Subset sum is the core of feature subset selection in ML. Given n features, find the subset that maximizes model accuracy. Exhaustive search is O(2n), so algorithms like sequential forward selection and genetic algorithms use heuristic pruning — essentially backtracking with greedy heuristics.

Problem 5: Partition to K Equal Sum Subsets (LC 698)

Problem: Given an array of integers and a number k, determine if the array can be divided into k non-empty subsets with equal sums.

Example: nums = [4, 3, 2, 3, 5, 2, 1], k = 4True (subsets: [5], [1,4], [2,3], [2,3])

Brute Force: Try All Partitions

def can_partition_brute(nums: list[int], k: int) -> bool:
    """Try all possible assignments of elements to k buckets.

    Each element can go into any of k buckets.
    Time: O(k^n), Space: O(n)
    Extremely slow for large inputs.
    """
    total = sum(nums)
    if total % k != 0:
        return False
    target = total // k
    buckets = [0] * k

    def backtrack(idx):
        if idx == len(nums):
            return all(b == target for b in buckets)
        for j in range(k):
            if buckets[j] + nums[idx] <= target:
                buckets[j] += nums[idx]
                if backtrack(idx + 1):
                    return True
                buckets[j] -= nums[idx]
            # Optimization: if bucket is empty, no need to try other empty buckets
            if buckets[j] == 0:
                break
        return False

    return backtrack(0)

Optimal: Sort Descending + Backtracking with Pruning

def can_partition_k_subsets(nums: list[int], k: int) -> bool:
    """Partition into k equal-sum subsets with aggressive pruning.

    Key optimizations:
    1. Sort descending: large numbers fail fast, prune early
    2. Skip identical bucket values (symmetry breaking)
    3. If any single element exceeds target, return False immediately
    4. If bucket is empty after failure, stop trying other buckets

    Time: O(k^n) worst case, much faster with pruning
    Space: O(n) recursion depth
    """
    total = sum(nums)
    if total % k != 0:
        return False
    target = total // k

    nums.sort(reverse=True)  # large elements first for fast pruning

    if nums[0] > target:
        return False

    buckets = [0] * k

    def backtrack(idx):
        if idx == len(nums):
            return True  # all elements assigned

        seen = set()  # avoid trying identical bucket states
        for j in range(k):
            if buckets[j] + nums[idx] > target:
                continue
            if buckets[j] in seen:
                continue  # symmetry breaking
            seen.add(buckets[j])

            buckets[j] += nums[idx]
            if backtrack(idx + 1):
                return True
            buckets[j] -= nums[idx]

            # If this bucket is empty and we failed, no point trying others
            if buckets[j] == 0:
                break

        return False

    return backtrack(0)

# can_partition_k_subsets([4,3,2,3,5,2,1], 4) -> True

Complexity Summary

ProblemBrute ForceOptimalKey Optimization
SubsetsO(2n × n)O(2n × n)Backtracking is already optimal
Subsets IIO(2n × n log n)O(2n × n)Sort + skip duplicates at same level
Combination Sum IIO(2n)O(2n) with heavy pruningSort + break when > remaining
Subset SumO(2n)O(n × target)Memoization (top-down DP)
Partition K SubsetsO(kn)O(kn) with pruningSort desc + symmetry breaking