def power(base, exp):
if exp == 0:
return 1
else:
return power(base, exp//2) * power(base, exp//2)
2, 7) # Should be 128 power(
1
Recursion, cont.
len
Let’s write a recursive function named rec_count
that takes a non-empty sequence as a parameter and returns the number of items in the sequence. Following our 4 step process:
Specify the function definition: rec_count(seq)
Specify the recursive relationship
In a previous class we discussed techniques for making the problem get “smaller”, including approaches that split a sequence (such as a list or string) into its first element and the rest of the sequence. Using that idea, we could express the recursive relationship as: the number of elements in a sequence is 1 plus the number of elements in the rest of the sequence.
Specify the base case
The smallest version of the problem is an empty sequence, which has 0 elements. To avoid using the len
function, we can take advantage that empty sequences are “falsy”, i.e., treated as False
.
Put it all together.
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).
import turtle as t
def spiral1(length, levels):
"""
Draw a spiral with 'levels' segments with initial 'length'
"""
# Implicit base case: do nothing if levels == 0
if levels > 0:
t.forward(length)
t.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
with “pending operations”. 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:
import turtle as t
def spiral2(length, levels):
"""
Draw a spiral with 'levels' segments with initial 'length'
"""
# Implicit base case: do nothing if levels == 0
if levels > 0:
t.forward(length)
t.left(30)
spiral2(0.95 * length, levels-1) # Recurse
t.right(30)
t.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 pending operations and state maintained in the call stack.
Check out the draw_tree
and broccoli
functions in recursive_drawing.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:
t.forward(length) # draw tree branch
t.right(45)
draw_tree(length/2, levels-1) # draw right subtree
t.left(45*2) # undo right turn, then turn left again
draw_tree(length/2, levels-1) # draw left subtree
t.right(45) # undo left turn
t.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. Let’s approach it using our 4 step process for implementing a recursive function:
Define the function header, including the parameters
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.
Let’s develop a recursive implementation for power
, with parameter base
and exp
(exponent). Using our 4 step process:
Define the function header, including the parameters
Define the recursive case
\[\mathrm{base}^{\mathrm{exp}} = \mathrm{base} * \mathrm{base}^{(\mathrm{exp}-1)}\]
Define the base case
\[\mathrm{base}^0 = 1\]
Put it all together
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 in memory and the number of frames grows with the size of the input.
Can we do better?
Recall that \(\mathrm{base}^{\mathrm{exp}}=\mathrm{base}^{\mathrm{exp}/2}*\mathrm{base}^{\mathrm{exp/}2}\). So what if instead of recursing by exp-1
, we recursed by exp/2
(dividing in half)? Our new complexity could be logarithmic, specifically \(log_2 (\mathrm{exp})\)!
Can we just replace base * power(base, exp-1)
by power(base, exp//2)*power(base, exp//2)
in our implementation like show below? No. There are two issues. First is a correctness issue, we we have to be careful about how we handle even and odd exponents. In the latter, using floor division directly will shrink exp
too fast, producing an incorrect! Notice that power(2,7)
should return 128, but we get 1.
def power(base, exp):
if exp == 0:
return 1
else:
return power(base, exp//2) * power(base, exp//2)
power(2, 7) # Should be 128
1
The second is performance. In each recursive call we are making two more recursive calls. How many recursive calls do we make in total? We can model the recursive call structure as a complete binary tree, i.e., a tree where we node has two children. A complete binary tree of height \(h\) has \(2^(h+1)-1\) nodes. Recall that our height is \(log_2(\textrm{exp})\), so our tree has approximately \(\textrm{exp}\) nodes. We are right back where we started. But we observe that two recursive calls are identical. We only need to do each call once, i.e., our recursive case can effectively be power(base, exp//2)**2
. Now we really only have $log_2(textrm{exp}) recursive calls!
Putting it all together, our final implementation is:
which produces the expected results:
Is this implementation still logarithmic? In the worst case the overhead is 2-fold (two calls for each division), but fortunately \(2log_2 n\)$ is still logarithmic!
Recursion is widely used algorithmic tool, especially when a problem has some form of self-similarity. We saw that the recursive formulation for the Tower of Hanoi was much simpler. In our upcoming lab and programming assignment we will draw fractal (i.e., self-similar shapes.) Another example are trees (in the Computer Science sense). We often use data structures in which nodes are connected in a hierarchical tree, i.e., all nodes can one have zero or more children, but all nodes except the root have just one parent. Each node in such a tree is similar, and algorithms for traversing the tree are often expressed recursively.
But recursion isn’t always the best tool for the task, or requires care in use. For example, sometimes a problem is readily expressed recursively but not always efficiently implemented that way. Recall the Fibonacci sequence
1, 1, 2, 3, 5, 8, 13, 21, ...
The nth Fibonacci number is defined as the sum of the previous two Fibonacci numbers (and the first two numbers are defined to be 1). So, the 3rd Fibonacci number is 2, the 4th Fibonacci number is 3, etc. We could express this as \(\textrm{fib}_n = \textrm{fib}_{n-1} + \textrm{fib}_{n-2}\), i.e., recursively. A potential implementation is:
def fib(n):
""" Calculates the nth Fibonacci number recursively """
if n <= 2:
return 1
else:
return fib(n-1) + fib(n-2)
If we tried this out for any large-ish number, e.g., 100, we would notice it is very slow. Why?
Consider the call stack for fib(5)
:
The call stack for fib(5)
:
fib(5)
fib(4)
fib(3)
fib(2)
fib(1)
fib(2)
fib(3)
fib(2)
fib(1)
Notice that the same computations are being performed repeatedly, e.g., fib(3)
is performed twice, fib(2)
three times, … Each time we double the input we are more than doubling the amount of computation we perform, and in fact the time for recursive solution grows exponentially with the size of the input (i.e., n
).
In these types of problems, where there are overlapping sub-problems, we combine recursion with memoization, i.e., we remember the result of previous problems, e.g., fib(3)
, and reuse that result instead of repeating the computation. Doing so can make the recursive approach as efficient as a non-recursive approach! Implementing memoization is beyond the scope of our course (but an important topic is subsequent classes like CSCI201 and CSCI302). What is important for us to understand is why the recursive implementation above for fib
can be slow.