Backtracking: The Art of Exploring Every Possibility
When a problem asks you to generate 'all possible combinations', 'all permutations', or 'all valid subsets', there is no shortcut. You must explore the entire state space. Backtracking is the disciplined way to do this without getting lost.
Introduction
Backtracking is essentially a highly structured Depth-First Search (DFS). It is used when we need to find all (or some) solutions to a computational problem that incrementally builds candidates to the solutions, and abandons a candidate ("backtracks") as soon as it determines that the candidate cannot possibly lead to a valid solution.
Think of it like navigating a massive maze. You walk down a path until you hit a dead end. When you hit the dead end, you don't teleport back to the start. You take a step back to the previous intersection, mark the dead-end path as bad, and try the next hallway.
The Three Steps of Backtracking
Every standard backtracking algorithm follows the exact same three-step physical rhythm:
- Make a Choice: Add an element to your temporary list.
- Explore: Recursively call your backtrack function to see where this choice leads.
- Undo the Choice: Remove the element you just added, returning the state to exactly how it was before step 1, so the loop can try the next possible choice.
Example: Generating Subsets (LeetCode #78)
Given an array [1, 2, 3], we want to find every possible subset.
- We start with an empty list
[]. We add it to our results. - Our loop starts at
1. We add1to our list. State:[1]. We recursively explore. - Inside the recursion, we add
2. State:[1, 2]. Explore. - Add
3. State:[1, 2, 3]. Explore. Loop finishes. - The Backtrack: The recursion unrolls. We remove
3. State goes back to[1, 2]. - The loop for
[1, 2]finishes. We backtrack again. Remove2. State goes back to[1]. - The loop for
[1]continues to the next number, which is3. We add3. State:[1, 3].
We systematically build, record, and tear down our state.
Why the "Undo" step is critical
In Java (and most languages), objects like ArrayList are passed by reference. If we don't remove the element after exploring it, the elements will just keep piling up into a single massive, incorrect list.
By adding, exploring, and removing, we keep a single, clean memory allocation that acts as a flexible workspace for the entire recursion tree.
- Time Complexity: $O(N \times 2^N)$ — For a set of size N, there are $2^N$ possible subsets, and we spend $O(N)$ time copying each subset into the result array.
- Space Complexity: $O(N)$ — The maximum depth of our recursion call stack.
Implementation
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), nums, 0);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> tempList, int[] nums, int start) {
// Add the current subset to our final result
result.add(new ArrayList<>(tempList));
for (int i = start; i < nums.length; i++) {
// Make a choice
tempList.add(nums[i]);
// Explore that choice
backtrack(result, tempList, nums, i + 1);
// UNDO the choice (Backtrack)
tempList.remove(tempList.size() - 1);
}
}
}