CMSC 14100 — Lecture 19

Last time, we were left with the following function definition for a function that computes the sum of the strings in a given list.


def strings_lengths(strings):
    """
    Given a list of strings, produces the sum of lengths of strings in 
    the list.
    
    Input:
        strings (list[str]): List of strings

    Output (int): sum of lengths of strings from strings
    """
    if strings == []:
        return 0
    else:
        first, *rest = strings
        return len(first) + strings_lengths(rest)
    

What does this actually look like in action? Well, suppose we try running this on the list ["shoe", "banana", "penguin"].

Another way of seeing this is the following: \begin{align*} &\ \mathtt{strings\_lengths(["shoe", "banana", "penguin"])} \\ =&\ 4 + \mathtt{strings\_lengths(["banana", "penguin"])} \\ =&\ 4 + (6 +\mathtt{strings\_lengths(["penguin"])}) \\ =&\ 4 + (6 + (7 + \mathtt{strings\_lengths([])})) \\ =&\ 4 + (6 + (7 + 0)) \\ =&\ 17 \end{align*}

In other words, we can get the result by doing exactly what we did before, by simply evaluating the function and performing the appropriate substitutions at each step.

Accumulation and tail recursion

Now, there are a few things about recursion on lists you might be wondering. First: what's the point—we have for loops. And secondly, from what we just saw, it doesn't seem great that we have to wait until we reach the base case before beginning to evaluate the expression.

In fact, the issue of delayed evaluation is a practical issue. The fact that we have to wait until the base case before evaluation is problematic because every recursive call of our function creates a new stack frame. On real computers, this is a real cost and it's possible to run out of memory in this way.

It turns out that our two observations are related: we can try to solve the problem of delayed evaluation and in doing so we will see how we can eventually land on the idea of loops.

The main issue with evaluation right now is the order in which we do evaluation. Our expression above requires knowing what our recursive calls evaluate to before being able to evaluate the sum. To avoid this, we would want to compute our sum before the recursive call. But this means we can only compute a partial sum. Furthermore, we would have to provide this partial sum to our recursive call. What might this look like?


def strings_lengths(strings, sum_so_far):
    """
    Given a list of strings, produces the sum of lengths of strings in 
    the list.
    
    Input:
        strings (list[str]): List of strings
        sum_so_far (int): sum of the lengths of the strings seen so far

    Output (int): sum of lengths of strings from strings
    """
    if strings == []:
        return sum_so_far
    else:
        first, *rest = strings
        return strings_lengths(rest, len(first) + sum_so_far)
    

So to account for the fact that we do some computation before our recursive call, we have to create an explicit parameter to pass that partial result. If we run this, we see that we get the same answer. However, if we write out the steps, we get the following: \begin{align*} &\ \mathtt{strings\_lengths(["shoe", "banana", "penguin"], 0)} \\ =&\ \mathtt{strings\_lengths(["banana", "penguin], 4 + 0)} \\ =&\ \mathtt{strings\_lengths(["penguin"], (6 + (4 + 0))} \\ =&\ \mathtt{strings\_lengths([], (7 + (6 + (4 + 0)))} \\ =&\ (7 + (6 + (4 + 0))) \\ \end{align*}

Here, I've left the expression unevaluated so we can see how the terms are gathered, but the actual function would evaluate on each step: \begin{align*} &\ \mathtt{strings\_lengths(["shoe", "banana", "penguin"], 0)} \\ =&\ \mathtt{strings\_lengths(["banana", "penguin], 4 + 0)} \\ =&\ \mathtt{strings\_lengths(["penguin"], 6 + 4)} \\ =&\ \mathtt{strings\_lengths([], 7 + 10)} \\ =&\ 17 \\ \end{align*}

Here, we see that sum_so_far plays the role as the accumulator variable sum in our loop in the iterative version. And this is really the secret: for loops are really this version of recursion:

Sorting

While the previous problem was something we could easily solve with a loop, there are some problems where thinking recursively can be a good idea. Let's consider a classical problem on lists: sorting a list of numbers.

Again, we try to view this problem through the lens of the our recursive definition.

So we have something that looks like


    def our_sort(lst):
        """
        Given a list of integers, produce a new list with the numbers 
        sorted from least to greatest.

        Input:
            lst (list[int]): a list of integers

        Output (list[int]): the list of numbers sorted in increasing order 
        """
        if lst == []:
            return []
        else:
            first, *rest = lst
            sorted_rest = our_sort(rest)
            ...
            sorted_lst = ???
            return sorted_lst
    

At this point, we recognize that we have the following pieces:

This makes our lives much easier: all we need to do to finish the job is to slide first into the right spot in our_sort(rest), which is sorted.

How do we do this? Let's take the opportunity to solve this problem recursively as well. Suppose we have to put an item item into a sorted list. What are our options?

This gives us the following.


def insert(item, lst):
    """
    Inserts a given item into a sorted list.

    Input:
        item (int): the item to be inserted
        lst (list[int]): a sorted list
    
    Output (list[int]): sorted list containing item and items from lst 
    """
    if lst == []:
        return [item]
    else:
        first, *rest = lst
        if item < first:
            return [item] + lst
        else:
            return [first] + insert(item, rest)
    

Suppose we tried running insert(5, [1,2,3,8,13,21]). What does this look like?

With less prose, we have \begin{align*} &\ \mathtt{insert(5, [1,2,3,8,13,21])} \\ =&\ \mathtt{[1] + insert(5, [2,3,8,13,21])} \\ =&\ \mathtt{[1] + ([2] + insert(5, [3,8,13,21]))} \\ =&\ \mathtt{[1] + ([2] + ([3] + insert(5, [8,13,21])))} \\ =&\ \mathtt{[1] + ([2] + ([3] + ([5] + [8,13,21])))} \\ =&\ \mathtt{[1,2,3,5,8,13,21])))} \\ \end{align*}

Now, we can complete our strategy for our_sort. If we have a non-empty list, we recursively sort the rest of our list. Then we insert the first element of the list into the rest of the list that's been sorted!


def insertion_sort(lst):
    """
    Given a list of integers, produces a list of the same integers in 
    sorted order.

    Input:
        lst (list[int]): list of integers

    Output: sorted list of integers (list[int])
    """
    if lst == []:
        return []
    else:
        first, *rest = lst
        return insert(first, insertion_sort(rest))
    

Because the way this sorting algorithm works is by repeatedly inserting an element into a sorted list, it is called insertion sort.

Let's see what this looks like with a short list, like [3, 1, 5].

More succinctly, we have \begin{align*} &\ \mathtt{insertion\_sort([3,1,5])} \\ =&\ \mathtt{insert(3, insertion\_sort([1,5]))} \\ =&\ \mathtt{insert(3, insert(1, insertion\_sort([5])))} \\ =&\ \mathtt{insert(3, insert(1, insert(5, insertion\_sort([]))))} \\ =&\ \mathtt{insert(3, insert(1, insert(5, [])))} \\ =&\ \mathtt{insert(3, insert(1, [5]))} \\ =&\ \mathtt{insert(3, [1,5])} \\ =&\ \mathtt{[1,3,5]} \end{align*}

Most treatments of insertion sort involve swapping list items around in a loop, but this version shows that it very naturally exploits the recursive structure of lists.