Week 10: sorting

This week is sorting. Like last week, there are multiple algorithms to consider and analyze.

Monday

The video from today’s lecture (Monday, April 6, 2020):

And here are the annoucements:

  • Quiz 4 is live and due by the end of Friday (Apr 10)

  • Lab 8 is up and due Saturday (Apr 11)

  • I’m still behind on grading…​

swapping two items in a list

In most languages, to swap two items in a list, you would do this (assuming i and j are valid indices):

tmp = L[i]     # save ith value to temporary location
L[i] = L[j]    # copy jth value to ith position
L[j] = tmp     # copy from tmp to jth position

Since this is a common operation, python provides a handy shortcut:

L[j],L[i] = L[i],L[j]

sorting in place

We would like to be able to sort a given list in place, meaning we don’t want to create a whole extra second list.

This week we will write functions that, given a list, sort the list from low to high: mysort(L)

Remember, since lists are mutable, the above call to mysort(L) doesn’t need to return the list. The mysort function can just move items around in the list, and those changes will be seen back in main() after the function is done.

bubble sort

We spent 5 minutes thinking about how we would sort numbers in a list from low to high, and a lot of students came up with the idea to compare two adjacent numbers in the list (e.g., L[i] and L[i+1]). If those numbers are out of order, swap them, then move on to the next two numbers in the list.

Here is the code to do the above comparisons and swaps, assuming we already have a list L:

for i in range(len(L) - 1):         # for all indices (except last one)
    if L[i] > L[i+1]:               # if items are out of order
        L[i],L[i+1] = L[i+1],L[i]   # swap them

This is part of an algorithm called the bubble sort. The only thing that is missing is, we need to repeat the above for loop, over and over, until the whole list is sorted.

Here’s the full code for the bubble sort algorithm:

def bubbleSort(L):
    """given a list, sort from low to high"""
    done = False
    while not done:
        nswaps = 0
        for i in range(len(L) - 1):
            if L[i] > L[i+1]:
                L[i],L[i+1] = L[i+1],L[i]
                nswaps = nswaps + 1
        if nswaps == 0:
            done = True

Add this to your sorts.py file and test it to make sure it works.

Wednesday

The video from today’s lecture (Wed, April 8, 2020):

Today’s topics:

  • selection sort

  • analysis of selection and bubble sort

  • assert() statements

  • timing the sorts for N, 2N, and 4N data

selection sort

Here’s the selection sort algorithm: find the smallest item in the list, swap it to position 0 in the list; now find (select) the second smallest item in the list, swap it to position 1, and so on…​

And here’s the bubble sort from last time: consider adjacent items in the list. If they are out of order, swap them. Do this for every adjacent pair in the list, moving from left to right. If you don’t make any swaps you are done. If you do make at least one swap, repeat the process (consider pairs all the way from left to right in the list)

Here is a video of the selection sort algorithm (click to play):

And here’s the code to add to sorts.py:

def selectionSort(L):
    """give a list, sort from low to high using selection sort"""
    # for each index
    for i in range(len(L)):
        # find smallest item from index to end
        index = i
        for j in range(i,len(L)):
            if L[j] < L[index]:
                index = j
        # swap smallest item to index
        L[index],L[i] = L[i],L[index]

analysis

Can you see that the selection sort is a quadratic (\$O(N^2)\$) sort? It’s got nested loops, and each loop depends directly on the length of the list we are trying to sort.

Because the loops are nested (one loop is inside the other), that means the inner loop gets run many times (however many times the outer loop runs).

For example, consider these nested loops, where the inner loop executes 3 times, and the outer loop executes 4 times:

>>> for i in [1,2,3,4]:
...    for j in ["A","B","C"]:
...       print(i,j)
...
1 A
1 B
1 C
2 A
2 B
2 C
3 A
3 B
3 C
4 A
4 B
4 C

Because the loops are nested, the total number of times the print(i,j) line is called is 12 (4*3).

Can you see that the bubble sort is also a quadratic (\$O(N^2)\$) sort?

If you look at the code from Monday, it’s easy to see the inner for loop depends directly on the length of the list. The outer while loop is a little harder to figure out.

Here’s a video of the bubble sort algorithm (click to play):

If you watch the smallest number in the video, as it’s being sorted, you can see it only moves one spot to the left with each pass. That means, in the worst possible case, when the smallest number starts on the far right, it will take N passes for the smallest number to move all the way to the left, where it belongs.

That means, in the worst case, bubble sort’s while loop will execute N times, making the bubble sort a quadratic algorithm.

the assert statement

This is just an extra function that is handy to use when you are testing a function. An assert() call just takes an assertion (like x > 5) and tells you if it is True or not. It does this in a funny way, giving no ouput if the assertion is True, and giving and AssertionError is the assertion is False.

Here’s a quick example:

>>> x = 5
>>> assert(x>0)
>>> assert(x<100)
>>> assert(x==6)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

The peculiar output (nothing if True, error if False) can be used in a test function: add a bunch of assert() statements for what you think should be True after your function runs, and if you don’t see any assertion errors, everything is working.

Here are some examples I added to our sorts.py file:

def main():
    L = [15,10,23,6,18,73,42]
    selectionSort(L)
    print(L)
    assert(L[0] == 6)
    assert(L[0] <= L[1])

    L = list('YACDZWXB')
    selectionSort(L)
    print(L)       # should be A B C D W X Y Z
    assert(L[0] == "A")
    assert(L[1] == "B")
    assert(L[7] == "Z")
    assert(L[0] <= L[1])

And here are what I am asserting should be True if my sorts are all working:

  • the first one says: if the sort worked, there should be a 6 as the first item in the list

  • the second one: if the sort worked, the first item should be less than or equal to the second item

  • for the letters: "A" should be at index 0

  • for the letters: "B" should be at index 1

  • for the letters: "Z" should be at index 7

  • for the letters: the first item should be less than or equal to the second item

If you run the sorts.py file now, and don’t see any assertion errors, then you know the assertions I made are all True, so my sorts are working!

This is just a nice way to test a function without having to visually look at output and see if things look correct (let the computer do the checking!).

timing the sorts

Even though we have analyzed the sorts, and know they are quadratic, it might be nice to actually time how long they take to run, and then compare the times vs the size of the lists being sorted.

Here’s the code in timing.py to do that:

from random import *
from time import *

# import our sort functions from sorts.py file
from sorts import *

def main():
    # ask user for N
    N = int(input("size of list? "))
    # create original list of length N, filled with random numbers
    ORIG = []
    for i in range(N):
        ORIG.append(randrange(-N,N))
    # make copy of list
    L = ORIG.copy()
    # get timestamp
    t1 = time()
    # sort list using one of our sorts
    selectionSort(L)
    # get another timestamp
    t2 = time()
    # print out total time it took to sort list
    print("selection sort time for N=%d:  %7.3f sec" % (N,t2-t1))

    # repeat for other sorts
    L = ORIG.copy()
    t1 = time()
    bubbleSort(L)
    t2 = time()
    print("   bubble sort time for N=%d:  %7.3f sec" % (N,t2-t1))

    L = ORIG.copy()
    t1 = time()
    L.sort()
    t2 = time()
    print("   python sort time for N=%d:  %7.3f sec" % (N,t2-t1))

main()

If you run the above for N=2000, N=4000, and N=8000, you should be able to tell the selection and bubble sorts are quadratic (their run times roughly quadruple each time you double the size of the list).

Friday

The video from today’s lecture (Friday, April 10, 2020):

Outline for today (also see FRIDAY file after running update21)

  • understand how insertion sort works (add insertion.py to sorts.py)

  • timing of our sorts (bubble, selection, insertion) vs L.sort()

  • comparing sorts: https://www.toptal.com/developers/sorting-algorithms

  • see sorts.examples for examples of our sorts

  • try the sorts.worksheet

  • idea of mergeSort(L)

  • also merge(L1,L2,L3) to merge already-sorted lists L1 and L2 into L3

  • next week: recursion

  • don’t forget quiz 4 due today, lab 8 due saturday

  • office hours for me today after class

  • ninja session tonight 7-9pm

insertion sort

Here’s the code for insertion sort:

def insertionSort(L):
    """sort L from low to high using insertion sort"""
    for i in range(1, len(L)):
        j = i
        while j>0 and L[j] < L[j-1]:
            L[j],L[j-1] = L[j-1],L[j]
            j = j - 1

The outer for loop just goes from index 1 to the end. The inner while loop is what does the inserting.

For each item in the list, assuming the items to the left of the current item are already in order, insert the current item into the correct location. This one is harder to picture until it gets going — see the animation below.

This is the insertion sort algorithm:

  • assume item at index 0 is already "sorted"

  • starting with item at index 1, check all items to the left and swap positions if needed

  • do the same for item at index 2, where now items at index 0 and 1 should be in order

  • do the same for item at index 3, where now items at index 0, 1, and 2 are in order …​and so on

Notice that, for each index, all items to the left are in order, and you are inserting the next item into the correct spot.

Here is a video of the insertion sort algorithm (click to play):

See if you can add insertionSort() to our sorts.py file. Include some test code at the bottom of sorts.py!

analysis of sorting algorithms

Each of the sorting algorithms we considered (selection, bubble, and insertion sort) has nested loops, where each loop’s length depends on the size on the list being sorted (N). This means the number of steps required for each sort, or the runtime, depends on the size of the list squared. These are called quadratic or N-squared sorts, because if the size of the list doubles, the number of steps quadruples. For example, if the runtime of the bubble sort was 10 seconds for a list of size N, it would take approximately 40 seconds to sort a list of size 2N.

a better way??

See this nice website for a quick animation and comparison of sorting algorithms: https://www.toptal.com/developers/sorting-algorithms

merge sort

Here is the basic merge sort algorithm:

  • if the list has two or more items in it, split it into two lists

  • sort each of the two split lists

  • merge the two sorted lists back together into one sorted list

The merge step is actually not that hard, given two sorted lists. If you know each list is already sorted, you can just compare the first item in each list, and take the smaller of the two.

the merging

Here’s a quick look at the merge step: assume we have two already-sorted lists, L1 and L2, and want to merge them into a third list L3. Can we write a merge(L1,L2,L3) function that will do that?

L1 = [1,2,5,7,34,60]   L2 = [3,4,5,6,8,10]   # both already sorted
L3 = [_,_,_,_,_,_,_,_,_,_,_,_]               # L3 has room for L1 and L2

For the above lists, we would just compare the leading numbers from L1 and L2, and whichever is smaller, store it in L3. So after the first 3 compares, they would look something like this (using an x to mark which items have been pulled from L1 and L2):

L1 = [x,x,5,7,34,60]   L2 = [x,4,5,6,8,10]
L3 = [1,2,3,_,_,_,_,_,_,_,_,_]

And the next step compares the 5 from L1 to the 4 from L2.

We’ll write this function next week. All I want you to see today is that the merge is a linear algorithm, since it directly depends on the size of the list.

the splitting

A more interesting question is, how do we sort each of the split lists? Do we use selection sort, bubble sort, or some other sort?

Since we are talking about merge sort, can we use merge sort to sort each of the split lists? This is called recursion: we are writing a function (mergeSort()), and somewhere in that function we call that function (or a copy of that function) to help solve the problem. This may seem odd, but it is perfectly fine. If you think about stack diagrams, calling any function just puts another function frame on the stack. Assuming there is an end somewhere, it is perfectly fine for a function to call itself and put another copy on the stack.

So here is a version of the mergeSort() function, assuming we have a valid merge() function, which is not hard to write (we will write it next week):

def mergeSort(L):
    """use merge sort to sort L from low to high"""
    if len(L) > 1:
       # split into two lists
       half = len(L)/2
       L1 = L[0:half]    # split using slicing
       L2 = L[half:]     # split using slicing
       # sort each list
       mergeSort(L1)
       mergeSort(L2)
       # merge them back into one sorted list
       merge(L1,L2,L)

Note that the splitting is just the same question we asked about binary search: how many times can we split the list in half before we end up with 1-item lists. And the answer is the same: log(N) times.

So we need to merge the lists back together log(N) times, and merging is a linear operation. That makes merge sort an NlogN algorithm (i.e., it is better than the quadratic sorts!).

We will write this and test it next week, after we learn all about recursion.