Combinations - Complete Backtracking Guide
Combinations
Introduction
Combinations are a fundamental concept in combinatorics where we select a subset of items from a larger set without regard to the order of selection. In the context of coding interviews and algorithms, Combinations typically refers to generating all possible combinations of a given size from a range of numbers (e.g., generating all combinations of size 2 from numbers 1 to 4).
This problem is a classic application of Backtracking, a general algorithmic technique for exploring all possible configurations of a problem. Unlike brute force, which might generate and filter, backtracking builds solutions incrementally and abandons a path as soon as it determines that the path cannot possibly lead to a valid solution.
The Backtracking Strategy
To solve the Combinations problem, we treat the decision-making process as a tree (State Space Tree):
- Decision: At each step, we decide whether to include the current number in our combination or skip it.
- State: We keep track of our current position in the array (
startIndex) and the current combination being built (path). - Base Case: If the current combination has reached the desired length (
k), we add it to our result list. - Backtrack: After exploring a path, we remove the last added element (
path.pop()) to allow the algorithm to explore other possibilities.
Key Terminology
- State Space Tree: A tree representation of all possible recursive calls. Each node represents a specific state of the recursion.
- Pruning: Stopping the recursion early if the remaining elements are insufficient to fill the remaining slots in the combination.
- Path: The current subset of numbers being constructed.
Algorithm Implementation
Problem Statement
Given two integers n and k, return all possible combinations of k numbers chosen from the range [1, n].
TypeScript Solution
function combine(n: number, k: number): number[][] {
const result: number[][] = [];
const path: number[] = [];
// Start backtracking from index 1
backtrack(1);
return result;
function backtrack(startIndex: number) {
// Base Case: If the combination is complete
if (path.length === k) {
result.push([...path]); // Push a copy of the path
return;
}
// Optimization: Pruning
// If we don't have enough numbers left to fill the combination, stop.
const remainingSlots = k - path.length;
const remainingNumbers = n - startIndex + 1;
if (remainingNumbers < remainingSlots) {
return;
}
// Iterate through potential candidates
for (let i = startIndex; i <= n; i++) {
// Decision: Include i
path.push(i);
// Recurse: Move to the next index to avoid duplicates
backtrack(i + 1);
// Backtrack: Undo the last decision
path.pop();
}
}
}How It Works
- Initialization: We start with an empty
pathand begin the recursion at index1. - Loop: We loop from the current
startIndexton. - Include: We add the current number to
path. - Recurse: We call
backtrack(i + 1). Notice we passi + 1, noti. This ensures we only pick numbers after the current one, preventing duplicate combinations (e.g.,[1, 2]and[2, 1]are treated as the same combination). - Pop: After the recursive call returns, we remove the number from
pathto restore the state for the next iteration of the loop.
Complexity Analysis
Time Complexity: $O(k imes C(n, k))$
- $C(n, k)$: This is the number of combinations, which is the dominant factor. It represents the total number of leaf nodes in the state space tree.
- $k$: For each valid combination, we spend $O(k)$ time copying the
pathinto theresultarray.
Space Complexity: $O(k imes C(n, k))$
- Output Space: We store all combinations.
- Auxiliary Space: The recursion stack goes as deep as $n$ in the worst case, but the space for the
patharray is $O(k)$.
Optimization: Pruning
A crucial optimization in backtracking is pruning. In the loop above, we added a check:
if (remainingNumbers < remainingSlots) return;This check prevents the algorithm from exploring branches where there aren't enough numbers left to complete the combination. For example, if n = 4, k = 3, and we are at index 4, there are no numbers left to form a combination of size 3.
Real-World Applications
- Poker Hand Analysis: Determining the probability of drawing specific hands (e.g., a flush or a straight).
- Team Selection: Choosing a committee of members from a pool of candidates where order doesn't matter.
- Cryptography: Generating possible keys or cipher combinations.
- Game AI: Generating all possible moves in a game tree (though often simplified with Alpha-Beta pruning).
Common Mistakes to Avoid
- Modifying the Input Array: Never modify the input array directly inside the recursion. Always use a separate
patharray. - Forgetting
path.pop(): This is the most common error. If you don't remove the last element, yourpathwill grow indefinitely, leading to incorrect results and stack overflow. - Incorrect Recursion Index: Using
backtrack(i)instead ofbacktrack(i + 1)will generate permutations instead of combinations, resulting in duplicate sets (e.g.,[1, 2]and[2, 1]appearing separately). - Passing Reference: Always push a copy of the path (
[...path]) to the result array, otherwise, all entries in the result will point to the same reference and be modified by subsequent backtracking steps.
Practice Strategy
- Start Simple: Try generating combinations of size 2 or 3 from a small set of numbers.
- Visualize: Draw the state space tree on paper to understand how the recursion explores different paths.
- Compare: Implement the same logic for Permutations (where order matters) to understand the difference in the recursion index (
ivs0with ausedarray).