Lecture 7 - List Slicing, more recursion

Lecture 7 - List Slicing, more recursion

Download the original Jupyter notebook

We will start this lecture with a short reminder of what we learned about using lists in Python. We can create a new list:

lst = [1,2,3,4,5,6,7]

And access it elements:

lst[1]
2

Attempting to access the element beyond the end of the list leads to a runtime error, as expected:

lst[20]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[94], line 1
----> 1 lst[20]

IndexError: list index out of range

What happens when we try to access an element with a negative index?

lst[-1]
7

Surprisingly, this does not yield a runtime error. Instead, by using an index -1 we access the last element of the list, i.e. this code is doing the same thing as:

lst[len(lst) - 1]
7

Similarly lst[-2] is the same as lst[len(lst)-2], and so on.

lst[-2]
6

This is a neat little trick that can be used to make your code slightly shorter, but you need to be aware of it, as it might lead to some unexpected behavior. Potentially, the index you are using is a result of some complicated calculation, and was supposed to be non-negative. If it turns negative because of a bug in the calculation, you might expect the program to crash with a runtime error; instead it will quietly return a value, leading to some more serious problems further down the line.

List slicing

A different, this time extremely useful piece of Python trickery is list slicing. In the sequare brackets, instead of index you can put a pair of indices, say lst[start : stop]. That creates a new list with values [lst[start], lst[start+1], ..., lst[stop-1]] – i.e. all values in the range start to stop (with stop excluded, and start included).

lst[3 : 5]
[4, 5]
lst[0:5]
[1, 2, 3, 4, 5]

For example, if we want to get a new list that contains all element of an old list, except for the first one, we can just write:

lst[1:len(lst)]
[2, 3, 4, 5, 6, 7]

Of course, we can assign this new list to a variable:

a = lst[1:len(lst)]
a
[2, 3, 4, 5, 6, 7]

We can skip either of the indices: if we skip the index after the colon, by default it is going to be len(lst). So, if we want to get a copy of all elements from the previous list, except for the first one, we can write:

a = lst[1:]

Similarly, if we skip the first index, by default it is zero:

b = lst[:3]
b
[1, 2, 3]

And again, we can put negative index at either location.

Exercise After the line of code b = lst[:-1], what will be the content of the list b?

b = lst[:-1]
b
[1, 2, 3, 4, 5, 6]

Exercise 2 What if we skip both indices?

b = lst[:]

Is this the same as b = lst?

(Short answer: no). See lecture 5 for detailed explanation; in short b = lst[:] will copy the entire content of the list, creating a new list with the same content (and assigning it to variable b). b = lst Makes variable b a reference to the same list, as the one refered to by variable lst.

lst = [1,2,3,4,5,6]
b = lst
b.append(10)
print("lst = ", lst, "b = ", b)
lst =  [1, 2, 3, 4, 5, 6, 10] b =  [1, 2, 3, 4, 5, 6, 10]
lst = [1,2,3,4,5,6]
b = lst[:]
b.append(10)
print("lst = ", lst, "b = ", b)
lst =  [1, 2, 3, 4, 5, 6] b =  [1, 2, 3, 4, 5, 6, 10]

Thy syntax b = lst[:] is unnecessairly cryptic way of copying a list. If you want to explicitly copy a list, write just b = lst.copy() instead.

Exercise Write a function rotate(lst, k) that returns a new list, rotating the list lst by k positions to the right

Examle rotate([5,6,10,11,-5], 2) == [10, 11, -5, 5, 6]

def rotate(lst, k):
    return lst[k:] + lst[:k]
rotate([5,6,10,11,-5], 2)
[10, 11, -5, 5, 6]

Recursion

At the end of last lecture, we wrote a simple recursive procedure for counting down:

def countdown(n):
    if n == 0:
        print("Done")
        return
    print("Counting down ", n)
    countdown(n-1)
    print("Counting up ", n)
countdown(4)
Counting down  4
Counting down  3
Counting down  2
Counting down  1
Done
Counting up  1
Counting up  2
Counting up  3
Counting up  4

We also wrote a short code for computing factorial, that is reminiscent of the inductive mathematical definition of factorial as

$$ 0! = 1$$

and

$$n! = n \cdot (n-1)!$$

for $n >0.$

def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n-1)
factorial(5)
120

Fibonacci

For a homework exercise you were asked to write a similar, recursive code for the $n$-th Fibonacci number, realizing recursion

$$\begin{align} \text{Fib}_0 & = 0\\ \text{Fib}_1 & = 1\\ \text{Fib}_n & = \text{Fib}_{n-1} + \text{Fib}_n \end{align}$$

The following code realizes this as a recursive function.

def Fibonacci(n):
    if n < 2:
        return n
    return Fibonacci(n-1) + Fibonacci(n-2)
Fibonacci(6)
8

Alert This is terrible way of computing $n$-th Fibonacci number, much worse than our iterative implementation from one of the eariler lectures. Note that with this recursive implementation, each of the previous Fibonacci numbers will be re-computed multiple time (and in fact, the running time of the code below is exponential in $n$). If we try to compute the $50$-th Fibonacci number it is not going to finish any time soon, but it would not be much of a problem for our previous implementation.

Basically the only upside of the code above is that it is very short, and much closer resembles the mathematical definition of Fibonacci numbers; you should treat it as an exercise in recursion, not a viable way to solve the problem.

Listing all combinations

So far we have seen mostly examples, where the recursion was used to write slightly shorter code, for problems we were earlier able to solve with just iteration, without any problem. Sometimes, as for Fibonacci numbers, this came with a cost of terrible efficiency loss.

We will now start looking at examples, where using recursion, and thinking about problems in this context becomes significantly advantagous:

Exercise

Write a function all_strings(k) that produces a list of all strings (each once) of length exactly k, with letters “a” and “b” only.

Example:

all_strings(1) == ["a", "b"]
all_strings(2) == ["aa", "ab", "ba", "bb"]
all_strings(3) == ["aaa", "aab", "aba", "abb", "baa", "bab", "bba", "bbb"]

**Hint**
How can we generate all strings of length $k$? If we had a list of all strings of length $k-1$, we can just `a` and `b` at the beginning of each of them, and that would make a list of all strings of length $k$.

**Solution**

::: {.cell execution_count=73}
``` {.python .cell-code}
def all_strings(k):
    if k == 1:
        return ["a", "b"]
        
    prev = all_strings(k-1)
    result = []
    for x in prev:
        result.append("a" + x)
        result.append("b" + x)
    return result

:::

all_strings(5)
['aaaaa',
 'baaaa',
 'abaaa',
 'bbaaa',
 'aabaa',
 'babaa',
 'abbaa',
 'bbbaa',
 'aaaba',
 'baaba',
 'ababa',
 'bbaba',
 'aabba',
 'babba',
 'abbba',
 'bbbba',
 'aaaab',
 'baaab',
 'abaab',
 'bbaab',
 'aabab',
 'babab',
 'abbab',
 'bbbab',
 'aaabb',
 'baabb',
 'ababb',
 'bbabb',
 'aabbb',
 'babbb',
 'abbbb',
 'bbbbb']

Exercise Write a function all_valid_strings(k) that produces all strings of length k with letters "a" and "b" only, such that no string of two consecutive "b" appears anywhere inside.

Example For example all_valid_strings(1) should be a list containing "a" and "b" in any order.

all_valid_strings(2) should contain three elements "aa", "ab", "ba", again in any order ("bb" is excluded as invalid).

all_valid_strings(3) should contain five elements ["aaa", "aab", "aba", "baa", "bab"].

Hint What can be the first letter in a valid string on k letters? Surely it is either “a”, or “b”. If the first letter is “a”, the rest is any valid string on k-1 letters; if the first letter is “b”, the next one must be “a” (since “bb” is not allowed), and what follows is a valid string on k-2 letters.

If you had access to the list of all valid strings on k-1 letters, and all valid strings on k-2 letters, you could generate a list of all valid strings on k letters by adding “a” at the beginning of each element of the first list, and adding “ba” at the beginning of each element of the second list.

Exercise (fixing wrong solution) After attempting to have solved the previous exercise on your own, check out the code below. What is wrong with it? It seems to be implementing the idea from the hint, and it is checking the base case.

def all_valid_strings_wrong(k):
    if k == 0:
        return [""]
        
    result = []
    l1 = all_valid_strings_wrong(k-1)
    for x in l1:
        result.append("a" + x)

    l2 = all_valid_strings_wrong(k-2)
    for x in l2:
        result.append("ba" + x)
    return result

Let us see that it in fact runs into infinite recursion:

all_valid_strings_wrong(5)
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In[139], line 1
----> 1 all_valid_strings_wrong(5)

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

    [... skipping similar frames: all_valid_strings_wrong at line 6 (1 times)]

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

Cell In[138], line 10, in all_valid_strings_wrong(k)
      7 for x in l1:
      8     result.append("a" + x)
---> 10 l2 = all_valid_strings_wrong(k-2)
     11 for x in l2:
     12     result.append("ba" + x)

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

    [... skipping similar frames: all_valid_strings_wrong at line 6 (2970 times)]

Cell In[138], line 6, in all_valid_strings_wrong(k)
      3     return [""]
      5 result = []
----> 6 l1 = all_valid_strings_wrong(k-1)
      7 for x in l1:
      8     result.append("a" + x)

RecursionError: maximum recursion depth exceeded

What is the issue? Checking the base case! Note that in all_valid_strings_wrong we recursively call the same function with k-1 and k-2. If, for example the value of k is 1, we will quickly make a recursive call to all_valid_strings_wrong with k = -1, and thigns quickly go downhill from there. We need to fix the base case to separately check if k == 0 or k == 1.

Solution

def all_valid_strings(k):
    if k == 0:
        return [""]
    if k == 1:
        return ["a", "b"]
        
    result = []
    l1 = all_valid_strings(k-1)
    for x in l1:
        result.append("a" + x)

    l2 = all_valid_strings(k-2)
    for x in l2:
        result.append("ba" + x)
    return result

Let’s try it:

all_valid_strings(5)
['aaaaa',
 'aaaab',
 'aaaba',
 'aabaa',
 'aabab',
 'abaaa',
 'abaab',
 'ababa',
 'baaaa',
 'baaab',
 'baaba',
 'babaa',
 'babab']

Seems to be working!

Note how this recursion resembles the recursion for Fibonacci numbers. What is the number of valid strings of length k?

Tower of Hanoi

In the first lecture of the theoretical part of the course, we discussed the mathematical puzzle Tower of Hanoi. Today we would like to write a simple recursive code that generates a sequence of valid moves for the puzzle.

What is the puzzle? Imagine you have three rods (say, the rod number 0, number 1 and number 2), initially on the rod 0 we have a stack of n discs with decreasing size. The goal is to have all those disks in the same order, on the rod 1. The rule is, that we cannot put a larger disk on top of a smaller disk, and from any rod we can only take a top-most disk, and put it on top of any other stack (that has currently larger disk on top)

How can we do it?

To move a stack of n disks from rod 0 to rod 1 you can just

  1. Move a stack of n-1 top disks from rod 0 to rod 2 (**Recursion**) 2. Move the bottom disk from rod0to rod23. Move a stack ofn-1disks from rod2to rod0` (Recursion again!)

Exercise Write a function hanoi_towers(n, source, target, aux) that generates a list of all moves needed to move n disks from rod source to rod target, using rod aux as the “auxilary rod”.

The output should be a list of pairs, each pair $(f, t)$ corresponds to an instruction: move the top-most disk from rod f to rod t.

def hanoi_towers(n, source, target, aux):
    if n == 0:
        return []
    result = hanoi_towers(n-1, source, aux, target)
    result.append( (source, target) )
    return result + hanoi_towers(n-1, aux, target, source)
hanoi_towers(3, 0, 1, 2)
[(0, 1), (0, 2), (1, 2), (0, 1), (2, 0), (2, 1), (0, 1)]

We will discuss this code in more details in the next lecture.