Consider the following code. What is this code doing? What is the output of the call go_back(3)
? (See lecture21.py for the docstring.)
def go_back(n):
if n == 0:
print("Stop")
else:
print("Go", n)
go_back(n-1)
print("Back", n)
The line print("Back", n)
is an example of a “pending operation”, that is, an operation that gets performed when control continues after the recursive call. Check this code out in Python Tutor.
Python has a limit on the height of the call stack (we saw that last time when we attempted “infinite” recursion). Depending on how many
recursive calls you make, e.g. factorial(1000)
, you may hit that limit
triggering a RecursionError
(even without infinite recursion). You can increase the limit with a function in the
sys
module, like shown below, thus enabling us to successfully compute factorial(1000)
.
import sys
sys.setrecursionlimit(10000)
Consider the following code to draw a spiral. In each iteration it draws a segment, turns left, and then recursively draws shorter segments (only 95% of the length).
def spiral1(length, levels):
"""
Draw a spiral with 'levels' segments with initial 'length'
"""
# Implicit base case: do nothing if levels == 0
if levels > 0:
forward(length)
left(30)
spiral1(0.95 * length, levels-1) # Recurse
Try running the code with say, spiral1(80, 45)
. Notice that the turtle stops and remains where the drawing ends at the inner tip of the spiral.
Let’s revise this code to “undo” the calls to forward
and left
. After the recursive call to spiral2(0.95 * length, levels-1)
, undo the left turn by turning right, and undo the forward by moving backward:
def spiral2(length, levels):
"""
Draw a spiral with 'levels' segments with initial 'length'
"""
# Implicit base case: do nothing if levels == 0
if levels > 0:
forward(length)
left(30)
spiral2(0.95 * length, levels-1) # Recurse
right(30)
backward(length)
If I started with the turtle at (0,0), what is the position of the turtle after invoke this function, e.g. spiral2(80, 45)
? The turtle ends up back at (0,0) because we are “retracing” the movements after
the recursive call. This is a common pattern in recursive functions in which we
“reset” the state after a recursive call using state maintained in the call
stack.
Check out the draw_tree
and broccoli
functions in lecture21.py:
def draw_tree(length, levels):
"""
Draw a recursive tree and return to where the turtle started
Args:
length: length of initial tree trunk
levels: number of tree trunk segments from bottom to top
"""
if levels > 0:
forward(length) # draw tree branch
right(45)
draw_tree(length/2, levels-1) # draw right subtree
left(45*2) # undo right turn, then turn left again
draw_tree(length/2, levels-1) # draw left subtree
right(45) # undo left turn
backward(length) # trace back down the tree branch
The above function draws nothing if levels is 0. This is another example of an implicit base case that simply returns (terminating execution) without doing anything.
For level == 1
, the turtle moves forward, turns right, draws a “level 0” tree (i.e., nothing), turns left, draws another “level 0” tree (i.e., nothing), turns right so it is facing the original direction, then goes backward to undo the original forward.
For level == 2
, the turtle moves forward, turns right, draws “level 1” tree (i.e., a line), turns left, draws another “level 1 “tree (i.e., a line), turns right so it is facing the original direction, then goes backward to undo the original forward.
At any level, the turtle moves forward, turns right, draws a level n-1 tree, turns left, draws another level n-1 tree, turns right so it is facing the original direction, then goes backward to undo the original forward.
Here are the trees drawn for levels 1, 2, 3, and 4:
Drawing the “broccoli” is very similar:
Define the function header, including the parameters
def broccoli(length)
Define the recursive case
Draw a line with three smaller broccolis at the end, one on the same heading, the other two offset by 20 degrees (by smaller we mean shorter “stem”).
Define the base case
Below a certain length, don’t draw a line (and more broccolis), just draw a dot.
Put it all together
Why do we include backward(length)
at the end of the recursive function? This ensures that the turtle ends up where it started at the end of each subtree that is drawn. Notice that this backward command matches the forward command a few lines up, and these are the only movements of the turtle!
How many lines does this program draw? Lots! How could we figure out exactly how many? We could return the count of the number of lines drawn.
There is no “one” way to make the problem smaller. However we have seen several common patterns that we can use as we implement our recursive functions:
Let’s develop a recursive implementation for power
, with parameter base
and exp
(exponent):
Define the function header, including the parameters
def power(base, exp)
Define the recursive case
\(base^{exp} = base * base^{(exp-1)}\)
Define the base case
\(base^0 = 1\)
Put it all together
def power(base, exp):
if exp == 0:
return 1
else:
return base * power(base, exp-1)
What is the time complexity of this implementation, i.e. how many times is
power
called? exp
+ 1 times, or linear with the size of exp
.
What is the space complexity of this implementation, i.e. how will the memory
grow as we increase exp
? Also linearly. Why? Every time we invoke a function,
Python creates a frame on the call stack. Each frame on the call stack requires
some amount of space and the number of frames grows with the size of the input.
Can we do better?
Recall that \(base^{exp}=base^{exp/2}*base^{exp/2}\). So what if
instead of recursing by exp-1
, we recursed by dividing exp
in half? Our new
complexity could be logarithmic, specifically $log_2 exp$$.
We have to be careful though about how we handle even and odd exponents (we will need different recursive cases for each):
def power(base, exp):
if exp == 0:
return 1
elif exp == 1:
return base
elif exp % 2 == 0:
sub = power(base, exp//2)
return sub * sub
else:
return base * power(base, exp-1)
Is this implementation still logarithmic? In the worst case the overhead is 2X (two calls for each division), and \(2log_2 n\) is still logarithmic.