CSE 102 – Tutorial 11 – Functional programming
Sections tagged with a green marginal line are mandatory.
Sections tagged with an orange marginal line are either more advanced or supplementary, and are therefore considered optional. They will help you deepen your understanding of the material in the course. You may skip these sections on a first pass through the tutorial, and come back to them after finishing the mandatory exercises.
Discussion of the tutorials with friends and classmates is allowed, with acknowledgment, but please read the Moodle page on “Collaboration, plagiarism, and AI”.
We expect you to write your solutions to all of the problems for this
tutorial in a file named fun.py and to upload it using the
form below. You can resubmit your solution as many times as you
like.
Tail recursion and conversion to purely functional code
Since Python does not have tail-call optimization, we will need to raise the recursion limit for this section.
import sys
sys.setrecursionlimit(100000)Recall the example of iterative and recursive versions of Euclid’s algorithm from Lecture 1:
def gcd_it(a,b):
while b > 0:
a,b = b,a%b
return adef gcd_rec(a,b):
if b == 0:
return a
return gcd_rec(b,a%b)Up to minor operational details, these two versions of the algorithm
have very similar executions. Notice that in the recursive version, the
recursive call to gcd_rec(b,a%b) is a tail
call, in the sense that the value returned by the recursive
call is not modified, and is just passed along as the result of the
function.
In contrast, consider the following iterative and recursive versions of the factorial function:
def fact_it(n):
p = 1
while n > 0:
p *= n
n -= 1
return pdef fact_rec(n):
if n == 0:
return 1
else:
return n * fact_rec(n-1)
Although both of these are correct implementations of the factorial
function \(n! = n\cdot (n-1)\cdots 1\)
and have the same input-output behavior, their flow of execution is
different. In fact, they perform the multiplications in opposite orders:
the iterative version multiplies from left-to-right (e.g.,
fact_it computes 4! = 24 by performing the series of
multiplications (((1*4)*3)*2)*1), while the recursive
version multiplies from right-to-left (e.g., fact_rec
computes 4! by performing the series of multiplications
4*(3*(2*(1*1)))). Notice that in the recursive version, the
call to fact_rec(n-1) is not a tail call.
Write a tail-recursive version of factorial that mimics the behavior of
fact_itby introducing an extra argumentp(called an “accumulator”) that represents the running product:def fact_acc(n, p) -> int: passCalling
fact_acc(n, 1)should return the same value asfact_it(n), i.e., n!, but the definition offact_accshould use only tail recursive calls and no iteration.Can you find a simple formula for the value of
fact_acc(n, p)for arbitrary integerspandn >= 0? State your answer as a comment, together with a justification of why the formula is correct.
Recall the n-queens problem as discussed in the lecture on
backtracking. The generator function queens(qs, n) below
yields all solutions to the n queens problem that extend a partial
solution qs represented as a list of integers between 0 and
n-1. (This is the same code as seen in class, but with added type
annotations for documentation.)
from typing import Iterator
def queens(qs: list[int], n: int) -> Iterator[list[int]]:
if len(qs) == n:
yield qs
return
for r in range(n):
# check for row conflict
if r in qs: continue
# check for "/" diagonal conflict (same row - column)
if any(qs[i]-i == r-len(qs) for i in range(len(qs))): continue
# check for "\" diagonal conflict (same row + column)
if any(qs[i]+i == r+len(qs) for i in range(len(qs))): continue
# extend board and recursively generate solutions
yield from queens(qs + [r], n)For example, below we use this backtracking solver to extract two solutions to the 8-queens problem:
In [1]: g = queens([], 8)
In [2]: next(g)
Out[2]: [0, 4, 7, 5, 2, 6, 1, 3]
In [3]: next(g)
Out[3]: [0, 5, 7, 2, 6, 3, 1, 4]The aim of the following optional exercises is to see how generators can be replaced by the use of higher-order functions.
For simplicity, in the first version below we ask you to write a purely functional backtracking solver that looks for a single solution to the n-queens problem, rather than all solutions.
Write a function
queens_one(qs, n, ksucc, kfail)of the following type:from typing import Callable def queens_one[T](qs: list[int], n: int, ksucc: Callable[[list[int]], T], kfail: Callable[[], T]) -> T: passHere the arguments
ksuccandkfailare functions called the “success continuation” and “failure continuation”, which the higher-order functionqueens_oneshould invoke in the case of success and failure respectively. More precisely, the specification ofqueens_oneis as follows: if there is a solutionsolto the n-queens problem extendingqs, then it should call and return the value ofksucc(sol); otherwise, it should call and return the value ofkfail().You should write
queens_oneas a pure recursive function, without using iteration or generators (and certainly without usingqueens!).Examples:
In [4]: queens_one([], 8, lambda sol : sol, lambda : print('No solution')) Out[5]: [0, 4, 7, 5, 2, 6, 1, 3] In [6]: queens_one([], 3, lambda sol : sol, lambda : print('No solution')) No solutionGuidance: you can start by copying the code for the generator function
queens, and begin transforming it. The yield operation (yield qs) should be replaced by an appropriate function call (return ksucc(qs)), and you can get rid of the for-loop by an appropriate use of recursion (feel free to introduce any auxiliary functions that you need). Finally, you should think carefully about how to construct the success and failure continuationsksuccandkfailin recursive calls toqueens_one(you may want to use lambda syntax).
Now we want to extend the higher-order backtracking solver to generate all solutions. For this it suffices to provide the success continuation an extra argument, which is itself a continuation that can be invoked to retrieve more solutions.
Write a function
queens_all(qs, n, ksucc, kfail)of the following type:def queens_all[T](qs: list[int], n: int, ksucc: Callable[[list[int], Callable[[], T]], T], kfail: Callable[[], T]) -> T: passThe specification of
queens_allis almost the same as forqueens_one, but now in the event that there is a solutionsolto the n-queens problem extendingqs,queens_all(qs, n, ksucc, kfail)should invokeksucc(sol, krest), wherekrestis a function (of no arguments) that can be appliedkrest()to generate all remaining solutions.As an example, you should be able to generate a list of all solutions to the n-queens problem by calling
queens_allwith appropriate success and failure continuations, as shown below.In [7]: queens_all([], 4, lambda sol, krest : [sol] + krest(), lambda : []) Out[7]: [[1, 3, 0, 2], [2, 0, 3, 1]] In [8]: queens_all([], 6, lambda sol, krest : [sol] + krest(), lambda : []) Out[8]: [[1, 3, 5, 0, 2, 4], [2, 5, 1, 4, 0, 3], [3, 0, 4, 1, 5, 2], [4, 2, 0, 5, 3, 1]]
Programming with binary trees, revisited
Throughout this section we will work with leaf-labelled binary trees, as discussed in class. We recall the encoding below — for simplicity, we assume that all leaves are labelled by integers.
class Leaf:
def __init__(self, value: int):
self.value = value
def __repr__(self):
return f'Leaf({self.value})'
class Node:
def __init__(self, left: 'Tree', right: 'Tree'):
self.left = left
self.right = right
def __repr__(self):
return f'Node({self.left}, {self.right})'
type Tree = Leaf | Node
# (if the above line gives you a syntax error, remove "type" from the previous line, i.e., replace by the following:)
# Tree = Leaf | NodeYou should copy these definitions into a file named
trees.py. (Or alternatively, download the file here.) Then you should add the line
from trees import Leaf, Node, Treeto your main source file.
For example, the values
example1 : Tree = Node(Leaf(0), Node(Leaf(1), Leaf(2)))
example2 : Tree = Node(Node(Leaf(0), Leaf(1)), Leaf(2))
example3 : Tree = Node(Node(Node(Leaf(0), Leaf(1)), Leaf(2)), Node(Leaf(3), Leaf(4)))
Also useful for testing purposes, the function
cbt(n, x=0) defined below constructs a complete binary tree
with \(2^n\) leaves labelled from \(x\) to \(x+2^n-1\).
def cbt(n: int, x: int = 0) -> Tree:
if n == 0: return Leaf(x)
else: return Node(cbt(n-1, x), cbt(n-1, x + 2**(n-1)))Pattern-matching and folds
Making use of recursion and structural pattern-matching (i.e., Python’s
match ... case ...syntax), write four functionscount_nodes(t),sum_leaves(t),leaves(t), andmirror(t)with the following specifications:count_nodes(t: Tree) -> intreturns the number of binary nodes in the treet;sum_leaves(t: Tree) -> intreturns the sum of the labels of the leaves in the treet;leaves(t: Tree) -> list[int]returns the list of leaf labels in the treet, listed from left to right;mirror(t: Tree) -> Treereturns the mirror image of the treet.
Examples:
In [9]: [(count_nodes(t), sum_leaves(t), leaves(t), mirror(t)) for t in [example1,example2,example3]] Out[9]: [(2, 3, [0, 1, 2], Node(Node(Leaf(2), Leaf(1)), Leaf(0))), (2, 3, [0, 1, 2], Node(Leaf(2), Node(Leaf(1), Leaf(0)))), (4, 10, [0, 1, 2, 3, 4], Node(Node(Leaf(4), Leaf(3)), Node(Leaf(2), Node(Leaf(1), Leaf(0)))))] In [10]: [count_nodes(cbt(n)) for n in range(10)] Out[10]: [0, 1, 3, 7, 15, 31, 63, 127, 255, 511]Warning: Make sure that you have imported the definitions of the tree classes from the
treesmodule as per the above instructions, rather than copying them into yourfun.pyfile. If you do the latter then your code will not pass the testing server!
You probably implemented all four functions from the previous exercise in a very similar way. In fact, they can all be written as folds over a binary tree in the following sense.
Write a higher-order function
fold_tree(t, f_leaf, f_node)which takes as input a binary treet, a unary functionf_leaf, and a binary functionf_node, and computes a value as follows: the functionf_leafis applied to obtain a value for each leaf label, and these values are combined by usingf_nodeat every binary node. For example,fold_tree(example1, g, h)should evaluate toh(g(0), h(g(1), g(2)))for any functionsgandh.Examples:
In [11]: fold_tree(cbt(4), lambda x:x, min) Out[12]: 0 In [13]: fold_tree(cbt(4), lambda x:x, max) Out[14]: 15
- If you have not already done so, add type annotations to your
definition of
fold_tree, trying to write a type for the function that is as general as possible.
Reimplement the four functions that you defined above, but now just in terms of
fold_tree. You should do so by defining the functionsf_leaf*andf_node*referenced below or by replacing them with appropriate lambda expressions.def count_nodes_as_fold(t: Tree) -> int: return fold_tree(t, f_leaf1, f_node1) def sum_leaves_as_fold(t: Tree) -> int: return fold_tree(t, f_leaf2, f_node2) def leaves_as_fold(t: Tree) -> list[int]: return fold_tree(t, f_leaf3, f_node3) def mirror_as_fold(t: Tree) -> Tree: return fold_tree(t, f_leaf4, f_node4)
Again just using
fold_treeand without making use of any auxiliary recursion, define a functioneq_tree(t: Tree, u: Tree) -> boolthat determines whether or not two treestanduare structurally equal.Examples:
In [15]: example1 == Node(Leaf(0), Node(Leaf(1), Leaf(2))) Out[15]: False In [16]: eq_tree(example1, Node(Leaf(0), Node(Leaf(1), Leaf(2)))) Out[16]: True In [17]: eq_tree(example2, Node(Leaf(0), Node(Leaf(1), Leaf(2)))) Out[17]: FalseHint: it may help to first define
eq_treeusing recursion and pattern-matching, before trying to define it as a fold. It is important to keep in mind thatfold_treecan compute a value of arbitrary type, in particular it can return a function as its answer.
Rémy’s algorithm
Rémy’s algorithm is a simple and elegant iterative algorithm for generating random binary trees of a fixed size. It is a uniform generator in the sense that every binary tree is generated with equal probability.
At a high level, the algorithm can be described as follows:
Start: a tree with one leaf with label 0.
For i from 1 to n:
Choose one existing subtree u at random. (There are 2n+1 subtrees when the tree has n internal nodes.)
Toss a coin.
If Heads, make u the left child of a new internal node; attach a new leaf with label i as the right child of the new internal node.
If Tails, do the symmetric thing: u goes on the right and the new leaf with label i goes on the left of a new internal node.
Here is a little animation of Rémy’s algorithm being used to generate a random binary tree with 16 leaves, followed by running the algorithm in reverse to deconstruct the tree:

Observe that as we grow new leaves, we also label them in the order of creation.
Write a function
remy(n: int) -> Treewhich uses Rémy’s algorithm to generate a uniformly random binary tree with \(n\) binary nodes and \(n+1\) leaves, with the leaves labelled \(0,\dots,n\) in order of creation.Examples:
In [18]: remy(8) Out[18]: Node(Node(Node(Node(Node(Leaf(7), Node(Leaf(3), Leaf(1))), Node(Leaf(2), Leaf(6))), Leaf(5)), Leaf(8)), Node(Leaf(0), Leaf(4))) In [19]: remy(8) Out[19]: Node(Leaf(1), Node(Leaf(2), Node(Node(Leaf(0), Leaf(5)), Node(Node(Leaf(8), Leaf(4)), Node(Leaf(7), Node(Leaf(6), Leaf(3)))))))Hint: to facilitate the step of picking a random subtree
uoftand replacing it by a larger treeNode(Leaf(k), u)orNode(u, Leaf(k)), begin by writing a function that enumerates every subtree together with a pointer from its immediate parent. More precisely, write a generator functionsubtrees(t: Tree, pt: Node, lct: bool) -> Iterator[tuple[Tree, Node, bool]]with the following specification:subtrees(t, pt, lct)generates all triples(u, p, lc)such thatuis a subtree oft,pis the immediate parent ofuint, andlcisTruejust in caseuis the left child ofp, under the assumption thatt = pt.leftiflct = True, andt = pt.rightiflct = False. Then you can writeremy(n)by repeatedly callingsubtreesand updating the tree appropriately. To simplify the code, it may help to begin by creating a tree with a dummy binary node at the root and a leaf labelled 0 as (say) its right child.