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
andk = 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 fromn
numbers. The combinatorial nature and the need to generate all possibilities suggests we're dealing with manageable constraints (typicallyn
andk
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
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:
- Include it: Add number
i
to our current combination and continue building with the remaining numbers - 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 sizek
, we've found a valid combination and save it - We use
append()
to add a number andpop()
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 foundt
: 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:
-
Base Case - Valid Combination Found:
if len(t) == k: ans.append(t[:]) return
When our temporary list
t
reaches sizek
, we've found a valid combination. We append a copy oft
to our answer (usingt[:]
to create a copy, not a reference). -
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. -
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 tot
- We recursively explore all combinations that include
i
by callingdfs(i + 1)
- We backtrack by removing
i
fromt
usingpop()
- We explore all combinations that exclude
i
by callingdfs(i + 1)
again
- First, we include number
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 EvaluatorExample 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:
-
Include/Exclude Pattern: At each number, we first try including it (append to
t
), explore all possibilities, then backtrack (pop fromt
) and explore excluding it. -
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
-
Pruning: When we reach
i > n
orlen(t) == k
, we stop exploring further. -
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, whereC(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)
excludesn
from possible selections - Using
backtrack(i, ...)
instead ofbacktrack(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
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
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!