Facebook Pixel

77. Combinations

Problem Description

You are given two integers n and k. Your task is to find all possible combinations of k numbers chosen from the range [1, n].

A combination is a selection of items where the order doesn't matter. For example, [1, 2] and [2, 1] are considered the same combination.

The function should return a list of all valid combinations, and you may return them in any order.

Example walkthrough:

  • If n = 4 and k = 2, you need to choose 2 numbers from [1, 2, 3, 4]
  • The valid combinations would be: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]

Key points:

  • Numbers are chosen from the inclusive range [1, n]
  • Each combination must contain exactly k numbers
  • No duplicate numbers within a single combination
  • The order of combinations in the output doesn't matter
  • The order of numbers within each combination doesn't matter for uniqueness (but typically returned in ascending order due to the algorithm's nature)

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem doesn't involve nodes and edges or graph traversal. We're working with combinations of numbers from a range.

Need to solve for kth smallest/largest?

  • No: We're not looking for a specific kth element; we need to generate all possible combinations.

Involves Linked Lists?

  • No: The problem deals with generating combinations of integers, not linked list operations.

Does the problem have small constraints?

  • Yes: The problem involves generating combinations where we need to explore all possible ways to choose k numbers from n numbers. The combinatorial nature and the need to generate all possibilities suggests we're dealing with manageable constraints (typically n and k are small enough to enumerate all combinations).

Brute force / Backtracking?

  • Yes: Since we need to generate all possible combinations and explore different choices systematically (choosing or not choosing each number), backtracking is the appropriate approach. We build combinations incrementally and backtrack when we've either found a valid combination or need to explore other possibilities.

Conclusion: The flowchart correctly leads us to use a Backtracking approach. This makes sense because:

  • We need to explore all possible combinations exhaustively
  • We make choices (include or exclude a number) and need to undo them to try different paths
  • The problem has a clear recursive structure where we build solutions incrementally
  • We need to maintain state (current combination) and restore it when backtracking
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we need to generate all combinations of k numbers from [1, n], we're essentially making a series of decisions: for each number, should we include it in our current combination or not?

Think of it like picking a team of k players from n candidates. At each step, we look at a candidate and decide: "Should this person be on the team or not?" Once we've made enough selections to form a complete team (exactly k members), we record that team. Then we backtrack to explore other possible team compositions.

The key insight is that for each number i from 1 to n, we have two choices:

  1. Include it: Add number i to our current combination and continue building with the remaining numbers
  2. Exclude it: Skip number i and continue building with the remaining numbers

This naturally forms a decision tree. Starting from number 1, we explore both branches (include 1 or exclude 1), then for number 2, we again explore both branches, and so on.

The backtracking pattern emerges because:

  • When we include a number and explore that path, we need to "undo" that inclusion (backtrack) to explore the path where we don't include it
  • We use a temporary list t to build our current combination
  • When t reaches size k, we've found a valid combination and save it
  • We use append() to add a number and pop() to remove it when backtracking

The recursion naturally handles the exploration of all paths. The function dfs(i) represents "starting from number i, explore all possible combinations." By calling dfs(i+1) after including or excluding number i, we systematically explore all 2^n possible selections, keeping only those with exactly k numbers.

This approach ensures we generate all combinations without duplicates, as we process numbers in order and never revisit earlier numbers.

Learn more about Backtracking patterns.

Solution Approach

The implementation uses a backtracking approach with a depth-first search (DFS) helper function. Let's walk through the key components:

Data Structures:

  • ans: A list to store all valid combinations found
  • t: A temporary list that acts as our current path/combination being built

Main Algorithm - DFS Function:

The dfs(i) function represents the decision point for number i. It follows this logic:

  1. Base Case - Valid Combination Found:

    if len(t) == k:
        ans.append(t[:])
        return

    When our temporary list t reaches size k, we've found a valid combination. We append a copy of t to our answer (using t[:] to create a copy, not a reference).

  2. Boundary Check:

    if i > n:
        return

    If we've gone beyond the range [1, n], there are no more numbers to consider, so we return.

  3. Two-Choice Recursion:

    t.append(i)      # Choice 1: Include number i
    dfs(i + 1)       # Explore with i included
    t.pop()          # Backtrack: Remove i
    dfs(i + 1)       # Choice 2: Exclude number i

    This is the core backtracking pattern:

    • First, we include number i by appending it to t
    • We recursively explore all combinations that include i by calling dfs(i + 1)
    • We backtrack by removing i from t using pop()
    • We explore all combinations that exclude i by calling dfs(i + 1) again

Execution Flow:

Starting with dfs(1), the algorithm explores a binary decision tree where each level represents a number from 1 to n. At each node, we branch into two paths: one where we include the current number and one where we don't.

For example, with n=4, k=2:

  • Start at 1: Include 1 or skip 1
  • If we include 1, move to 2: Include 2 (get [1,2]) or skip 2
  • If we skip 2, move to 3: Include 3 (get [1,3]) or skip 3
  • And so on...

Time Complexity: O(C(n,k) × k) where C(n,k) is the number of combinations. We generate C(n,k) combinations, and for each, we spend O(k) time to copy it to the answer.

Space Complexity: O(k) for the recursion stack and temporary list t, not counting the output space.

The beauty of this approach is its simplicity - by systematically making include/exclude decisions for each number and using backtracking to explore all paths, we naturally generate all possible combinations without duplicates or missing any valid combination.

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's trace through the algorithm with n = 3 and k = 2 to find all 2-number combinations from [1, 2, 3].

Initial Setup:

  • ans = [] (stores final combinations)
  • t = [] (current combination being built)
  • Start with dfs(1)

Step-by-step execution:

dfs(1): t = []
├─ Include 1: t = [1]
│  └─ dfs(2): t = [1]
│     ├─ Include 2: t = [1, 2]
│     │  └─ dfs(3): len(t) = 2 = k ✓
│     │     └─ Add [1, 2] to ans, return
│     │  └─ Backtrack: t = [1]
│     └─ Exclude 2:
│        └─ dfs(3): t = [1]
│           ├─ Include 3: t = [1, 3]
│           │  └─ dfs(4): len(t) = 2 = k ✓
│           │     └─ Add [1, 3] to ans, return
│           │  └─ Backtrack: t = [1]
│           └─ Exclude 3:
│              └─ dfs(4): i > n, return
│  └─ Backtrack: t = []
└─ Exclude 1: t = []
   └─ dfs(2): t = []
      ├─ Include 2: t = [2]
      │  └─ dfs(3): t = [2]
      │     ├─ Include 3: t = [2, 3]
      │     │  └─ dfs(4): len(t) = 2 = k ✓
      │     │     └─ Add [2, 3] to ans, return
      │     │  └─ Backtrack: t = [2]
      │     └─ Exclude 3:
      │        └─ dfs(4): i > n, return
      │  └─ Backtrack: t = []
      └─ Exclude 2:
         └─ dfs(3): t = []
            ├─ Include 3: t = [3]
            │  └─ dfs(4): t = [3], len(t) = 1 < k
            │     └─ i > n, return (can't form valid combination)
            │  └─ Backtrack: t = []
            └─ Exclude 3:
               └─ dfs(4): i > n, return

Key observations from the walkthrough:

  1. Include/Exclude Pattern: At each number, we first try including it (append to t), explore all possibilities, then backtrack (pop from t) and explore excluding it.

  2. Valid Combinations Found:

    • [1, 2] - found when we included 1, then included 2
    • [1, 3] - found when we included 1, excluded 2, then included 3
    • [2, 3] - found when we excluded 1, included 2, then included 3
  3. Pruning: When we reach i > n or len(t) == k, we stop exploring further.

  4. Backtracking in Action: After finding [1, 2], we pop 2 to get back to [1], allowing us to explore [1, 3]. Similarly, we pop 1 to get back to [] to explore combinations starting with 2.

Final Result: ans = [[1, 2], [1, 3], [2, 3]]

This systematic exploration ensures we find all combinations exactly once, with no duplicates and no missed combinations.

Solution Implementation

1class Solution:
2    def combine(self, n: int, k: int) -> List[List[int]]:
3        """
4        Generate all combinations of k numbers from the range [1, n].
5      
6        Args:
7            n: The upper bound of the range (inclusive)
8            k: The number of elements in each combination
9      
10        Returns:
11            A list of all possible combinations
12        """
13        def backtrack(start: int, current_combination: List[int]) -> None:
14            """
15            Recursively build combinations using backtracking.
16          
17            Args:
18                start: The current number to consider for inclusion
19                current_combination: The combination being built
20            """
21            # Base case: found a valid combination of size k
22            if len(current_combination) == k:
23                result.append(current_combination[:])  # Add a copy to the result
24                return
25          
26            # Pruning: if we've gone beyond n, stop exploring
27            if start > n:
28                return
29          
30            # Choice 1: Include the current number in the combination
31            current_combination.append(start)
32            backtrack(start + 1, current_combination)
33          
34            # Backtrack: Remove the current number
35            current_combination.pop()
36          
37            # Choice 2: Skip the current number and move to the next
38            backtrack(start + 1, current_combination)
39      
40        # Initialize the result list and temporary combination list
41        result: List[List[int]] = []
42        temp_combination: List[int] = []
43      
44        # Start the backtracking from number 1
45        backtrack(1, temp_combination)
46      
47        return result
48
1class Solution {
2    // List to store all valid combinations
3    private List<List<Integer>> result = new ArrayList<>();
4    // Temporary list to build current combination
5    private List<Integer> currentCombination = new ArrayList<>();
6    // Total numbers available (1 to n)
7    private int totalNumbers;
8    // Target combination size
9    private int combinationSize;
10
11    /**
12     * Generate all combinations of k numbers from 1 to n
13     * @param n - upper bound of numbers (1 to n)
14     * @param k - size of each combination
15     * @return List of all possible combinations
16     */
17    public List<List<Integer>> combine(int n, int k) {
18        this.totalNumbers = n;
19        this.combinationSize = k;
20        backtrack(1);
21        return result;
22    }
23
24    /**
25     * Recursive backtracking to generate combinations
26     * @param currentNumber - current number being considered for inclusion
27     */
28    private void backtrack(int currentNumber) {
29        // Base case: found a valid combination of size k
30        if (currentCombination.size() == combinationSize) {
31            // Add a copy of current combination to result
32            result.add(new ArrayList<>(currentCombination));
33            return;
34        }
35      
36        // Base case: exceeded the range of available numbers
37        if (currentNumber > totalNumbers) {
38            return;
39        }
40      
41        // Choice 1: Include current number in the combination
42        currentCombination.add(currentNumber);
43        backtrack(currentNumber + 1);
44      
45        // Backtrack: Remove the number to try without it
46        currentCombination.remove(currentCombination.size() - 1);
47      
48        // Choice 2: Exclude current number from the combination
49        backtrack(currentNumber + 1);
50    }
51}
52
1class Solution {
2public:
3    vector<vector<int>> combine(int n, int k) {
4        vector<vector<int>> result;              // Store all valid combinations
5        vector<int> currentCombination;          // Track current combination being built
6      
7        // Recursive function to generate combinations
8        // Parameter: current number to consider (1 to n)
9        function<void(int)> backtrack = [&](int currentNumber) {
10            // Base case: found a valid k-sized combination
11            if (currentCombination.size() == k) {
12                result.emplace_back(currentCombination);
13                return;
14            }
15          
16            // Base case: exceeded the range of available numbers
17            if (currentNumber > n) {
18                return;
19            }
20          
21            // Choice 1: Include current number in the combination
22            currentCombination.emplace_back(currentNumber);
23            backtrack(currentNumber + 1);
24          
25            // Backtrack: Remove current number to explore other possibilities
26            currentCombination.pop_back();
27          
28            // Choice 2: Skip current number and move to next
29            backtrack(currentNumber + 1);
30        };
31      
32        // Start the recursion from number 1
33        backtrack(1);
34      
35        return result;
36    }
37};
38
1/**
2 * Generate all possible combinations of k numbers from 1 to n
3 * @param n - The upper bound of numbers (from 1 to n)
4 * @param k - The number of elements in each combination
5 * @returns All possible combinations as a 2D array
6 */
7function combine(n: number, k: number): number[][] {
8    // Store all valid combinations
9    const result: number[][] = [];
10    // Current combination being built
11    const currentCombination: number[] = [];
12  
13    /**
14     * Depth-first search to generate combinations
15     * @param currentNumber - The current number being considered for inclusion
16     */
17    const generateCombinations = (currentNumber: number): void => {
18        // Base case: found a valid combination of size k
19        if (currentCombination.length === k) {
20            // Create a copy of current combination and add to result
21            result.push([...currentCombination]);
22            return;
23        }
24      
25        // Base case: exceeded the range of available numbers
26        if (currentNumber > n) {
27            return;
28        }
29      
30        // Choice 1: Include the current number in the combination
31        currentCombination.push(currentNumber);
32        generateCombinations(currentNumber + 1);
33      
34        // Backtrack: Remove the current number
35        currentCombination.pop();
36      
37        // Choice 2: Exclude the current number from the combination
38        generateCombinations(currentNumber + 1);
39    };
40  
41    // Start the DFS from number 1
42    generateCombinations(1);
43  
44    return result;
45}
46

Time and Space Complexity

Time Complexity: O(2^n)

The algorithm explores a binary decision tree where at each step (for each number from 1 to n), we make two choices: either include the current number or exclude it. This creates a complete binary tree of height n, resulting in 2^n total recursive calls. Even though we terminate early when len(t) == k, in the worst case we still explore O(2^n) nodes of the decision tree.

Space Complexity: O(n)

The space complexity consists of:

  • Recursion stack depth: O(n) in the worst case, as we can go up to n levels deep in the recursion
  • The temporary list t: O(k) space, which stores at most k elements
  • The answer list ans: O(C(n,k) * k) space to store all combinations, where C(n,k) is the binomial coefficient representing the number of combinations

If we only consider auxiliary space (excluding the output), the space complexity is O(n) dominated by the recursion stack. If we include the output space, it becomes O(C(n,k) * k) where C(n,k) = n!/(k!(n-k)!).

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

Common Pitfalls

1. Appending Reference Instead of Copy

The Pitfall: One of the most frequent mistakes is appending the temporary list directly to the result without creating a copy:

if len(current_combination) == k:
    result.append(current_combination)  # WRONG! Appends a reference
    return

Why it's problematic: Since current_combination is modified throughout the backtracking process, all entries in result will end up pointing to the same list object. When the algorithm finishes, result will contain multiple references to the same empty list (after all elements have been popped during backtracking).

The Solution: Always append a copy of the current combination:

if len(current_combination) == k:
    result.append(current_combination[:])  # Creates a new list
    # Alternative: result.append(list(current_combination))
    return

2. Inefficient Exploration Without Pruning

The Pitfall: Not implementing early termination when it's impossible to form a valid combination:

def backtrack(start, current_combination):
    if len(current_combination) == k:
        result.append(current_combination[:])
        return
  
    # Missing optimization: continuing even when there aren't enough numbers left
    for i in range(start, n + 1):
        current_combination.append(i)
        backtrack(i + 1, current_combination)
        current_combination.pop()

The Solution: Add pruning to avoid exploring paths that cannot lead to valid combinations:

def backtrack(start, current_combination):
    if len(current_combination) == k:
        result.append(current_combination[:])
        return
  
    # Pruning: check if we have enough numbers left to form a combination
    remaining_needed = k - len(current_combination)
    remaining_available = n - start + 1
    if remaining_needed > remaining_available:
        return
  
    for i in range(start, n + 1):
        current_combination.append(i)
        backtrack(i + 1, current_combination)
        current_combination.pop()

3. Using the Wrong Starting Index

The Pitfall: Starting from index 0 or using the wrong indexing in recursive calls:

def backtrack(start, current_combination):
    # ... base cases ...
  
    for i in range(start, n):  # WRONG! Missing n itself
        current_combination.append(i)
        backtrack(i, current_combination)  # WRONG! Should be i + 1
        current_combination.pop()

Why it's problematic:

  • Using range(start, n) excludes n from possible selections
  • Using backtrack(i, ...) instead of backtrack(i + 1, ...) allows duplicate selections of the same number

The Solution: Ensure correct range boundaries and increment properly:

for i in range(start, n + 1):  # Include n
    current_combination.append(i)
    backtrack(i + 1, current_combination)  # Move to next number
    current_combination.pop()

4. Memory Leak with Global Variables

The Pitfall: Using class-level or global variables that aren't reset between multiple test cases:

class Solution:
    result = []  # Class variable - persists between calls!
  
    def combine(self, n: int, k: int):
        # result still contains data from previous calls
        self.backtrack(1, [], n, k)
        return self.result

The Solution: Always initialize result containers within the method:

class Solution:
    def combine(self, n: int, k: int):
        result = []  # Local variable, fresh for each call
      
        def backtrack(start, current_combination):
            # ... implementation ...
          
        backtrack(1, [])
        return result
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

What is the best way of checking if an element exists in an unsorted array once in terms of time complexity? Select the best that applies.


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More