Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 64

Recursive Subset Generation

Recommended resources

Hello! Welcome to our first lesson in the Backtracking module.

In our last session, we solved the "Word Search II" problem by combining a Trie with a backtracking algorithm. We saw how the Trie could guide our recursive search, aggressively pruning paths that wouldn't lead to valid words. This was a fairly complex application of backtracking.

Today, we'll take a step back to solidify the core principles of backtracking itself. Our goal is to solve a fundamental problem that is a cornerstone of both technical interviews and competitive programming: generating all subsets of a set. This will allow us to focus purely on the recursive structure and decision-making process at the heart of backtracking.

By the end of this lesson, you will be able to generate all subsets of a set using a recursive approach, mastering a pattern known as "include/exclude" that is applicable to a wide range of combinatorial problems.

1. Understanding Subsets and the Power Set

First, let's clarify our goal. Given a set of distinct elements, say {1, 2, 3}, we want to find all possible subsets. A subset is a selection of elements from the original set, where the order doesn't matter. The collection of all possible subsets is called the power set.

For {1, 2, 3}, the power set is:
[ [], [1], [2], [3], [1, 2], [1, 3], [2, 3], [1, 2, 3] ]

A key observation is that for a set with n elements, its power set will always contain subsets. This is because for each element, we have two choices: it's either in a subset, or it's not. With 3 elements, we have subsets.

To get a clear visual and conceptual introduction, let's watch a short video segment.

Recursion Subset, Subsequence, String Questions

The video 'Recursion Subset, Subsequence, String Questions' by Kunal Kushwaha provides a great explanation of what subsets are and introduces the core recursive idea.

Please watch from 00:24:16 to 00:32:41. Focus on the distinction between a 'subset' and a 'subsequence' (in this context, they are used interchangeably) and the core idea of taking some elements and ignoring others.


2. The Recursive Strategy: A Tree of Choices

The insight that for each element we have two choices—include it or exclude it—is the key to our recursive solution. We can visualize this as a decision tree. At each level of the tree, we decide what to do with one element from the input set.

Let's trace this for the input [1, 2, 3]:

  1. Start with an empty current subset [].
  2. Consider 1:
    • Path 1 (Exclude 1): We move to the next element (2), keeping our subset as [].
    • Path 2 (Include 1): We add 1 to our subset, making it [1], and move to the next element (2).
  3. Consider 2 (This happens twice, once for each path from step 2):
    • From Path 1 ([]):
      • Path 1a (Exclude 2): Move to element 3 with subset [].
      • Path 1b (Include 2): Move to element 3 with subset [2].
    • From Path 2 ([1]):
      • Path 2a (Exclude 2): Move to element 3 with subset [1].
      • Path 2b (Include 2): Move to element 3 with subset [1, 2].
  4. Consider 3: We repeat the process. When we run out of elements to consider, the current subset we're holding is complete, and we add it to our final list of results.

This process explores all possibilities and generates every single subset. The following image illustrates this decision-making process perfectly.

Subset Generation Decision Tree
This decision tree shows the two choices (Include/Exclude) at each level for the input `[1, 5, 6]`. Each path from the root to a leaf generates one of the eight possible subsets.

3. Implementing Backtracking in Java

Now, let's translate this concept into a practical Java implementation. While the "include/exclude" idea can be implemented with two separate recursive calls, a more common and extensible pattern for backtracking problems uses a loop. This is the approach you'll most often see in interview solutions for problems like combinations and permutations.

The general pattern is:

  1. Add the current state (the subset we've built so far) to our results.
  2. Loop through the available choices.
  3. Make a choice: Add an element to our current subset.
  4. Recurse: Call the function again to explore further choices.
  5. Backtrack: Remove the element we just added, so we can explore other branches of the decision tree.

This article provides a very clear explanation and a clean Java implementation of this pattern.

78. Subsets - Detailed Explanation

The article '78. Subsets - Detailed Explanation' from Design Gurus gives a fantastic walkthrough of the backtracking approach and its implementation.

Please read 'Approach 1: Backtracking (Depth-First Search)', study the 'Java Code (Backtracking Approach)', and look at the 'Step-by-Step Walkthrough'. This will give you a solid foundation for the code we're about to analyze.

Code Deconstruction

Let's break down the Java code from the article. The structure is typical for a LeetCode-style problem.

import java.util.*;

public class Solution {
    
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        // Start the backtracking process from index 0 with an empty current subset
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }

    private void backtrack(int[] nums, int start, List<Integer> currentSubset, List<List<Integer>> result) {
        // 1. Add a *copy* of the current subset to the results.
        // This represents a valid subset at this point in the recursion.
        result.add(new ArrayList<>(currentSubset));

        // 2. Iterate through the remaining elements to make a new choice.
        for (int i = start; i < nums.length; i++) {
            // 3. Make a choice: Include the element nums[i].
            currentSubset.add(nums[i]);
            
            // 4. Recurse: Explore further, starting from the next index (i + 1).
            backtrack(nums, i + 1, currentSubset, result);
            
            // 5. Backtrack: Undo the choice. Remove the last element added.
            currentSubset.remove(currentSubset.size() - 1);
        }
    }
}

Key Points to Note:

  • start parameter: This is crucial. By starting the loop from start and passing i + 1 in the recursive call, we ensure that we only consider elements at or after the current one. This prevents us from generating permutations (like [2, 1] after we've already generated [1, 2]) and avoids reusing the same element within a subset.
  • The Backtracking Step: currentSubset.remove(...) is the essence of backtracking. After the recursive call backtrack(nums, i + 1, ...) returns, it means we have explored all possible subsets that include nums[i]. We must then remove nums[i] to explore the next branch of the tree, which starts by including nums[i+1] instead.
  • The Copy Constructor: The line result.add(new ArrayList<>(currentSubset)); is one of the most common places to make a mistake. In Java, objects are passed by reference. If we wrote result.add(currentSubset);, we would be adding a reference to the currentSubset list. Since we modify this same list throughout the entire backtracking process, all entries in result would end up pointing to the same final, empty list. By creating a new ArrayList<>(), we add a snapshot (a copy) of the subset at that moment in time.

To see this code in action, let's watch one more video.

[Java] Leetcode 78/90. Subsets I/II [Backtracking #6]

The video '[Java] Leetcode 78/90. Subsets I/II' by Eric Programming provides a clear walkthrough of this exact implementation style.

Please watch from 01:19 to 08:59. The first part (until 05:33) visualizes the decision tree for this loop-based approach. The second part shows the live coding and explains the same critical points we just discussed, like using i + 1 and creating a new list copy.

Test your understanding!

Imagine you are tracing the backtrack function for nums = [1, 2, 3]. The execution has just returned from the call backtrack(nums, 2, [1], result) where i=1 (for element 2) was being processed. The currentSubset is [1]. What is the very next action the code will take?

Show answer

The next action is to backtrack. The code will execute currentSubset.remove(currentSubset.size() - 1);, removing the 2 that was added before the recursive call. This changes currentSubset from [1, 2] back to [1]. The loop for i will then continue to its next iteration, i=2, where it will add 3 to currentSubset.


4. Complexity Analysis

Understanding the performance of this algorithm is critical for interviews. Given your background in algorithm analysis, let's break it down.

  • Time Complexity: O(N × 2^N)

    • The recursive function generates subsets in total.
    • For each of these subsets, we create a new ArrayList to add to our result list. In the worst case, a subset can contain all N elements, so creating a copy takes O(N) time.
    • Therefore, the total time complexity is the number of subsets multiplied by the time to copy each one.
  • Space Complexity: O(N × 2^N)

    • The primary space consumption comes from the result list, which stores all subsets. The total number of elements across all subsets is approximately N/2 * , which simplifies to O(N × 2^N).
    • The recursion stack also uses space. The maximum depth of the recursion is N, so the space used by the call stack is O(N). This is often called the "auxiliary space complexity".
    • When an interviewer asks for space complexity, it's good practice to clarify if they mean the space including the output, or just the auxiliary space used by the algorithm.

The Design Gurus article and the Kunal Kushwaha video both contain good sections on complexity if you'd like to review.


Conclusion

In this lesson, we broke down the fundamental backtracking problem of generating all subsets of a set. This is a must-know pattern for any coding interview.

Key Takeaways:

  • The Core Idea: For each element, you decide whether to include it or exclude it from the current subset.
  • The Backtracking Pattern: A common way to implement this is with a loop and recursion: add choice -> recurse -> remove choice.
  • The start Index: Using a start index is an elegant way to prevent duplicate combinations and re-using elements.
  • The Copy Pitfall: Always add a copy of your temporary list to the final results list to avoid issues with object references.
  • Exponential Complexity: Be aware that this algorithm is inherently exponential, as the size of the output is .

Preview of the Next Lesson:
We will continue building on this foundation. In the next lesson, we will tackle another classic backtracking problem: generating all permutations of a set. While it also involves exploring choices, the constraints are different: the order of elements matters, and every element must be used exactly once. This will introduce a slightly different way to manage state during backtracking.