Groundwork Groundwork

Part III: Algorithms

Chapter 12: Recursion and Problem Solving

A recursive method is one that calls itself. That sounds circular and is not, provided the method always calls itself on a smaller version of the problem and knows when to stop.

Start with something you can already write two ways. The factorial of 5 is 5 × 4 × 3 × 2 × 1.

120
120

The recursive version rests on a fact about factorials: 5! is just 5 × 4!. If you can get 4!, you are done. And 4! is 4 × 3!, and so on down to 1! which is simply 1.

The two required parts

Every recursive method needs both of these, and leaving out either one breaks it:

  1. A base case—a version of the problem small enough to answer outright, with no further call. Here: if (n <= 1) { return 1; }.
  2. A recursive case—which calls itself on a smaller input and builds the answer from the result. Here: return n * factorial(n - 1);.

The recursive case must move toward the base case. factorial(n - 1) shrinks n each time, so it must eventually reach 1. Write factorial(n) instead and the method calls itself forever, or tries to:

This recursion never reaches its base case, so it calls itself until Java runs out of room and stops the program. It crashes on purpose: the error is the lesson.

Exception in thread "main" java.lang.StackOverflowError

Every call that has not finished yet is waiting for an answer, and Java keeps what each one is holding—its own n, and where to carry on when the answer comes back—in an area of memory called the stack. A runaway recursion piles up calls that never finish, until, thousands of calls deep, the stack is full and Java stops the program with a StackOverflowError. That error nearly always means a missing or unreachable base case. At a terminal, Java follows the first line with a long column of at lines naming factorial again and again; the box shows the first line alone.

Following the calls

Printing at each level shows what actually happens. To indent its lines, the traced version needs to know how deep each call is, so it takes a second parameter, depth. Whoever calls it should not have to pass the 0 that the first call starts from, so a one-parameter factorialTraced passes it for them. That is Chapter 6's overloading, two methods of one name, the shorter calling the longer with the starting value, and recursive methods use it constantly.

factorial(4) called
  factorial(3) called
    factorial(2) called
      factorial(1) called
      base case, returning 1
    returning 2 * factorial(1) = 2
  returning 3 * factorial(2) = 6
returning 4 * factorial(3) = 24

main calls factorialTraced(4) for what it prints, and does nothing with the 24 it gives back: a call can stand on its own like that.

Notice the shape: the calls go all the way down to the base case before any of them returns, and then the answers come back up. Each level is waiting, holding its own n, for the level below to finish. Nothing is actually multiplied until the bottom is reached.

That is the mental model worth keeping. Recursion is not a loop that goes around; it is a stack of unfinished work that unwinds.

More examples

Summing an array:

The smaller problem here is the rest of the array, everything after its first item. Chapter 7's Arrays.copyOfRange could copy the rest out, but copying on every call is wasted work. Instead, the method takes an index, start, that says where the rest begins, and each call moves it one place along. A one-parameter total starts it at 0.

15

The base case is an empty rest: once start reaches numbers.length, no items are left, and their sum is 0. Otherwise: the item at start plus the sum of everything after it.

Reversing a string:

A String already has a way to take its rest: text.substring(1) is a new, shorter String holding everything after the first character (Chapter 8).

noisrucer

Counting down:

3
2
1
Liftoff.

return; with no value ends a void method at once, as it did in Chapter 6's project. This method has nothing to give back, only something to print.

When recursion goes wrong: Fibonacci

The Fibonacci sequence starts 0, 1, and each later number is the sum of the two before it. The recursive definition is so natural it almost writes itself:

0 1 1 2 3 5 8 13 21 34

This is correct and it is a disaster. fib(30) makes 2,692,537 calls, about 2.7 million; fib(50) would take longer than you will wait.

The reason is that it recomputes the same values endlessly. Calculating fib(5) requires fib(4) and fib(3); but fib(4) also requires fib(3), which gets computed from scratch a second time—and each of those recomputes fib(2), and so on. Each step up in n multiplies the work by about 1.6: fib(30) makes eleven times the calls fib(25) makes. Growth by a steady factor like that is exponential. Big-O rounds the factor up to 2 and writes it O(2ⁿ): fib(n) never makes more than 2ⁿ calls.

The fix is to remember answers already computed, a technique called memoization. A map from each n to its answer does the remembering: before working a value out, look for it in the map, and after working it out, put it there.

Two things in the program below need a word. The answers are longs, Chapter 3's whole numbers with room for far bigger values, because Fibonacci numbers outgrow an int quickly: fib(47) is 2,971,215,073, past the largest int, 2,147,483,647. And a map holds objects, so its values are Long, the object twin of long, as Integer is of int (Chapter 7); Java converts between the two on its own. The map is made by a one-parameter fibFast, so each outside call starts with an empty one, and it is handed along to every recursive call. It is a HashMap: nothing prints it, so its order does not matter, and its lookups are O(1) on average, as Chapter 11 showed.

12586269025
2880067194370816120

That returns instantly. A map—the Chapter 9 structure—turns an O(2ⁿ) algorithm into an O(n) one by ensuring each value is computed exactly once. This is a genuinely important idea, and choosing the right data structure is again what made the difference.

A loop also handles Fibonacci perfectly well, in O(n) and with no recursion at all:

12586269025

a and b are two neighbors in the sequence, moving along it one place each pass: the new a is the old b, and the new b is the sum of the two. next holds the sum while a takes b's old value, which is Chapter 2's temporary variable doing the job it did in the swap.

When to use recursion

Anything recursive can be written with a loop, and anything looped can be written recursively. The choice is about clarity.

Recursion fits when the problem is naturally self-similar: tree and folder structures, nested menus, dividing a problem in half repeatedly, or puzzles like the Towers of Hanoi whose rules are stated recursively.

Loops fit when you are stepping through a sequence. Summing an array recursively, as above, is a fine teaching exercise and worse code than Chapter 7's loop.

For a first course the honest summary is: recognize recursion, be able to trace it, and know that some problems become much simpler when expressed this way. Do not force it where a loop is clearer.

A problem that wants recursion

Binary search from Chapter 11, written recursively, is arguably clearer than the loop version—the "search this half instead" step becomes a literal call. The range still to search is two more parameters, low and high, and a two-parameter binarySearch starts them at the whole array:

5
-1

The base cases are "the range is empty" and "found it." Each recursive call searches a strictly smaller range, so it always terminates.

Common mistakes

MistakeWhat happens
No base caseStackOverflowError
A base case that is never reachedStackOverflowError
Not returning the recursive call's resultmissing return statement—javac refuses it
Not shrinking the inputInfinite recursion, then StackOverflowError
Using recursion where a loop is clearerSlower and harder to read
An int for answers that outgrow itWrong, even negative, answers with no error; use long

The third row is a method that makes its recursive call, factorial(n - 1);, and does nothing with the answer: a method that promises an int must return one on every path. The last row is fibFast written with ints: fib(47) and everything after it come out wrong, fib(50) as -298632863, because an int overflows silently, as Chapter 3 showed.

Try It

Exercises 1, 3, 4, 5 and 6 name their methods exactly, as Chapter 6's did, and Check calls them with the cases listed under "What it should do"; exercise 5's cases write each array in place, as new int[] {1, 2, 1, 1}. Exercises 2 and 7 print, and Check compares what they print with the lines the exercise describes. Write each one recursively: Check cannot tell a loop from a recursion, but the recursion is the practice.

1. Write int power(int base, int exponent), returning base to the power exponent, recursively and without Math.pow. The exponent is 0 or more, and any number to the power 0 is 1.

What it should do:

  • power(2, 10) -> 1024
  • power(5, 0) -> 1
  • power(3, 3) -> 27
  • power(7, 1) -> 7
  • power(10, 4) -> 10000
Show a solution
/// Returns base to the power exponent, recursively.
int power(int base, int exponent) {
    if (exponent == 0) {
        return 1;
    }
    return base * power(base, exponent - 1);
}

void main() {
    IO.println(power(2, 10));
    IO.println(power(5, 0));
}

Each call multiplies in one more base and asks for a power one smaller, until the exponent reaches 0, where the answer is 1 with no call at all. power(2, 10) makes eleven calls, for the exponents 10 down to 0.

2. Write void countDownUp(int n), printing n down to 1 and then 1 back up to n, one number to a line, using one recursive method. countDownUp(3) prints 3, 2, 1, 1, 2, 3: the 1 appears twice.

Show a solution
/// Prints n down to 1, then 1 back up to n.
void countDownUp(int n) {
    if (n <= 0) {
        return;
    }
    IO.println(n);
    countDownUp(n - 1);
    IO.println(n);
}

void main() {
    countDownUp(3);
}

Each call prints its n on the way down, before the recursive call, and again on the way back up, after that call returns. countDownUp(1) prints 1, calls countDownUp(0), which does nothing, and prints 1 again: that is why the 1 appears twice.

3. Write int digitSum(int n), returning the sum of a number's digits, recursively: digitSum(1234) is 1 + 2 + 3 + 4, which is 10. The number is 0 or more.

What it should do:

  • digitSum(1234) -> 10
  • digitSum(0) -> 0
  • digitSum(9) -> 9
  • digitSum(1000) -> 1
  • digitSum(99) -> 18
Show a solution
/// Returns the sum of the digits of n, recursively.
int digitSum(int n) {
    if (n < 10) {
        return n;
    }
    return n % 10 + digitSum(n / 10);
}

void main() {
    IO.println(digitSum(1234));
}

n % 10 is the last digit and n / 10 is the number with that digit taken off, Chapter 3's way of splitting a quantity into units. A number below 10 is a single digit, which is its own sum.

4. Write boolean isPalindrome(String text), recursively: whether the text reads the same forward and backward, exactly as written ("racecar" does; "Racecar" does not). Empty text and a single letter are palindromes.

What it should do:

  • isPalindrome("racecar") -> true
  • isPalindrome("hello") -> false
  • isPalindrome("a") -> true
  • isPalindrome("") -> true
  • isPalindrome("abba") -> true
  • isPalindrome("abca") -> false
  • isPalindrome("Racecar") -> false
Show a solution
/// Returns whether text reads the same forward and backward.
boolean isPalindrome(String text) {
    if (text.length() <= 1) {
        return true;
    }
    if (text.charAt(0) != text.charAt(text.length() - 1)) {
        return false;
    }
    return isPalindrome(text.substring(1, text.length() - 1));
}

void main() {
    IO.println(isPalindrome("racecar"));
    IO.println(isPalindrome("hello"));
}

Text of length 0 or 1 is a palindrome with nothing to check. Otherwise the first and last characters must match, and then what lies between them must be a palindrome too: text.substring(1, text.length() - 1) is that middle, the text with both ends taken off. Two chars compare safely with !=.

5. Write int countItem(int[] items, int target), counting how many times the target appears in the array, recursively. As total did, give it a second method that takes the index where the rest begins.

What it should do:

  • countItem(new int[] {1, 2, 1, 1}, 1) -> 3
  • countItem(new int[] {}, 5) -> 0
  • countItem(new int[] {1, 2}, 3) -> 0
  • countItem(new int[] {7}, 7) -> 1
  • countItem(new int[] {4, 1, 4}, 4) -> 2
Show a solution
/// Returns how many times target appears in items.
int countItem(int[] items, int target) {
    return countItem(items, target, 0);
}

/// Counts target in items from index start to the end.
int countItem(int[] items, int target, int start) {
    if (start == items.length) {
        return 0;
    }
    int rest = countItem(items, target, start + 1);
    if (items[start] == target) {
        return 1 + rest;
    }
    return rest;
}

void main() {
    IO.println(countItem(new int[] {1, 2, 1, 1}, 1));
}

The two-parameter countItem starts the count at index 0, and the three-parameter one counts from start to the end. Past the last item there is nothing left to count; otherwise the answer is the count of the rest, plus one when the item at start is the target.

6. Write int gcd(int a, int b) using Euclid's algorithm. The GCD, the greatest common divisor, is the largest number that divides both; the GCD of a and b equals the GCD of b and a % b, and the GCD of a and 0 is a.

What it should do:

  • gcd(12, 8) -> 4
  • gcd(7, 13) -> 1
  • gcd(10, 0) -> 10
  • gcd(48, 18) -> 6
  • gcd(0, 9) -> 9
Show a solution
/// Returns the greatest common divisor of a and b, by Euclid's algorithm.
int gcd(int a, int b) {
    if (b == 0) {
        return a;
    }
    return gcd(b, a % b);
}

void main() {
    IO.println(gcd(12, 8));
}

Each call replaces the pair with b and a % b, until the second number is 0; the first is then the GCD. gcd(12, 8) becomes gcd(8, 4), then gcd(4, 0), which is 4.

7. Solve the Towers of Hanoi for 3 disks, printing each move. Three pegs, A, B and C, hold disks of three sizes, all on A to begin with, largest at the bottom. Move them all to C, one disk at a time, never putting a larger disk on a smaller one, in the fewest moves, which is seven. Write void hanoi(int n, String from, String to, String spare), call it as hanoi(3, "A", "C", "B"), and print each move as Move disk 1 from A to C, numbering the disks from 1, the smallest, to 3.

Show a solution
/// Prints the moves that take n disks from one peg to another.
void hanoi(int n, String from, String to, String spare) {
    if (n == 0) {
        return;
    }
    hanoi(n - 1, from, spare, to);
    IO.println("Move disk " + n + " from " + from + " to " + to);
    hanoi(n - 1, spare, to, from);
}

void main() {
    hanoi(3, "A", "C", "B");
}

To move n disks, move the n - 1 above the largest out of the way, onto the spare peg; move the largest; then move the n - 1 back on top of it. Each of those moves of n - 1 disks is the same puzzle, smaller, with the pegs swapping parts, and moving 0 disks is doing nothing: the base case. Three disks take seven moves, and n disks take 2ⁿ - 1.

Capstone project: a student records system

This brings together everything in the course: methods, loops, conditionals, lists, maps, records, searching, and sorting.

One thing in it is new. Each student's scores are a list inside the record, and the component's type is written List<Integer>. List is to lists what Chapter 9's Map is to maps: a variable or a component typed List holds any kind of list, an ArrayList or the one that List.of makes, which can never change. So each student's scores can be written List.of(88, 92, 85) and stored as they are, and average takes a List<Integer> for the same reason. A List is read the way an ArrayList is, with size(), isEmpty() and a loop.

# ID   Name              Gr  Avg Grade
--------------------------------------
1 1002 Katherine Johnson 11 96.7     A
2 1001 Grace Hopper      10 94.7     A
3 1004 Ada Lovelace      9  88.3     B
4 1009 Edsger Dijkstra   10 71.3     C
5 1007 Alan Turing       9  55.0     F
--------------------------------------

By grade level
  Grade 9: 2 students, average 71.7
  Grade 10: 2 students, average 83.0
  Grade 11: 1 students, average 96.7

Class statistics
  Students:      5
  Class average: 81.2
  Top student:   Katherine Johnson (96.7)
  Passing:       4 of 5

Lookup 1007:   Alan Turing
Lookup 9999:   null
Names with 'a': 5

Read through that program and count what it uses. Methods with documentation comments and single responsibilities. Loops and conditionals. Lists, maps and records, including a record that holds a list. Comparators and lambdas to sort. Binary search on a sorted list, and linear search where the data is not sorted by the field being searched. String.format alignment throughout.

That is the whole course.

A few points of craft worth extracting.

findById requires its input sorted by id, which is why the call site sorts a copy first. Its documentation comment says so, because a method with a precondition that is not documented is a trap. When a caller violates it, binary search does not fail loudly—it returns a confidently wrong answer.

The roster numbers its lines with i + 1, as Chapter 7's numbered lists did: a rank counts from 1, where an index counts from 0.

printClassStats finds the top student with a best-so-far loop that keeps the whole record rather than just the number, so the name and the average come with it.

And "Grade 11: 1 students" is grammatically wrong. Fixing that is your first exercise: it needs a conditional expression, Chapter 10's ? :, and noticing it at all is the skill worth having.