Tutorial 11: Recursion 101
Log in to enable code uploads
You need to log in to be able to upload your work as you go. Use your Polytechnique LDAP login here.
Setup: before you start
Create a new empty project in Spyder, and call it “Tutorial_11”.
Now create a new program file, recursion.py
. All the
functions you write have to be added to this file.
Files to download
You will need to download the following file, right-clicking on the link and selecting “Save link as…” to save it in your Tutorial_11 project directory.
Context and Objectives
In this tutorial, we will give a gentle introduction to recursion, a fundamental concept from computer science. The basic idea is that to evaluate a function on an argument, it is often helpful to call that same function on another argument.
The examples we start with today are intentionally simple to get you used to the concepts (and in fact most of the exercises could equally well be solved using loops - though if you do not use recursion, then we may deduct points). In later tutorials we will see recursion in more complicated contexts.
Skills we will practice today: recursion.
Exercise 1: Greatest common divisor
Remember from your math classes that the greatest common divisor
gcd(a, b)
of two non-negative integers a
and
b
is the greatest integer that divides both a
and b
. In principle, we could compute
gcd(a, b)
by going over all positive integers smaller than
a
and b
and testing if they divide both
numbers. But it is obvious that when a
and b
are big, then this will be terribly inefficient.
Fortunately, it has been known for more
than two thousand years that there is a more efficient way of
computing gcd(a, b)
. This algorithm relies on two
observations:
- For all non-negative integers x we have gcd(x, 0) = gcd(0, x) = x.
- For all positive integers x and y we have gcd(x, y) = gcd(y, x mod y) (where x mod y is the remainder when dividing x by y).
Write a function gcd(x, y)
that turns the two
observations above into a recursive algorithm.
Test your function:
In [0]: gcd(2, 5)
Out[0]: 1
In [0]: gcd(28, 12)
Out[0]: 4
In [1]: gcd(217190401, 216144349)
Out[1]: 593
Upload your file recursion.py
:
Exercise 2: Palindromes
A palindrome is a string (or word) that is the same
read from the front and from the back. For example, 'ANNA'
is a palindrome, as are 'ANA'
and 'OTTO'
. In
contrast, 'DAVE'
, 'MARIA'
and
'BLARB'
are not palindromes. On the other
hand, both ''
(the empty string) and 'A'
are palindromes.
In this exercise, we will write a function
is_palindrome()
that checks if a given word is a
palindrome. Your function should begin as follows:
def is_palindrome(word):
"""Check if input is a palindrome."""
The function is_palindrome(word)
should return
True
if and only if word
is a palindrome. The
idea for a recursive test for being a palindrome is simple: a word is a
palindrome if and only if it has the same letter at the beginning and at
the end, and if the sub-word that we get by omitting the first and last
of its letters is also a palindrome.
Test your function, for example as follows:
In [1]: is_palindrome('ABBA')
Out[1]: True
In [2]: is_palindrome('ABA')
Out[2]: True
In [3]: is_palindrome('ABAB')
Out[3]: False
Upload your file recursion.py
:
Exercise 3: Recursive powering
Assume that you would like to compute a large power of an integer, say 21000. One was to do this would be to multiply 2 by itself 999 times. This will require 999 multiplication. Can we do better? Yes, we can use 499 multiplications to compute 2500 and then get 21000 with a single multiplication. We can continue this line of reasoning and compute 2500 from 2250, from 2125, etc.
Based on this, we propose the following algorithm from computing ab:
- if
b == 0
orb == 1
, return the result, - if b is even, compute ab/2 and square it,
- if b is odd, compute a(b - 1)/2, square, and multiply by a.
You can see, that this algorithm is naturally recursive since it uses
smaller powers to compute the larger ones. Implement this algorithm by
completing the function rec_pow
below:
def rec_pow(a, b):
"""Compute a**b recursively"""
if b == 0:
pass # your code here
if b == 1:
pass # your code here
if b % 2 == 0:
pass # your code here
# your code here
Test your function:
In [4]: rec_pow(2, 3)
Out[4]: 8
In [5]: rec_pow(2, 9)
Out[5]: 512
In [6]: rec_pow(3, 100)
Out[6]: 515377520732011331036461129765621272702107522001
Upload your file recursion.py
:
Remark: A natural variation of the algorithm above would be to compute and multiply a(b - 1)/2 and a(b + 1)/2 in the case of odd b. However, this is not really a good idea in terms of efficiency. Why?
Interlude: Some dangers of recursion
Recursion is a very popular concept in particular in more theoretical parts of computer science, because it allows to express algorithms in a very concise and elegant way. In practice however, there are some problems that often appear…
Consider the following little toy function that decides if a given number is even.
def even(n):
"""Return True if and only if the given number is even."""
if n == 0:
return True
if n == 1:
return False
return even(n-2)
Stack limits
Copy the function even()
above to your file
recursion.py
. Then run it on the console for example as
follows:
In [7]: even(0)
Out[7]: True
In [8]: even(10)
Out[8]: True
In [9]: even(1)
Out[9]: False
In [10]: even(100)
Out[10]: True
In [11]: even(101)
Out[11]: False
In [12]: even(10000)
The last call should give you a long error message that ends similar to
...
RecursionError: maximum recursion depth exceeded in comparison
To understand what the problem is here, we have to think about how
recursion works on the computer: whenever a function is called, Python
remembers that call until the function executes a return
statement. These calls are remembered in an area of the memory that is
called the stack, because when one function calls
another, the two calls are stacked on another.
In a recursive function, for each function call that has not yet
finished, the stack contains some information on that call. For example,
when we call even(1000)
the stack should at some point
contain information on even(1000)
, even(998)
,
even(996)
and so on, all the way down to
even(0)
. Unfortunately, the space on the stack is limited!
To avoid this space from filling up too much, Python has a limit on how
deep a recursion can be before causing an error such as the one we see
above.
In Python, there are only two ways around this problem: make sure
that the function is only called on inputs where you know that the stack
never contains too many recursive calls (for example, when calling
even()
only for numbers up to 1000
, everything
should be fine). The only other solution is to avoid recursion
altogether when the recursion depth would be too big and instead work
with loops.
We can also modify the depth of recursion that Python will allow. For
example, we can decrease the allowed depth to only 120 recursive calls
by adding the following lines to the top of
recursion.py
:
import sys
120) sys.setrecursionlimit(
We recommend that you add these lines now. Otherwise, the error messages of problematic programs often become to long to find errors efficiently.
Termination
To illustrate another problem you might encounter, run
even(-2)
. You should again get an error message telling you
that you have reached the recursion limit. This time the problem is a
little different than before: the problem is not that the space required
to compute the result is too big, but rather that the recursion for
input -2
never reaches a base case of the
recursion. If there was no limit on the recursion depth, then the
recursion would never terminate and the program would go into an
infinite loop.
To avoid this type of problem, it is important to make sure that for
every possible input the recursion will terminate eventually. For the
function even()
this is relatively straightforward to do,
but for more complex recursions this is sometimes highly
non-trivial.
Exercise 4: Binary search
Implementation
One very important operation that is performed in many applications is finding an element in a list. As was already discussed when sets were introduced in this course, generally this problem requires one to go over all elements in the list.
An important special case is that of ordered data, i.e., the case in which the elements in the list are ordered with respect to some order. If this is the case, we can proceed as follows:
- If the list contains just one element, we make a direct comparison and are done afterwards.
- Otherwise, we compare the element
e
that we are looking for with the elementm
in the middle of the list.- If
e
andm
are equal, we have founde
in the list and we are done. - If
m
is greater thane
, thene
must be in the first half of the list (if it appears at all) and thus we can recursively look for it there. - Otherwise,
e
must appear in the second half of the list (if it appears at all) and thus we can perform recursion on the second half.
- If
The algorithm that we described above is called binary search and is one of the most fundamental algorithms. It is the first instantiation of the divide and conquer algorithm design principle, where one solves a problem by cutting it into smaller subproblems that can then be solved more easily.
Implement binary search by writing the following function:
def binary_search(sorted_list, lower, upper, element):
"""Return the position of the element in the sublist of sorted_list
starting at position lower up to (but excluding) position upper
if it appears there. Otherwise return -1.
"""
Afterwards, test your function for example as follows (note that strings can be compared exactly as numbers; for the comparisons the lexicographical order is used):
In [13]: s = ['b', 'c', 'd', 'f', 'g', 'h']
In [14]: binary_search(s, 0, 6, 'd')
Out[14]: 2
In [15]: binary_search(s, 0, 6, 'b')
Out[15]: 0
In [16]: binary_search(s, 0, 6, 'h')
Out[16]: 5
In [17]: binary_search(s, 0, 3, 'h')
Out[17]: -1
In [18]: binary_search(s, 0, 6, 'e')
Out[18]: -1
In [19]: binary_search(s, 0, 6, 'a')
Out[19]: -1
In [20]: binary_search(s, 0, 6, 'z')
Out[20]: -1
Upload your file recursion.py
:
Performance tests
Recall that Python list
s have a .index()
method, which computes essentially the same thing as
binary_search
: if l
is a list, then
l.index(x)
finds the index of (the first occurrence of)x
inl
;binary_search(l, 0, len(l), x)
finds the index ofx
inl
.
Testing membership is similar. We know that x in l
returns True
if x
is in l
, and
False
if it is not; but we could also use
binary_search(l, 0, len(l), x) != -1
to get exactly the
same result.
Why would we bother writing and using binary_search
,
rather than just using index
and in
? The
answer is that on large sorted lists,
binary_search
can compute the index much faster. The
index
method and the in
operator do not know,
and cannot take advantage of, the fact that the data is sorted; they are
obliged to plod linearly through the list, inspecting every single
element until they find the one they are looking for. In contrast,
binary_search
can use the fact that the data is sorted to
quickly zoom in towards the position where the required element should
be found.
To test whether binary search really is faster than the
in
operator, we use the module timeit
that can be used to call a function several times and measure the time
it needs to run.
We start by importing the timeit
module:
In [21]: import timeit
To have some benchmark instances, we create sorted random lists of
integers using the following function, which you should copy into your
recursion.py
file.
import random
def random_increasing_integer_sequence(length):
"""Return an increasing list of integers of the given length."""
= 0
current = []
res for i in range(length):
+= random.randint(1, 10)
current
res.append(current)return res
Now we can test the performance of our function:
In [22]: s = random_increasing_integer_sequence(20000)
In [23]: 10000 in s
Out[23]: False
In [24]: timeit.timeit('10000 in s', number=10000, globals=globals())
Out[24]: 2.6033250330074225
In [25]: timeit.timeit('binary_search(s, 0, len(s), 10000)', number=10000, globals=globals())
Out[25]: 0.055350435999571346
(You might get slightly different results, but the speed difference should be clear.)
Each of our calls of timeit.timeit
executes the given
statement 10000 times (that’s what the number=10000
argument does). The returned value is the total time (in seconds) that
these executions took. You should see that your
binary_search
outperforms the standard list lookup easily.
If not, you should check if you have implemented the right
algorithm.
The function binary_search
that we have just written not
only allows to look for integers but also for all other objects that can
be ordered in Python. To illustrate this, copy the following function to
your file:
def pp_words():
with open('sortedpp.txt') as file:
return [w.strip() for w in file]
The function pp_words
returns a list containing all
words that appear in Pride and
Prejudice in lexicographic order. Test your function
binary_search
for this input as well:
In [26]: s = pp_words()
In [27]: timeit.timeit('"TELEVISION" in s', number= 40000, globals = globals())
Out[27]: 2.9767137309972895
In [28]: timeit.timeit('binary_search(s, 0, len(s)-1, "TELEVISION")', number= 40000, globals = globals())
Out[28]: 0.19482769099704456
Pride and prejudice, being from the 19th century, predates television; so searching for the word “TELEVISION” in the text represents a worst-case search.
Optional Exercise: Wrapping recursion
It is often useful to not expose implementation details to the user,
in this case the arguments lower
and upper
that in most cases are probably not needed. Thus, it is customary to
wrap similar recursive functions by another function without
the arguments only needed for the recursion. Write a function
binary_search_simple()
that uses
binary_search()
and starts as follows:
def binary_search_simple(sorted_list, element):
"""Return the position of the element in sorted_list if it appears
there. Otherwise return -1.
"""
Exercise 5: Generating subsets
Our goal in this exercise would be to list all the subsets of a given
set. For example, if the input set was {1, 2, 3}
, the
algorithm is expected to return
{}, {1}, {2}, {3}, {1, 2}, {1, 3}, {2, 3}, {1, 2, 3}
(in
some order). One can do this recursively as follows: 1. take any element
of the set (for example, 1
) 2. using a recursive call,
compute the subsets of the set without the chosen element (in the
example, {}, {2}, {3}, {2, 3}
) 3. for each computed subset
S
, append to the final result both S
and a
copy of S
with 1
added (for example,
{2, 3}
would yield both {2, 3}
and
{1, 2, 3}
).
Implement this approach in the function find_subsets
, we
give a starter code below
def find_subsets(s):
"""Return a list of all the sub sets of set `s`
(in any order)
"""
if len(s) == 0:
return [set()]
# to use `copy` function, add `from copy import copy` at the beginning of your file
= copy(s)
s_copy = s_copy.pop()
elem = find_subsets(s_copy)
result ...
You can test your function by typing
In [29]: find_subsets(set())
Our[29]: [set()]
In [30]: find_subsets({1, 2})
Out[30]: [set(), {2}, {1}, {1, 2}]
In [31]: find_subsets({4, 5, 6})
Out[31]: [set(), {6}, {5}, {5, 6}, {4}, {4, 6}, {4, 5}, {4, 5, 6}]
Upload your file recursion.py
:
Exercise 6: Generating permutations
Now we will write a function to find all permutations of several
distinct objects. For example, if the input list was
[1, 2, 3]
, the algorithm is expected to return
[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]
(in some order). One can do this recursively as follows:
- take any element of the set (for example,
1
) - using a recursive call, compute the permutations of the list without
the chosen element (in the example,
[2, 3], [3, 2]
) - for each computed permutation
P
, add the element1
at all possible positions and append such permutations to the final result. For example,1
and[2, 3]
would yield[1, 2, 3]
,[2, 1, 3]
and[2, 3, 1]
.
Implement this approach in the function
find_permutations
.
def find_permutations(arr):
"""Return a list of all the permutations of the list arr."""
...
You can test your function by typing
In [32]: find_permutations([])
Out[32]: [[]]
In [33]: find_permutations([1, 2])
Out[33]: [[2, 1], [1, 2]]
In [34]: find_permutations(['a', 'b', 'c'])
Out[34]: [['c', 'b', 'a'], ['b', 'c', 'a'], ['b', 'a', 'c'], ['c', 'a', 'b'], ['a', 'c', 'b'], ['a', 'b', 'c']]
Upload your file recursion.py
:
Exercise 7: Finding acronyms
For given phrase, say How are you
, we will call an
acronym a word formed by taking one letter from each of the
words in the phrase (letters are taken in the same order as the words in
the phrase). For example, we can obtain HEY
as
How arE You
. Our goal will be to write a function taking as
input a phrase and a dictionary of legible words and returning a set of
all possible acronyms.
We will do this by writing a recursive function
find_acronyms_rec(phrase_words, prefix, word_list)
where
phrase_words
is a list of words, prefix
is a
string, and word_list
is a list of words. The function
should return all the words from word_list
which start with
a prefix
followed by an acronym of the phrase formed by
phrase_words
. The idea is that, on each step, we choose all
possible first letters of the acronym from the first word, and run
recursively on the remaining phrase. To give some intuition, let us
apply this strategy to our example.
- We start with
prefix = ''
andphrase_words = ["how", "are", "you"]
. - The first letter in the sought acronym must come from the word
"how"
, so there are three options:'h'
,'o'
, and'w'
. - For each of the three options, we want to complement the chosen
letter with an acronym for
["are", "you"]
, so we run the functionfind_acronyms_rec
recursively three times settingprefix
to be"h"
,"o"
, and"w"
,phrase_words
to be["are", "you"]
, anddictionary
to be the same as before, and then we take a union of the results. - At the bottom level of the recursion, we will arrive at a situation
when
prefix
is a three-letter word andpharse_words
is an empty list, so it will remain to check ifprefix
appears indictionary
.
For convenience, this recursive function will be wrapped into a non-recursive one as follows:
def find_acronyms(phrase, word_list):
"""Find each word in word_list that can be constructed
by taking one letter from each word in phrase, in order.
"""
return find_acronyms_rec(phrase.split(), '', word_list)
def find_acronyms_rec(phrase_words, prefix, word_list):
pass
We will use the following function to load a list of words (for example, this one):
def load_dictionary(filename):
with open(filename, 'r') as f:
= [line.strip() for line in f]
words return words
Once you complete find_recursive_rec
, you can test your
code:
In [35]: words = load_dictionary("wordlist.txt")
In [35]: find_acronyms('how are you', words)
Out[35]: {'way', 'hay', 'wry', 'hao', 'hey', 'oro'}
In [36]: find_acronyms('per aspera ad astra', words)
Out[36]: {'erat', 'peat', 'rear', 'peas', 'pear'}
Upload your file recursion.py
:
Remark that, if word_list
is sorted,
you can speed up the computation by using the binary search function you
have implemented before.
Optional Exercise: More acronyms
You may find that, for many phrases, the function we wrote does not find an acronym (especially, if there are short words). We will relax the conditions in two ways:
- we will allow to skip some words my introducing a parameter
min_words_included
; - we will allow to take several letters from each word.
With these relaxations, making the code working efficiently is an interesting challenge as the number of options to consider grows dramatically. By the way, there is a nice webapp finding such acronyms quite fast.