Skip to content

4.18.4 — Subsets II

LeetCode 90 · Medium

The problem

Return all unique subsets. The input may contain repeated values.

[1,2,2]  →  [[], [1], [1,2], [1,2,2], [2], [2,2]]

[2] appears once even though there are two 2s to choose from, and [1,2] appears once even though it could be formed with either 2.

The pattern

4.18.1 unchanged, plus one guard. But the guard is the most-missed detail in this chapter, so it is worth deriving rather than memorising.

Where the duplicates come from

Draw the tree for [1,2,2] using the start-index loop, and mark the two 2s as 2ₐ and 2ᵦ:

                        []
           /            |            \
        [1]           [2ₐ]          [2ᵦ]        ← level 1
        / \             |
    [1,2ₐ] [1,2ᵦ]    [2ₐ,2ᵦ]                    ← level 2
       |
   [1,2ₐ,2ᵦ]

Two collisions, and they have the same cause. At level 1, choosing 2ₐ and choosing 2ᵦ produce the identical subset [2], and the whole subtree beneath them is identical too. Same at level 2 with [1,2ₐ] and [1,2ᵦ].

The duplicates are always siblings — different choices made at the same position in the tree. They are never along a path, because along a path you are building a longer subset, and [2ₐ, 2ᵦ] is legitimately [2,2].

So the rule is: at each level, use each distinct value only once.

The guard

Sort the array first so that equal values are adjacent, then:

python
if i > start and nums[i] == nums[i - 1]:
    continue

i > start, not i > 0. This is the entire subtlety.

  • i == start is the first choice at this level. It must always be allowed, even if it equals the previous value in the array — because at this level the previous value was chosen by the parent, not by a sibling.
  • i > start means a sibling earlier in this loop already tried this value, so trying it again produces a duplicate subtree.

Write i > 0 instead and [2,2] disappears, because the second 2 gets skipped even when the first 2 is its parent rather than its sibling.

Trace [1,2,2] with each version once. It takes a minute and it fixes this rule permanently.

The solution

python
class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        nums.sort()                                  # equal values must be adjacent
        result = []
        path = []

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

            for i in range(start, len(nums)):
                if i > start and nums[i] == nums[i - 1]:
                    continue                         # a sibling already used this value

                path.append(nums[i])
                backtrack(i + 1)
                path.pop()

        backtrack(0)
        return result
ts
function subsetsWithDup(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);
  const result: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number): void {
    result.push([...path]);

    for (let i = start; i < nums.length; i++) {
      if (i > start && nums[i] === nums[i - 1]) continue;

      path.push(nums[i]);
      backtrack(i + 1);
      path.pop();
    }
  }

  backtrack(0);
  return result;
}

Sorting is mandatory, not a convenience. The guard compares nums[i] with nums[i-1], which only finds duplicates if equal values sit next to each other. Skip the sort on [2,1,2] and the duplicates survive.

Everything else is 4.18.1: every node is an answer, i + 1 means no reuse, and the path is copied when recorded.

Trace

[1,2,2] sorted stays [1,2,2].

At start = 0: record []. Loop i = 0, 1, 2.

  • i = 0 (value 1): i == start, allowed. Path [1], recurse from 1.
    • record [1]. Loop i = 1, 2.
    • i = 1 (value 2): i == start (start is 1), allowed. Path [1,2], recurse from 2 → record [1,2], then [1,2,2].
    • i = 2 (value 2): i > start and equals nums[1]skipped. This is what stops the second [1,2].
  • i = 1 (value 2): i > start (start is 0) but nums[1] != nums[0], allowed. Path [2], recurse from 2 → record [2], then [2,2].
  • i = 2 (value 2): i > start and equals nums[1]skipped. This stops the second [2].

Six unique subsets. ✓

The alternative: deduplicate afterwards

You could generate everything and filter:

python
seen = set(tuple(s) for s in all_subsets)

It works and it is easy. It also builds the entire duplicated search tree first, which for heavily repeated input can be exponentially larger than the answer. On [2]*10 it explores 1,024 paths to produce 11 subsets.

Pruning at the source is the point of the exercise, and saying why the filter approach is worse is a better answer than either one alone.

Complexity

O(2^n \times n) in the worst case, when every value is distinct and nothing is pruned. With duplicates it is proportional to the number of unique subsets, which is exactly what the guard buys.

O(n) space beyond the output.

Where this goes next

The same guard, unchanged, appears in:

  • Combination Sum II4.18.5.
  • 3Sum — the duplicate skips in 4.5.3 are the same idea in a loop rather than a recursion.
  • Permutations II — a different guard, not used[i-1], because permutations have no start index and the sibling relationship is expressed differently. Compare the two conditions side by side; understanding why they differ is worth more than memorising either.

What the interviewer will push on

"Why must you sort first?" The guard compares adjacent elements.

"Why i > start and not i > 0?" The first choice at each level must always be allowed; only siblings are duplicates. Give the [2,2] example.

"Could you just deduplicate at the end?" Yes, and explain why it can be exponentially more work.

"How does this differ from the guard in Permutations II?" No start index there, so the sibling test becomes not used[i-1].

One thing to volunteer: say "duplicates come from siblings, never from ancestors" before writing the guard. That sentence is the derivation, and it makes the i > start obvious rather than magical.

Next: 4.18.5 Combination Sum II — this guard and the no-reuse rule working together.