Algorithms and Data Structures · Chapter 6
Recursion and Backtracking
You already know recursion from the basics: a method calls itself. In this chapter you see what happens in memory, when recursion fits better than a loop, and how backtracking systematically tries out all possibilities: arrangements, subsets, paths through a maze.
Base case and recursive case
Every recursive method has two parts:
- The base case is so small that the result is known right away: a number with one digit, a text with at most one character, an empty array.
- The recursive case reduces the problem to a smaller version of the same problem and calls the method itself for it.
You always check the base case first. With arrays, the problem gets smaller by moving an index along (index + 1), with texts by substring(1), with numbers by n - 1 or n / 10.
public class Main { public static int countDigits(int n) { if (n < 10) { return 1; } return 1 + countDigits(n / 10); }
public static String reverse(String s) { if (s.length() <= 1) { return s; } return reverse(s.substring(1)) + s.charAt(0); }
public static void main(String[] args) { System.out.println(countDigits(7)); System.out.println(countDigits(2026)); System.out.println(reverse("stack")); }}The call stack
Every method call gets its own area on the call stack with its own parameters and local variables. When factorial(4) calls itself, four calls end up on top of each other, each with its own n. Only when the top one is done does the one below continue.
It follows that whatever comes before the recursive call runs in the order of the calls, whatever comes after it runs in reverse order. The indentation in the example shows how deep the stack currently is.
public class Main { public static int factorial(int n, String indent) { System.out.println(indent + "factorial(" + n + ") called"); int result; if (n <= 1) { result = 1; } else { result = n * factorial(n - 1, indent + " "); } System.out.println(indent + "factorial(" + n + ") returns " + result); return result; }
public static void main(String[] args) { factorial(4, ""); }}The stack is limited. A few thousand calls on top of each other work fine, millions do not: then Java stops with a StackOverflowError.
Recursion or loop?
Anything a loop can do, recursion can do too, and vice versa. Both sums in the example need O(n) steps. The recursive version, however, also needs O(n) space on the stack, the loop only one variable.
Rule of thumb:
- Loop when you simply go through an array or a list from front to back.
- Recursion when the problem naturally splits into subproblems of the same kind: Towers of Hanoi, trees (next chapter), quicksort and merge sort, and all tasks where you try out possibilities.
public class Main { public static int sumRecursive(int[] numbers, int index) { if (index == numbers.length) { return 0; } return numbers[index] + sumRecursive(numbers, index + 1); }
public static int sumLoop(int[] numbers) { int total = 0; for (int number : numbers) { total += number; } return total; }
public static void main(String[] args) { int[] numbers = {4, 8, 15, 16, 23, 42}; System.out.println(sumRecursive(numbers, 0)); System.out.println(sumLoop(numbers)); }}Backtracking: choose, explore, undo
Backtracking builds a solution step by step. At each point there are several possibilities. For each of them, the same pattern applies:
- Choose: fix one possibility, for example append a letter and mark it as used.
- Explore: solve the next step recursively.
- Undo: reverse the choice so that the next possibility starts from a clean state.
When the solution is complete (all letters placed, exit reached), it is printed, counted or stored. If a partial solution no longer fits (queen threatened, wall in the way), you abandon this branch right away. That is exactly what makes backtracking faster than blindly trying all combinations.
public class Main { static int found = 0;
public static void permute(char[] letters, boolean[] used, StringBuilder current) { if (current.length() == letters.length) { System.out.println(current); found++; return; } for (int i = 0; i < letters.length; i++) { if (used[i]) { continue; } used[i] = true; // choose current.append(letters[i]); permute(letters, used, current); // explore current.deleteCharAt(current.length() - 1); used[i] = false; // undo } }
public static void main(String[] args) { char[] letters = {'a', 'b', 'c'}; permute(letters, new boolean[letters.length], new StringBuilder()); System.out.println(found + " permutations"); }}Running time: quickly exponential
Recursive solutions that call themselves twice per call grow fast. The Towers of Hanoi need 2ⁿ − 1 moves for n disks, so O(2ⁿ). Every extra disk doubles the work.
The same goes for backtracking: there are 2ⁿ subsets and n! arrangements of n elements. For n = 20 that is more than a million subsets and more than 2 quintillion arrangements. Such algorithms are only suitable for small n. Abandoning a branch as soon as it no longer fits helps a lot, but often does not change the order of magnitude in the worst case.
public class Main { static long moves = 0;
public static void move(int n, char from, char to, char via) { if (n == 0) { return; } move(n - 1, from, via, to); moves++; move(n - 1, via, to, from); }
public static void main(String[] args) { for (int n = 1; n <= 20; n += 4) { moves = 0; move(n, 'A', 'C', 'B'); System.out.println(n + " disks: " + moves + " moves"); } }}Common mistakes
Missing or wrong base case
Without a base case, the method calls itself endlessly until a
StackOverflowErrorcomes. A wrong base case is just as tricky:if (n <= 1) return n;gives0instead of1forfactorial(0). Check the base case with the smallest inputs, including0and the empty array.The problem does not get smaller
sumFrom(numbers, index)instead ofsumFrom(numbers, index + 1)always calls itself with the same value and never reaches the base case. Every recursive call has to get closer to the base case. Withn - 2instead ofn - 1it can also be skipped:n == 0never matches an odd number,n <= 0does.Forgetting to undo
In backtracking, every choice has to be reversed after exploring: remove the letter again, set
used[i]back tofalse, set the maze cell back to'.'. Otherwise the next possibility starts with leftovers from the previous one. And if you store a partial solution, store a copy (new ArrayList<>(current)), because the list itself is about to change again.