Recursion
Something is recursive if it is defined using itself. A simple (albeit hardly useful and contrived) example is the following function:
f :: Int -> Intf n = f (n + 1)
def f(n): return f(n + 1)
As defined, the body of function f
invokes itself. In other
words, it is defined using itself. Readers who are unconvinced that
f
is a recursive definition may see that it is analogous
to the following mathematical definition, which is clearly recursive:
Data types can also be defined recursively:
data SinglyLinkedList a = Empty | Node a (SinglyLinkedList a) deriving (Show, Eq)
from abc import ABCfrom dataclasses import dataclass
class SinglyLinkedList(ABC): pass
class Empty(SinglyLinkedList): pass
@dataclassclass Node(SinglyLinkedList): head: object tail: SinglyLinkedList
Likewise, you can see that the SinglyLinkedList
class has a subclass
Node
which itself holds another SinglyLinkedList
. This makes
SinglyLinkedList
a recursive data structure.
The core idea we present in this section is that we can write recursive functions by thinking structural-inductively.
Induction
We shall begin by describing a proof by induction for a statement over
the natural numbers. The principle of a proof by induction is as
follows: given a predicate
is true (for all natural numbers , implies )
Then
Modus Ponens codifies the following idea: if a proposition
Let us attempt to write a proof by induction. We start with an implementation of the factorial function, then prove that it is correct:
factorial :: Int -> Intfactorial n | n == 0 = 1 | otherwise = n * factorial (n - 1)
def factorial(n): return 1 if not n else \ n * factorial(n - 1)
Let factorial(n)
returns
We prove
Basis. Trivial. factorial(0)
returns 1
. In other words,
Inductive. Suppose for some natural number factorial(k)
returns
- By definition of
factorial
,factorial(k + 1)
returns(k + 1) * factorial(k)
. - By our supposition, this evaluates to
, which is, by definition, . - Thus, if for some
, factorial(k)
returns, then factorial(k + 1)
returns. In other words, .
As such, since we have proven
Recursion via Inductive Reasoning
Naturally (haha), the next question to ask would be, “how do we make use of induction to write recursive functions?” As above, the recipe for a proof by induction involves (broadly) two steps:
- Proof of the basis, e.g.
- The inductive proof, e.g.
. Typically, the inductive step is completed by supposing for some , and showing .
We can write recursive functions similarly by providing:
- Non-recursive computation for the result of the base-case, e.g.
; - Recursive computation of
based on the result of assuming that gives the correct result.
Example: Natural Numbers
Let us start with a simple description of the natural numbers:
In our usual understanding of the natural numbers,
A formulation of the natural numbers in Haskell/Python might be the following:
data Nat = Zero | Succ Nat deriving (Show, Eq)
class Nat: pass
@dataclassclass Zero(Nat): pass
@dataclassclass Succ(Nat): pred: Nat
In which case, the number 3 can be written as follows:
three = Succ (Succ (Succ Zero))
three = Succ(Succ(Succ(Zero())))
Let us attempt to define addition over the natural numbers as we have formulated above, recursively:
ghci> three = Succ (Succ (Succ Zero))ghci> two = Succ (Succ Zero)ghci> add three twoSucc (Succ (Succ (Succ (Succ Zero))))
>>> three = Succ(Succ(Succ(Zero())))>>> two = Succ(Succ(Zero()))>>> add(three, two)Succ(pred=Succ(pred=Succ(pred=Succ(pred=Succ(pred=Zero())))))
We might decide to perform recursion on the first addend (doing so on
the second addend is fine as well). In computing
, or - the successor of some natural number
.
The first case is straightforward since Zero
above), and add(k, n)
correctly gives Succ(add(k, n))
.
Therefore, we arrive at the following solution:
add :: Nat -> Nat -> Natadd Zero n = nadd (Succ k) n = Succ (add k n)
def add(m, n): return n if m == Zero() else \ Succ(add(m.pred, n))
In the Python definition, using structural pattern matching which we present in Chapter 2.4 (Pattern Matching), we may also write the following definition which might be more intuitive:
def add(m, n): return n if m == Zero() else \ Succ(add(m.pred, n)) match m: case Zero(): return n case Succ(k): return Succ(add(k, n))
Example: Singly Linked Lists
At this point you might be wondering why we had given such an odd
formulation of the natural numbers in Python, when we could have used
the int
type instead (we totally could). One core idea we
would like to make apparent in this formulation, is that recursion via
inductive reasoning can be done over the structure of data. Our
formulation shows that natural numbers are recursive data structures,
where the successor of a natural number has a predecessor who is also,
likewise, a natural number. This should make writing recursive functions
over other kinds of recursive data structures not too great of a leap from
writing recursive functions over natural numbers. To show this, consult
our SinglyLinkedList
data structure from above before we proceed to
write recursive functions over them using inductive reasoning.
First, we shall write a function that appends an element to the end of a singly linked list.
ghci> append 1 EmptyNode 1 Emptyghci> append 2 $ append 1 EmptyNode 1 (Node 2 Empty)
>>> append(1, Empty())Node(head=1,tail=Empty())>>> append(2, append(1, Empty()))Node(head=1,tail=Node(head=2,tail=Empty()))
We can perform recursion over the structure of the list. There are two possible structures of the list:
- The empty list
- A node of a head element and a tail list
In the former, we append to an empty list, which should give the singleton. Note once
again that because the empty list is non-recursive, our solution for
appending to the empty list likewise requires no recursion. For the
second case of Node
, i.e.
Observe that:
Therefore, we can write:
append :: a -> SinglyLinkedList a -> SinglyLinkedList aappend y Empty = Node y Emptyappend y (Node x xs) = Node x (append y xs)
def append(x, ls): if ls == Empty(): return Node(x, Empty()) return Node(ls.head, append(x, ls.tail))
# Using structural pattern matching:def append2(x, ls): match ls: case Empty(): return Node(x, Empty()) case Node(e1, xs): return Node(e1, append2(x, xs))
We shall give another example by writing list reversals recursively,
going straight into our derivation. Reversing the empty list gives the
empty list. For nonempty lists our goal is to have
reverse' :: SinglyLinkedList a -> SinglyLinkedList areverse' Empty = Emptyreverse' (Node x xs) = append x (reverse' xs)
def reverse(ls): if ls == Empty(): return Empty() return append(ls.head, reverse(ls.tail))
# Using structural pattern matching:def reverse2(ls): match ls: case Empty(): return Empty() case Node(e1, xs): return append(e1, reverse2(xs))
By this point you should be able to see that recursion can be done via the following based on the structure of the data:
- If the structure of the data is non-recursive, provide a non-recursive computation that computes the result directly
- If the structure of the data is recursive, recursively solve the problem on the
substructure(s) of the data (e.g.
pred
ortail
of the natural number or list), and include its result in your main result
You should be well aware that data structures may be more complex. For example, solving a problem for a structure may require more than one recursive calls, one non-recursive call and one recursive call, etc. To make this apparent, let us look at a formulation of a binary tree of integers:
data Tree = EmptyTree | TreeNode Tree Int Tree deriving (Show, Eq)
class Tree: pass
@dataclassclass EmptyTree(Tree): pass
@dataclassclass TreeNode(Tree): left: Tree val: int right: Tree
Now let us attempt to write a function that sums all integers in the
tree. Again there are two possible structures a tree can have: the first
being the empty tree, which has sum 0. For tree nodes, we have two
subtrees, left
and right
, from whom we may recursively obtain their
sums using our function. Then, the sum of the entire tree is just the
total of the value at the node, the sum of the left subtree and the sum
of the right subtree:
sumTree :: Tree -> IntsumTree EmptyTree = 0sumTree (TreeNode l v r) = sumTree l + v + sumTree r
def sum_tree(t): if t == EmptyTree(): return 0 return t.val + sum_tree(t.left) + sum_tree(t.right)
# Structural pattern matchingdef sum_tree(t): match t: case EmptyTree(): return 0 case TreeNode(l, v, r): return sum_tree(l) + v + sum_tree(r)
In summary, our formulation of the natural numbers reveals that numbers are also structurally recursive, and therefore, are amenable to recursive computations. We can extend this idea to all recursive structures, which as you will see in these notes, is very common.