You must be authenticated to submit your files

CSE 102 – Tutorial 2 – Dynamic programming

General instructions

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.

When submitting a solution, the system will do some basic validation of your files and run some automated tests on your solutions. A green tick tells you that your file compiles and has passed all of the automated tests. A red tick tells you either that your file is badly broken (e.g., a syntax error) or that your solutions fail one or more of the automated tests. You can see the result of the tests by clicking on “See my uploads” in the upper-left menu, and then clicking on the right arrow button next to each file you submitted.

Although passing all of our tests provides evidence that your solutions are correct, it might not guarantee it, as typically we would need to run infinitely many tests! You are encouraged to write your own tests, and more generally to think about why your code works and not just rely on the automated tests.

Discussion of the tutorials with friends and classmates is allowed, with acknowledgment, but please read the Moodle page on “Collaboration vs plagiarism/cheating”.

Fruit flight of fantasy

We expect you to write your solution to these problems in a file named flight.py and to upload it using the form below. You can resubmit your solution as many times as you like.

Upload form is only available when connected

For the sake of this exercise, imagine that you are a fruit bat flying eastwards across a triangular grid. At each unit of time, you can either fly due East or North-East or South-East. Some locations of the grid contain fruit (indicated by yellow circles below), and your goal is to eat as many of these as possible before making it to the end of the grid, after which you’ll go back into your cave to hibernate. For example, the grid might look like this:

(bat and fruit on grid)

In this case, there is a single optimal flight path travelling East, then North-East, then North-East, and finally South-East:

(optimal flight through grid)

Your first task will be to implement a brute-force solution to this problem by exhaustive enumeration. By “exhaustive enumeration”, we mean the approach of generating all possible flight paths, computing their fruit value, and picking the one with the maximum value. Although this brute-force approach is very inefficient, it provides a baseline and will help us to get a better understanding of the problem specification.

We need to pick a representation for flight paths and for grids, so let us use the following:

For testing purposes, you may find it convenient to copy these examples into your Python file:

example_path = [0,1,1,-1]
example_grid = [{-1,0},{-1,1,2},{-3,2},{-2,1,4}]
  1. Write a generator flights(n) which yields all possible flights of a given length, in any order. For example, the generator object created by calling flights(4) should eventually yield the flight \([0,1,1,-1]\) depicted above, as well as all other possible length-four flights such as \([1,1,1,1]\), \([0,0,0,0]\), \([-1,0,-1,0]\), etc., in some arbitrary order. Hint: use recursion.

  2. Write a function fruit_value(grid, path) which takes a grid (represented as a list of sets as explained above) and a flight path (represented as a list of displacements) and returns the number of pieces of fruit encountered by the bat along the path. For example, fruit_value(example_grid, example_path) should evaluate to 4.

Now it is very easy to write a very inefficient function for computing the value of an optimal path:

def optimal_brute(grid):
    return max(fruit_value(grid,path) for path in flights(len(grid)))

You should test for yourself that it works:

In [1]: optimal_brute(example_grid)
Out[1]: 4

Since there are \(3^n\) possible paths through a triangular grid of length \(n\), the brute force approach quickly becomes intractable. We can do much better with dynamic programming! Our first step will be to establish a recursive formula for the value of an optimal flight path. To that end, we need to consider paths starting at arbitrary locations on the triangular grid. Let us write \(V(i,j)\) for the value of an optimal flight path ending in column \(n\) and starting at row \(i\), column \(j\), where we assume that the bat has already consumed any fruit at that location \((i,j)\) so that it does not contribute to the value of the path. Then \(V(i,n) = 0\) for all \(i\), and one easily checks that the values \(V(i,j)\) satisfy the recurrence

\[ V(i,j) = \max_{d \in \{-1,0,1\}} F(i+d,j+1) + V(i+d,j+1) \]

for all \(1 \le j < n\), where \(F(i,j)\) is either 1 or 0 depending on whether there is fruit at location \((i,j)\).

  1. Translate the above recurrence into a recursive function optimal_rec(grid, i=0, j=0) which computes the value \(V(i,j)\) of an optimal path starting at location \((i,j)\) on an \(n\)-column grid. Note that we take the arguments \(i\) and \(j\) to have default value 0 for convenience. In particular, optimal_rec(grid) should compute the same value as optimal_brute(grid).

In terms of efficiency, the recursive approach is not much better than the brute-force approach since it still considers all \(3^n\) paths through the grid implicitly, in the sense that they are represented implicitly in the call stack. The key property that allows us to obtain a more efficient algorithm via dynamic programming is that the value of an optimal path from \((i,j)\) is independent of the path taken to \((i,j)\). This means that the values \(V(i,j)\) only have to be computed once, and can then be reused when computing the values of \(V(i',j')\) for any \(j' < j\). We can see this better if we label the nodes of our example grid with the values of \(V(i,j)\) for each \((i,j)\) (recall that any fruit at \((i,j)\) does not contribute to the value of \(V(i,j)\)):

(values of V(i,j) on example grid)

In general, a triangular grid of length \(n\) has \(1 + 3 + 5 + \dots + (2n+1) = (n+1)^2\) nodes, so there are only \((n+1)^2\) values of \(V(i,j)\) to compute.

  1. Modify your recursive solution so that it performs memoization to solve the optimal path problem using top-down dynamic programming. It will be convenient to define a recursive function optimal_td(grid, i=0, j=0, memo=None) taking an extra “memo table” as an optional argument, which is initialized to be an empty dictionary on the first call to the function, as indicated below.

    def optimal_td(grid, i=0, j=0, memo=None):
        if memo is None: memo = {}
        # ...

Your function optimal_td(grid) should compute the same value as optimal_rec(grid) and optimal_brute(grid), but much more quickly. Of course the difference will be negligible for the tiny example grid above, but should be noticeable even on relatively small grids since \(3^n\) grows much more quickly than \((n+1)^2\). For testing purposes, you could use the functions below to construct some larger grids:

def line_grid(n):
    return [{j+1} for j in range(n)]
def cyclic_grid(n,k):
    return [{i for i in range(-j,j+1) if (j*j+j+i)%k == 0} for j in range(1,n+1)]

For example, try to reproduce the interaction below, observing how long it takes to compute each answer.

In [2]: optimal_brute(line_grid(12)), optimal_brute(cyclic_grid(13,5))
Out[2]: (12, 8)

In [3]: optimal_rec(line_grid(12)), optimal_rec(cyclic_grid(13,5))
Out[3]: (12, 8)

In [4]: optimal_td(line_grid(12)), optimal_td(cyclic_grid(13,5))
Out[4]: (12, 8)

Perform some experiments with the different versions of the optimal path value computation, and record your observations as a comment in your submission file. You can use the function below to get more precise timing statistics:

import time
def timed(f, x):
    '''Runs f(x) and returns a pair of the resulting value and the elapsed time.'''
    t0 = time.time()
    v = f(x)
    t1 = time.time()
    return (v, t1-t0)

Once you’ve implemented the bottom-up version of the computation (optimal_bu below), repeat your experiments and extend your observations.

Having successfully implemented a recursive top-down solution to the problem of computing the values of optimal paths, your next goal will be to implement an iterative, bottom-up approach. For this it may be helpful to look again at the diagram above where each node is labelled by the corresponding value of \(V(i,j)\). Notice that the values in the last column are all equal to 0, while the values in each inner column only depend on the values in the column to its immediate right. This means that we can compute the value of the optimal path by working from right to left, starting with the rightmost column \(j = n\) and running down to the trivial leftmost column \(j = 0\), at each iteration computing the values of \(V(i,j)\) for all \(-j \le i \le j\).

  1. Write a function optimal_bu(grid) that computes the value of the optimal path through the grid using a bottom-up dynamic programming approach, as described above. You should not use recursion, only definite iteration (i.e., for-loops).

Finally, hibernation time is approaching! Knowing the theoretical value of an optimal flight path is not good enough – you need the actual path itself. Luckily, finding one is also possible using dynamic programming.

  1. Write a function optimal_path(grid) that computes an optimal path through the grid, returning the corresponding list of displacements. (In general there could be multiple paths that are optimal, and your function only has to return one of them.) Some examples:

    In [5]: optimal_path(example_grid)
    Out[5]: [0, 1, 1, -1]
    
    In [6]: optimal_path(line_grid(12))
    Out[6]: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    
    In [7]: optimal_path(cyclic_grid(13,5))
    Out[7]: [-1, 0, -1, -1, 0, 1, 1, -1, -1, 0, 1, 1, -1]

    Hint: You can use either an iterative bottom-up or recursive top-down approach. With a bottom-up approach, it is enough to compute a displacement for every node indicating the direction of an optimal path, and then use that to reconstruct a path at the end of the right-to-left sweep. If using a top-down approach, a good strategy is to adapt your function optimal_td to compute both the optimal value and the optimal path at the same time, and then extract the path.

  1. Adapt your solution to the previous exercise to write a generator optimal_paths(grid) that successively yields all optimal paths through the grid in an arbitrary order. Some examples:

    In [8]: list(optimal_paths(example_grid))
    Out[8]: [[0, 1, 1, -1]]
    
    In [9]: modified_example_grid = [{0,-1}, {1,2,-1}, {2,-2}, {1,4,-2}]
    In [10]: list(optimal_paths(modified_example_grid))
    Out[10]: [[-1, 0, -1, 0], [0, -1, -1, 0], [0, 1, 1, -1]]
    
    In [11]: g = optimal_paths(cyclic_grid(13,5))
    In [12]: next(g)
    Out[12]: [-1, 0, -1, -1, 0, 1, 1, -1, -1, 0, 1, 1, -1]
    
    In [13]: next(g)
    Out[13]: [-1, 0, -1, -1, 0, 1, 1, -1, -1, 1, 0, 1, -1]

Optimal typesetting (optional)

We expect you to write your solution to these problems in a file named text.py and to upload it using the form below. You can resubmit your solution as many times as you like.

Upload form is only available when connected

For this exercise, imagine that you are the CEO of a startup company whose business model is based on providing a service that formats texts into highly-aesthetic fixed-width blocks. The user provides a text \(T\) in the form of a list of words and specifies an upper limit \(L\) on the number of characters per line (including spaces), and you automatically format the text into a list of lines \(\ell_1,\dots,\ell_n\) so as to minimize the “defect”

\[ D = \sum_{i=1}^n (L - |\ell_i|)^3 \]

corresponding to the sum of cubes of numbers of trailing characters. For example, consider the following text:

lorem = ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', \
         'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', \
         'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua']

With a limit of \(L = 20\) characters per line, a naive way of formatting the text into lines is as below:

Lorem ipsum dolor   |
sit amet consectetur|
adipiscing elit sed |
do eiusmod tempor   |
incididunt ut labore|
et dolore magna     |
aliqua              |

Unfortunately this looks awful, with a defect of 3³ + 0³ + 1³ + 3³ + 0³ + 5³ + 14³ = 2744. In contrast, this is how your service would format the text:

Lorem ipsum dolor   |
sit amet consectetur|
adipiscing elit     |
sed do eiusmod      |
tempor incididunt   |
ut labore et        |
dolore magna aliqua |

With a defect of only 3³ + 0³ + 5³ + 6³ + 3³ + 8³ + 1³ = 908, the text is now beautifully typeset. The business prospects for your startup are looking rosy, if only you can get the service to work correctly!

  1. Write a function line_defect(L, T) which given the parameter \(L\) and a list of words \(T\), computes the defect \((L - |\ell|)^3\) of the line \(\ell\) composed of the list of words \(T\) separated by space characters. If \(|\ell| > L\) (i.e., if the list of words does not fit in a line), you should return math.inf.

    Some examples:

    In [14]: line_defect(20, ['Lorem', 'ipsum', 'dolor'])
    Out[14]: 27
    
    In [15]: line_defect(20, ['Lorem', 'ipsum', 'dolor', 'sit'])
    Out[15]: inf
  2. Write a function best_defect(L, T), which given the parameter \(L\) and a list of words \(T\), returns the minimum overall defect \(D = \sum_{i=1}^n (L - |\ell_i|)^3\) of formatting the list of words into a list of lines \(\ell_1,\dots,\ell_n\).

    Some examples:

    In [16]: best_defect(20, lorem)
    Out[16]: 908
    
    In [17]: best_defect(22, lorem)
    Out[17]: 293

    Hint: Using dynamic programming, compute the minimum defect of the subtext \(T[0:i]\) for each \(0 \le i \le n\), where \(n\) is the number of words in \(T\). In particular, figure out how to compute the minimum defect of \(T[0:i]\) given the minimum defects of \(T[0:j]\) for all \(j < i\). Finally return the minimum defect of \(T[0:n]\).

  3. Write a function best_format, which given \(L\) and \(T\) as above, returns a list of lists of words representing an optimal formatting of \(T\) into lines of length \(\le L\).

    For testing purposes, you may find it convenient to use the following function for printing line-formatted messages:

    def print_message(L, msg):
        print('-' * L)
        print('\n'.join([' '.join(blk).ljust(L) + '|' for blk in msg]))
        print('-' * L)

    Some examples:

    In [18]: best_format(20, lorem)
    Out[18]:
    [['Lorem', 'ipsum', 'dolor'],
     ['sit', 'amet', 'consectetur'],
     ['adipiscing', 'elit'],
     ['sed', 'do', 'eiusmod'],
     ['tempor', 'incididunt'],
     ['ut', 'labore', 'et'],
     ['dolore', 'magna', 'aliqua']]
    
    In [19]: for L in [20,22,18]: print_message(L, best_format(L, lorem))
    --------------------
    Lorem ipsum dolor   |
    sit amet consectetur|
    adipiscing elit     |
    sed do eiusmod      |
    tempor incididunt   |
    ut labore et        |
    dolore magna aliqua |
    --------------------
    ----------------------
    Lorem ipsum dolor     |
    sit amet consectetur  |
    adipiscing elit sed   |
    do eiusmod tempor     |
    incididunt ut labore  |
    et dolore magna aliqua|
    ----------------------
    ------------------
    Lorem ipsum       |
    dolor sit amet    |
    consectetur       |
    adipiscing        |
    elit sed do       |
    eiusmod tempor    |
    incididunt ut     |
    labore et dolore  |
    magna aliqua      |
    ------------------

    Hint: refine your solution for best_defect.

Crocodile sequences (optional)

We expect you to write your solution to these problems in a file named crocs.py and to upload it using the form below. You can resubmit your solution as many times as you like.

Upload form is only available when connected

You will recall from your biology classes that crocodiles can lay three kinds of eggs – green, red, or blue – where:

Hence a single crocodile egg can hatch a potentially unbounded number of baby crocodiles. Moreover, these crocodile siblings always come out arranged in an orderly line according to the nesting structure of the eggs. For example, the green egg

ultimately hatches the following line of siblings:

For this exercise, imagine that you are applying for a job at a crocodile nursery, and that as part of the interview process, you will be asked to look at a line of crocodile babies and quickly determine whether they could possibly be a complete group of siblings.

  1. Write a function crocsibs(bcs) which given a string bcs describing a line of baby crocodiles, determines whether or not it could possibly be a complete group of siblings. The line is described by a color-coded string with the capital letters G, B, and R representing a green baby, a blue baby, and a red baby respectively.

    For example, crocsibs should return True on the string "RBRGRBR" since there is a green egg producing a red-blue-red-green-red-blue-red sequence of baby crocodiles (as shown above). Likewise, it should return True on "RBR", since such a sequence of babies can be produced by a blue egg (B -> GR -> RBR). On the other hand, it should return False on "RR", since there is no type of crocodile egg that will hatch just two red siblings.

    Hint: Let \(G(i,j)\), \(B(i,j)\), \(R(i,j)\) be the logical assertions that the subsequence bcs[i:j] can possibly be hatched by a green egg, a blue egg, or a red egg respectively. Find mutually recursive equations for \(G(i,j)\), \(B(i,j)\), and \(R(i,j)\), and then solve them using dynamic programming.