Recursion can be mind-bending but really it doesn’t rely on anything we haven’t learned yet.
We should be quite used to functions calling other functions.
average
function.It should take an array of numbers as its argument and return their average.
How do we compute the average?
Sum the numbers in the array and divide the sum by the length of the array.
const average = (ns) => {
return sum(ns) / ns.length;
}
Assuming sum
does what its name suggests, this is clearly correct.
sum
come from?Who knows?
Maybe we’ve already written it.
Maybe we’ll write it next.
Maybe we’ll go to the function store and buy it.
Doesn’t really matter.
Building functions from smaller functions is one of the most powerful ways to manage complexity in our programs.
average
like this?const average = (ns) => {
return ns.reduce((tot, n) => tot + n, 0) / ns.length;
}
const average = (ns) => {
let tot = 0;
for (let i = 0; i < ns.length; i++) {
tot += ns[i];
}
return tot / ns.length;
}
All three versions express the idea of “compute the average of an array of numbers by summing the numbers in the array and dividing by the array’s length”.
I’d argue that the original version expresses it most clearly by abstracting away the “summing the numbers” part.
But this does mean we need to be comfortable with abstraction: we need to be okay with assuming for the moment that sum
does what we’d expect.
We can refer to sum
as a “helper function” in the context of average
.
It helps by doing part of the job, leaving the final putting together of the pieces to average
.
Helper functions always solve only part of the problem.
If they solved the whole problem they’d be the function we’re trying to write.
n! = n × (n - 1) × (n - 2) … × 1
4! = 4 × 3 × 2 × 1 = 24
factorial
It should be a function that takes a single non-negative integer, n, as its argument and returns n!.
Following mathematical convention, factorial(0)
should return 1.
Note that if 0! is 1, for all n > 0 the definition of factorial:
n! = n × (n - 1) × (n - 2) … × 1
could also be written:
n! = n × (n - 1)!
So let’s pull out the ACME catalog, and order up a helper function
factorialOfNMinusOne
that for any n > 0 can compute the factorial of n - 1.
Now implement factorial
using our helper function:
const factorial = (n) => {
if (n === 0) { // Can't call factorialOfNMinusOne with 0.
return 1;
} else {
return n * factorialOfNMinusOne(n);
}
}
(Yes, there is a more direct way to implement factorial
in terms of factorialOfNMinusOne
but I’m ignoring that for pedagogical reasons.)
Just like defining average
in terms of sum
, if factorialOfNMinusOne
works as advertised, this should be a correct implementation of factorial
that works for for any non-negative integer n.
So we’ve got to write factorialOfNMinusOne
ourselves.
Luckily, we’ve now got a working factorial function so we can define factorialOfNMinusOne
like this:
const factorialOfNMinusOne = (n) => {
return factorial(n - 1);
}
Since factorial
works for all numbers greater than or equal to zero, and factorialOfNMinusOne
is only required to work for numbers greater than zero this should always work.
In fact, factorialOfNMinusOne
is so simple we might as well write it in-line:
const factorial = (n) => {
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
It seems circular since factorial
calls itself.
But it’s more of a spiral, since each call to factorial
is solving a “smaller” problem and eventually, it bottoms out at the case where n is 0.
This is just like how the helper function sum
only solved part of the larger problem of computing the average.
The call to factorial(n - 1)
is only solving part of the larger problem of factorial(n)
.
The smallest problem—when n is 0—is handled specially.
All recursive functions have this same basic structure.
A base case, which is the smallest problem to be solved and which can be answered directly.
A recursive case, where the function calls itself with a smaller problem and then uses the answer it gets to produce the answer to the larger problem.
Factorial could be solved non-recursively, a.k.a. “iteratively”:
const factorial = (n) => {
let answer = 1;
for (let i = 1; i <= n; i++) {
answer *= i;
}
return answer;
}
But it’s maybe easier to see that the recursive solution is correct since it’s basically a translation of the mathematical definition.
Other problems are even more inherently recursive, such as searching a tree of values for a specific value.
Find if a given item is in a tree:
const inTree = (tree, item) => {
if (isLeaf(tree)) {
// Base case: we're at a leaf node
return tree === item
} else {
// Recursive case: check both branches in the tree
return (
inTree(tree.left, item) ||
inTree(tree.right, item)
);
}
};
We could write this iteratively but becuase of the branching it’s not just a simple for loop.
Similarly, searching the virtual tree of possible positions in a board game:
const canWin = (position, player) => {
if (gameOver(position)) {
return winner(position) === player;
} else {
const nexts = possibleNextPositions(position);
for (let i = 0; i < nexts.length; i++) {
if (canWin(nexts[i], player)) {
return true;
}
}
return false;
}
};
In simple cases this is just whatever the argument to the function is. If the function operates on numbers we’re recursing on numbers. If it takes an array, we’re recursing on an array. If it’s some tree structure, it’s that.
If the function takes more than one argument, probably one of them is the thing we’re recursing on.
But not always. In the problem set we’ll be doing soon, there’s at least one problem that requires recursing on both its arguments.
What is the smallest version of the problem? If we’re recursing on numbers it’s probably 0 or 1. If we’re recursing on an array or string, it’s probably the empty array or empty string.
But the real test of whether something is the base case is that we can determine the answer without solving a yet smaller problem.
Start by writing the if
statement that detects the base case and returns the appropriate answer. For instance
const sumOfArray = (ns) => {
if (ns.length === 0) {
return 0; // The sum of no numbers (an empty array) is 0.
}
// ...
}
The recursive case will contain a call to the function but with a “smaller” version of the problem, i.e. one step closer to the base case.
For instance, if the base case is an empty array, we shrink the problem by making an array that is one item smaller.
Write the recursive case, combining the part of the problem not handled by the recursive call with the result of the recursive call.
const sumOfArray = (ns) => {
if (ns.length === 0) {
return 0; // The sum of no numbers (an empty array) is 0.
} else {
return ns[0] + sumOfArray(ns.slice(1));
}
}
If you want to walk through how a recursive function works, stey by step, you might enjoy this visualization.