Lecture 6 - String, tuples and basic recursion

Lecture 6 - String, tuples and basic recursion

Download the original Jupyter notebook

Let us start this lecture with a solution to the exercise given at the end of the last lecture.

Exercise Write a function filter_list(lst, low, high) that takes three arguments: list lst, and integers low and high. It should return a new list, with all elements from lst that are between low and high (including both end-points)

What is wrong with the solution below?

### WRONG SOLUTION!

def filter_list_wrong(lst, low, high):
    result = []
    for i in range(len(lst)):
        if i >= low and i <= high:
            result.append(i)
    return result

The loop for i in range(len(lst)): makes i the index into the list: the value of i is integer, iterating from 0 to len(lst)-1. We were supposed to check what is the value in the list at a given index, but we do not do it. Instead, we treat it as a value inside the loop, we compare if i>= low and i<=high: and then append i to the list result. This is not the intended behavior

filter_list_wrong([2,16,3,6,9,4, -1], 3, 10)
[3, 4, 5, 6]

If we decide to iterate over the index i, we should access the value at a given index using lst[i] inside the loop, in both comparisons and the argument to append function.

def filter_list(lst, low, high):
    result = []
    for i in range(len(lst)):
        if lst[i] >= low and lst[i] <= high:
            result.append(lst[i])
    return result
filter_list([1,7,11,5,6], 3, 8)
[7, 5, 6]

Alternatively, we can use for x in lst: construction. Now x will iterate over all values in the list:

def filter_list(lst, low, high):
    result = []
    for x in lst:
        if x >= low and x <= high:
            result.append(x)
    return result
filter_list([1,7,11,5,6], 3, 8)
[7, 5, 6]

String and tuples

There are two more data types that behave similarly to lists in many aspects. We have already seen strings:

hello = "Hello"
type(hello)
str

As it turns out, strings support several of similar operations to lists (all those data types are collectively called “sequences”). For instance, we can access the value at the position of index 3 in the string: it is just going to be the fourth of this string:

hello[3]
'l'

We can use the len function with a string to get the number of characters:

len(hello)
5

And we can iterate over all characters in the string, the same way we did with the list:

for x in hello:
    print("Character: " + x)
Character: H
Character: e
Character: l
Character: l
Character: o

The main difference Strings in Python are immutable: we cannot change the value at the given position of the string - that will lead to a runtime error.

hello[3] = 'x'
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[89], line 1
----> 1 hello[3] = 'x'

TypeError: 'str' object does not support item assignment

This, while may sound annoying at times, is actually potentially useful: we do not need to worry about the fact that there might be two different references to the same string, and the string was modified, using a different reference, in a way that is unclear to us.

Instead of modifying a string, we typically construct a new string with the desired modifications applied. Some of this can be done using Python built-in functions. For example the function upper returns a new string which is an upper case version of the string to which it is applied:

hello.upper()
'HELLO'

Importantly, this does not modify the original string. If we look at the string referred to by a variable hello, it has not changed.

hello
'Hello'

Tuples

Tuples is a data type used to store a sequence of values of arbitrary type, similarly to lists. On the other hand, similarly to Strings, tuples are immutable. We cannot change a value of an element in a tuple, we cannot add a new element to an existing tuple (we can create a new tuple, with more elements).

To create a new tuple, we use similar syntax as in creating a list, except that we use the round brackets instead of square brackets:

new_tuple = (10, 15, 20)
new_list = [10, 15, 20]
type(new_tuple)
tuple
type(new_list)
list

Again, we can look up a value at a given index of the tuple:

new_tuple[2]
20

We can also iterate over the elements in a tuple, with the same syntax as for the list:

for x in new_tuple:
    print("Element: ", x)
Element:  10
Element:  15
Element:  20

And of course check the length of a tuple:

len(new_tuple)
3

As we mentioned, the tuples are immutable (similarly to strings). We cannot modify the first element in a tuple:

new_tuple[1] = 0
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[105], line 1
----> 1 new_tuple[1] = 0

TypeError: 'tuple' object does not support item assignment

Nor we can append an element to an already existing tuple:

new_tuple.append(3)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[107], line 1
----> 1 new_tuple.append(3)

AttributeError: 'tuple' object has no attribute 'append'

Note that instead of appending an element to an existing tuple, we could create a new tuple that is a concatenation of the old one, and a one-element tuple:

new_tuple = new_tuple + (3,)
new_tuple
(10, 15, 20, 3)

This is creating a new tuple, by concatenating two old tuples, and making the variable new_tuple reference this new one. Note also a syntax (3,) (with a comma after the only element) to create a one-element tuple with a single value 3.

tuple

We will use lists and tuples typically in a different contexts. Lists are going to be used, where we would like to store and opperate on large, potentially variable size collections of objects of similar type (database of all records about employees in our company, or a list of all primes in a given range).

Tuples are used typically to store small constant-sized groups of values, that are convenient to pass around together. One of a very common applications is a situation where we are trying to write a function that we would like to return two different values. Any function in Python returns just one by design value, but there is a simple workaround: we can make this value to be a tuple, and the corresponding elements are whatever we wanted to return.

This is simplified by the following syntax in python where, given a tuple (say, in the example below a pair), we can assign the first value in this pair to one variable, and a second to another variable, with a single assignment instruction:

x = (2, 10)
type(x)
tuple
a, b = x
a
2
b
10

This is basically the same as writing a = x[0] and b = x[1], but in a single line.

Exercise Write a function that takes a list x, and returns a pair (mean, std_dev) of all elements from the list.

As a reminder

$$\text{mean} = \frac{1}{n} \sum_{i < n} x[i]$$

$$\text{stddev} = \sqrt{\frac{1}{n} \sum_{i < n} (x[i] - \text{mean})^2}$$

And you can compute a square root of a number by including import math at the beginning of your code, and then using a function math.sqrt(5)

import math
def mean_and_std_dev(x):
    n = len(x)
    the_sum = 0
    variance = 0
    for t in x:
        the_sum = the_sum + t
    mean = the_sum / n
    
    for t in x:
        variance = variance + (t - mean) ** 2
    mean = the_sum / n
    std_dev = math.sqrt(variance / n)
    return (mean, std_dev)

Let us see how to use it. The following call returns a pair, mean and standard deviation from a sequence:

mean_and_std_dev([1,5,10,11])
(6.75, 4.02336923485777)

We can simmultanously assign it to a pair of variables, say a and b:

a, b = mean_and_std_dev([1,5,10,11])

We can now use those two returned values, referring to them by separate names:

a
6.75
b
4.02336923485777

Note that the swapping trick we learned in one of the previous lectures was exactly of this form: it created a pair (b,a) (omitting the paranthesis), and then assigned the first value of this pair to the variable a, and second to the variable b:

a, b = b, a

Recursion

We discussed functions as a useful way to organize your code into logical chunks that can be re-used. We discussed usage of local variables in a function, which do not interfere with the rest of the code.

Here, we would like to introduce a new, and powerful concept: a function can call itself. To understand how a program behaves when this happens we need a notion of a frame: the local variables in a function are not associated with a function definition. They are associated with a function call. Every time we call a function, we create a new set of local variables - you can visualize them enclosed in a common box, and call this box a frame.

When a function recursively calls itself, the new function call will be associated a separate frame, with all new variables: several of those frames might be simultanously alive at a given time. It is convenient to visualize them organized into a stack, with the top frame associated with the most-recent function call.

Calling another function corresponds to creating a new frame with all the local variables, and putting it on top of the stack, then executing the code of this function. Returning from a function will remove the top-most frame from the stack, and (if the function returns some value) passing it in the caller as a result of the appropriate expression.

This has all been visualized on additional slides.

Example: Countdown

Let us see this on a very simple example of a function countdown that attempts to count from n to 0 using a recursive implementation:

def countdown(n):
    if n == 0:
        print("It is zero!")
        print("Hurra")
        return

    print("Count ", n)
    countdown(n-1)
countdown(5)
It is zero!
Hurra
Count  1
Count  2
Count  3
Count  4
Count  5

One of the typical use-cases for the recursion is that it lets us think about a complicated problem, by reducing it to a similar form of a problem, but for a smaller input. If, in addition, we know how to solve the “base case” - some fixed small inputs, we know how to solve a problem for any input.

We can think about the count-down function in this way. How do we count down from $n$ to $0$?

  1. If $n$ is already zero, it’s simple. Just count “zero” and be done with it.
  2. If $n$ is greater than zero, to count down from $n$ to zero, say $n$, and then count down from $n-1$ to zero (this is the recursive call)

Compare the code above with this description of the algorithm

Exercise 2 What happens if we switch the order of the last two expressions? Instead of

print("Count ", n)
countdown(n-1)

let’s say that we have

countdown(n-1)
print("Count ", n)

Look at the code below, and try to predict output of countdown(5) before running the code, and scrolling to the output.

def countdown(n):
    if n == 0:
        print("It is zero!")
        print("Hurra")
        return

    countdown(n-1)
    print("Count ", n)

What is your prediction? Try to run the code later, and see if the prediction matches the answer.

Take your favorite code editor and try to follow the code step-by-step, stepping in to every function call.

countdown(4)
It is zero!
Hurra
Count  1
Count  2
Count  3
Count  4

Exercise What happens if we print something both before and after the function call? Try to analyze the code below, and make sure you understand why its output is what it is.

def countdown(n):
    if n == 0:
        print("It is zero!")
        print("Hurra")
        return

    print("Entered countdown", n)
    countdown(n-1)
    print("Exiting countdown ", n)
countdown(5)
Entered countdown 5
Entered countdown 4
Entered countdown 3
Entered countdown 2
Entered countdown 1
It is zero!
Hurra
Exiting countdown  1
Exiting countdown  2
Exiting countdown  3
Exiting countdown  4
Exiting countdown  5

What happen if we skip the base case?

Python has a limit into the number of recursive calls. If we skip the check of the base-case, our function will keep calling itself, until it reaches the limit, and result in a runtime error.

def counter(n):
    counter(n-1)
    print("Hello")
counter(5)
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In[130], line 1
----> 1 counter(5)

Cell In[129], line 2, in counter(n)
      1 def counter(n):
----> 2     counter(n-1)
      3     print("Hello")

Cell In[129], line 2, in counter(n)
      1 def counter(n):
----> 2     counter(n-1)
      3     print("Hello")

    [... skipping similar frames: counter at line 2 (2975 times)]

Cell In[129], line 2, in counter(n)
      1 def counter(n):
----> 2     counter(n-1)
      3     print("Hello")

RecursionError: maximum recursion depth exceeded

Exercise Previously we wrote an implementation of factorial using a simple loop. Mathematician often provide a recursive definition of factorial:

$$0! = 1$$

and

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

for $n > 0$.

This defines the factorial for zero, and for $n$ when $n > 0$ in terms of factorial of $n-1$. This recursive definition can be rewritten almost verbatim as a Python function.

Write a recursive function factorial(n) that calculates $n!$ using recursive definition.

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

Homework exercise Fibonacci numbers are given by a similar recursive formula

$$\text{Fib}_0 = 0$$

$$\text{Fib}_1 = 1$$

and for $n > 1$, we have

$$\text{Fib}_n = \text{Fib}_{n-1} + \text{Fib}_{n-2}.$$

While calculating fibonacci numbers using the recursive function reflecting this recursive formula is generally terrible idea (the iterative method presented before is much better), try to implemenent it nevertheless as an exercise.

def fibonacci(n):
    ...