Week 10: Recursion & Backtracking
1. Overviewโ
Welcome to Week 10! After mastering Binary Search, we now dive into one of the most intellectually challenging but rewarding topics in computer science: Recursion and Backtracking.
Recursion is a method where the solution to a problem depends on solutions to smaller instances of the same problem. Backtracking builds upon this by exploring all potential paths in a "Decision Tree" and retreating (backtracking) when it hits a dead end or finds a solution. This is the primary tool for solving combinatorial problems (permutations, subsets) and constraint satisfaction problems (Sudoku, N-Queens).
Goals for this week:
- Understand the JVM Call Stack and how recursive calls are managed in memory.
- Master the "Base Case" vs. "Recursive Step" logic.
- Learn the Backtracking Template: Choose, Explore, Un-choose.
- Understand the difference between Permutations, Combinations, and Subsets.
Knowledge You Need Before Startingโ
- Strong control-flow basics and method call understanding in Java.
- Confidence tracing small trees/graphs manually.
- Ability to define clear base cases and shrinking subproblems.
- Comfort with arrays/lists mutation and rollback patterns.
2. Theory & Fundamentalsโ
2.1 Mental Model: Recursion as Delegationโ
The hardest part of recursion is trusting it. The key mental shift is:
"I don't solve the whole problem. I solve the current step, then delegate the rest to a smaller version of myself."
The Russian Nesting Doll Analogy:
Open the outermost doll โ set it aside โ open next doll โ ... โ reach the smallest doll (base case)
โ put smallest doll back in โ put that into the next โ ... โ back to the outermost
Each "doll" is a stack frame. Opening = recursive call. Closing = returning from that call.
Fibonacci as delegation:
fib(5) = ?
"I don't know. I'll delegate:
fib(5) = fib(4) + fib(3)"
fib(4) = ?
"fib(4) = fib(3) + fib(2)"
fib(3) = fib(2) + fib(1)
fib(2) = fib(1) + fib(0) = 1 + 0 = 1
fib(1) = 1 โ BASE CASE
fib(0) = 0 โ BASE CASE
Results bubble back up:
fib(2) = 1, fib(3) = 2, fib(4) = 3, fib(5) = 5
The two mandatory parts of any recursive function:
| Part | Purpose | What happens if you forget it |
|---|---|---|
| Base Case | Stop recursion when the smallest problem is reached | StackOverflowError โ infinite recursion |
| Recursive Step | Solve a strictly smaller subproblem | StackOverflowError โ not making progress |
2.2 The JVM Call Stackโ
Every time a method is called in Java, the JVM pushes a new stack frame onto the Stack Memory. This frame stores:
- The method's local variables
- The method's parameters
- The return address (where to go when this call finishes)
Calling fib(4):
Stack grows โ Stack shrinks โ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
| fib(0) n=0 | result: 0
โโโโโโโโโโโโโโโโโ
| fib(1) n=1 | result: 1
โโโโโโโโโโโโโโโโโ
| fib(2) n=2 | โโโโโ result: 1
โโโโโโโโโโโโโโโโโ
| fib(3) n=3 | result: 2
โโโโโโโโโโโโโโโโโ
| fib(4) n=4 | result: 3
โโโโโโโโโโโโโโโโโ
JVM Stack limit โ 5,000-10,000 frames (default 1MB)
Deep recursion on large N โ StackOverflowError
Practical implication: For an input of size N where your recursion goes N levels deep, you use O(N) stack space. This is often invisible until you hit a 10,000+ depth tree or array.
When to convert recursion to iteration:
- Input can be very large (N > 10,000)
- Space is constrained
- The recursion is tail-recursive (the recursive call is the last operation) โ Java doesn't optimize tail calls, but you can convert manually to a loop
2.3 What is Backtracking?โ
Backtracking = Recursion + Undoing Choices
The core idea: you are exploring a Decision Tree. At each node, you make a choice, go deeper, and if you reach a dead end (constraint violated) or a solution (goal reached), you undo that choice and try the next option.
The Decision Tree for permutations of [1, 2, 3]:
[ ]
/ | \
[1] [2] [3]
/ \ / \ / \
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
| | | | | |
[1,2,3][1,3,2] [2,1,3][2,3,1] [3,1,2][3,2,1]
โ
โ
โ
โ
โ
โ
The algorithm explores each branch fully before backtracking to try the next sibling.
The Three Keys โ expanded:
| Key | Question | Example (Permutations) |
|---|---|---|
| Choice | What options do I have right now? | "Which unused number do I pick next?" |
| Constraint | Is this choice valid? | "Is this number already in my current path?" |
| Goal | Am I done? | "Does my path have all N numbers?" |
Backtracking vs. Brute Force:
- Brute force generates ALL possibilities and checks each one after.
- Backtracking prunes invalid paths early, never completing them.
Backtracking prunes at [X] to avoid exploring entire subtrees:
[ ]
/ | \
[1] [2] [3]
/
[1,1] โ Constraint violated! Prune here.
Never explore [1,1,2], [1,1,3], etc.
Worst case: Even with pruning, backtracking can be exponential. But for well-constrained problems (like Sudoku), pruning eliminates the vast majority of the search space.
2.4 Java Specifics: The Deep Copy Pitfallโ
This is the single most common bug in backtracking โ failing to copy the current path when adding to results.
// The problem: Java List is passed by REFERENCE
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
path.add(1); path.add(2); path.add(3);
result.add(path); // โ result contains a REFERENCE to path, not a copy
path.clear(); // Clearing path also clears what's in result!
System.out.println(result); // prints [[]] โ empty! The reference was modified.
Visualized:
After result.add(path):
result[0] โโโโโโโโโโโถ [1, 2, 3] (same object as path)
path โโโโโโโโโโโถ [1, 2, 3]
After path.clear():
result[0] โโโโโโโโโโโถ [] (same object โ now empty!)
path โโโโโโโโโโโถ []
// โ
CORRECT โ create a new independent copy:
result.add(new ArrayList<>(path));
After result.add(new ArrayList<>(path)):
result[0] โโโโโโโโโโโถ [1, 2, 3] (NEW independent object)
path โโโโโโโโโโโถ [1, 2, 3] (original)
After path.clear():
result[0] โโโโโโโโโโโถ [1, 2, 3] โ
(unaffected)
path โโโโโโโโโโโถ []
Why does new ArrayList<>(path) work? It constructs a new list by copying all elements by value. Since the elements are Integer (primitives wrapped in objects), this is a true independent snapshot.
2.5 Permutations vs. Combinations vs. Subsetsโ
These three are the building blocks of almost every backtracking problem. Understanding the difference unlocks pattern recognition.
| Definition | Example (from [1,2,3]) | Order matters? | Count | |
|---|---|---|---|---|
| Permutations | All arrangements of all elements | [1,2,3], [1,3,2], [2,1,3]... | โ Yes | N! |
| Combinations | All ways to choose K elements | Choose 2: [1,2], [1,3], [2,3] | โ No | C(N, K) |
| Subsets | All possible subsets (all sizes) | [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3] | โ No | 2^N |
The code difference:
Permutations: loop from 0 each time, skip used elements, no "start" index
Combinations: loop from "start" each time, advance start in recursive call
Subsets: same as combinations but add to result at EVERY node, not just leaves
Decision: Does order matter?
- Yes โ Permutation โ loop from 0, track used with
boolean[]orcontains() - No โ Combination/Subset โ loop from
startindex to avoid revisiting
2.6 Using StringBuilder vs. List in Backtrackingโ
For string-based backtracking problems, StringBuilder is more efficient than List<Character> because it avoids boxing:
// โ Slower โ boxing/unboxing chars
List<Character> path = new ArrayList<>();
path.add('a');
path.remove(path.size() - 1);
// โ
Faster โ use StringBuilder
StringBuilder sb = new StringBuilder();
sb.append('a');
sb.deleteCharAt(sb.length() - 1); // efficient O(1) delete from end
When to use each:
- String problems (phone number combos, parentheses, palindrome partitioning) โ
StringBuilder - Number/object problems (permutations, subsets, combinations) โ
List<Integer>
2.7 Pruning Strategies โ Making Backtracking Fasterโ
Pruning = skipping a branch early when you know it can't lead to a valid solution. The more you prune, the faster your solution runs.
Strategy 1 โ Sort first, skip duplicates:
// For problems like Subsets II (with duplicates in input):
Arrays.sort(nums); // sort first
for (int i = start; i < nums.length; i++) {
// Skip duplicates at the same recursion level
if (i > start && nums[i] == nums[i - 1]) continue; // โ pruning
path.add(nums[i]);
backtrack(res, path, nums, i + 1);
path.remove(path.size() - 1);
}
Strategy 2 โ Constraint check before recursing:
// For Combination Sum (target sum):
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > remaining) break; // โ prune: sorted array, can stop early
path.add(candidates[i]);
backtrack(res, path, candidates, i, remaining - candidates[i]);
path.remove(path.size() - 1);
}
Strategy 3 โ Row/column/box tracking for Sudoku:
// Instead of scanning the board each time:
boolean[][] rowUsed = new boolean[9][10]; // rowUsed[row][digit]
boolean[][] colUsed = new boolean[9][10]; // colUsed[col][digit]
boolean[][] boxUsed = new boolean[9][10]; // boxUsed[box][digit]
// O(1) validity check instead of O(81) scan
boolean isValid = !rowUsed[row][digit] && !colUsed[col][digit] && !boxUsed[boxIndex][digit];
3. Code Templates (Java)โ
Template 1: Basic Recursion Structureโ
public ReturnType solve(Parameters params) {
// 1. Base Case โ ALWAYS FIRST
if (baseCondition) {
return baseValue;
}
// 2. Recursive Step โ solve a strictly smaller subproblem
ReturnType subResult = solve(smallerParams);
// 3. Combine โ use subResult to compute this level's answer
return combine(currentWork, subResult);
}
Template 2: Subsets (Add at every node)โ
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> path, int[] nums, int start) {
result.add(new ArrayList<>(path)); // โ Add at EVERY node (including empty)
for (int i = start; i < nums.length; i++) {
path.add(nums[i]); // Choose
backtrack(result, path, nums, i + 1); // Explore (i+1 prevents reuse)
path.remove(path.size() - 1); // Un-choose
}
}
Template 3: Combinations (Add only at leaves)โ
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), n, k, 1);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> path, int n, int k, int start) {
if (path.size() == k) {
result.add(new ArrayList<>(path)); // โ Add only when size reached
return;
}
// Pruning: no need to go further if not enough numbers left
// Remaining spots needed: k - path.size()
// Numbers left: n - i + 1 must be >= k - path.size()
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
backtrack(result, path, n, k, i + 1);
path.remove(path.size() - 1);
}
}
Template 4: Permutations (Track used with boolean array)โ
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), nums, new boolean[nums.length]);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> path,
int[] nums, boolean[] used) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue; // Constraint: skip already used
used[i] = true; // Choose
path.add(nums[i]);
backtrack(result, path, nums, used); // Explore
path.remove(path.size() - 1); // Un-choose
used[i] = false;
}
}
Prefer
boolean[] usedoverpath.contains():contains()isO(N)per call, making the total complexityO(N^2 \cdot N!). Aboolean[]check isO(1), givingO(N \cdot N!).
Template 5: Combination Sum (Allow reuse, prune by remaining)โ
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // sort enables early termination
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), candidates, target, 0);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> path,
int[] candidates, int remaining, int start) {
if (remaining == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > remaining) break; // โ Pruning: sorted array
path.add(candidates[i]);
backtrack(result, path, candidates, remaining - candidates[i], i); // i not i+1 (reuse allowed)
path.remove(path.size() - 1);
}
}
Template 6: String Backtracking with StringBuilderโ
public List<String> generatePasswords(int n) {
List<String> result = new ArrayList<>();
backtrack(result, new StringBuilder(), n);
return result;
}
private void backtrack(List<String> result, StringBuilder current, int n) {
if (current.length() == n) {
result.add(current.toString()); // snapshot the string
return;
}
for (char c : choices) {
if (!isValid(current, c)) continue; // Constraint check
current.append(c); // Choose
backtrack(result, current, n); // Explore
current.deleteCharAt(current.length() - 1); // Un-choose
}
}
Template 7: Grid Backtracking (Word Search)โ
private static final int[][] DIRS = {{0,1},{0,-1},{1,0},{-1,0}};
public boolean backtrackGrid(char[][] board, boolean[][] visited,
int row, int col, String word, int index) {
if (index == word.length()) return true; // All characters matched
// Bounds + visited + character check
if (row < 0 || row >= board.length || col < 0 || col >= board[0].length
|| visited[row][col] || board[row][col] != word.charAt(index)) {
return false;
}
visited[row][col] = true; // Choose (mark as part of current path)
for (int[] dir : DIRS) {
if (backtrackGrid(board, visited, row + dir[0], col + dir[1], word, index + 1)) {
visited[row][col] = false; // Un-choose before returning
return true;
}
}
visited[row][col] = false; // Un-choose (backtrack)
return false;
}
Template 8: Handling Duplicates (Sort + Skip)โ
// For Subsets II, Permutations II โ input has duplicate values
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums); // Critical: sort first so duplicates are adjacent
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), nums, 0);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> path,
int[] nums, int start) {
result.add(new ArrayList<>(path));
for (int i = start; i < nums.length; i++) {
// Skip duplicates at the SAME recursion level (not across levels)
if (i > start && nums[i] == nums[i - 1]) continue;
path.add(nums[i]);
backtrack(result, path, nums, i + 1);
path.remove(path.size() - 1);
}
}
4. Problem-Solving Frameworkโ
Step 1 โ Classify the Problem (1 minute)โ
Ask:
- "Do I need ALL solutions?" โ Backtracking
- "Does order matter?" โ Yes: Permutation. No: Combination/Subset
- "Can I reuse elements?" โ Yes: Combination Sum style (pass
i, noti+1). No: passi+1 - "Are there duplicates in the input?" โ Sort first + skip duplicate branches
Step 2 โ Define the Decision Treeโ
Mentally sketch:
- Root: Empty state / starting state
- Branches: What choices do I have at each node?
- Leaves: What is a complete/valid solution?
- Pruning: What constraint kills a branch early?
Example: Generate parentheses of length 2n
Root: ""
Choice at each step: add "(" or ")"
Constraint:
- Can't add ")" if closedCount >= openCount (unbalanced)
- Can't add "(" if openCount >= n (already used all opens)
Goal: string length == 2n
Step 3 โ Write the Signatureโ
private void backtrack(
List<List<Integer>> result, // accumulate answers
List<Integer> currentPath, // current state being built
int[] input, // the input data
int start, // where to start (for combinations)
boolean[] used, // which elements are used (for permutations)
int remaining // remaining budget (for sum problems)
) { ... }
Not every parameter is needed in every problem. Only include what the current problem needs to enforce constraints.
Step 4 โ Write Base Case Firstโ
// What does a complete solution look like?
if (currentPath.size() == targetSize) { // permutation / combination
if (remaining == 0) { // sum problem
if (index == word.length()) { // string matching
if (row == board.length) { // grid completed
result.add(new ArrayList<>(currentPath)); // โ ALWAYS deep copy!
return;
}
Step 5 โ Write the Loopโ
for (/* choices */) {
if (/* constraint violated */) continue; // or break if sorted
// Choose
currentPath.add(choice);
used[i] = true;
// Explore
backtrack(result, currentPath, ...);
// Un-choose (MUST mirror the choose step exactly)
currentPath.remove(currentPath.size() - 1);
used[i] = false;
}
Step 6 โ Trace Through a Small Exampleโ
Before submitting, manually trace the first 2-3 branches of the decision tree:
- Does the base case trigger correctly?
- Does the un-choose step restore state to what it was before the choose step?
- Are duplicates handled correctly?
5. Pattern Recognition Guideโ
Signal โ Pattern Mappingโ
| Problem signal | Pattern | Key technique |
|---|---|---|
| "All possible combinations/subsets" | Subset backtracking | Add at every node, start index |
| "All permutations" | Permutation backtracking | Loop from 0, boolean[] used |
| "Combinations summing to target" | Combination sum | remaining variable, prune if > remaining |
| "Generate valid parentheses" | String backtracking | Track open/close counts as constraints |
| "Word search in grid" | Grid backtracking | visited[][] as choose/unchoose |
| "N-Queens / Sudoku" | Constraint backtracking | Check row/col/box validity before placing |
| "Palindrome partitioning" | String backtracking | Check if substring is palindrome before recursing |
| "Letter combinations (phone)" | Cartesian product | One loop per digit, no start index |
| "Duplicates in input, unique results" | Sort + skip | if (i > start && nums[i] == nums[i-1]) continue |
| "Restore IP addresses" | String partitioning | Validate segment, limit segment count |
Detailed Recognition Walkthroughsโ
Recognizing Subsets vs. Combinationsโ
Subsets (LC 78): "Return ALL possible subsets" โ the empty set and the full set are both valid.
- Add
currentPathto result at every node before looping. - No goal check needed (every state is valid).
Combinations (LC 77): "Return all combinations of exactly K elements."
- Add
currentPathto result only at leaves (whenpath.size() == k). - Use the pruning optimization:
i <= n - (k - path.size()) + 1.
Recognizing Permutation Duplicates (LC 47 โ Permutations II)โ
When input has duplicates ([1, 1, 2]), naive permutation gives duplicate results ([1, 1, 2] appears twice). Fix:
- Sort the array:
[1, 1, 2] - In the loop:
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
Why !used[i-1]? If nums[i-1] was used at a previous level, we're at a different branch and the duplicate is valid. If nums[i-1] was not used (backtracked), then we'd produce the same result we already had โ skip it.
Recognizing Generate Parentheses (LC 22)โ
When you see: "Generate all valid combinations of N pairs of parentheses"
- "At each step I choose '(' or ')'."
- Constraints:
openCount <= nandcloseCount <= openCount. - No
startindex needed โ just track open/close counts. - Goal:
string.length() == 2 * n.
Recognizing N-Queensโ
When you see: "Place N queens on an NรN board so no two attack each other"
- Place one queen per row (reduces search space from
N^NtoN!). - Check column conflict:
colUsed[col]. - Check diagonal conflict:
diagonal1Used[row - col + n]anddiagonal2Used[row + col]. - Backtrack by removing the queen and un-marking all three.
6. Common Mistakes & How to Avoid Themโ
Mistake 1: Missing the Deep Copyโ
// โ WRONG โ all results point to the same mutable list
result.add(currentPath);
// โ
CORRECT โ snapshot the current state
result.add(new ArrayList<>(currentPath));
// For 2D results (list of lists):
result.add(new ArrayList<>(currentPath)); // shallow copy of the inner list is fine
// as long as elements are immutable (Integer, String)
Mistake 2: Not Un-choosing After Recursionโ
// โ WRONG โ state is permanently modified, future branches are corrupted
path.add(nums[i]);
backtrack(result, path, nums, i + 1);
// forgot: path.remove(path.size() - 1);
// โ
CORRECT โ every choose must have a matching un-choose
path.add(nums[i]);
backtrack(result, path, nums, i + 1);
path.remove(path.size() - 1); // restore state
Mistake 3: Using path.contains() Instead of boolean[] usedโ
// โ Slow โ O(N) per call, total O(Nยฒ * N!)
if (path.contains(nums[i])) continue;
// โ
Fast โ O(1) per call, total O(N * N!)
if (used[i]) continue;
Mistake 4: Wrong start Index for Reusable vs. Non-reusable Elementsโ
// Combination Sum: elements CAN be reused
backtrack(result, path, candidates, remaining - candidates[i], i); // โ i, not i+1
// Combinations: elements CANNOT be reused
backtrack(result, path, nums, i + 1); // โ i+1 moves past current element
Mistake 5: Forgetting to Sort Before Skipping Duplicatesโ
// โ WRONG โ duplicates may not be adjacent without sorting
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) continue; // only works if sorted!
...
}
// โ
CORRECT โ sort first, THEN apply the duplicate skip
Arrays.sort(nums); // critical prerequisite
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) continue;
...
}
Mistake 6: Missing the Base Case or Making It Unreachableโ
// โ WRONG โ base case never triggers if recursion passes it by
private void backtrack(List<Integer> path, int[] nums, int start) {
if (path.size() > nums.length) { // uses > instead of ==, triggers too late
result.add(new ArrayList<>(path));
return;
}
...
}
// โ
CORRECT โ exact equality check
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
7. Worked Examplesโ
Example 1: LeetCode 78 โ Subsetsโ
Thinking process:
- "All possible subsets" including empty set โ add at every node.
- To avoid duplicates like
[1,2]and[2,1]being counted separately: usestartindex. - No base case needed โ every state is valid.
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> path,
int[] nums, int start) {
result.add(new ArrayList<>(path)); // Add current state (every node is valid)
for (int i = start; i < nums.length; i++) {
path.add(nums[i]); // Choose
backtrack(result, path, nums, i + 1); // Explore
path.remove(path.size() - 1); // Un-choose
}
}
}
Decision tree for nums = [1, 2, 3]:
Add []
โโโ Choose 1 โ Add [1]
โ โโโ Choose 2 โ Add [1,2]
โ โ โโโ Choose 3 โ Add [1,2,3] (no more elements)
โ โโโ Choose 3 โ Add [1,3] (no more elements)
โโโ Choose 2 โ Add [2]
โ โโโ Choose 3 โ Add [2,3] (no more elements)
โโโ Choose 3 โ Add [3] (no more elements)
Result: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]] โ
(8 = 2ยณ subsets)
Time: O(N \cdot 2^N). Space: O(N) call stack.
Example 2: LeetCode 46 โ Permutationsโ
Thinking process:
- "All permutations" โ order matters, every element used exactly once.
- Loop from 0 each time (order matters), skip if
used[i]. - Base case: when
path.size() == nums.length.
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(result, new ArrayList<>(), nums, new boolean[nums.length]);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> path,
int[] nums, boolean[] used) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.add(nums[i]);
backtrack(result, path, nums, used);
path.remove(path.size() - 1);
used[i] = false;
}
}
}
Trace for nums = [1, 2, 3], first branch only:
path=[], used=[F,F,F]
i=0: used[0]=T, path=[1]
i=0: skip (used[0]=T)
i=1: used[1]=T, path=[1,2]
i=0: skip, i=1: skip
i=2: used[2]=T, path=[1,2,3] โ ADD [1,2,3] โ
un-choose: used[2]=F, path=[1,2]
un-choose: used[1]=F, path=[1]
i=2: used[2]=T, path=[1,3]
i=0: skip, i=1: used[1]=T, path=[1,3,2] โ ADD [1,3,2] โ
...
un-choose: used[0]=F, path=[]
i=1: ... (generates [2,1,3], [2,3,1])
i=2: ... (generates [3,1,2], [3,2,1])
Total: 6 = 3! โ
Time: O(N \cdot N!). Space: O(N).
Example 3: LeetCode 22 โ Generate Parenthesesโ
Thinking process:
- "Generate all valid parentheses" โ string building problem.
- Constraints: open count โค n, close count โค open count.
- No
startindex needed โ trackopenandclosecounts. - Goal: string length =
2 * n.
class Solution {
public List<String> generateParenthesis(int n) {
List<String> result = new ArrayList<>();
backtrack(result, new StringBuilder(), 0, 0, n);
return result;
}
private void backtrack(List<String> result, StringBuilder current,
int open, int close, int n) {
if (current.length() == 2 * n) {
result.add(current.toString());
return;
}
if (open < n) { // Can add more open brackets
current.append('(');
backtrack(result, current, open + 1, close, n);
current.deleteCharAt(current.length() - 1);
}
if (close < open) { // Can add close only if more opens exist
current.append(')');
backtrack(result, current, open, close + 1, n);
current.deleteCharAt(current.length() - 1);
}
}
}
Decision tree for n = 2:
""
โโโ "(" (open=1, close=0)
โ โโโ "((" (open=2, close=0)
โ โ โโโ "(()" (open=2, close=1)
โ โ โโโ "(())" โ
ADD
โ โโโ "()" (open=1, close=1)
โ โโโ "()(" (open=2, close=1)
โ โโโ "()()" โ
ADD
Result: ["(())", "()()"] โ
Time: O(4^N / \sqrt N) (Catalan number). Space: O(N).
Example 4: LeetCode 79 โ Word Searchโ
Thinking process:
- "Find if word exists in grid" โ explore paths in a grid.
- Use
visited[][]as the un-choose mechanism. - Try all 4 directions from each cell.
- Prune: out of bounds, already visited, wrong character.
class Solution {
private static final int[][] DIRS = {{0,1},{0,-1},{1,0},{-1,0}};
public boolean exist(char[][] board, String word) {
int m = board.length, n = board[0].length;
boolean[][] visited = new boolean[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (backtrack(board, visited, i, j, word, 0)) return true;
}
}
return false;
}
private boolean backtrack(char[][] board, boolean[][] visited,
int row, int col, String word, int index) {
if (index == word.length()) return true; // all characters matched
if (row < 0 || row >= board.length || col < 0 || col >= board[0].length
|| visited[row][col]
|| board[row][col] != word.charAt(index)) {
return false;
}
visited[row][col] = true; // Choose
for (int[] dir : DIRS) {
if (backtrack(board, visited, row + dir[0], col + dir[1], word, index + 1)) {
visited[row][col] = false;
return true;
}
}
visited[row][col] = false; // Un-choose
return false;
}
}
Time: O(M \cdot N \cdot 4^L) where L = word length. Space: O(L) call stack.
Example 5: LeetCode 51 โ N-Queens (Classic Constraint Satisfaction)โ
Thinking process:
- Place one queen per row โ reduces branching factor.
- Three constraints: column conflict, diagonal conflict, anti-diagonal conflict.
- Track conflicts with
boolean[]sets forO(1)checks. - Collect board states when all N queens are placed.
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> result = new ArrayList<>();
boolean[] cols = new boolean[n];
boolean[] diag1 = new boolean[2 * n]; // row - col + n
boolean[] diag2 = new boolean[2 * n]; // row + col
char[][] board = new char[n][n];
for (char[] row : board) Arrays.fill(row, '.');
backtrack(result, board, cols, diag1, diag2, 0, n);
return result;
}
private void backtrack(List<List<String>> result, char[][] board,
boolean[] cols, boolean[] diag1, boolean[] diag2,
int row, int n) {
if (row == n) {
List<String> solution = new ArrayList<>();
for (char[] r : board) solution.add(new String(r));
result.add(solution);
return;
}
for (int col = 0; col < n; col++) {
int d1 = row - col + n, d2 = row + col;
if (cols[col] || diag1[d1] || diag2[d2]) continue; // Prune
// Choose
board[row][col] = 'Q';
cols[col] = diag1[d1] = diag2[d2] = true;
backtrack(result, board, cols, diag1, diag2, row + 1, n);
// Un-choose
board[row][col] = '.';
cols[col] = diag1[d1] = diag2[d2] = false;
}
}
}
Time: O(N!) with heavy pruning in practice. Space: O(N).
8. Decision Tree: Which Backtracking Template?โ
Do I need to enumerate something?
โโโ YES โ Backtracking
Does order matter in the result?
โโโ YES โ Permutation template
โ โโโ No duplicates in input โ boolean[] used
โ โโโ Duplicates in input โ Sort + (i > 0 && nums[i] == nums[i-1] && !used[i-1])
โโโ NO โ Does it need a fixed size?
โโโ YES โ Combination template (add only at leaves, start index)
โโโ NO โ Subset template (add at every node, start index)
Is there a sum constraint?
โโโ YES โ Track remaining budget, prune if candidate > remaining
โโโ Can elements be reused? โ YES: pass i | NO: pass i+1
Is it a string?
โโโ YES โ StringBuilder template (append/deleteCharAt)
โโโ Track open/close counts or other string-specific state
Is it a grid?
โโโ YES โ Grid backtracking (visited[][], 4-direction loop)
Is it a constraint satisfaction (Sudoku, N-Queens)?
โโโ YES โ One choice per row/constraint, boolean[] for O(1) conflict check
9. Complexity Cheat Sheetโ
| Problem Type | Time | Space |
|---|---|---|
| Subsets | O(N \cdot 2^N) | O(N) call stack |
| Combinations (N choose K) | O(K * C(N, K)) | O(K) call stack |
| Permutations | O(N \cdot N!) | O(N) call stack |
| Combination Sum | O(N^(T/M)) where T=target, M=min candidate | O(T/M) |
| Generate Parentheses | O(4^N / \sqrt N) (Catalan) | O(N) |
| Word Search | O(M \cdot N \cdot 4^L) | O(L) |
| N-Queens | O(N!) worst case, much less with pruning | O(N) |
| Sudoku Solver | O(9^81) worst case, typically much faster | O(81) |
10. 7-Day Practice Plan (21 Problems)โ
For each problem, follow this ritual:
- Classify: Subset, Combination, Permutation, or Constraint Satisfaction?
- Draw 2-3 branches of the decision tree on paper.
- Write the signature with only the parameters you need.
- Write the base case first.
- Write the loop with Choose โ Explore โ Un-choose.
- Trace through your code manually for a small input.
Day 1: Easy Recursion Warm-Up
- Fibonacci Number (LC 509) โ Understand delegation + memoization intro
- Reverse String (LC 344) โ Recursive two-pointer
- Merge Two Sorted Lists (LC 21) โ Recursive vs iterative comparison
Day 2: Subsets & Combinations 4. Subsets (LC 78) โ Add at every node 5. Subsets II (LC 90) โ Sort + skip duplicates 6. Combinations (LC 77) โ Add only at leaves
Day 3: Sum-Based Problems 7. Combination Sum (LC 39) โ Reuse allowed, prune by remaining 8. Combination Sum II (LC 40) โ No reuse, handle duplicates 9. Combination Sum III (LC 216) โ Fixed size + fixed sum
Day 4: Permutations
10. Permutations (LC 46) โ boolean[] used
11. Permutations II (LC 47) โญ โ Sort + !used[i-1] trick
12. Letter Case Permutation (LC 784) โ Two branches per char
Day 5: String Backtracking 13. Letter Combinations of a Phone Number (LC 17) โ Cartesian product of digit mappings 14. Generate Parentheses (LC 22) โญ โ Open/close count constraints 15. Partition Palindrome (LC 131) โ Check palindrome before recursing
Day 6: Grid Backtracking (Advanced)
16. Word Search (LC 79) โ visited[][] as choose/unchoose
17. Number of Islands (LC 200) โ DFS as backtracking on grid
18. Flood Fill (LC 733) โ Grid DFS with state change
Day 7: The Hard Classics 19. N-Queens (LC 51) โญ โ Column + diagonal constraint arrays 20. Sudoku Solver (LC 37) โญ โ Row/col/box tracking 21. Restore IP Addresses (LC 93) โ Segment validation + count constraint
11. Mock Interview Moduleโ
Problem: The Password Cracker (Restricted Combinations)โ
Context: You are building a security testing tool. A system allows passwords consisting of exactly N digits (0-9). To prevent simple patterns, the rule is: no two adjacent digits can be the same.
Question: Write public List<String> generatePasswords(int n) that returns all valid passwords of length n.
Step 1: Clarifying Questions & Expected Answersโ
| Candidate asks | Interviewer answers | Why it matters |
|---|---|---|
| "Are leading zeros allowed?" | Yes (0123 is valid) | Use String, not integer parsing |
| "What is the expected N?" | Up to 4 or 5 | Exponential output, but manageable |
| "Should the list be sorted?" | No preference | Simplifies implementation |
| "Is the empty string a valid password?" | No, N โฅ 1 | Base case definition |
Step 2: Brute Forceโ
Generate all 10^N strings, filter invalid ones:
- For N=4: 10,000 candidates โ filter โ
9 \times 10^(N-1)valid results. - Wasteful: you generate strings you immediately discard.
Step 3: Optimized Solution (Backtracking)โ
Build digit-by-digit, skip invalid choices early:
public List<String> generatePasswords(int n) {
List<String> result = new ArrayList<>();
if (n <= 0) return result;
backtrack(result, new StringBuilder(), n);
return result;
}
private void backtrack(List<String> result, StringBuilder current, int n) {
if (current.length() == n) {
result.add(current.toString()); // snapshot
return;
}
for (int digit = 0; digit <= 9; digit++) {
// Constraint: no two adjacent digits the same
if (current.length() > 0 &&
current.charAt(current.length() - 1) - '0' == digit) {
continue; // pruning: skip same digit as previous
}
current.append(digit); // Choose
backtrack(result, current, n); // Explore
current.deleteCharAt(current.length() - 1); // Un-choose
}
}
Complexity analysis:
- First digit: 10 choices.
- Each subsequent digit: 9 choices (any digit except the previous one).
- Total valid passwords:
10 \times 9^(N-1). - Time:
O(N \times 10 \times 9^(N-1))โ generating + copying each string of length N.
Step 4: Follow-up Questionsโ
Q1: "What if the constraint is: no digit can appear more than twice in the entire password?"
Expected answer:
"I'd track a frequency count int[] freq = new int[10] alongside the StringBuilder.
- Before choosing digit
d: checkfreq[d] < 2. - Choose:
freq[d]++. - Un-choose:
freq[d]--. This addsO(10) = O(1)overhead per call and no change to the overall approach."
private void backtrack(List<String> result, StringBuilder current, int n, int[] freq) {
if (current.length() == n) { result.add(current.toString()); return; }
for (int digit = 0; digit <= 9; digit++) {
if (freq[digit] >= 2) continue; // new constraint
current.append(digit);
freq[digit]++; // Choose
backtrack(result, current, n, freq);
current.deleteCharAt(current.length() - 1);
freq[digit]--; // Un-choose
}
}
Q2: "How would you count the valid passwords without generating them all?"
Expected answer: "Use combinatorics. For length N with the 'no adjacent equal' constraint:
f(N) = 10 \times 9^(N-1)
For more complex constraints (like the frequency cap), use dynamic programming instead of backtracking:
dp[i][last][freq_state]= number of valid passwords of lengthiending in digitlastwith frequency statefreq_state.- This avoids exponential blowup when you only need the count, not the actual passwords."
Q3: "What if N is very large (N = 100)? The result list would be enormous. How do you handle this?"
Expected answer: "If the output is too large to return, the API should change to a streaming or iterator pattern:
- Return an
Iterable<String>that lazily generates the next password on demand. - Or write passwords to a file/stream as they are generated.
- Or just return the count instead. This is a common production design question: don't materialize results you can stream."
12. Quick Reference: Backtracking Java Idiomsโ
// โโ DEEP COPY (CRITICAL) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
result.add(new ArrayList<>(path)); // copy List<Integer>
result.add(current.toString()); // copy StringBuilder โ String
result.add(Arrays.copyOf(arr, arr.length)); // copy int[]
for (char[] row : board) solution.add(new String(row)); // copy char[][] grid
// โโ REMOVE LAST ELEMENT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
path.remove(path.size() - 1); // List<Integer> โ removes by index
sb.deleteCharAt(sb.length() - 1); // StringBuilder
used[i] = false; // boolean[]
// โโ CHOOSE/UN-CHOOSE PAIR IDIOMS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// List:
path.add(val);
recurse();
path.remove(path.size() - 1);
// StringBuilder:
sb.append(c);
recurse();
sb.deleteCharAt(sb.length() - 1);
// Grid visited:
visited[r][c] = true;
recurse();
visited[r][c] = false;
// N-Queens:
board[row][col] = 'Q'; cols[col] = diag1[d1] = diag2[d2] = true;
recurse(row + 1);
board[row][col] = '.'; cols[col] = diag1[d1] = diag2[d2] = false;
// โโ DUPLICATE SKIPPING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Arrays.sort(nums); // MUST sort first!
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) continue; // skip at same level
// โโ PERMUTATION DUPLICATE SKIPPING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Arrays.sort(nums);
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue; // key condition
...
// โโ PRUNING SHORTCUTS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (candidates[i] > remaining) break; // sorted array + sum constraint
if (n - i + 1 < k - path.size()) break; // not enough elements left for combination
// โโ GRID BOUNDS CHECK โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (r < 0 || r >= m || c < 0 || c >= n) return;
if (visited[r][c] || board[r][c] != target) return;
// โโ N-QUEENS DIAGONAL INDICES โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
int d1 = row - col + n; // top-left to bottom-right diagonal
int d2 = row + col; // top-right to bottom-left diagonal
13. Visualizing the State Spaceโ
One of the hardest parts of backtracking is keeping track of the "path" and seeing how the algorithm decides to turn back. Use these two complementary views when reasoning about a backtracking problem:
View 1 โ The Decision Tree (which choices were made):
[]
โโโโโโโโโโโโผโโโโโโโโโโโ
[1] [2] [3]
โโโดโโ โโโดโโ โโโดโโ
[1,2][1,3] [2,1][2,3] [3,1][3,2]
โ โ โ โ โ โ
[1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]
View 2 โ The Call Stack (which frames are active):
Exploring [1, 2, 3]:
Frame 4: backtrack(path=[1,2,3]) โ ADDING TO RESULT
Frame 3: backtrack(path=[1,2]) โ waiting
Frame 2: backtrack(path=[1]) โ waiting
Frame 1: backtrack(path=[]) โ waiting
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
main() โ entry point
Practice drawing both views for small inputs (N=3) before attempting the full implementation. The decision tree shows the big picture; the call stack shows what's in memory at any moment.