The heart of our recursive functions so far is our recursive definition of lists:
A list is either:
- Empty (
null
) or- Non-empty (
(cons head tail)
) with ahead
element and a sublist, itstail
.
Our recursive functions mirror this recursive definition. So it stands to reason that if we are able to give a recursive definition for any type, then we can perform recursion over that type in a similar manner!
Many kinds of data admit recursive definitions, and you will encounter many of them throughout your computer science journey. However, there is one datatype that trumps them all—perhaps even more so than a list! It is the natural number. Recall that the natural numbers are the non-negative integers. This includes the numbers \(0\), \(1\), \(2\), \(3\), …, etc.
Our definition of a list was recursive because at least one of the cases included a smaller list inside of itself.
head [ ??? tail ??? ]
^ ^
| |
element sublist
We can see via the picture where the smaller list is located: its the tail of the list! However, now let’s consider a natural number, say \(5\):
5
Ugh. Unlike lists, it is not immediately obvious what the “smaller” natural number is in this situation. Where is it hiding?
One way to discover where the smaller natural number exists inside of \(5\) is to answer the following question: how can we express \(5\) in terms of some small natural number? This perspective opens up many possibilities! For example, the following expressions all evaluate to \(5\):
And so forth. Of these options, addition-by-one is most like the head-tail relationship for lists. Let’s compare the two perspectives:
(list 1 2 3 4 5)
.
We can express this list as (cons 1 (list 2 3 4 5))
where 1
is the head and (list 2 3 4 5)
is the tail.
We join the head and tail to form the overall list with cons
.(+ 1 4)
where \(4\) is the smaller natural number.
We add one to this number to obtain the natural number \(5\).In general, we call \(5\) the successor of the smaller natural number \(4\). We can see that almost every natural number has a successor:
However, because natural numbers are non-negative integers, \(0\) is not a successor of any other number. It is the base case for our recursive natural number definition! Combining these two ideas, we arrive at our recursive definition for the natural numbers:
A natural number is either:
- Zero or
- The successor or “plus one” of a smaller natural number.
With this definition, we can solve problems using recursive decomposition on natural numbers!
A classical example of a mathematical function with a recursive definition is factorial, written \(n!\). \(n!\) is the product of the numbers from \(1\) to \(n\), e.g., \(5! = 1 \times 2 \times 3 \times 4 \times 5 = 120\). In general, \(n! = 1 \times \cdots \times n\).
Suppose we were to go to implement a factorial
function that returns \(n!\) when given an natural number argument n
.
We can’t implement the “⋯” in Scheme directly, so we need to find some alternative way of expressing this computation.
Let’s see if we can find a recursive decomposition for factorial
in terms of our recursive definition for the natural numbers:
To compute the factorial of some natural number
n
:
- When
n
is zero… (a)- When
n
is non-zero… (b)
n
is zero, we should return 0!
.
But what is 0!
?
It turns out that the factorial function is defined so that \(0!\) is 1.
This seems arbitrary since asking the question “multiply the numbers from 1 to 0” is a malformed question!
Indeed, we define \(0! = 1\) by convention because it makes our mathematical formulae work without the need for special cases.When n
is non-zero, it is the successor of some smaller number, call it k
with \(k + 1 = n\).
How can I express factorial of \(n\) in terms of factorial of \(k = n - 1\)?
Let’s look at a concrete example: suppose \(n = 5\) so \(k = 5 - 1 = 4\).
We have:
\begin{align} 5! =&\; 1 \times 2 \times 3 \times 4 \times 5 \\ 4! =&\; 1 \times 2 \times 3 \times 4 \end{align}
In terms of recursive design, our recursive assumption says we can compute \(4!\). How can we then compute \(5!\) in terms of \(4!\)? We can see above that \(5! = 4! \times 5\)! We can then generalize this idea to conclude that when \(n\) is non-zero, \(n! = n \times (n-1)!\).
This gives us a final recursive decomposition for factorial:
To compute the factorial of some natural number
n
:
- When
n
is zero, the factorial of0
is1
by definition.- When
n
is non-zero, the factorial ofn
isn
times the factorial ofn-1
.
We can then translate this decomposition directly into our desired function:
(define factorial (lambda (n) (if (zero? n) 1 (* n (factorial (- n 1)))))) (test-case "factorial zero" = 1 (factorial 0)) (test-case "factorial non-zero" = 120 (factorial 5))
Note that we use the zero?
function to test whether n
is zero which works only because we assume as a precondition that n
is a natural number, i.e., non-negative.
We could also instead choose to use a comparison operator, e.g., (= n 0)
or (> n 0)
(and flip the order of the conditional branches), to achieve the same effect.
To access the predecessor of n
(i.e., the number of which n
is a successor), we use subtraction.
Alternatively, we can also use pattern matching, passing in an appropriate numeric literal for the base case:
(define factorial (lambda (n) (match n [0 1] [_ (* n (factorial (- n 1)))]))) (test-case "factorial zero" = 1 (factorial 0)) (test-case "factorial non-zero" = 120 (factorial 5))
In the recursive case, we can’t use a pattern to bind the number “one less than n
.”
So instead, we use a wildcard pattern, _
to match anything that reaches the second branch.
Note that match
expressions are consumed in top-down order, so if n
is not 0
, then—assuming n
is a natural number—it is non-zero, so the second branch should always apply.
For numeric recursion, i.e., recursion over the natural numbers, you should feel free to use either pattern matching or an appropriate if
or cond
expression.
One final thing to observe is that the standard recursive definition of factorial typically presented as:
\begin{align} 0! =&\; 1 \\ n! =&\; n \times (n-1)! \end{align}
Aligns very closely with the pattern matching version of our code. Indeed, pattern matching in programming comes from the use of pattern matching to elegantly define functions in mathematics. Because programming draws on mathematics so much, we typically find that pattern matching allows us to concisely describe mathematical functions algorithms.
We can use numeric recursion to implement recursively defined mathematical functions. However, what elevates numeric recursion to such importance is that we can express many recursive patterns in terms of the natural numbers! Indeed, the natural numbers form an ordering:
\[0, 1, 2, 3, 4, 5, \ldots\]So we expect that if a data structure or algorithm can be expressed in some “ordered” fashion, we can use numeric recursion! For example, consider the recursively defined triangles that motivated our initial exploration into recursion:
(import image) (overlay (triangle 250 "outline" "blue") (triangle 225 "outline" "blue") (triangle 200 "outline" "blue") (triangle 175 "outline" "blue") (triangle 150 "outline" "blue") (triangle 125 "outline" "blue") (triangle 100 "outline" "blue") (triangle 75 "outline" "blue") (triangle 50 "outline" "blue") (triangle 25 "outline" "blue"))
This code is clearly redundant! How can we use numeric recursion to capture the recursive nature of this pattern?
To do so, we can observe that there is are two potential “order” to the triangles: we can think about drawing them from top-to-bottom according to our overlay
invocation or in the other order, from bottom-to-top.
Let’s generalize this computation to draw an arbitrary number of triangles, so let’s draw them bottom-to-top, i.e., inside out, so that we can draw an unbounded number of triangles!
Our recursive decomposition will decompose the task of drawing n
such triangles:
To draw
n
triangles:
- When
n
is zero… (a)- When
n
is non-zero… (b)
When \(n\) is non-zero, we need to draw \(n\) triangles by first recursively drawing \(n-1\) triangles—that’s our recursive assumption. How do we then draw the \(n\)th additional triangle? We need a formula for the size of the \(n\)th triangle in terms of \(n\)! To derive this, let’s put the sizes in a table according to their order according to the natural numbers. Alternatively, we can think of the number as their index in the ordering:
By observing the pattern and using algebra, we can see that the size of triangle \(n\) is \(n \times 25\).
We can then use overlay
to combine the \(n\)th triangle with the remaining \(n-1\) triangles.
This gives us our final decomposition:
To draw
n
triangles:
- When
n
is zero, draw a triangle with size \(0\).- When
n
is non-zero, overlay a triangle of size \(n \times 25\) with a drawing of \(n-1\) triangles.
Again, we can translate this decomposition into code directly:
(import image) (define nested-triangles (lambda (n) (match n [0 (triangle 0 "outline" "blue")] [_ (overlay (triangle (* n 25) "outline" "blue") (nested-triangles (- n 1)))]))) (nested-triangles 10) (nested-triangles 5) (nested-triangles 0) (nested-triangles 25)
If factorial is the operation that performs repeated multiplication, then we call the operation that performs repeated addition the terminal. For example:
(terminal 10)
> 55
(terminal 0)
> 0
factorial
and give a recursive decomposition for terminal
.terminal
function based on this decomposition.We defined (nested-triangle n)
to draw n
triangles.
Effectively, each triangle drawn by the function decreased in size by 25
which we derived in our recursive decomposition.
Instead of drawing n
triangles, we can instead take the size of the largest triangle as input and decrease that size by 25
directly, instead of computing it as a formula of the number of triangles n
.
(nested-triangle size)
that takes the size of the overall image as input.
The size
here becomes the size
of the outermost triangle, and each successive triangle decreases by 25
down to 0
.
For example (nested-triangle 250)
should produce our original example of 10
triangles.(nested-triangle size)
and figure out which size
parameters produce the examples generated in the reading.nested-triangle
.
Is there any upsides or downsides to having the parameter of the function be the size of the drawing versus the number of triangles?