def count_down(n):
if n == 0: # base case
return
print(n)
count_down(n-1) # recursive call
count_down(5)5
4
3
2
1
Lecture 7 - Trees and Recursion
Recursion is a programming technique where a function calls itself. It can be used to solve problems that can be broken down into smaller sub-problems. The function calls itself until a base case (trivial case) is reached, at which point the function returns a value and “retraces its steps” to reach the original call.
Simple example:
5
4
3
2
1
Simple example with return value:
15
Roll-out of calls:
sum_numbers(5)
5 + sum_numbers(4)
5 + 4 + sum_numbers(3)
5 + 4 + 3 + sum_numbers(2)
5 + 4 + 3 + 2 + sum_numbers(1)
5 + 4 + 3 + 2 + 1 + sum_numbers(0) # base case returns 0
5 + 4 + 3 + 2 + 1 + 0 # now everything is computed backwards
5 + 4 + 3 + 2 + 1
5 + 4 + 3 + 3
5 + 4 + 6
5 + 10
15
Or, we can actually print the roll-out with a recursive function:
print_numbers(5)
5 + sum_numbers(4)
5 + 4 + sum_numbers(3)
5 + 4 + 3 + sum_numbers(2)
5 + 4 + 3 + 2 + sum_numbers(1)
5 + 4 + 3 + 2 + 1 + sum_numbers(0)
5 + 4 + 3 + 2 + 1 + 0
5 + 4 + 3 + 2 + 1
5 + 4 + 3 + 3
5 + 4 + 6
5 + 10
15
Order of recursion:
visiting 5
visiting 4
visiting 3
visiting 2
visiting 1
visiting 0
0
1 + 0
2 + 1
3 + 2
4 + 3
5 + 4
'5'
Computing 5 * factorial(4)
Computing 4 * factorial(3)
Computing 3 * factorial(2)
Computing 2 * factorial(1)
Computing 1 * factorial(0)
Returning 1
120
All above cases are direct recursion - the function calls (only) itself. Indirect recursion is when the function calls another function that calls the original function.
is 5 even?
is 4 odd?
is 3 even?
is 2 odd?
is 1 even?
is 0 odd?
False
---
is 5 odd?
is 4 even?
is 3 odd?
is 2 even?
is 1 odd?
is 0 even?
True
Yes, multiple indirect recursion is also possible but let’s not do that.
Nested recursion is when a function calls itself inside a recursive call - the inner recursive call needs to return before the outer recursive call can continue.
ackermann(1 - 1, ackermann(1, 2 - 1)) # nested
ackermann(1 - 1, ackermann(1, 1 - 1)) # nested
ackermann(1 - 1, 1)
1 + 1
2 + 1
3 + 1
4
Fibonacci is a typical example of a simple tree recursion.
fibonacci(5)
fibonacci(4)
fibonacci(3)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(1)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(3)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(1)
5
However, tree recursions are mostly useful to traverse trees or graphs in general (or grids, which are basically also graphs). Be careful as tree recursions can easily “explode”, consuming too much memory.
Most things done recursively can also be done with loops. Let’s compare:
This function never stops because it has no base case.
The stopping condition is explicit, and each recursive call decreases
n, so the function makes measurable progress.
Recursion is not automatically “better” than loops. It is usually better when:
Iteration is often better when:
A beginner-friendly rule is:
Prefer recursion when it makes the structure of the problem clearer, but do not force recursion into problems that are naturally iterative.
Frame 0: show_frames
Frame 1: factorial
Frame 2: factorial
Frame 3: factorial
Frame 4: factorial
Frame 5: factorial
Frame 6: factorial
Frame 7: <module>
Frame 8: run_code
Frame 9: run_ast_nodes
Frame 10: run_cell_async
Frame 11: _pseudo_sync_runner
Frame 12: _run_cell
Frame 13: run_cell
Frame 14: run_cell
Frame 15: do_execute
Frame 16: execute_request
Frame 17: execute_request
Frame 18: dispatch_shell
Frame 19: shell_main
Frame 20: _run
Frame 21: _run_once
Frame 22: run_forever
Frame 23: start
Frame 24: start
Frame 25: launch_instance
Frame 26: <module>
Frame 27: _run_code
Frame 28: _run_module_as_main
120
Memoization is a technique to store the results of expensive function calls and reuse them when the same input is encountered again. This can be used to cache results in recursion.
cache = {}
def fib_memo(n):
if n in cache:
return cache[n]
if n <= 1:
return n
cache[n] = fib_memo(n-1) + fib_memo(n-2)
return cache[n]
def fib_normal(n):
if n <= 1:
return n
return fib_normal(n-1) + fib_normal(n-2)
print("Fibonacci runtime without memoization:")
%timeit fib_normal(10)
print("Fibonacci runtime with memoization:")
%timeit fib_memo(10)Fibonacci runtime without memoization:
7.78 μs ± 989 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Fibonacci runtime with memoization:
67.7 ns ± 6.16 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
Alternatively, use functools.lru_cache decorator.
General rule is if you can do it nicely with a loop, do it with a loop. There are, however, some problems that are better done recursively.
For example: - tree and graph traversals (search) - divide-and-conquer algorithms (merge sort, quick sort) - backtracking (e.g., maze solving)
A tree is a non-linear data structure composed of nodes connected by edges. One node is the root, and others branch off. It is basically a directed acyclic graph (DAG).
The following requires graphviz to be installed: https://graphviz.org/
from graphviz import Digraph
def draw_simple_tree():
tree = Digraph(format='pdf')
tree.node('A')
tree.node('B')
tree.node('C')
tree.node('D')
tree.node('E')
tree.node('F')
tree.node('G')
tree.edge('A', 'B')
tree.edge('A', 'C')
tree.edge('B', 'D')
tree.edge('B', 'E')
tree.edge('C', 'F')
tree.edge('C', 'G')
return tree
draw_simple_tree()There are many different types of trees - they don’t have to be binary.
class Node:
def __init__(self, key):
self.left = None
self.right = None
self.val = key
# Insert and Inorder Traversal
def insert(root, key):
if root is None:
return Node(key)
if key < root.val:
root.left = insert(root.left, key)
else:
root.right = insert(root.right, key)
return root
def inorder(root):
if root:
inorder(root.left)
print(root.val, end=' ')
inorder(root.right)
r = Node(50)
insert(r, 30)
insert(r, 20)
insert(r, 40)
insert(r, 70)
insert(r, 60)
insert(r, 80)
inorder(r)20 30 40 50 60 70 80
More on trees next time.
Trees are inherently recursive data structures - recursion is a natural way to traverse them. Most operations on trees would be very difficult to do with loops.
350
Common recursive pattern:
Every recursive call creates a new stack frame that stores:
That means recursion uses memory proportional to the maximum depth of nested calls.
If the recursion depth is d, the auxiliary stack space
is usually:
O(d).This is why recursive solutions that are elegant on shallow trees may fail on very deep structures in Python.