Lecture 9

Last time: list comprehension, numbers in binary, how computer memory works, mutable vs immutable.

Where we are in the course: We are still learning programming basics and working on mathematical examples to do that. Don't worry, more serious math is coming :)

Today: Example: sorting. Complexity, big-O notation.

This under the hood behaviour difference between mutable and immutable objects is very important.

In [1]:
x = 1
y = x
print(x,y)
y = 999
print(x,y)
1 1
1 999

x didn't change.


But doing a similar thing for lists:

In [2]:
x = [1,2,3]
y = x
print(x,y)
y[0] = 999
print(x,y)
[1, 2, 3] [1, 2, 3]
[999, 2, 3] [999, 2, 3]

They both change. We explained the reason why: it's because for lists and other mutable objects, running x = [1,2,3] creates a list [1,2,3] somewhere in memory, and stores, in x the address of that spot. When we say y=x, x and y are pointing to the same object. So changing y[0] changes x[0] as well.


In function arguments too:

In [3]:
def f(x):
    x += 1
    return x
In [4]:
x = 0
print(f(x), x)
1 0

The value of x didn't change because a copy of x was made inside the function.

But for lists:

In [5]:
def f(zs):
    zs[0] = 999
    return zs
In [6]:
xs = [1,2,3]
print(xs, f(xs))
[999, 2, 3] [999, 2, 3]

changing x[0] inside the function changed it for good.


But there's more funny stuff:

In [7]:
def f(zs):
    zs = [1,2,3]
    return zs
In [8]:
xs = [9,9,9,9]
f(xs)
Out[8]:
[1, 2, 3]

xs's was changed inside the function right? so if I print xs...

In [9]:
print(xs)
[9, 9, 9, 9]

It hadn't changed at all! What is going on?


In the function, zs is a local variable, and a copy of xs, but not a copy of the list that xs is pointing to. It's a copy of the address where the list lives. This is why we were able to change xs when we did zs[0]=999 before, because a change in the list that zs and xs are both ponting to was made. Now, if we say, zs=[1,2,3], then a totally new list [1,2,3] is created, and zs is now pointing to it. But xs is still pointing to what it was pointing to before, which was [9,9,9,9] in this case.


Sorting

We want to write a function that will sort a list of numbers:

e.g. we want sort([2, 15 ,-1 ,8 ,7]) to return:
[15,8,7,2,-1]

Idea for algorithm: Move the maximum element to the top of the list, then move the maxiumum of the rest to the top of the list...

Pseudo-code first level:

input: xs
output: a list with the same entries as xs but x[i]>=x[j] for all i>j
    N = length of xs
    for i=0,...,N-1
        mloc = the location of the maximum of xs from i to N-1
        swap xs[mloc] and xs[i]

This algorithm is called selection sort. There are much better algorithms like merge sort, quick-sort.

Of course we need to expand "the location of the maximum of xs from i to N-1" as code as well.

In [10]:
# returns the index of the max of the list
def max_loc_of_part(xs, start, end):  # this is actually officially called argmax
    current_max = xs[start]
    current_max_location = start
    for i in range(start, end):
        if current_max < xs[i]:
            current_max = xs[i]
            current_max_location = i
    return current_max_location            
In [11]:
# let's test:
max_loc_of_part([1,2,3,4,5],0,5)
Out[11]:
4
In [12]:
max_loc_of_part([6,2,3,4,5],0,2)
Out[12]:
0
In [13]:
max_loc_of_part([6,2,3,4,5],2,3)
Out[13]:
2

During the sorting, we'll also need to swap things. Let's make that into a function too:

In [14]:
# note that this swaps *in place*
def swap(xs, i, j):
    dum = xs[i]
    xs[i] = xs[j]
    xs[j] = dum
    #return xs  #(we don't need to return because xs is changed by the function, but we could do it)
    
# let's test:
xs = [1,2,3]
swap(xs,0,1)
print(xs)
[2, 1, 3]
In [15]:
def sort(xs):
    N = len(xs)
    for i in range(N):
        swap(xs, max_loc_of_part(xs,i,N), i)
    return xs
In [16]:
xs = [2, 15 ,-1 ,8 ,7]
sort(xs)
Out[16]:
[15, 8, 7, 2, -1]


Note that we could have written the same algorithm in one go like this:

In [17]:
def sort_with_not_great_code(xs):
    N = len(xs)
    for i in range(N):
        current_max = xs[i]
        current_max_location = i
        for j in range(i, N):
            if current_max < xs[j]:
                current_max = xs[j]
                current_max_location = j
        dum = xs[i]
        xs[i] = xs[current_max_location]
        xs[current_max_location] = dum
    return xs

# test
xs = [2, 15 ,-1 ,8 ,7]
sort_with_not_great_code(xs)
Out[17]:
[15, 8, 7, 2, -1]

But it is harder to read, and most importantly, you don't get any parts that you can test indepdently and make sure are ok. So it's better to break the problem down to smaller parts.


Next time, we will make some improvements to this code.

Questions and exercises:

  • Change the code above so that it deals with empty lists.
  • Insertion sort. Code up the following sorting algorithm: take a list xs, make a new list ys = []. For each element x in xs, insert x into ys in a way so that ys stays sorted. e.g. if ys = [10,8,4,1] and we are inserting x = 5, ys will be [10,8,5,4,1]. (you can use ys.insert(place, new_element) if you want or write it yourself)
  • Bubble-sort: start with a list xs. Go through xs from start to finish comparing two items at a time. If two elements are out of order, swap them. Repeat until you can go through the entire list without making a single swap. Code bubble-sort.