1. Sum of numbers from 1 up to n

Recall the countUp function to print numbers from 1 up to n:

In [1]:
def countUp(n):
    if n <= 0:
        pass
    else:
        countUp(n-1)
        print(n)
        
countUp(5)
1
2
3
4
5

How would we write a function to return the sum of numbers from 1 up to n?

We can do this by returning a value from the recursive function instead of printing the numbers.

In [2]:
def sumUp(n):
    """Returns sum of integers from 1 up to n"""
    if n <= 0:
        return 0
    else:
        return n + sumUp(n - 1)
    
print('The sum from 1 up to 5 is', sumUp(5))
The sum from 1 up to 5 is 15

Note: It never hurts to name the subResult of the recursive call and the result of the whole call (and often helps you think about what's going on):

In [3]:
def sumUp(n):
    """Returns sum of integers from 1 up to n"""
    if n <= 0:
        return 0
    else:
        subResult = sumUp(n-1) # Result returned by recursive call
        result = n + subResult # Result for whole call 
        return result
    
print('The sum from 1 up to 5 is', sumUp(5))
The sum from 1 up to 5 is 15

2. Debugging Recursive Functions

Often things will go wrong, and your recursive function is not doing what you expect it to do.
Use debugging techniques to figure out what is going on. In Python, you can add print statements in the function body to do that.
Add such statements everywere there might be the opportunity to make a mistake:

  • am I passing the right parameters and changing them from call to call?
  • am I handling the base case correctly (if there is one)?
  • am I calculating the return value properly?

You can see this in action, with the following version of sumUpDebugging(n), which contains three debugging print statements.

In [4]:
def sumUpDebugging(n):
    """sumUp with debugging statements."""
    
    print(f'entering sumUp({n})')
    if n == 0:
        print('sumUp(0) returns 0')
        return 0
    else:
        subResult = sumUpDebugging(n-1)
        result = n + subResult
        print(f'sumUp({n}) returns {result}')
        return result

sumUpDebugging(5)
entering sumUp(5)
entering sumUp(4)
entering sumUp(3)
entering sumUp(2)
entering sumUp(1)
entering sumUp(0)
sumUp(0) returns 0
sumUp(1) returns 1
sumUp(2) returns 3
sumUp(3) returns 6
sumUp(4) returns 10
sumUp(5) returns 15
Out[4]:
15

We can even add an extra prefix arg to track how "deep" we are in the recursion

In [5]:
def sumUpDebuggingWithPrefix(prefix, n):
    """sumUp with extra string arg and debugging statements."""
    print(f"{prefix} entering sumUp({n})")
    if n == 0:
        print(f'{prefix} sumUp(0) returns 0')
        return 0
    else:
        subResult = sumUpDebuggingWithPrefix(prefix + '| ', n-1)
        result = n + subResult
        print(f'{prefix} sumUp({n}) returns {result}')
        return result

sumUpDebuggingWithPrefix('', 5)
 entering sumUp(5)
|  entering sumUp(4)
| |  entering sumUp(3)
| | |  entering sumUp(2)
| | | |  entering sumUp(1)
| | | | |  entering sumUp(0)
| | | | |  sumUp(0) returns 0
| | | |  sumUp(1) returns 1
| | |  sumUp(2) returns 3
| |  sumUp(3) returns 6
|  sumUp(4) returns 10
 sumUp(5) returns 15
Out[5]:
15

3. Exercise 1: Factorial

How many ways can you arrange 3 items in a sequence? How about 4?

The general answer is given by factorial(n), often denoted in math as n! It is given by the formula: n! = n * (n-1) * (n-2) * (n-3) * ... * 3 * 2 * 1

Your Turn: Below, define a factorial function that computes the factorial of n without any loops, using the same logic as sumUp.

In [6]:
def factorial(n):
    """Returns n! using recursion"""
    # Your code here
    # See the lab notebook for this solution
In [7]:
def test_factorial(n):
    print(f"{n}! is {factorial(n)}")
          
test_factorial(3)
test_factorial(4)
test_factorial(5) 
test_factorial(100) # Python can calculate very large integers!
3! is 6
4! is 24
5! is 120
100! is 93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

4. Exercise 2: SumBetween

Below, define a function sumBetween(lo,hi1) that sums the integers between the two integers lo and hi, inclusive. It should be defined via recursion, without using any loops.

In [8]:
def sumBetween(lo, hi):
    """Returns the sum of the integers between lo and hi (inclusive) using recursion"""
    # Your code here
    # See the lab notebook for this solution
In [9]:
def test_sumBetween(lo, hi):
    print(f"sumBetween({lo}, {hi}) is {sumBetween(lo,hi)}")
    
test_sumBetween(3,7)
test_sumBetween(1,10)
test_sumBetween(1,100)
sumBetween(3, 7) is 25
sumBetween(1, 10) is 55
sumBetween(1, 100) is 5050

5. Fruitful Recursion with Lists

We have seen recursion in the context of printing and adding numbers with countUp and sumUp, respectively. Now we will consider recursive functions that create lists:

5.1 countDownList

Here is a function that creates a list of numbers from n down to 1.

In [10]:
def countDownList(n):
    """Returns a list of numbers from n down to 1.
    For example, countDownList(5) returns [5,4,3,2,1].
    """
    if n <= 0:
        return []
    else:
        return [n] + countDownList(n-1)
    
def test_countDownList(n):
    print(f"countDownList({n}) is {countDownList(n)}")
    
test_countDownList(3)
test_countDownList(5)
test_countDownList(20)
countDownList(3) is [3, 2, 1]
countDownList(5) is [5, 4, 3, 2, 1]
countDownList(20) is [20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

5.2. Exercise 3: countDownListPrintResults

Modify countDownList to be the function countDownListPrintResults that prints out the list returned by each recursive call. For example:

In[]: countDownListPrintResults(5)
[]
[1]
[2, 1]
[3, 2, 1]
[4, 3, 2, 1]
[5, 4, 3, 2, 1]
Out[]:
[5, 4, 3, 2, 1]
In [11]:
def countDownListPrintResults(n):
    """Behaves like countDownList that additionally prints out 
    intermediary results.
    """
    # Your code here
    if n <= 0:
        print([]) 
        return [] # base case returns empty list
    else:
        cntDwnList = [n] + countDownListPrintResults(n-1)
        print(cntDwnList)
        return cntDwnList
In [12]:
countDownListPrintResults(5)
[]
[1]
[2, 1]
[3, 2, 1]
[4, 3, 2, 1]
[5, 4, 3, 2, 1]
Out[12]:
[5, 4, 3, 2, 1]

5.3. Exercise 4: countDownListMut

In countDownList, the list concatenation operation + was use to create a new list that combined the singletonlist [n] with a list resulting from the recursive call. So a new list is created for every recursive call.

But there's a different implementation strategy that creates exactly one list (the empty list in the base cas) and uses list mutation to keep adding elemesnts to the front of that one list in every recursive call. Flesh out the countDownListMut function below to express this strategy.

In [13]:
def countDownListMut(n):
    """Returns a list of numbers from n down to 1.
       For example, countDownListMut(5) returns [5,4,3,2,1].
       Unlike countDownList, countDownListMut should use 
       list mutation to add n to the front of the list
    """
    if n <= 0:
        return []
    else:
        subresult = countDownListMut(n-1)
        # Below, use list mutation on subresult to add n to the front of the list
        # Your code here
        subresult.insert(0, n)
        return subresult
In [14]:
def test_countDownListMut(n):
    print(f"countDownListMut({n}) is {countDownListMut(n)}")
    
test_countDownListMut(3)
test_countDownListMut(5)
test_countDownListMut(20)
countDownListMut(3) is [3, 2, 1]
countDownListMut(5) is [5, 4, 3, 2, 1]
countDownListMut(20) is [20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

5.4. Exercise 5: countUpList

Write a new function countUpList, which returns a list of integers from 1 up to n. You can use either the list concatenation strategy from countDownList or the list mutation strategy from countDownListMut.

In [15]:
def countUpList(n):
    """Returns a list of integers from n up to 1.
    For example, for n=5, the returned value is [1, 2, 3, 4, 5].
    """
    # Your code here
    # See the lab notebook for this solution
In [16]:
def test_countUpList(n):
    print(f"countDownUpList({n}) is {countUpList(n)}")
    
test_countUpList(3)
test_countUpList(5)
test_countUpList(20)
countDownUpList(3) is [1, 2, 3]
countDownUpList(5) is [1, 2, 3, 4, 5]
countDownUpList(20) is [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

6 Fibonacci Numbers

Fibonacci numbers model the number of pairs of rabbits in a given month, assuming:

  1. rabbits never die;
  2. sexually mature rabbits produce a new rabbit pair every month; and
  3. rabbits become sexually mature after one month.

This leads to the following recursive formula for the nth Fibonacci number fib(n):

fib(0) = 0 ; no pairs initially
fib(1) = 1 ; 1 pair introduced the first month
fib(n) = fib(n-1) ; pairs never die, so live to next month
         + fib(n-2) ; all sexually mature pairs produce a pair each month

6.1 Exercise 6: fibRec

Write the function fibRec that will calculate the nth Fibonacci number recursively.

In [17]:
def fibRec(n):
    """Returns the nth Fibonacci number."""
    # Your code here
    # See the lab notebook for this solution
In [18]:
def test_fibRec(n):
    print(f"fibRec({n}) is {fibRec(n)}")

for i in range(11):
    test_fibRec(i)
fibRec(0) is 0
fibRec(1) is 1
fibRec(2) is 1
fibRec(3) is 2
fibRec(4) is 3
fibRec(5) is 5
fibRec(6) is 8
fibRec(7) is 13
fibRec(8) is 21
fibRec(9) is 34
fibRec(10) is 55

6.2 Inefficiency of Fibonacci recursion

How long does it take to calculate fibRec(n)? Let's look at the time (in seconds) to calculate some values.

In [19]:
import time

def timedFibRec(n):
    """Helper function that measures elapsed time (in seconds) for a function call."""
    start = time.time()
    result = fibRec(n)
    end = time.time()
    timeTaken = end - start
    return (n, timeTaken, result)
In [20]:
timedResults = [timedFibRec(n) for n in range(5,36,5)]
for result in timedResults:
    print(result)
(5, 2.1457672119140625e-06, 5)
(10, 1.1920928955078125e-05, 55)
(15, 0.00011301040649414062, 610)
(20, 0.0012400150299072266, 6765)
(25, 0.014474153518676758, 75025)
(30, 0.1431868076324463, 832040)
(35, 1.5764169692993164, 9227465)

What's the ratio of the time for fibRec(n+5) to fibRec(n) for the above examples?

In [21]:
[resultsPair[1][1]/resultsPair[0][1] for resultsPair in zip(timedResults, timedResults[1:])]
Out[21]:
[5.555555555555555,
 9.48,
 10.972573839662447,
 11.672562968659873,
 9.892585942776195,
 11.009512645507844]

Based on the above results, we can estimate how long it will take to calculate fibRec(100.

  • It takes about 10 times longer to calculate fibRec(n+5) as it does to calculate fibRec(n).
  • fib(35) takes about 1.6 seconds.
  • 100 = 35 + 65 = 35 + (5*13). So fib(100) will take about 1.6 * 10^13 seconds
  • It turns out that there are about 3 x 10^7 seconds in a year.
  • So the approximate number of years to calculate fibRec(100) is (1.6 * 10^13)/(3 * 10^7) which is about 0.5 * 10^6 years = half a million years!

6.3 Exercise 7: fibLoop

Here is an iterative way to view Fibonacci numbers: the Fibonacci sequence starts with 0 and 1, and each successive number is the sum of the previous two:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...

The nth Fibonacci number is the value corresponding at the nth index. So fib(0) is 0, fib(1) is 1, fib(2) is 1, fib(3) is 2, etc.

Based on this perspective, Fibonacci numbers can be calculated efficiently via a loop!

Below, define a fibLoop function that calcualtes the _n_th Fibonacci numbers using a loop.

In [22]:
def fibLoop(n):
    """Use a loop to calculate the nth Fibonacci number."""
    # HINT: it helps to use simultaneous tuple assignment
    # Your code here
    fibi = 0
    fibi_next = 1
    for i in range(1, n+1):
        fibi, fibi_next = fibi_next, fibi+fibi_next
    return fibi
In [23]:
def test_fibLoop(n):
    print(f"fibLoop({n}) is {fibLoop(n)}")
    
for i in range(10,101,10):
    test_fibLoop(i)
fibLoop(10) is 55
fibLoop(20) is 6765
fibLoop(30) is 832040
fibLoop(40) is 102334155
fibLoop(50) is 12586269025
fibLoop(60) is 1548008755920
fibLoop(70) is 190392490709135
fibLoop(80) is 23416728348467685
fibLoop(90) is 2880067194370816120
fibLoop(100) is 354224848179261915075

7 Fruitful Spiraling Turtles

Now we consider turtle line-drawing functions that return values in addition to drawing a picture.

In some semesters we have a separate turtle_recursion notebook with examples of such fumctions; in other semesters, we introduce the turtle line-drawing functions in this notebook.

7.1 Review: spiral

We have seen a recursive function spiral, that creates the spiral images shown in the slides.

In [1]:
from turtle import *
In [25]:
def spiral(sideLen, angle, scaleFactor, minLength):
    '''
    Recursively creates a spiral, takes these parameters:
    1. sideLen is the length of the current side;
    2. angle is the amount the turtle turns left to draw the next side;
    3. scaleFactor is the multiplicative factor by which to scale the next side 
    (it is between 0.0 and 1.0);
    4. minLength is the smallest side length that the turtle will draw.
    '''
    if sideLen >= minLength:
        fd(sideLen)
        lt(angle)
        spiral(sideLen*scaleFactor, angle, scaleFactor, minLength)

To test the function with large values, we need to move the turtle from (0,0) in the center
of the canvas to some other point. However, whenever the turtle moves, it leaves a trail behind.
This is why we used the methods pu and pd (pen up and pen down).

To keep everything clean, we'll wrap it all in the function.

In [26]:
def drawSpiral(sideLen, angle, scaleFactor, minLength):
    """Helper function to prepare the window for drawing and 
    then calling the spiral function.
    """
    reset()
    padding = 100
    width = sideLen + padding
    height = sideLen + padding
    setup(width, height, 0, 0)
    pu()
    goto(-height/2+padding/2, -width/2+padding/2)
    pd()
    spiral(sideLen, angle, scaleFactor, minLength)
    #exitonclick()
    
drawSpiral(625, 90, 0.7, 100)

7.2 Review: spiralBack (Invariance)

A function is invariant relative to an object’s state if the state of the object is the same
before and after the function is invoked.

The function spiralBack that is an example of invariant function:

In [27]:
def spiralBack(sideLen, angle, scaleFactor, minLength):
    '''Draws a spiral. The state of the turtle (position,
    direction) after drawing the spiral should be the 
    same as before drawing the spiral. 
    '''
    if sideLen >= minLength:
        fd(sideLen)
        lt(angle)
        spiralBack(sideLen*scaleFactor, angle, scaleFactor, minLength)
        rt(angle)
        bk(sideLen)
In [28]:
reset()
spiralBack(200, 95, 0.8, 40)

7.3 A Fruitful Spiral: spiralLength

Run this code below to setup the screen and turtle for the next series of exercises. Run just once!

In [29]:
setup(600, 600, 0, 0)

def resetTurtle():
    reset()
    pu()
    goto(-250, -250)
    pd()

We can apply our new knowledge of fruitful recursion to our turtle spiral example. We modify the spiralBack function to be a spiralLength function that returns the total length of the lines in the spiral in addition to drawing the spiral and bringing the turtle back to its initial location and orientation.

In [30]:
def spiralLength(sideLen, angle, scaleFactor, minLength):
    """Draws a spiral based on the given parameters and leaves the turtle's location
    and orientation invariant. Also returns the total length of lines in the spiral.
    """
    if sideLen < minLength:
        return 0
    else:
        fd(sideLen) 
        lt(angle) 
        subLen = spiralLength(sideLen*scaleFactor, angle, scaleFactor, minLength) 
        rt(angle)
        bk(sideLen)
        return sideLen + subLen
In [31]:
resetTurtle()
totalLen = spiralLength(100, 90, 0.5, 5)
print("The total length of the spiral is", totalLen)
The total length of the spiral is 193.75

7.4 Exercise 8: spiralCount

Now, copy below the working version of spiralLength and modify it to be a function spiralCount that returns the number of lines in the spiral.

In [32]:
def spiralCount(sideLen, angle, scaleFactor, minLength):
    """Draws a spiral based on the given parameters and leaves the turtle's location
    and orientation invariant. Also returns the number lines in the spiral.
    """
    # Your code here
    if sideLen < minLength:
        return 0
    else:
        fd(sideLen); 
        lt(angle) # Put two statements on one line with semi-colon
        subCount = spiralCount(sideLen*scaleFactor, angle, scaleFactor, minLength) 
        rt(angle); 
        bk(sideLen)
        return 1 + subCount
In [33]:
resetTurtle()
totalCount = spiralCount(100, 90, 0.5, 5)
print("The number of lines in the spiral is", totalCount)
The number of lines in the spiral is 5

7.5 Exercise 9: spiralTuple

Now, copy below your working version of spiralLength and modify it to be a function spiralTuple that returns a pair (i.e., a 2-tuple) of:

  1. the length of lines in the spiral and
  2. the number of lines in the spiral.
In [34]:
def spiralTuple(sideLen, angle, scaleFactor, minLength):
    """Draws a spiral based on the given parameters and leaves the turtle's 
    location and orientation invariant. Also returns a pair (2-tuple) of 
    (1) the length of lines in the spiral
    (2) the number of lines in the spiral.
    """
    # Your code here
    if sideLen < minLength:
        return (0, 0)
    else:
        fd(sideLen); 
        lt(angle) # Put two statements on one line with semi-colon
        subLen, subCount = spiralTuple(sideLen*scaleFactor, angle, scaleFactor, minLength) 
        rt(angle); 
        bk(sideLen)
        return sideLen + subLen, 1 + subCount
In [35]:
resetTurtle()
(totalLength, totalCount) = spiralTuple(512, 90, 0.5, 5)
print("The spiral has", totalCount, "lines whose total length is", totalLength)
The spiral has 7 lines whose total length is 1016.0

8. Fruitful trees (puns abound!)

Similarly to the modifications that we did to functions that draw spirals, we can modify the function that draws trees to become a fruitful function, to count the number of branches that are drawn.

8.1 Review: tree

Below is the original tree function for drawing a tree

In [36]:
def tree(levels, trunkLen, angle, shrinkFactor):
    '''Draws recursively a tree design with the following parameters:
    1. levels is the number of branches on any path from the root to a leaf
    2. trunkLen is the length of the base trunk of the tree
    3. angle is the angle from the trunk for each subtree
    4. shrinkFactor is the shrinking factor for each subtree.
    '''
    if levels <= 0:
        pass
    else:
        # Draw the trunk.
        fd(trunkLen)
        # Turn and draw the right subtree.
        rt(angle)
        tree(levels-1, trunkLen*shrinkFactor, angle, shrinkFactor)
        # Turn and draw the left subtree.
        lt(angle * 2)
        tree(levels-1, trunkLen*shrinkFactor, angle, shrinkFactor)
        # Turn back and back up to root without drawing.
        rt(angle)
        pu()
        bk(trunkLen)
        pd()

8.2 A Fruitful Tree: branchCount

Copy and modify the solution of tree so that it becomes branchCount, which in addition to drawing the tree also returns the number of all branches in the tree.

Note: Don't forget to change the function name in the recursive calls from tree to branchCount when you paste the solution in the cell below.

In [37]:
def branchCount(levels, trunkLen, angle, shrinkFactor):
    '''Draws recursively a tree design with the following parameters:
    1. levels is the number of branches on any path from the root to a leaf
    2. trunkLen is the length of the base trunk of the tree
    3. angle is the angle from the trunk for each subtree
    4. shrinkFactor is the shrinking factor for each subtree.
    '''
    # Your code here
    if levels <= 0:
        return 0
    else:
        # Draw the trunk.
        fd(trunkLen)
        # Turn and draw the right subtree.
        rt(angle)
        # Keep track of branches for the right subtree
        branchCount1 = branchCount(levels-1, trunkLen*shrinkFactor, angle, shrinkFactor)
        # Turn and draw the left subtree.
        lt(angle * 2)
        # Keep track of branches for the left subtree
        branchCount2 = branchCount(levels-1, trunkLen*shrinkFactor, angle, shrinkFactor)
        # Turn back and back up to root without drawing.
        rt(angle)
        pu()
        bk(trunkLen)
        pd()
        return 1 + branchCount1 + branchCount2

Let's draw a small tree with level 3:

In [38]:
reset()
lt(90) # Face north first to grow the tree upwards
print(f"The number of branches drawn is {branchCount(3, 60, 45, 0.6)}")
The number of branches drawn is 7

Let's draw a bigger tree with level 7:

In [39]:
reset()
lt(90) # Face north first to grow the tree upwards
speed(20)
print(f"The number of branches drawn is {branchCount(7, 75, 30, 0.8)}")
The number of branches drawn is 127

This is the end of the notebook!