Hello! Welcome to our next lesson on backtracking.
In our last two lessons, we've built a solid foundation. We started with generating all subsets (the "include/exclude" pattern) and then moved to permutations, where we used an in-place swap-based method to handle all possible orderings.
Today, we'll tackle another classic problem that sits between subsets and permutations: combinations. The goal is to generate all possible groups of a specific size k from a given set of n elements.
For example, given n=4 (numbers 1 to 4) and k=2, the combinations are [1,2], [1,3], [1,4], [2,3], [2,4], and [3,4]. Notice that [1,2] is included, but [2,1] is not, because in combinations, the order of elements within a group does not matter.
This problem is a staple in technical interviews and competitive programming. By the end of this lesson, you will be able to generate all combinations of a given size from a set using a recursive backtracking approach.
1. From Subsets to Combinations: The Core Idea
The problem of generating combinations is a constrained version of the "generate all subsets" problem. We are still selecting elements, but with a strict rule: the final subset must have exactly k elements.
We can visualize this as building a decision tree. At each step, we decide whether to include the current number in our combination.

The fundamental backtracking strategy remains the same:
- Choose: Pick a number to add to our current combination.
- Explore: Recursively try to complete the combination from the remaining numbers.
- Unchoose: Remove the number we just added, so we can explore other paths (backtrack).
The key challenge is ensuring we don't generate duplicate combinations (like [1,2] and [2,1]) and that we stop once our combination reaches size k. We can solve this elegantly by always picking the next number in an increasing order. If we pick 1, the next number we consider can be 2, 3, or 4, but never a number before 1.
This general approach is often presented as a universal template for backtracking problems, which is a powerful mental model for interviews.
Solve ANY Backtracking Problem on Leetcode (Template + Explanation)
The video 'Solve ANY Backtracking Problem on Leetcode' by Bitflip provides an excellent overview of a general backtracking template. It shows how the same template used for subsets can be easily adapted for combinations.
Please watch from 05:13 to 05:39. This short clip demonstrates how modifying the base case of a standard subset-generation algorithm is all you need to solve the combinations problem.
2. Implementation in Java
Let's translate this refined "choose-explore-unchoose" strategy into Java code. The trick is to use an index start that tells our recursive function where to begin its search for the next number. This naturally prevents duplicates and keeps the combinations ordered.
The following article provides a concise explanation and a clean Java implementation of this exact approach.
The article 'Backtracking : Master it like a pro' on dev.to provides a great, practical breakdown of the combinations problem.
Please read section '2. Combinations' and the trace that follows it. Focus on the Java code provided. Pay close attention to how the backtrack(i + 1, ...) call ensures that we only move forward, preventing duplicates like [2,1] after [1,2] is found.
Code Deconstruction
Here is a well-structured Java implementation based on the principles we've discussed. This is a common and effective pattern for solving this problem in an interview setting.
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), 1, n, k);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> currentCombination, int start, int n, int k) {
// Base Case: If the combination is of the desired size, add it to the results.
if (currentCombination.size() == k) {
result.add(new ArrayList<>(currentCombination)); // Add a copy!
return;
}
// Explore further options.
// The loop starts from `start`, ensuring we only pick numbers greater than the previous one.
for (int i = start; i <= n; i++) {
// 1. Choose: Add the number `i` to the current combination.
currentCombination.add(i);
// 2. Explore: Recurse with the next starting number.
backtrack(result, currentCombination, i + 1, n, k);
// 3. Unchoose (Backtrack): Remove the last added number to explore other possibilities.
currentCombination.remove(currentCombination.size() - 1);
}
}
}
Key Points to Analyze:
startParameter: This is the most critical parameter. It tells the function which number to start considering for the next spot in the combination. By passingi + 1in the recursive call, we enforce that once we've used a number, we can only use larger numbers next. This single-handedly solves the[1,2]vs.[2,1]problem.- Base Case:
if (currentCombination.size() == k)is our stopping condition. When our temporary list reaches the target sizek, we have found a valid combination. We add a copy of it to our final result list andreturnto stop this path of recursion. - The Loop: The loop
for (int i = start; i <= n; i++)iterates through all valid candidates for the current position in the combination.
Test your understanding!
Trace the execution of combine(3, 2). What is the state of currentCombination and start right before the first time the base case is met?
Show answer
- Initial call:
backtrack(result, [], 1, 3, 2) - Loop
i=1:currentCombinationbecomes[1]. Callbacktrack(result, [1], 2, 3, 2). - Inside the new call, loop
i=2:currentCombinationbecomes[1, 2]. Callbacktrack(result, [1, 2], 3, 3, 2). - Now, inside this third call, the base case
currentCombination.size() == 2is met. The state right before this check iscurrentCombination = [1, 2]and the parameters for this call werestart=3, n=3, k=2.[1, 2]is added to the results.
3. Pruning the Decision Tree: A Key Optimization
Our current code works perfectly, but it explores some branches unnecessarily. For instance, if n=4, k=3 and we are at i=4 with an empty currentCombination, our code will try to explore this path. But it's impossible to form a combination of size 3 starting from the number 4.
We can make our algorithm "smarter" by pruning these dead-end branches. The rule is simple: if the number of elements we still need to form a combination is greater than the number of elements available, we can stop.
- Elements still needed:
k - currentCombination.size() - Elements available:
n - i + 1
So, if k - currentCombination.size() > n - i + 1, we can break the loop. A slightly cleaner way to write this check is to adjust the loop boundary: for (int i = start; i <= n - (k - currentCombination.size()) + 1; i++).
The following video explains this optimization process very clearly.
Ep11- Combinations | Find all possible combinations of k numbers out of the range 1 to N | Recursion
The video 'Ep11- Combinations' from LearnYard provides a detailed walkthrough, starting with a basic recursion and then introducing two crucial optimizations, or pruning techniques.
Please watch from 17:28 to 23:45. The presenter explains why certain recursive paths are futile and how to add a condition to prune the search tree. This avoids exploring paths where it's impossible to complete a combination of the required size.
Here's the optimized code:
private void backtrack(List<List<Integer>> result, List<Integer> currentCombination, int start, int n, int k) {
if (currentCombination.size() == k) {
result.add(new ArrayList<>(currentCombination));
return;
}
// Optimization: Stop if not enough elements are left to form a valid combination.
// The last possible start index is n - (k - currentCombination.size()) + 1
for (int i = start; i <= n - (k - currentCombination.size()) + 1; i++) {
currentCombination.add(i);
backtrack(result, currentCombination, i + 1, n, k);
currentCombination.remove(currentCombination.size() - 1);
}
}
This optimization can significantly improve runtime performance, a key consideration in both interviews and competitive programming.
4. Complexity Analysis
Let's break down the performance of our algorithm.
-
Time Complexity: O(k * C(n, k))
- The algorithm generates
C(n, k)combinations, whereC(n, k)is the binomial coefficient "n choose k". - For each of these combinations, we perform an
O(k)operation to create a new list and add it to our results. - The recursive calls themselves explore
C(n, k)paths that lead to solutions. Therefore, the total time is dominated by finding and storing these solutions.
- The algorithm generates
-
Space Complexity: O(k)
- This refers to the auxiliary space used by the algorithm, not including the output list.
- The
currentCombinationlist will hold at mostkelements. - The depth of the recursion call stack will also be at most
k. - Therefore, the space complexity is
O(k). - (As always, if an interviewer asks for space complexity, it's wise to clarify if they want to include the space for the final
resultlist, which would beO(k * C(n, k))).
Conclusion
You've now learned how to solve another fundamental backtracking problem. By adapting the subset generation logic with a size constraint and an intelligent starting index, you can efficiently generate all combinations.
Key Takeaways:
- Combinations are subsets of a fixed size
k, where order does not matter. - The
backtrack(..., start, ...)andbacktrack(..., i + 1, ...)pattern is the key to generating unique combinations without repetition. - The base case checks for
currentCombination.size() == k. - The backtracking template of Choose -> Explore -> Unchoose is robust and adaptable.
- Pruning the search tree by checking if enough elements remain is a crucial optimization.
Preview of the Next Lesson:
So far, our backtracking problems have been about generating groups of elements (subsets, permutations, combinations). In our next lesson, we'll apply this same template to a different kind of problem: a constraint satisfaction problem. We will implement a backtracking solution to find all valid configurations for the N-Queens problem, where we must place N queens on a chessboard such that no two queens threaten each other. This will introduce more complex "is valid" checks into our backtracking logic.