Description
Subsets II
Given an integer array nums that may contain duplicates, return all possible subsets (the power set).
The solution set must not contain duplicate subsets. Return the solution in any order.
Example 1:
Input: nums = [1,2,2]
Output: [[],[1],[1,2],[1,2,2],[2],[2,2]]
Example 2:
Input: nums = [0]
Output: [[],[0]]
Constraints:
1 <= nums.length <= 10-10 <= nums[i] <= 10
Brute Force Solution (Standard Backtracking + Set)
Generate all possible subsets using simple recursion, sort each subset, and store them in a Set to filter out duplicates.
import java.util.*;
class Solution {
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums); // Sort to ensure duplicate subsets match identical patterns
Set<List<Integer>> resultSet = new HashSet<>();
backtrack(nums, 0, new ArrayList<>(), resultSet);
return new ArrayList<>(resultSet);
}
private void backtrack(int[] nums, int index, List<Integer> current, Set<List<Integer>> result) {
if (index == nums.length) {
result.add(new ArrayList<>(current));
return;
}
// 1. Include nums[index]
current.add(nums[index]);
backtrack(nums, index + 1, current, result);
current.remove(current.size() - 1);
// 2. Exclude nums[index]
backtrack(nums, index + 1, current, result);
}
}
- Time Complexity: — subsets generated, copying each takes up to , plus set insertion cost.
- Space Complexity: — Storing duplicate subsets inside the Hash Set before returning.
Most Optimized & Intuitive Solution (Striver’s Pattern)
Intuition
Sort the array first so duplicate numbers are adjacent (e.g., [1, 2, 2]).
At every recursive step, we decide which element to add at the current position of our subset:
- First Choice (
i == start): Always valid. Picknums[i]to start a branch. - Subsequent Choices (
i > start): Ifnums[i] == nums[i - 1], skip it. We already explored an identical choice at this exact position level.
Decision Tree for nums = [1, 2, 2]
[]
/ | \
[1] [2] [2] <-- SKIP! (i > start && nums[i] == nums[i-1])
/ \ |
[1,2] [1,2] [2,2]
/ ^
[1,2,2] SKIP!
import java.util.*;
class Solution {
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums); // Step 1: Group identical elements together
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int start, List<Integer> current, List<List<Integer>> result) {
// Every valid node in the decision tree is a unique subset
result.add(new ArrayList<>(current));
for (int i = start; i < nums.length; i++) {
// Step 2: Skip identical elements at the SAME decision level
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
current.add(nums[i]);
backtrack(nums, i + 1, current, result); // Move to next index
current.remove(current.size() - 1); // Backtrack
}
}
}
Complexity
- Time Complexity: — Generates only unique subsets (at most ). Copying each subset to
resulttakes . - Space Complexity: — Recursion stack depth is at most .
Easy Memory Rule
“Sort first. Inside the loop, if
i > startandnums[i] == nums[i-1], skip it.”
What is Backtracking?
Backtracking is a trial-and-error algorithm technique. Think of it as exploring a maze:
- You walk down a path making decisions.
- If you reach a dead end (or complete a valid path), you step backward to the last intersection.
- You try a different path from that same intersection.
In code, backtracking boils down to a 3-step blueprint inside a recursive function:
1. CHOOSE --> Pick an option and add it to your current solution path.
2. EXPLORE --> Recurse deeper to make the next set of choices.
3. UN-CHOOSE --> Remove the option (backtrack) to restore the state for the next choice.
How Backtracking Works in Subsets II
When generating subsets for nums = [1, 2, 2], we reuse a single list current throughout the entire recursion rather than copying array states at every single step.
Here is the exact code snippet for reference:
// 1. CHOOSE
current.add(nums[i]);
// 2. EXPLORE
backtrack(nums, i + 1, current, result);
// 3. UN-CHOOSE (The Backtrack Step)
current.remove(current.size() - 1);
Step-by-Step Execution Walkthrough (nums = [1, 2, 2])
[]
/ | \
[1] [2] [2] (Skipped!)
/ \ |
[1, 2] [1, 2] [2, 2]
| (Skipped!)
[1, 2, 2]
-
Root Call (
start = 0,current = [])- Add
[]toresult.
- Add
-
Branch 1: Pick
1(i = 0)- Choose: Add
1current = [1] - Explore:
Recursetostart = 1 - Add
[1]toresult. - Sub-branch 1a: Pick first
2(i = 1)- Choose: Add
2current = [1, 2] - Explore:
Recursetostart = 2 - Add
[1, 2]toresult.
- Choose: Add
- Sub-sub-branch: Pick second
2(i = 2)- Choose: Add
2current = [1, 2, 2] - Explore:
Recursetostart = 3 - Add
[1, 2, 2]toresult. Reaches end of loop. - Un-choose: Remove last
2current = [1, 2] - Loop finishes at this level.
- Un-choose: Remove
2current = [1]
- Choose: Add
- Sub-branch 1b: Try second
2(i = 2)i > start() andnums[2] == nums[1]Skip duplicate!- Loop finishes at this level.
- Un-choose: Remove
1current = [](Back to clean slate!)
- Choose: Add
-
Branch 2: Pick first
2(i = 1)- Choose: Add
2current = [2] - Explore:
Recursetostart = 2 - Add
[2]toresult. - Pick second
2(i = 2)current = [2, 2]Add[2, 2]toresult. - Un-choose: Remove
2current = [2]. - Un-choose: Remove
2current = [].
- Choose: Add
-
Branch 3: Try second
2(i = 2)i > start() andnums[2] == nums[1]Skip duplicate!
Why the “Un-choose” Step Matters
Without current.remove(current.size() - 1), the list current would continuously grow ([1], [1, 2], [1, 2, 2], [1, 2, 2, 2], ...) because memory would contaminate across different decision branches.
By immediately removing the item after the recursive call returns, current acts as a reusable workspace canvas, keeping the space complexity lean at auxiliary space instead of allocating thousands of intermediate arrays.