78. Subsets

MediumBit ManipulationArrayBacktracking
Leetcode Link

Problem Description

The LeetCode problem asks us to generate all the possible subsets from a given list of unique integers. The term 'subset' refers to any combination of numbers that can be formed from the original list, including the empty set and the set itself. For instance, if the input is [1,2,3], then the subsets would include [], [1], [2], [3], [1,2], [1,3], [2,3], and [1,2,3]. The objective is to list down all these possibilities. We should also ensure that there are no duplicate subsets in the solution and the subsets can be returned in any order.

Intuition

To solve the problem, we are using a concept known as Depth-First Search (DFS). The process involves exploring each element and deciding whether to include it in the current subset (t). At each step, we have the choice to either include or not include the current element in our subset before moving to the next element.

For example, if our set is [1, 2, 3], the DFS approach starts at the first element (1 then 2 then 3). For each element, we take two paths: in one path we include the element into the subset, and in the other, we do not.

Here's what the decision tree would look like for [1, 2, 3]:

  1. Start with an empty subset [].
  2. Choose whether to include 1 or not, resulting in subsets [] and [1].
  3. For each of these subsets, choose whether to include 2, leading to [], [1], [2], and [1, 2].
  4. Finally, for each of these, choose whether to include 3, ending with [], [1], [2], [1, 2], [3], [1, 3], [2, 3], and [1, 2, 3].

At the end of this process, we have explored all possible combinations, and our ans list contains all of them. Each recursive call represents a decision, and by exploring each decision, we exhaust all possibilities and build the power set.

Learn more about Backtracking patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece:

Which of the two traversal algorithms (BFS and DFS) can be used to find whether two nodes are connected?

Solution Approach

The reference solution provided is a recursive approach using Depth-First Search (DFS) to build up all possible subsets. Here's a step-by-step explanation of how the code implements this algorithm:

  • We define a helper function called dfs with the parameter i, which represents the current index in the nums array we are considering.

  • The base case for the recursion is when i equals the length of nums. At this point, we know we've considered every element. We make a copy of the current subset t and append it to our answer list ans.

  • If we haven't reached the end of the array, we have two choices: we can either include the current element or not. First, we choose not to include it by simply calling dfs(i + 1) without modifying t.

  • After returning from the above call, we then decide to include the current element by appending nums[i] to t. Then we call dfs(i + 1) again to continue exploring further with the element included.

  • After the second recursive call returns, we need to backtrack, which means removing the last element added to t, essentially undoing the decision to include the current element. This is done by calling t.pop().

  • The initial call to dfs starts with index 0, indicating that we start exploring from the first element of the array.

  • The algorithm uses a backtracking pattern, which is evident in the decision to include or not include an element and the subsequent reversal of that decision.

  • The use of recursion and backtracking allows us to explore all possible combinations of elements, resulting in the power set of nums. This is because at each step we go as deep as we can into one combination (depth-first), and then we backtrack to explore a new one.

  • The code avoids the formation of duplicate subsets by relying on the fact that nums contains unique elements and by systematically exploring all possible combinations without repeating them.

The data structure used to keep track of the current subset under construction is a simple list t. The list ans collects the subsets as they are constructed. The reason we take a slice t[:] while appending to ans is to pass a copy of the current subset instead of a reference to t, which will continue to be modified as the algorithm proceeds.

To summarize, the solution uses a DFS approach to recursively build subsets and backtrack when necessary, adding each complete subset to the solution once all elements have been considered.

Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:

What's the output of running the following function using the following tree as input?

1def serialize(root):
2    res = []
3    def dfs(root):
4        if not root:
5            res.append('x')
6            return
7        res.append(root.val)
8        dfs(root.left)
9        dfs(root.right)
10    dfs(root)
11    return ' '.join(res)
12
1import java.util.StringJoiner;
2
3public static String serialize(Node root) {
4    StringJoiner res = new StringJoiner(" ");
5    serializeDFS(root, res);
6    return res.toString();
7}
8
9private static void serializeDFS(Node root, StringJoiner result) {
10    if (root == null) {
11        result.add("x");
12        return;
13    }
14    result.add(Integer.toString(root.val));
15    serializeDFS(root.left, result);
16    serializeDFS(root.right, result);
17}
18
1function serialize(root) {
2    let res = [];
3    serialize_dfs(root, res);
4    return res.join(" ");
5}
6
7function serialize_dfs(root, res) {
8    if (!root) {
9        res.push("x");
10        return;
11    }
12    res.push(root.val);
13    serialize_dfs(root.left, res);
14    serialize_dfs(root.right, res);
15}
16

Example Walkthrough

Let's take a smaller example set [1, 2] to illustrate the solution approach.

  1. Initially, our subset t is empty, and we start at index 0. The first decision is whether to include 1 in our subset or not.

  2. We first choose not to include 1, so our subset t remains []. We call dfs(1) to handle the next element (index 1 now refers to 2 in our set).

  3. At index 1, again we decide whether to include 2 or not. First, we choose not to include it, so subset t remains as []. We've now considered every element, so we append this subset to our answer list ans, which now has [[]].

  4. Backtracking to the previous choice for 2, this time we include it in the subset t, which now becomes [2]. Again, each element has been considered, so we add [2] to ans, which becomes [[], [2]].

  5. Now we have finished exploring the possibilities for index 1 (with element 2), so we backtrack to the situation before including 2. This means we remove 2 from our subset t by calling t.pop().

  6. We then backtrack to the first decision at index 0 and choose the path where we include 1 in our subset. Now, t contains [1]. We move to index 1 by calling dfs(1) to make decisions for 2.

  7. At index 1, we start with decision not to include 2 first, which means our subset t does not change, and is [1]. Since we are at the end, we add this to ans, resulting in [[], [2], [1]].

  8. Finally, we include 2 in our subset to have [1, 2] and since all elements are now considered, we include this subset in ans, resulting in [[], [2], [1], [1, 2]].

Throughout this process, we've been adding our subset t only when we have considered all elements, which means when i is equal to the length of nums. This ensures we include every possible combination in our ans, the list of all subsets ([], [1], [2], [1, 2] for our example).

The solution methodically explores all combinations through recursive DFS calls and includes backtracking to make sure that we cover different subsets as we make different decisions (to include/not include an element). The incremental nature of adding to the subset t, along with the corresponding backtracking step, ensures the completeness and correctness of the algorithm.

Solution Implementation

1class Solution:
2    def subsets(self, nums: List[int]) -> List[List[int]]:
3        # A helper function using depth-first search to find all subsets
4        def depth_first_search(index: int):
5            # Once we've considered all elements, take a snapshot of the current subset
6            if index == len(nums):
7                all_subsets.append(current_subset[:])
8                return
9          
10            # Exclude the current element and move to the next
11            depth_first_search(index + 1)
12          
13            # Include the current element and move to the next
14            current_subset.append(nums[index])
15            depth_first_search(index + 1)
16          
17            # Backtrack: remove the current element before going up the recursion tree
18            current_subset.pop()
19
20        # This list will hold all the subsets
21        all_subsets = []
22        # This is a temporary list to hold the current subset
23        current_subset = []
24        # Start the depth-first search from index 0
25        depth_first_search(0)
26        # Return the final list of all subsets
27        return all_subsets
28
1class Solution {
2    // A list to store all subsets
3    private List<List<Integer>> subsetsList = new ArrayList<>();
4  
5    // A temporary list to store one subset
6    private List<Integer> tempSubset = new ArrayList<>();
7  
8    // An array to store the given numbers
9    private int[] numbers;
10
11    /**
12     * This is the main method that returns all possible subsets of the given array.
13     * @param nums Array of integers for which subsets are to be found.
14     * @return A list of all possible subsets of the given array.
15     */
16    public List<List<Integer>> subsets(int[] nums) {
17        this.numbers = nums;
18        // Start the Depth-First Search (DFS) with the first index
19        depthFirstSearch(0);
20        return subsetsList;
21    }
22
23    /**
24     * This method uses Depth-First Search (DFS) to explore all potential subsets.
25     * @param index The current index in the numbers array being explored.
26     */
27    private void depthFirstSearch(int index) {
28        // If the current index has reached the length of the array,
29        // it means we've formed a subset which can now be added to the list of subsets.
30        if (index == numbers.length) {
31            subsetsList.add(new ArrayList<>(tempSubset));
32            return;
33        }
34      
35        // Case 1: The current number is excluded from the subset,
36        // so we simply call dfs on the next index.
37        depthFirstSearch(index + 1);
38      
39        // Case 2: The current number is included in the subset.
40        // Add the current number to the tempSubset.
41        tempSubset.add(numbers[index]);
42      
43        // Move on to the next index to explore further with the current number included.
44        depthFirstSearch(index + 1);
45      
46        // Backtrack: remove the last number added to the tempSubset,
47        // this effectively removes the current number from the subset.
48        tempSubset.remove(tempSubset.size() - 1);
49    }
50}
51
1class Solution {
2public:
3    vector<vector<int>> subsets(vector<int>& nums) {
4        // Initialize the answer vector to hold all subsets
5        vector<vector<int>> answer;
6        // Temporary vector to hold the current subset
7        vector<int> currentSubset;
8      
9        // Define a recursive function to generate all possible subsets
10        function<void(int)> generateSubsets = [&](int index) -> void {
11            // Base case: If we have considered all numbers
12            if (index == nums.size()) {
13                // Add the current subset to the answer
14                answer.push_back(currentSubset);
15                return;
16            }
17            // Recursive case 1: Exclude the current number and move to the next
18            generateSubsets(index + 1);
19          
20            // Include the current number in the subset
21            currentSubset.push_back(nums[index]);
22            // Recursive case 2: Include the current number and move to the next
23            generateSubsets(index + 1);
24          
25            // Backtrack: Remove the current number before going back up the recursion tree
26            currentSubset.pop_back();
27        };
28      
29        // Start the recursion with the first index
30        generateSubsets(0);
31        return answer;
32    }
33};
34
1// This function generates all possible subsets of the given array.
2function subsets(nums: number[]): number[][] {
3    // 'allSubsets' will store all the subsets.
4    const allSubsets: number[][] = [];
5  
6    // 'currentSubset' is a temporary storage to build each subset.
7    let currentSubset: number[] = [];
8
9    // Helper function to perform depth-first search to build subsets.
10    const buildSubsets = (index: number): void => {
11        // If the current index is equal to the length of 'nums',
12        // a subset is complete and we can add a copy to 'allSubsets'.
13        if (index === nums.length) {
14            allSubsets.push(currentSubset.slice());
15            return;
16        }
17        // Recursive case 1: Exclude the current element and move to the next.
18        buildSubsets(index + 1);
19      
20        // Include the current element in the 'currentSubset'.
21        currentSubset.push(nums[index]);
22
23        // Recursive case 2: Include the current element and move to the next.
24        buildSubsets(index + 1);
25
26        // Backtrack: Remove the last element before going up the recursive tree.
27        currentSubset.pop();
28    };
29
30    // Start building subsets from the first index.
31    buildSubsets(0);
32    return allSubsets;
33}
34
Not Sure What to Study? Take the 2-min Quiz:

Which technique can we use to find the middle of a linked list?

Time and Space Complexity

The provided code is a solution for finding all subsets of a given set of numbers. This is implemented using a depth-first search (DFS) approach with backtracking.

Time Complexity:

Each number has two possibilities: either it is part of a subset or it is not. Thus, for each element in the input list nums, we are making two recursive calls. This results in a binary decision tree with a total of 2^n leaf nodes (where n is the number of elements in nums).

This leads to a total of 2^n function calls. In each call, we deal with O(1) complexity operations (excluding the recursive calls), such as appending an element to the temporary list t or appending the list to ans.

Therefore, the time complexity of the code is O(2^n).

Space Complexity:

For space complexity, we consider two factors: the space used by the recursive call stack and the space used to store the output.

  1. Recursive Call Stack: In the worst case, the maximum depth of the recursive call stack is n (the number of elements in nums), as we make a decision for each element. Hence, the space used by the call stack is O(n).

  2. Output Space: The space used to store all subsets is the dominating factor. Since there are 2^n subsets and each subset can be at most n elements, the output space complexity is O(n * 2^n).

However, it is important to note that in the context of subsets or combinations problems, the space used to store the output is often considered as auxiliary space and not part of the space complexity used for algorithmic analysis. If we only consider the auxiliary space (ignoring the space for the output), the space complexity would be O(n).

Taking both aspects into account, the total space complexity of the code is O(n * 2^n) considering the space for the output, or O(n) if we are only considering auxiliary space.

Learn more about how to find time and space complexity quickly using problem constraints.

Fast Track Your Learning with Our Quick Skills Quiz:

Which of the following array represent a max heap?


Recommended Readings


Got a question? Ask the Teaching Assistant anything you don't understand.

Still not clear? Ask in the Forum,  Discord or Submit the part you don't understand to our editors.


TA 👨‍🏫