← back to CS 115
Week 10Guttag §Chapter 10283 min full read
7 concepts17 worked examples27 exercises3 exam-level7 figures
What are you here for?

10 Searching and sorting: linear search, bisection search, bubble, selection and merge sort, and the recursion they are written with

Start with this

One question before you read anything. Getting it wrong is the point: it shows you what this section is for.

§10.0 — what sort hands back

Before the searches and sorts, three things from earlier weeks decide whether your code here works at all. First one: a student wants a sorted list and writes the line the way you would write it for sorted.

nums = [5, 2, 9]
nums = nums.sort()
print(nums)
Find(a) Write exactly what this prints.
Given
  • nums starts as the list [5, 2, 9].

  • sort is the list method, called on nums itself.

IPython console
Hint 1/4

The question is not what the sorted list is. It is what the expression on the right of the equals sign evaluates to.

Hint 2/4

A method that changes the object it was called on and has nothing new to report hands back None. sort is one of those.

Hint 3/4

So nums.sort() rearranges the list and evaluates to None, and then that None is assigned to the name nums. The starting list was [5, 2, 9].

Hint 4/4

The sorted list exists for a moment and then loses its only name. The program prints None.

Show solution

Evaluate the right hand side first

$$\texttt{nums.sort()}$$

Python evaluates the right of an assignment before it binds the name, so the sort runs now.

$$\texttt{nums} \to [2, 5, 9]$$

The list object itself is rearranged. Nothing is copied.

$$\texttt{nums.sort()} \to \texttt{None}$$

The method has no new object to report, and a function with nothing to return hands back None.

Then bind the name

$$\texttt{nums = None}$$

The assignment overwrites the only name that pointed at the sorted list.

$$\texttt{print(nums)} \to \texttt{None}$$

What is printed is whatever the name holds now, which is None.

Answer $$\boxed{\texttt{None}}$$
Check

Independent check: print(type(nums)) after the same two lines prints <class 'NoneType'>, which a list could never print.

Rule to carry: if a line both sorts and assigns, one of the two is wrong. Use L.sort() alone, or copy = sorted(L).

What this looks like in Spyder
The Variable Explorer tab showing a table with Name, Type, Size and Value columns for the names the program created.

The tab next to Help. It lists every name your program left behind with its type and its size, so it answers the one question a listing cannot: what is in this name right now. The stepper on this page shows the same four columns.

Spyder 5.5.1 on Linux, running the program in its own editor. A different version moves a few toolbar icons; the panes are named the same.

A till program holds 20000 product codes in a list and looks up four of them. For each code it walks the list from the front until it finds a match. The codes it is asked for happen to sit near the end.

# One full scan of the price list for every code we look up.
codes = list(range(0, 40000, 2))
wanted = [39998, 39996, 39994, 39992]
comparisons = 0
for w in wanted:
    for c in codes:
        comparisons += 1
        if c == w:
            break
print('codes in the list:', len(codes))
print('comparisons for 4 lookups:', comparisons)

Sample Run:

codes in the list: 20000
comparisons for 4 lookups: 79994

Eighty thousand comparisons for four lookups. The list was already in ascending order the whole time, and nothing in the program used that. The same four lookups on the same list can be answered in fewer than sixty comparisons.

By the end of this section you can look at a list and a lookup and say which of the two searches applies and how many comparisons it will take at worst; write , , and both searches from a specification, iteratively or recursively; and take a short program built from any of them and write down exactly what it prints.

In 60 seconds

Searching a list costs one pass unless the list is sorted, in which case it costs the number of times you can halve it; sorting costs a lot once and makes every later search cheap; and every here can be written either as a loop or as a function that calls itself on a smaller piece.

Linear search, any list
$$n \text{ comparisons at worst},\quad O(n)$$

The list is in no particular order, or you will search it only a few times. Works on anything you can compare with the equals sign.

Bisection search, sorted list only
$$\log_{2} n + 1 \text{ comparisons at worst},\quad O(\log n)$$

The list is sorted ascending. On an unsorted list it gives wrong answers silently, so check the order first.

Bubble sort and selection sort
$$\tfrac{n(n-1)}{2} \text{ comparisons},\quad O(n^{2}),\quad \text{in place}$$

A lab sheet names one of them, or the list is short. Both rearrange the list you handed in and return nothing.

Merge sort
$$n\log_{2} n \text{ comparisons},\quad O(n\log n),\quad \text{new list}$$

The list is long. It needs room for copies, and it hands back a sorted list instead of changing yours.

Three most common mistakes
  1. Running a bisection search on a list nobody sorted. It answers -1 for a value that is sitting in the list, and no error is raised.

  2. Writing numbers = numbers.sort(). The method sorts in place and hands back None, so the name now holds None and the sorted list is gone.

  3. A recursive function whose recursive call has no return in front of it. Every deeper call runs, the answer is computed, and then it is thrown away: the function returns None.

The weights for this course are labs 20, midterm 40 and final 40. Two real papers from this course were checked while this page was written: on the midterm the question that only asked what four short programs print was 30 of 100, and on the final the same kind of question was 20 of 100. This week's material comes after the midterm, so it is final material, and the trace questions on this page are the exercise for that block of marks.

How much time do you have?
10 minutes

The two searches, the one condition that separates them, and the cost of each. This is what a multiple choice question or a one line lab step asks for.

The 60-second card · Linear search, and the one extra line a sorted list buys you · Bisection search · Formula card
45 minutes

Enough to write any of the five algorithms from a written specification, to write a recursive function over a list, and to trace a search or a sort loop on paper without running it.

The 60-second card · A function that calls itself · Linear search, and the one extra line a sorted list buys you · Bisection search · Bubble sort and selection sort · Merge sort · Scaffolding comes off · B · computation
full read

Everything, including why sorting first is worth paying for only after about fifteen lookups, what each algorithm costs with the counts measured rather than claimed, and the eight places students lose marks on this material.

The 60-second card · Recall first · Conventions · A function that calls itself · Linear search, and the one extra line a sorted list buys you · Bisection search · What each search costs · Bubble sort and selection sort · Merge sort · Paying for a sort once · Method boxes · Look-alike pairs · Scaffolding comes off · Full exam-style question · Practice set · Mistake ledger · Glossary
By the end of this section
  1. Write a function that solves a list problem by calling itself on a shorter piece, naming the base case first and making sure every path ends in a return.

  2. Implement linear search on a list in no particular order and on a list sorted ascending, and say what the second version may do on the first kind of list.

  3. Implement bisection search both as a while loop and as a recursive function, and trace the values of first, last and mid step by step.

  4. Count the comparisons each search makes in the best, average and , and name the complexity class of each.

  5. Write bubble sort and selection sort from a specification, trace one pass of each on paper, and say which one has finished after a given number of passes.

  6. Write the merge step and the recursive merge sort around it, and say what merge sort hands back and what it leaves untouched.

  7. Decide from the number of lookups whether sorting the list first pays for itself, and pick between sort, sorted and a dictionary for a given task.

Syllabus coverage

Simple Algorithms and Data Structures — covered

The two shapes every algorithm here uses: a loop over a list, or a function calling itself on a shorter piece of it. Base and recursive case, what a call costs in memory, how to turn one shape into the other. The data structure throughout is the list from the structured types week, plus the dictionary as a search-free lookup.

Search and Sort Algorithms — covered

  • Linear search on a list in no particular order and the early stop that a sorted list allows
  • bisection search as a loop and as a
  • bubble sort with the already sorted flag
  • selection sort
  • merge sort with its merge step and the comparison function passed in as a parameter, which is where the course introduces the one line
  • the built in sort and sorted

Chapter 10 — covered

The chapter's own argument, which is about cost rather than code: what each of these algorithms costs in comparisons, why the same problem has several algorithms with different costs, and the closing idea of , that the price of one sort is worth paying when many searches follow.

Hash tables — off syllabus

The last part of the chapter, where the dictionary is opened up and the hash function that gives it constant time lookup is written out.

The result is used on this page, because deciding whether to sort at all needs it: a dictionary lookup does not search. How the hash function itself is built is not developed here.

Recall first
Big O and the worst case

The cost of a program is counted in basic steps, not seconds, and only the fastest growing term is kept: keep the largest term of a sum and drop any constant multiplying it. So 1000 + x + 2x squared is O(x squared). The worst case is the largest number of steps over all inputs of a given size, and it is the case reported unless a block says best or average. The classes this page uses are O(1), O(log n), O(n), O(n log n) and O(n squared), in that order of growth.

Every claim on this page about one algorithm being better than another is a claim in these terms. Without the worst case convention the sentence linear search takes n steps is simply false, because sometimes it takes one.

List indexing, slicing and the list methods

len(L) is the number of items, and the valid indices run from 0 to len(L) - 1. L[i:j] is a NEW list holding items i up to but not including j; L[:] is a new list holding all of them. L.append(x) adds one item at the end and returns nothing. L.pop(i) removes the item at index i and returns it. x in L walks the list and gives True or False.

Every algorithm here is written with these and nothing else. The difference between a slice and the list itself is the difference between merge sort and bubble sort in memory.

A function, its parameters and its return

def f(a, b=0): gives b the value 0 when the caller leaves it out. A function that reaches the end of its body without a return hands back None. A parameter is a new name; assigning to it inside the function does not touch the caller's name, but changing the object it points at does.

The default parameter is how the recursive functions here hide their index from the first caller, and the missing return is the most common fault in a recursive function.

The less than sign on your own objects

Writing def __lt__(self, other): in a class decides what a < b means for two objects of that class. Once it is there, L.sort(), sorted(L), min and max all work on a list of those objects, because all of them ask the same question.

The lab this week sorts a list of objects. The sort code does not change at all; the only new line is in the class.

Try it yourself first (2 questions)
1§10.0 — a list handed to a function that sorts it

Second one. A function is given a list and sorts it. The caller prints its own name afterwards.

def tidy(values):
    """Sorts the list it was given."""
    values.sort()
    return len(values)

data = [4, 1, 3]
how_many = tidy(data)
print(how_many)
print(data)
Find(a) Write both lines of output.
Given
  • data starts as [4, 1, 3].

  • tidy calls sort on its parameter and returns the length.

IPython console
Hint 1/4

Two separate questions: what the function returns, and what the caller's list looks like after the call.

Hint 2/4

A parameter is a new name for the SAME object. Sorting through that name sorts the one list that both names point at.

Hint 3/4

So values and data are two names for [4, 1, 3]. The sort rearranges that one object, and len of it is 3.

Hint 4/4

The function returns 3, and data is now sorted.

Show solution

Bind the parameter

$$\texttt{tidy(data)}$$

The call gives the parameter values the same object data points at, not a copy.

$$\texttt{values.sort()}$$

Chosen by the author over sorted precisely because the caller is meant to see the change.

Read both names afterwards

$$\texttt{len(values)} = 3$$

Sorting never changes the length, so the return value is the starting length.

$$\texttt{data} \to [1, 3, 4]$$

The same object was rearranged, so the caller's name shows the sorted order.

Answer $$\boxed{3 \;/\; [1, 3, 4]}$$
Check

Independent check: replacing values.sort() with values = sorted(values) leaves data as [4, 1, 3], because that line rebinds the local name instead of touching the object.

2§10.0 — the midpoint of two indices

Third one, and the one that breaks a hand written bisection search. Two indices, and the two ways of halving the distance between them.

first = 3
last = 8
print((first + last) / 2)
print((first + last) // 2)
print(type((first + last) // 2))
Find(a) Write all three lines.
Given
  • first is 3 and last is 8.

  • One slash is division, two slashes are whole division.

IPython console
Hint 1/4

Three separate questions: the value with one slash, the value with two, and the type of the second one.

Hint 2/4

One slash always gives a float, even when the division comes out even. Two slashes divide and throw the fraction away, giving an int.

Hint 3/4

With first 3 and last 8 the sum is 11. Eleven over two is 5.5; eleven whole divided by two is 5.

Hint 4/4

5.5, then 5, then the type of 5.

Show solution

Divide both ways

$$(3 + 8) / 2 = 5.5$$

A single slash is true division, and its result is a float whatever the operands.

$$(3 + 8) // 2 = 5$$

Whole division drops the fraction. The value 5 is chosen over rounding to 6 because Python truncates towards minus infinity, and for positive numbers that is just dropping the tail.

Ask which one an index accepts

$$\texttt{type(5)} \to \texttt{int}$$

Whole division of two ints gives an int, and only an int can index a list.

Answer $$\boxed{5.5 \;/\; 5 \;/\; \texttt{<class 'int'>}}$$
Check

Independent check on the truncation rather than rounding: (3 + 10) // 2 gives 6 and not 7, although 13 over 2 is 6.5.

Notation
symbolreads asmeanswatch out
$n$

n

The number of items in the list an algorithm is working on.

It is the length of the list, never the value of an item in it. A list of three numbers has n equal to 3 even if one of the numbers is 900000.

$O(n)$

order n, or big O of n

An upper bound on how the number of steps grows as the list gets longer. O(n) says the steps grow in step with the list length.

O throws away constants and smaller terms, so 3n + 40 and n are both O(n). Two algorithms in the same class can still differ by a factor of ten on your machine.

$first, last, mid$

first, last, mid

The three index names in the bisection loop: the left end of the part still worth searching, the right end of it, and the index halfway between them.

last is an index, so it starts at len(L) - 1 and not at len(L). One wrong character here gives an IndexError on the second call rather than the first.

$//$

whole division, or floor division

Divides and throws the fraction away, giving an int. The midpoint is computed with it because a list index has to be a whole number.

A single slash gives a float, and a float cannot be used as an index. This is the single most common reason a hand written bisection search raises TypeError.

$L[i:j]$

L from i up to j

A new list holding the items of L from index i up to but not including index j. Merge sort splits a list with two of these.

It is a new list, so slicing inside a recursion copies. That is why merge sort uses more memory than the sorts that swap in place.

$L[:]$

all of L

A new list holding everything L holds. Merge sort returns this in its base case so that the caller never gets a second name for the caller's own list.

L[:] and L are different objects. Writing return L in the base case instead would hand back the very list that was passed in, and a later change would show up in both places.

$log_2 n$

log base two of n

How many times n can be halved before one item is left. For 20000 items that is 14.

Complexity classes are written O(log n) with no base, because changing the base only multiplies by a constant and O drops constants. The count of halvings is still base two.

Conventions used here
Every output block on this page is a real run.

No output here was predicted by eye. Every program was written to a file, run, and the characters it printed were copied back in. That is why one block below ends in a blank line: the program really printed an empty string there.

In a programming course the printed answer is the whole claim. A page that guesses output teaches the guess.

The tools this page keeps inside.

Numbers, text, True and False, if, while, for, range, len, def, return, default parameter values, lists with append, pop, remove, index, sort and sorted, slicing, dictionaries with keys and pop, files, tuples, two dimensional lists, and classes with __init__, private attributes, __lt__ and __repr__. Nothing else. No comprehension, no f string, no enumerate, no zip, no key= keyword on sort, no module beyond what the course has imported.

The lab sheets say to use only what the course has covered, and the exam is closed book with a fixed list of functions printed on the cover. A shortcut you cannot use in the exam is a shortcut that costs marks.

The one shortcut this week does add.

The one line lambda appears once, because the course's own merge sort carries compare = lambda x, y: x < y as a default parameter. Every solution on this page also shows the same thing written as a named def, and the named version is the one to copy in an exam answer unless the question asks for a lambda.

Leaving the lambda out would leave the course's own merge sort unreadable, and putting it everywhere would teach a form the rest of the course does not use.

Where the comparison counts come from.

Every count of comparisons on this page was produced by running the algorithm with a counter added to it, not by evaluating a formula. Where a formula and a count disagree the count is printed and the formula is called approximate.

A formula like n log n is a growth rate, not a promise about a particular list. The measured number is what the machine did.

Ascending order, and what happens to ties.

Sorted means ascending unless a block says otherwise: smallest first, and for text the order Python's less than sign gives, which puts every capital letter before every small one. Two items that compare equal keep the order they were in for merge sort and for the built in sort, and may not for the two swapping sorts, so no claim about ties is made for those.

A lab sheet that says sort by count, then by name for ties, is asking about exactly this, and a wrong tie rule is a wrong answer even when the list looks sorted.

In place against hands back a new list.

For every operation here two separate questions are answered: does it change the list you gave it, and what does it hand back. Bubble sort and selection sort change the list and hand back nothing. Merge sort changes nothing and hands back a new list. sort changes and hands back None; sorted changes nothing and hands back a list.

Most lost marks on this material are not about the algorithm at all, they are about which of these four a line of code did.

The lab this section is written for.

This page is the eighth lab: a class given an ordering, a container class whose data is private, a bubble sort over the objects in it that uses that ordering, and a recursive search over the same list that prints a report. The worked example near the end of the page is that whole shape with different data.

The lab sheet asks for a sort and a recursive search inside a class, not for either one on its own, and the joint is where the marks are.

The docstring every function here carries.

Every function and method on this page starts with a docstring in the course's form: what the parameters are assumed to be, then what it returns or what it changes. For a recursive function the docstring also says what the index parameter means, because that is the part a reader cannot guess.

The lab sheets ask for docstrings, and for a recursive function the docstring is what makes the recursive call readable.

10.1A function that calls itself: the base case and the recursive case

A function that hands the rest of the list back to itself, so the code only has to say what to do with one item.

The complexity week gave us a way to price a program. This week is about the programs, and three of the five need a shape we have not written.

Solvable with what we have
  • Walk a list with for i in range(len(L)).

  • Build an answer up in a variable across the passes of a loop.

  • Give a parameter a default value.

  • Split a list with L[:middle] and L[middle:].

Not solvable yet
  • Write the report the lab asks for, whose specification says the search must be recursive.

  • Sort by splitting a list in half and sorting each half the same way. No loop shape fits the middle step.

  • Write bisection search without a while loop.

The first worked example below is the honest first attempt: a function that adds up prices by taking the first one and asking itself for the rest. It raises IndexError: list index out of range.

Why it fails

The idea is right and the stopping is missing. Nothing in it says what the answer is when no items are left, so the calls run down to the empty list and then ask for prices[0] of it.

RuleRule 10.1: the two parts of a recursive function
Conditions
  • There is a smallest input for which the answer needs no work at all. For a list that is usually the empty list, or the single item at the end.

  • Every recursive call is made on something strictly smaller, so the smallest input is always reached.

  • Every branch of the function ends in a return, including the one that contains the recursive call.

$$\boxed{\begin{aligned}&\textbf{base case:}\ \ \texttt{if } \langle\text{smallest input}\rangle\texttt{: return } \langle\text{answer, no work}\rangle\\&\textbf{recursive case:}\ \ \texttt{return } \langle\text{one item}\rangle \oplus f(\langle\text{the rest}\rangle)\end{aligned}}$$

Say what the answer is when there is nothing left to do, and then say how to build the answer for the whole thing out of one item and the answer for everything except that item. Those two sentences are the whole function. You never write the middle.

Looks like this, but is not

This has a base case and a call to itself.

def total(prices, i=0):
    """Meant to add up prices from index i on."""
    if i == len(prices):
        return 0
    return prices[i] + total(prices, i)

The call passes i again instead of i + 1, so every call is handed the problem it was given and the base case can never be reached. Having a base case is not what makes a recursion finish; getting closer to it on every call is.

The total of a basket of prices, first without a base case and then with one

Add up a list of prices by taking the first one and asking the same function for the rest. Here is the attempt with the recursive line and nothing else.

def total(prices):
    """Adds up a list of prices. There is no base case."""
    return prices[0] + total(prices[1:])

basket = [2.5, 3.0, 1.5]
print(total(basket))

Run:

Traceback (most recent call last):
  File "basket.py", line 6, in <module>
    print(total(basket))
          ^^^^^^^^^^^^^
  File "basket.py", line 3, in total
    return prices[0] + total(prices[1:])
                       ^^^^^^^^^^^^^^^^^
  File "basket.py", line 3, in total
    return prices[0] + total(prices[1:])
                       ^^^^^^^^^^^^^^^^^
  File "basket.py", line 3, in total
    return prices[0] + total(prices[1:])
                       ^^^^^^^^^^^^^^^^^
  [Previous line repeated 1 more time]
IndexError: list index out of range

Now the same function with two lines added, and a third call on the empty list to prove the new lines work.

def total(prices):
    """Assumes prices is a list of numbers. Returns their sum."""
    if len(prices) == 0:
        return 0
    return prices[0] + total(prices[1:])

basket = [2.5, 3.0, 1.5]
print('items:', len(basket))
print('total:', total(basket))
print('empty basket:', total([]))

Sample Run:

items: 3
total: 7.0
empty basket: 0
FindWhy the first version stops with an error, and what the second one prints.
Given
  • basket is [2.5, 3.0, 1.5].

  • The recursive line is prices[0] + total(prices[1:]).

  • [1.5][1:] is the empty list [].

Solution

Follow the calls down to the empty list

$$[2.5, 3.0, 1.5] \to [3.0, 1.5] \to [1.5] \to []$$

Each call slices off the front, so the list really is getting shorter and the recursion is not circular.

$$\texttt{prices[0]}\ \text{on}\ []$$

There is no item 0 of an empty list, so the read raises IndexError. The fault is not in the recursive line; it is that the recursive line is the only line.

Add the line that answers for nothing

$$\texttt{if len(prices) == 0: return 0}$$

0 is the sum of no numbers, chosen over any other value because adding it to the rest changes nothing. It must come before the read, not after.

$$2.5 + (3.0 + (1.5 + 0)) = 7.0$$

The additions all happen on the way back up, once the base case has handed its 0 to the innermost waiting call.

$$\texttt{total([])} = 0$$

The call that exercises the new line on its own. A recursive function should always be tested on the input its base case describes.

Answer $$\boxed{\texttt{IndexError}\ \text{first};\ \texttt{items: 3},\ \texttt{total: 7.0},\ \texttt{empty basket: 0}}$$
Check

Independent check of the total without the recursion: 2.5 plus 3.0 is 5.5 and plus 1.5 is 7.0, which is what printed.

Four calls for three prices, and each one slices the tail, so the copying costs about n squared over 2 item moves. An index parameter avoids that and is what the next example uses.

This is the loop the hook of the section does not close, so close it here: the missing piece was never the recursive call, it was the sentence about what to answer when there is nothing left.

factorial of 5, written as a base case and one multiplication

Write factorial(n) without a loop. The mathematics already has the two sentences in it: 1 factorial is 1, and n factorial is n times (n minus 1) factorial.

def factorial(n):
    """Assumes n is an int and n >= 0. Returns n!."""
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n - 1)

value = 5
print('factorial of', value, 'is', factorial(value))

Sample Run:

factorial of 5 is 120

To see the four calls that answer actually took, the same function with a print on the way in and a print on the way out.

def factorial(n):
    """Assumes n is an int and n >= 0. Returns n!."""
    print('call   factorial(' + str(n) + ')')
    if n == 0 or n == 1:
        print('base   factorial(1) gives 1')
        return 1
    answer = n * factorial(n - 1)
    print('return factorial(' + str(n) + ') gives', answer)
    return answer

result = factorial(4)
print('result:', result)

Sample Run:

call   factorial(4)
call   factorial(3)
call   factorial(2)
call   factorial(1)
base   factorial(1) gives 1
return factorial(2) gives 2
return factorial(3) gives 6
return factorial(4) gives 24
result: 24
FindThe value of factorial(5), and the order the calls finish in.
Given
  • n is an int and not negative.

  • 0 factorial and 1 factorial are both 1.

Solution

Name the input that needs no work

$$\texttt{if n == 0 or n == 1: return 1}$$

Both are base cases, and both are needed: 0 is chosen as well as 1 because factorial(0) is a legal call and with only the n == 1 test it would fall through to n times factorial(-1).

Say how the whole answer is built from one step

$$\texttt{return n * factorial(n - 1)}$$

The one item is the factor n; the rest is the same problem one size smaller. Written with n - 1 rather than n // 2 because the base case is one step away from every value, so shrinking by one reaches it.

$$\texttt{factorial(5)} = 5 \cdot \texttt{factorial(4)}$$

The first call cannot finish until the deeper one does.

$$= 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1 = 120$$

Once the base case returns 1, each waiting call multiplies and returns, innermost first.

Answer $$\boxed{\texttt{factorial of 5 is 120}}$$
Check

Independent check without recursion: 5 factorial is the product of 1 through 5, which a loop gives as 1, 2, 6, 24, 120.

Five calls for n equal to 5, and every one of them is alive at the same time. The number of calls is n, so this is O(n) in time and, unlike the loop version, O(n) in memory too.

The order of the two prints in the traced version is the thing to carry: every call goes all the way down before any multiplication happens. A recursive function that prints before its call prints top down, and one that prints after its call prints bottom up.

A recursive report over a stock list, the shape the lab asks for

A shop keeps two lists of the same length: the product names and their counts. Print one line for every product whose count is below a limit, saying how many to order. The specification says the search must be recursive, so there is no for anywhere.

def find_low(counts, names, limit, i):
    """Assumes counts and names are lists of the same length, limit an int,
    i an index. Prints every name whose count is below limit, from index i on."""
    if i == len(counts):
        return
    if counts[i] < limit:
        print(limit - counts[i], 'of', names[i], 'must be ordered')
    find_low(counts, names, limit, i + 1)

names = ['gofret', 'benimo', 'simit', 'metro']
counts = [4, 5, 5, 7]
find_low(counts, names, 6, 0)

Sample Run:

2 of gofret must be ordered
1 of benimo must be ordered
1 of simit must be ordered
FindThe lines printed, and why the function needs an index parameter.
Given
  • names is ['gofret', 'benimo', 'simit', 'metro'].

  • counts is [4, 5, 5, 7], in the same order.

  • The limit is 6, and the call starts at index 0.

Solution

Turn the index into the shrinking thing

$$\texttt{find\_low(counts, names, limit, i)}$$

The list itself is not sliced. An index is added as a parameter and that is what shrinks, chosen over counts[1:] because slicing copies the list on every call and here nothing is returned anyway.

$$\texttt{if i == len(counts): return}$$

The base case is reaching the end. A bare return is right because the function reports by printing and has no value to hand back.

Do the work for one index, then pass the rest on

$$\texttt{if counts[i] < limit:}$$

The test is on one item only. Whether any other item is low is not this call's business.

$$\texttt{print(limit - counts[i], 'of', names[i], ...)}$$

The shortfall is limit minus count, so for gofret it is 6 - 4 = 2. Both lists are read at the same index, which is what keeps the name attached to the right count.

$$\texttt{find\_low(counts, names, limit, i + 1)}$$

Last line, and no return in front of it on purpose: there is nothing to return. In a function that computes a value this missing return would be the bug in the next box down.

Answer $$\boxed{\texttt{2 of gofret}\ /\ \texttt{1 of benimo}\ /\ \texttt{1 of simit}}$$
Check

Independent check by eye against the data rather than the code: the counts below 6 are 4, 5 and 5, sitting at indices 0, 1 and 2, and metro's 7 is not below 6.

One call per item, so four calls for four products. Same cost as the loop; the recursion buys nothing here except that the specification asked for it.

This is the pattern for every recursive pass over a list in this course: an index parameter, a base case that compares it with len, the work for one index, then the same call with i + 1.

Checkpoint
§10.1 — where a recursive print lands

Thirty seconds. The function prints before it calls itself, and the base case prints as well.

def shrink(word):
    """Prints the word, then the word with its first letter removed, and so on."""
    if len(word) == 0:
        print('done')
        return
    print(word)
    shrink(word[1:])

shrink('sort')
Find(a) Write every line it prints, in order.
Given
  • The call is shrink('sort').

  • word[1:] is the word without its first letter.

  • 'sort'[1:] is 'ort', and ''[1:] is ''.

IPython console
Hint 1/4

You need the sequence of words the calls are made with, and then which of them reach a print.

Hint 2/4

The print comes before the recursive call, so a word is printed on the way down. The base case has its own print, so it appears too.

Hint 3/4

Starting from 'sort', the calls are made with 'sort', 'ort', 'rt', 't', ''. The first four have a positive length.

Hint 4/4

Four words on four lines, then the word done from the base case.

Show solution

List the arguments the calls are made with

$$\texttt{'sort'} \to \texttt{'ort'} \to \texttt{'rt'} \to \texttt{'t'} \to \texttt{''}$$

Each call drops the first letter, so the length falls by one and the empty string is reached after four calls.

Read the print in each call

$$\texttt{len(word) > 0} \Rightarrow \texttt{print(word)}$$

The first four calls take the second branch and print their own word before going deeper.

$$\texttt{len('') == 0} \Rightarrow \texttt{print('done')}$$

The fifth call takes the base case, prints and returns without calling again.

Answer $$\boxed{\texttt{sort, ort, rt, t, done}\ \text{on five lines}}$$
Check

Independent check on the count: the word has four letters, one line per letter is four, plus one line from the base case is five.

Print before the call and you read the input shrinking. Print after the call and you would read the same words in the opposite order.

⚠ No base case at all

The recursive line is the interesting one and gets written first. The stopping line has nothing to do with the idea, so it feels like boilerplate and gets left out.

wrong$$\texttt{def total(prices):}\;\texttt{return prices[0] + total(prices[1:])}$$
right$$\texttt{if len(prices) == 0: return 0}\;\text{first, then the recursive line}$$
⚠ The recursive call with no return in front of it

A recursive call looks like a statement, because in a printing function like the stock report it is one. In a function that computes a value it is part of an expression that has to be returned.

wrong$$\texttt{count\_down(n - 1)}$$
right$$\texttt{return count\_down(n - 1)}$$
⚠ A call that does not get closer to the base case

The index or the slice is copied from the parameter list without the change. The function then looks complete: it has both parts, and it still never stops.

wrong$$\texttt{return f(L, i)}$$
right$$\texttt{return f(L, i + 1)}$$

Walk the list from the front and compare; only a sorted list lets you stop before the end and still be right.

With the recursive shape in hand, here is the first of the two searches, the one that asks nothing at all of the list it is given.

RuleRule 10.2: linear search, and the early stop
Conditions
  • The items can be compared with ==. Nothing else is required, so this works on a list in any order and on items of any type.

  • The early stop line is legal only when the list is sorted ascending. On any other list it may return a wrong answer, and it will not raise an error while doing so.

$$\boxed{\begin{aligned}&\textbf{any list:}&&\texttt{for i in range(len(L)):}\\& &&\quad\texttt{if L[i] == e: return True}\\& &&\texttt{return False}\\[2pt]&\textbf{sorted only:}&&\quad\texttt{if L[i] > e: return False}\end{aligned}}$$

Look at each item in turn. The moment one of them equals what you want, answer yes and leave. If you get to the end without a match, answer no. If the list happens to be sorted, you may also answer no as soon as an item is bigger than what you want, because everything after it is bigger still.

Looks like this, but is not

The sorted version is shorter and faster, and it looks like a strict improvement. Here it is on a list nobody sorted, looking for a value that is sitting in it.

def search(L, e):
    """The sorted-list version, with the early stop."""
    for i in range(len(L)):
        if L[i] == e:
            return True
        if L[i] > e:
            return False
    return False

unsorted = [34, 6, 16, 35, 1]
print('16 is in the list:', 16 in unsorted)
print('search says:      ', search(unsorted, 16))

Run:

16 is in the list: True
search says:       False

in says 16 is there and the function says it is not. The first item is 34, which is bigger than 16, so the early stop fires on comparison one and the other four items are never looked at. The line if L[i] > e: return False is not an optimisation of linear search, it is a different function with a precondition.

looking forin the listno early exitstops at the first match

34, at index 0

yes

5

1

7

no

5

5

Stopping at the first match helps a lot when the item is near the front and not at all when the item is absent, because absence can only be established by looking at everything. That is why both versions are called O(n): the worst case is the same, and only the lucky cases differ.

linear_search on a list in no particular order, with and without the early exit

The lecture's version sets a flag and keeps going to the end. Write it, then write the version that leaves the moment it finds the item, and count the comparisons each one makes.

def linear_search(L, e):
    """Assumes L is a list, e any value. Returns True if e is in L."""
    found = False
    for i in range(len(L)):
        if e == L[i]:
            found = True
    return found

plates = [34, 6, 16, 35, 1]
print(linear_search(plates, 16))
print(linear_search(plates, 7))

Sample Run:

True
False

Now both, with a counter, so the difference is a number rather than an opinion.

def linear_search(L, e):
    """Returns (found, comparisons) with no early exit."""
    found = False
    comparisons = 0
    for i in range(len(L)):
        comparisons += 1
        if e == L[i]:
            found = True
    return found, comparisons

def linear_search_early(L, e):
    """Returns (found, comparisons), stopping at the first match."""
    comparisons = 0
    for i in range(len(L)):
        comparisons += 1
        if e == L[i]:
            return True, comparisons
    return False, comparisons

plates = [34, 6, 16, 35, 1]
print('no early exit, looking for 34:', linear_search(plates, 34))
print('early exit,    looking for 34:', linear_search_early(plates, 34))
print('no early exit, looking for  7:', linear_search(plates, 7))
print('early exit,    looking for  7:', linear_search_early(plates, 7))

Sample Run:

no early exit, looking for 34: (True, 5)
early exit,    looking for 34: (True, 1)
no early exit, looking for  7: (False, 5)
early exit,    looking for  7: (False, 5)
FindWhat each version returns, and how many comparisons each makes.
Given
  • The list is [34, 6, 16, 35, 1], in no particular order.

  • Two targets are tried: 34, which sits at index 0, and 7, which is absent.

Solution

Write the version that cannot be wrong about anything

$$\texttt{found = False}$$

The answer starts as no, so that a list with no match needs no special case at the end.

$$\texttt{for i in range(len(L)):}$$

Indices rather than items, chosen because the lab specifications in this course are written in terms of positions and because the sorted version below needs the index too.

$$\texttt{if e == L[i]: found = True}$$

Recording the match rather than leaving. This is the lecture's form and it is correct, just not thrifty.

Replace the flag with an immediate answer

$$\texttt{if L[i] == e: return True}$$

Returning from inside the loop ends the function, so no later item can change the answer. Safe because a match is final: nothing found later could make the answer no.

$$\texttt{return False}$$

Reached only when the loop ran out, which is exactly the case where the answer is no. It must be outside the loop; inside, it would answer after the first item.

Read the counts

$$34 \Rightarrow 5 \text{ against } 1$$

The target is first, so the thrifty version stops after one comparison and the flag version still walks all five.

$$7 \Rightarrow 5 \text{ against } 5$$

An absent value cannot be ruled out early on an unsorted list, so both versions pay the full price.

Answer $$\boxed{\texttt{True}\ /\ \texttt{False};\ 5\ \text{against}\ 1\ \text{comparisons for a hit at the front}}$$
Check

Independent check on the counter itself: for the absent value 7 both versions report 5, and the list has 5 items, so the counter is counting comparisons and not something else.

Worst case n comparisons for both, 1 for the thrifty one and n for the flag one. Both are O(n), because O reports the worst case.

Returning early changes the best case and not the class. When a question asks for the complexity of linear search the answer is O(n) either way; when it asks how many comparisons for this list and this target, the early exit matters.

Checkpoint
§10.2 — how many items a search looked at

Thirty seconds. The function returns the number of items it looked at before it could answer.

def linear_search(L, e):
    """Returns how many items it looked at before it answered."""
    looked = 0
    for i in range(len(L)):
        looked += 1
        if L[i] == e:
            return looked
    return looked

rooms = [204, 111, 307, 204, 118]
print(linear_search(rooms, 204))
print(linear_search(rooms, 118))
print(linear_search(rooms, 500))
Find(a) Write the three numbers it prints.
Given
  • The list is [204, 111, 307, 204, 118], in no particular order.

  • 204 appears twice, at index 0 and at index 3.

  • The three targets are 204, 118 and 500.

IPython console
Hint 1/4

For each target, the question is at which index the function first gets to return, or whether it runs off the end.

Hint 2/4

The counter goes up once per item examined, and the return inside the loop fires on the first match, so a match at index i costs i + 1 looks.

Hint 3/4

The list is [204, 111, 307, 204, 118]. 204 matches at index 0. 118 matches at index 4. 500 matches nowhere.

Hint 4/4

One look for 204, five for 118, five for 500.

Show solution

Locate each target

$$204 \to \text{index } 0$$

First item, so the loop returns on its first turn.

$$118 \to \text{index } 4$$

Last item, so every earlier item was looked at first.

$$500 \to \text{absent}$$

No match, so the loop runs out and the count after the loop is the full length.

Turn an index into a count

$$\text{count} = \text{index} + 1$$

The counter is increased before the test, so a match at index i has already counted i + 1 looks.

Answer $$\boxed{1 \;/\; 5 \;/\; 5}$$
Check

Independent check on the last two: a present item at the end and an absent item cost the same, 5, which is the list length.

An absent value always costs the full list on an unsorted list. That single fact is the whole argument for sorting.

⚠ Answering no from inside the loop

The two branches look symmetric, so an else: return False next to the return True reads as tidy. It is not symmetric: one match settles the question, one mismatch settles nothing.

wrong$$\texttt{if L[i] == e: return True}\;\texttt{else: return False}$$
right$$\texttt{if L[i] == e: return True}\;\text{inside, }\texttt{return False}\;\text{outside}$$
⚠ Using the early stop on a list that is not sorted

The line comes from the same box as the rest of the function and looks like part of it. The precondition lives in the docstring, and the docstring is the first thing dropped when code is copied.

wrong$$\texttt{if L[i] > e: return False}\;\text{on }\texttt{[34, 6, 16, 35, 1]}$$
right$$\text{sort first, or drop the line and pay the full }n$$
⚠ Setting the flag and carrying on when the answer is already known

The lecture's own first version does this, and it is correct, so it gets copied into places where the cost matters. It only becomes a real fault inside a loop that searches many times.

wrong$$\texttt{found = True}\;\text{then keep looping}$$
right$$\texttt{return True}\;\text{at the match}$$

Compare with the middle item of what is left and drop the half that cannot contain the target.

Sortedness bought linear search half the average and nothing in the worst case. Here is what the same sortedness is really worth.

RuleRule 10.3: bisection search on a sorted list
Conditions
  • The list must be sorted ascending. On any other list the function returns a wrong answer and raises nothing.

  • last is an index, so it starts at len(L) - 1. The loop condition is first <= last with the equals sign, because a window of one item still has to be looked at.

  • The two window ends move past mid, to mid - 1 and mid + 1. Moving them to mid itself leaves the window the same size and the loop never ends.

$$\boxed{\begin{aligned}&\texttt{first = 0},\quad \texttt{last = len(L) - 1}\\&\texttt{while first <= last:}\\&\quad \texttt{mid = (first + last) // 2}\\&\quad \texttt{e < L[mid]}\ \Rightarrow\ \texttt{last = mid - 1}\\&\quad \texttt{e > L[mid]}\ \Rightarrow\ \texttt{first = mid + 1}\\&\quad \text{equal}\ \Rightarrow\ \texttt{return mid}\\&\texttt{return -1}\end{aligned}}$$

Keep two indices marking the stretch of the list that could still hold the target. Look at the item halfway between them. If the target is smaller, the answer cannot be to the right of that item, so move the right end just left of it. If the target is larger, move the left end just right of it. If it is equal, you are done. When the two ends cross, the stretch is empty and the target was never there.

Proof

Why the number of comparisons is the number of halvings. Start with a window of n items. Each turn of the loop looks at one item and then keeps at most half of what was left, because mid is the middle and one of the two sides is dropped along with mid itself.

So after k turns the window holds at most n divided by 2 to the power k items. The loop can only continue while the window holds at least one item, so it stops by the time n over 2 to the k reaches 1.

Solving 1 = n over 2 to the k gives 2 to the k equals n, that is k equals log base 2 of n. The count of comparisons is that many plus one, because the last window of one item is also compared.

Checked against a run rather than left as algebra: 20000 items can be halved 14 times, and the measured worst case for 20000 items is 15 comparisons, which is 14 plus one.

Looks like this, but is not

Bisection search on a list of room numbers that nobody sorted. The list is a perfectly good list and the function is the function from the box, unchanged.

def binary_search(L, e):
    """Assumes L is sorted ascending. Returns the index of e, or -1."""
    first = 0
    last = len(L) - 1
    while first <= last:
        mid = (first + last) // 2
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid
    return -1

room = [12, 45, 3, 88, 27]
print(binary_search(room, 27))
print(27 in room)
print(binary_search(room, 12))
print(12 in room)

Run:

-1
True
-1
True

Two values that are in the list are reported as absent, and in disagrees with the search on both. Nothing is broken; the function did exactly what it promises, which is to answer correctly for a sorted list. Sortedness is not a nice to have here the way it was for linear search.

binary_search for 156, 102 and 150 in a sorted list of nine ids

Write the loop version. It should return the index of the value, or -1 when the value is absent. Then run the same function with the window printed at every turn so the three values of mid are visible.

def binary_search(L, e):
    """Assumes L is a list sorted in ascending order.
    Returns the index of e in L, or -1 if e is not in L."""
    first = 0
    last = len(L) - 1
    while first <= last:
        mid = (first + last) // 2
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid
    return -1

ids = [102, 118, 125, 137, 140, 156, 171, 188, 190]
print('index of 156:', binary_search(ids, 156))
print('index of 102:', binary_search(ids, 102))
print('index of 150:', binary_search(ids, 150))

Sample Run:

index of 156: 5
index of 102: 0
index of 150: -1

The same search for 188, with the window printed before every comparison.

def binary_search(L, e):
    """Prints the window it is still searching before every comparison."""
    first = 0
    last = len(L) - 1
    while first <= last:
        mid = (first + last) // 2
        print('first', first, 'last', last, 'mid', mid, 'L[mid]', L[mid])
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid
    return -1

ids = [102, 118, 125, 137, 140, 156, 171, 188, 190]
print('answer:', binary_search(ids, 188))

Sample Run:

first 0 last 8 mid 4 L[mid] 140
first 5 last 8 mid 6 L[mid] 171
first 7 last 8 mid 7 L[mid] 188
answer: 7
FindThe three returned values, and the values mid takes for 188.
Given
  • The list is [102, 118, 125, 137, 140, 156, 171, 188, 190], sorted ascending, nine items.

  • Three targets: 156 at index 5, 102 at index 0, and 150 which is absent.

Solution

Set the window to the whole list

$$\texttt{first = 0},\ \texttt{last = 8}$$

last is len(L) - 1 and not len(L), because it is an index into the list and 9 is not one.

Halve until the window is empty

$$\texttt{mid = (0 + 8) // 2 = 4},\ \texttt{L[4] = 140}$$

Whole division, so mid is an int. For an even sized window it lands on the lower of the two middle items, which is fine: either one splits the list.

$$188 > 140 \Rightarrow \texttt{first = 5}$$

Everything from index 0 to 4 is at most 140, so none of it can be 188. Five of the nine items are gone after one comparison.

$$\texttt{mid = (5 + 8) // 2 = 6},\ \texttt{L[6] = 171}$$

The same rule applied to the window that is left.

$$188 > 171 \Rightarrow \texttt{first = 7}$$

Indices 5 and 6 are now gone as well.

$$\texttt{mid = (7 + 8) // 2 = 7},\ \texttt{L[7] = 188}$$

Equal, so the function returns 7. Three comparisons for nine items.

Check the case with no answer

$$e = 150:\ \texttt{first} > \texttt{last}$$

The window closes without a match, the loop condition fails, and the return -1 after the loop is reached. That return must be outside the loop; inside it, the search would give up after one comparison.

Answer $$\boxed{5 \;/\; 0 \;/\; -1}$$
Check

Independent check without the algorithm: L[5] is 156 and L[0] is 102, so the two returned indices really do hold the values asked for, and 150 is not among the nine numbers listed.

Three comparisons for nine items. Nine can be halved three times, and 2 to the power 3 is 8, just under 9.

The return value is an index, not True or False. That is worth more: an index lets the caller read, change or delete the item, and -1 is the agreed way of saying there is none.

The same search written recursively, with start and end as parameters

Write bisection search again with no loop. The window ends become parameters, and dropping half the list becomes a call on a narrower window.

def binary_search(arr, s_val, start, end):
    """Assumes arr is sorted ascending, start and end are indices.
    Returns the index of s_val between start and end, or -1."""
    if start > end:
        return -1
    mid = (start + end) // 2
    if arr[mid] == s_val:
        return mid
    elif arr[mid] > s_val:
        return binary_search(arr, s_val, start, mid - 1)
    else:
        return binary_search(arr, s_val, mid + 1, end)

ids = [102, 118, 125, 137, 140, 156, 171, 188, 190]
print('index of 125:', binary_search(ids, 125, 0, len(ids) - 1))
print('index of 999:', binary_search(ids, 999, 0, len(ids) - 1))

Sample Run:

index of 125: 2
index of 999: -1
FindWhat plays the part of the loop, and what plays the part of the final return -1.
Given
  • The list is the same nine sorted ids.

  • The first call is made with start 0 and end len(ids) - 1.

  • Targets: 125, which is at index 2, and 999, which is absent.

Solution

Turn the loop condition into a base case

$$\texttt{if start > end: return -1}$$

The loop ran while first <= last, so it stopped when the ends crossed. The same crossing is the base case here, and it carries the same answer.

Turn the two window updates into two calls

$$\texttt{arr[mid] > s\_val} \Rightarrow \texttt{return binary\_search(arr, s\_val, start, mid - 1)}$$

Instead of assigning to last and going round again, the narrower window is handed to a new call. The return in front is essential: the answer comes back from the deeper call and has to be passed on.

$$\texttt{else} \Rightarrow \texttt{return binary\_search(arr, s\_val, mid + 1, end)}$$

The other half. Both calls shrink the window, so the crossing base case is always reached.

Read the two answers

$$125 \Rightarrow 2$$

Same index the loop version gives, because it is the same algorithm with the state moved into parameters.

$$999 \Rightarrow -1$$

The target is above everything, so start climbs past end and the base case answers.

Answer $$\boxed{2 \;/\; -1}$$
Check

Independent check against the loop version: both versions were run on the same nine item list and returned the same index for a present value and -1 for an absent one.

The same number of comparisons as the loop, so both are O(log n) in time. The recursive one also keeps one call frame per comparison, so its memory is O(log n) where the loop's is O(1).

Whenever a loop keeps a couple of index variables and narrows them, it can be rewritten as a recursion by making those variables parameters. The loop condition becomes the base case, inverted.

Checkpoint
§10.3 — three windows and the answer

Thirty seconds. The function prints first, last and mid before each comparison, and the target is the very first item of the list.

def binary_search(L, e):
    """Prints first, last and mid before every comparison."""
    first = 0
    last = len(L) - 1
    while first <= last:
        mid = (first + last) // 2
        print(first, last, mid)
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid
    return -1

marks = [12, 25, 33, 41, 58, 64, 77]
print('answer', binary_search(marks, 12))
Find(a) Write every line, including the last one.
Given
  • The list is [12, 25, 33, 41, 58, 64, 77], sorted ascending, seven items.

  • The target is 12, which sits at index 0.

  • mid is (first + last) // 2, whole division.

IPython console
Hint 1/4

The item is at the front, and this search does not start at the front. So the question is how many windows it takes to walk down to index 0.

Hint 2/4

Each turn prints the window and then moves one end past mid. The target is below L[mid] every time, so last is the end that moves.

Hint 3/4

Start with first 0 and last 6. The list is [12, 25, 33, 41, 58, 64, 77], so L[3] is 41 and L[1] is 25.

Hint 4/4

Three windows, then the returned index on a fourth line.

Show solution

Turn 1

$$\texttt{(0 + 6) // 2 = 3},\ \texttt{L[3] = 41}$$

The middle of the whole list.

$$12 < 41 \Rightarrow \texttt{last = 2}$$

Indices 3 to 6 are all at least 41, so they go, and so does index 3 itself.

Turns 2 and 3

$$\texttt{(0 + 2) // 2 = 1},\ \texttt{L[1] = 25}$$

The middle of the window that is left.

$$12 < 25 \Rightarrow \texttt{last = 0}$$

The window is now a single item, index 0 to 0.

$$\texttt{(0 + 0) // 2 = 0},\ \texttt{L[0] = 12}$$

Equal, so the function returns 0. This turn only happens because the loop condition uses <= and not <.

Answer $$\boxed{\texttt{0 6 3},\ \texttt{0 2 1},\ \texttt{0 0 0},\ \texttt{answer 0}}$$
Check

Independent check on the number of turns: seven items can be halved twice to get below one, so the worst case is three comparisons, and three windows were printed.

A window of one item is still a window. Every off by one bug in this algorithm is about whether that last window gets looked at.

⚠ last starting at len(L) instead of len(L) - 1

len(L) is the number people say out loud, and range(len(L)) is written that way everywhere else. Here the name holds an index, not a count.

wrong$$\texttt{last = len(L)}$$
right$$\texttt{last = len(L) - 1}$$
⚠ while first < last, without the equals sign

It reads as a sensible guard, and it even works for most targets, so a quick test passes. It skips the case where the window has shrunk to exactly one item.

wrong$$\texttt{while first < last:}$$
right$$\texttt{while first <= last:}$$
⚠ Moving a window end to mid rather than past it

last = mid looks tighter and safer than last = mid - 1, as though it were the cautious choice. The item at mid has already been compared and cannot be the answer, so keeping it means the window can stop shrinking.

wrong$$\texttt{last = mid}\;/\;\texttt{first = mid}$$
right$$\texttt{last = mid - 1}\;/\;\texttt{first = mid + 1}$$

Linear search costs the list length at worst and bisection search costs the number of times the list can be halved.

Both searches are written. Before the sorts, put a price on them, because the price is what the exam asks about and it is what decides which one to use.

TheoremResult 10.4: the cost of the two searches
Conditions
  • Counted in comparisons of one list item against the target. Every other step in both loops happens a fixed number of times per comparison, so the comparison count and the step count grow together.

  • n is the length of the list. The for linear search assumes the target is present and equally likely to be at any position.

$$\boxed{\begin{aligned}&\text{linear search}&&\text{best } 1,\ \text{average } n/2,\ \text{worst } n&&O(n)\\&\text{bisection search}&&\text{best } 1,\ \text{worst } \log_{2} n + 1&&O(\log n)\end{aligned}}$$

Walking a list costs, at worst, one look per item, and on average half of that. Halving a list costs one look per halving, and a list can only be halved as many times as the power of two that reaches its length. That is why a hundred thousand items cost a hundred thousand looks one way and seventeen the other.

Looks like this, but is not

It is tempting to read O(log n) beats O(n) as bisection search is faster. Here is a case where it is not. The list has seven sorted items and the target is the first one.

Linear search compares L[0] with the target and returns: one comparison. Bisection search starts in the middle at index 3 and has to walk down, which the check above measured at three comparisons.

O describes how the cost grows, not what the cost is for one particular list and one particular target. For small n, and for targets near the front, linear search can win. The two lines in the figure would cross somewhere on the left if the plot went low enough.

nlinear search, worstbisection search, worsthalvings of n

10

10

4

3

100

100

7

6

1000

1000

10

9

10000

10000

14

13

100000

100000

17

16

Read the last two columns together: the measured worst case is always the number of halvings plus one, which is the count the derivation in the box predicted. Read the first two columns together: ten times the data costs ten times the work one way and three more comparisons the other. Between the first row and the last, the list grew ten thousand fold and bisection search's bill went from 4 to 17.

How many comparisons for the 20000 codes in the hook

The till program at the top of the page held 20000 sorted codes and spent 79994 comparisons on four lookups. Work out what bisection search would spend, from the halving count rather than from a library.

# How many times can 20000 be halved before only one item is left?
size = 20000
halvings = 0
while size > 1:
    size = size // 2
    halvings += 1
print('halvings:', halvings)
print('2 to that power:', 2 ** halvings)

Sample Run:

halvings: 14
2 to that power: 16384

And the same four lookups done by bisection search, counted the same way the hook counted its own.

# Same list, same four codes, but each lookup throws away half the list.
codes = list(range(0, 40000, 2))
wanted = [39998, 39996, 39994, 39992]
comparisons = 0
for w in wanted:
    first = 0
    last = len(codes) - 1
    while first <= last:
        mid = (first + last) // 2
        comparisons += 1
        if codes[mid] == w:
            break
        elif codes[mid] > w:
            last = mid - 1
        else:
            first = mid + 1
print('comparisons for the same 4 lookups:', comparisons)

Sample Run:

comparisons for the same 4 lookups: 56
FindThe worst case comparisons per lookup, and the total for four.
Given
  • The list holds 20000 codes, sorted ascending.

  • Four lookups are made, all near the end of the list.

  • Linear search spent 79994 comparisons on them.

Solution

Count the halvings

$$20000 \to 10000 \to 5000 \to \cdots \to 1$$

Whole division each time, so the count is exact rather than fractional. A loop does this in three lines and needs no logarithm function.

$$14 \text{ halvings},\quad 2^{14} = 16384$$

16384 is under 20000 and 2 to the 15 is 32768, which is over, so 14 is right and log base 2 of 20000 lies between 14 and 15.

Turn halvings into comparisons

$$14 + 1 = 15 \text{ comparisons at worst}$$

The plus one is the last window, which holds a single item and still has to be compared.

$$4 \times 15 = 60 \text{ at worst}$$

Four independent lookups, so the worst cases simply add.

Compare with what the linear version paid

$$79994 / 56 \approx 1428$$

The measured bisection total is 56 rather than the worst case 60, because three of the four targets were found before the window closed. Either way the linear program did over fourteen hundred times the work.

Answer $$\boxed{15 \text{ per lookup at worst},\ 56 \text{ measured for four}}$$
Check

Independent check from the other direction: the largest list a 15 comparison bisection search can handle is 2 to the 15 minus 1, which is 32767, and 20000 is under that.

Both programs read the same list in the same order with the same list operations. The only difference is which index is looked at next.

The claim in the hook, fewer than sixty comparisons, was not a guess: it is four times the halving count plus one, and the measured run came in at 56.

Checkpoint
§10.4 — the cost of a doubled list

Thirty seconds. A sorted list of 1000 names is searched by bisection search, and the measured worst case is 10 comparisons. The list then grows to 2000 names, still sorted.

Find(a) What is the worst case now?
Given
  • The measured worst case for 1000 items is 10 comparisons.

  • The list doubles in length and stays sorted.

  • The same bisection search is used.

Hint 1/4

The question is how many extra halvings a doubled list needs, not how much longer the list is.

Hint 2/4

The worst case is the number of halvings plus one, and doubling n adds exactly one halving, because 2 times n halved once is n again.

Hint 3/4

The old list was 1000 items and cost 10. The new one is 2000 items, which halves once down to 1000.

Hint 4/4

One more comparison than before.

Show solution

Relate the two windows

$$2000 \xrightarrow{\text{1 comparison}} 1000$$

One comparison on the 2000 item window discards half of it, leaving 1000 items.

$$10 + 1 = 11$$

The rest of the work is exactly the 1000 item problem, whose cost is given as 10.

Answer $$\boxed{11}$$
Check

Independent check against the measured table: 1000 items cost 10 and 10000 items cost 14.

Multiply the data by a constant and an O(log n) cost goes up by a constant. Multiply it by a constant and an O(n) cost is multiplied by that constant.

⚠ Writing O(n/2) or O(2n)

The measured average really is about n over 2, so writing it down feels more precise. O has already thrown constants away, so the extra precision is not extra information, it is a different notation.

wrong$$O(n/2)\;\text{or}\;O(2n)$$
right$$O(n)$$
⚠ Calling the whole task O(log n) when the list had to be sorted first

The search really is O(log n), and the sentence use bisection search, it is O(log n) is true about the search. The sort that made it legal is O(n log n) and belongs in the same bill.

wrong$$\text{sort} + \text{one search} = O(\log n)$$
right$$\text{sort} + \text{one search} = O(n\log n)$$
⚠ Counting the lines of the loop body instead of the turns

The body of the bisection loop has four or five lines, so a count of 5 log n appears. It is not wrong arithmetic, it is a constant multiple, and reporting it as the complexity mixes a step count with a growth class.

wrong$$O(5\log n)$$
right$$O(\log n)$$

10.5Bubble sort and selection sort: swapping the list into order where it lies

Two sorts that rearrange the list you handed in and return nothing, both paying about n squared over 2 comparisons.

Bisection search needs a sorted list, and so far we have only ever been handed one. Here is the first way of making one, and the price of it.

MethodRule 10.5: the two in place sorts
Conditions
  • Both change the list they are given and return nothing. Calling one of them on the right of an assignment stores None.

  • Both need only the less than or greater than sign on the items, so both work on numbers, on text, and on objects of a class that defines the comparison.

  • Bubble sort's inner range must be len(data) - j - 1. The minus one is because the loop compares data[k] with data[k + 1], and the minus j is because the last j items are already in place.

$$\boxed{\begin{aligned}&\textbf{bubble}&&\text{pass over the list, swap any neighbours out of order};\\& &&\text{each pass carries one largest item to the end}\\&\textbf{selection}&&\text{find the smallest of what is left, put it at the front};\\& &&\text{repeat with the front one place further along}\\&\textbf{cost}&&\tfrac{n(n-1)}{2}\ \text{comparisons} = O(n^{2})\end{aligned}}$$

Bubble sort keeps walking the list and swapping any two neighbours that are in the wrong order; after one walk the largest item cannot be anywhere but the end, after two walks the two largest are at the end, and so on. Selection sort works from the other end: it scans for the smallest item, puts it first, and then does the same for the rest of the list. Both compare about half of every pair of items, which is n times n minus one over two.

Proof

Where n times n minus one over two comes from. Bubble sort's first pass makes n minus 1 comparisons, the second n minus 2, and so on down to 1 on the last pass.

So the total is the sum of 1 up to n minus 1, which is n times n minus one over two. For n = 5 that is 10, and the reversed five item list measured in the table below took exactly 10 comparisons.

Selection sort makes the same comparisons in a different order: its first scan looks at n items, the second at n minus 1, down to 1, which is the same sum plus n.

Both are therefore O(n squared), and the factor of one half is dropped because O drops constants.

Looks like this, but is not

One pass swaps every neighbour that is out of order, so it is tempting to think the list comes out sorted. Here is one pass, and then a second one on the result.

def one_pass(data):
    """Walks the whole list once, swapping neighbours out of order."""
    for k in range(len(data) - 1):
        if data[k] > data[k + 1]:
            data[k], data[k + 1] = data[k + 1], data[k]
    return data

queue = [7, 3, 9, 1, 5]
print(one_pass(queue))
print(one_pass(queue))

Run:

[3, 7, 1, 5, 9]
[3, 1, 5, 7, 9]

After the first pass the list is [3, 7, 1, 5, 9], and 1 is still behind 7. A value can only move left one place per pass, because once the walk has gone past a position it never comes back. The 9 moved three places right in a single pass; the 1 needs as many passes as places it has to travel left.

versionswapsfinal list

the course's version, swaps at every smaller item

10

[1, 2, 3, 4, 5]

find the smallest first, then swap once

2

[1, 2, 3, 4, 5]

Both sort correctly, and the course's version does five times the swaps. The comparison count is the same for both, so both are still O(n squared), and this is a constant factor rather than a class. Worth knowing because the lecture's version is the one to reproduce in an exam and the other is the one to reach for when a swap is expensive.

bubble_sort with the already sorted flag, and the passes it actually makes

Write bubble sort the way the lecture writes it, with a flag that stops the outer loop early when a pass makes no swaps, and print the list after every pass so the outer loop's work is visible.

def bubble_sort(data):
    """Prints the list after every pass."""
    issorted = False
    j = 0
    while j < len(data) - 1 and not issorted:
        issorted = True
        for k in range(len(data) - j - 1):
            if data[k] > data[k + 1]:
                issorted = False
                temp = data[k]
                data[k] = data[k + 1]
                data[k + 1] = temp
        print('after pass', j + 1, data)
        j = j + 1

marks = [55, 90, 40, 72, 61]
bubble_sort(marks)

Sample Run:

after pass 1 [55, 40, 72, 61, 90]
after pass 2 [40, 55, 61, 72, 90]
after pass 3 [40, 55, 61, 72, 90]

And the point of the flag: the number of passes on a list that is already sorted against a list that is in reverse order.

def bubble_sort(data):
    """Counts the passes it actually makes."""
    issorted = False
    j = 0
    while j < len(data) - 1 and not issorted:
        issorted = True
        for k in range(len(data) - j - 1):
            if data[k] > data[k + 1]:
                issorted = False
                temp = data[k]
                data[k] = data[k + 1]
                data[k + 1] = temp
        j = j + 1
    return j

print('passes on a sorted list: ', bubble_sort([40, 55, 61, 72, 90]))
print('passes on a shuffled one:', bubble_sort([90, 72, 61, 55, 40]))

Sample Run:

passes on a sorted list:  1
passes on a shuffled one: 4
FindThe sorted list, the number of passes, and what the flag saves.
Given
  • The list is [55, 90, 40, 72, 61].

  • The outer loop runs while j < len(data) - 1 and not issorted.

  • issorted is set to True at the start of each pass and back to False by any swap.

Solution

Write the inner pass

$$\texttt{for k in range(len(data) - j - 1):}$$

Two corrections in one expression: minus 1 because the body reads data[k + 1], and minus j because the last j positions already hold the j largest items and comparing them again is wasted work.

$$\texttt{if data[k] > data[k + 1]:}$$

Strictly greater, so equal neighbours are left alone. Using >= instead would swap equal items and the flag would never settle on a list with duplicates.

$$\texttt{temp = data[k]}$$

The three line swap needs the temporary because the first assignment destroys the value it is about to need. The next box down shows what happens without it.

Write the outer loop and the flag

$$\texttt{issorted = True}\ \text{at the top of each pass}$$

Optimism: assume this pass will find nothing to do, and let any swap contradict it. Setting it once before the outer loop instead would stop the sort after the first pass.

$$\texttt{while j < len(data) - 1 and not issorted:}$$

At most n minus 1 passes are ever needed, because each pass settles one more position, and the flag cuts it short when the list is already in order.

Read the passes

$$\text{pass }1 \to [55, 40, 72, 61, 90]$$

90 travelled to the end, matching the figure.

$$\text{pass }2 \to [40, 55, 61, 72, 90]$$

Sorted already, but the loop cannot know that until a pass makes no swaps.

$$\text{pass }3:\ \text{no swaps, flag stays True}$$

The third pass is the price of knowing, and the flag stops a fourth. On the already sorted list this single pass is the whole cost, measured as 1 pass against 4.

Answer $$\boxed{[40, 55, 61, 72, 90];\ 3 \text{ passes};\ 1 \text{ pass when already sorted}}$$
Check

Independent check on the flag rather than on the sort: an already sorted list takes 1 pass and its reverse takes 4, and 4 is len minus 1 for a five item list, which is the most the outer loop can ever run.

Ten comparisons for five items in the worst case, which is 5 times 4 over 2. The flag improves the best case to n minus 1 comparisons and leaves the worst case alone.

The flag makes bubble sort O(n) on an already sorted list and leaves it O(n squared) everywhere else. When an exam asks for the complexity of bubble sort the answer is O(n squared), because that is the worst case.

selection_sort, and why the lecture's version swaps more than it needs to

Write selection sort the way the lecture writes it: walk the unsorted tail and swap whenever you meet something smaller than the item at the front of that tail, printing the list each time a position is settled.

def selection_sort(L):
    """Prints the list every time the front of the unsorted part is fixed."""
    suffix_start = 0
    while suffix_start != len(L):
        for i in range(suffix_start, len(L)):
            if L[i] < L[suffix_start]:
                L[suffix_start], L[i] = L[i], L[suffix_start]
        print('position', suffix_start, 'settled:', L)
        suffix_start += 1

marks = [55, 90, 40, 72, 61]
selection_sort(marks)

Sample Run:

position 0 settled: [40, 90, 55, 72, 61]
position 1 settled: [40, 55, 90, 72, 61]
position 2 settled: [40, 55, 61, 90, 72]
position 3 settled: [40, 55, 61, 72, 90]
position 4 settled: [40, 55, 61, 72, 90]

The lecture's version swaps on every smaller item it meets. The textbook description says find the smallest, then swap once. Both sort; count the swaps.

def selection_sort_swaps(L):
    """The course version: swaps whenever it meets a smaller item.
    Returns how many swaps it did."""
    swaps = 0
    suffix_start = 0
    while suffix_start != len(L):
        for i in range(suffix_start, len(L)):
            if L[i] < L[suffix_start]:
                L[suffix_start], L[i] = L[i], L[suffix_start]
                swaps += 1
        suffix_start += 1
    return swaps

def find_min_then_swap(L):
    """Finds the smallest item first and swaps once per position.
    Returns how many swaps it did."""
    swaps = 0
    for start in range(len(L)):
        smallest = start
        for i in range(start + 1, len(L)):
            if L[i] < L[smallest]:
                smallest = i
        if smallest != start:
            L[start], L[smallest] = L[smallest], L[start]
            swaps += 1
    return swaps

a = [5, 4, 3, 2, 1]
b = [5, 4, 3, 2, 1]
print('course version swaps:  ', selection_sort_swaps(a), a)
print('find min then swap:    ', find_min_then_swap(b), b)

Sample Run:

course version swaps:   10 [1, 2, 3, 4, 5]
find min then swap:     2 [1, 2, 3, 4, 5]
FindThe sorted list, what each printed row means, and the swap counts of the two versions.
Given
  • The list is [55, 90, 40, 72, 61].

  • suffix_start is the index of the first unsorted item.

  • The swap is written with the two names on one line, L[a], L[b] = L[b], L[a].

Solution

Fix one position at a time

$$\texttt{while suffix\_start != len(L):}$$

The name says what the variable is: the start of the part not yet settled. When it reaches the length there is nothing left.

$$\texttt{for i in range(suffix\_start, len(L)):}$$

The inner scan starts at the front of the unsorted tail, not at 0, because everything before it is already smaller than everything in it.

$$\texttt{if L[i] < L[suffix\_start]: swap}$$

The item at the front of the tail is the current best guess at the smallest, and every swap improves the guess.

Read the settled rows

$$\text{position }0 \to [40, 90, 55, 72, 61]$$

40 is the smallest of all five and is now first. Notice 55 and 90 have traded places as a side effect of the swaps.

$$\text{position }3 \to [40, 55, 61, 72, 90]$$

Sorted after four positions, and the fifth pass runs anyway because the loop is counting positions and not checking for order.

Count the swaps of both versions

$$10 \text{ against } 2 \text{ on }[5,4,3,2,1]$$

The lecture's version swaps every time it meets a smaller item, which on a reversed list is every time. Finding the smallest first and swapping once per position costs at most n swaps.

Answer $$\boxed{[40, 55, 61, 72, 90];\ 10 \text{ swaps against } 2}$$
Check

Independent check that both versions agree on the answer and not just on the count: both printed [1, 2, 3, 4, 5] from the same reversed input, so the cheaper one is not cheaper by being wrong.

Selection sort makes its n times n minus one over two comparisons whatever the list looks like. There is no flag and no early exit, so best case and worst case are the same, unlike bubble sort.

Bubble sort can notice that it is finished; selection sort cannot. That is the one difference an exam question can hang on, since their complexity class is identical.

Checkpoint
§10.5 — how many passes on a nearly sorted list

Thirty seconds. Bubble sort with the flag, printing the list after every pass and returning the number of passes it made.

def bubble_sort(data):
    """Prints the list after every pass and returns the number of passes."""
    issorted = False
    j = 0
    while j < len(data) - 1 and not issorted:
        issorted = True
        for k in range(len(data) - j - 1):
            if data[k] > data[k + 1]:
                issorted = False
                data[k], data[k + 1] = data[k + 1], data[k]
        print(data)
        j = j + 1
    return j

passes = bubble_sort([2, 1, 4, 3])
print('passes', passes)
Find(a) Write every printed line, including the passes count.
Given
  • The list is [2, 1, 4, 3].

  • issorted is set True at the top of each pass and cleared by any swap.

  • The outer loop runs while j < len(data) - 1 and not issorted.

IPython console
Hint 1/4

Two questions: what the list looks like after each pass, and how many passes the loop makes before the flag stops it.

Hint 2/4

One pass swaps every neighbour out of order. The flag can only stop the loop after a pass in which nothing was swapped, so a sorted list still costs one extra pass.

Hint 3/4

The list [2, 1, 4, 3] has two swaps to make and they are not neighbours of each other, so one pass fixes both.

Hint 4/4

Two printed lists and then the number 2.

Show solution

Pass 1

$$2 > 1 \Rightarrow [1, 2, 4, 3]$$

First pair out of order, so it is swapped.

$$2 < 4 \Rightarrow \text{leave}$$

Nothing to do in the middle.

$$4 > 3 \Rightarrow [1, 2, 3, 4]$$

Last pair swapped. The pass made two swaps, so the flag is False and the loop goes round again.

Pass 2

$$\texttt{range(len(data) - 1 - 1)} = \texttt{range(2)}$$

With j equal to 1 the inner loop is shorter: the last position is known to be settled.

$$1 < 2,\ 2 < 3 \Rightarrow \text{no swaps}$$

The flag stays True, so the outer condition fails and j has been increased twice.

Answer $$\boxed{[1, 2, 3, 4]\ \text{twice},\ \texttt{passes 2}}$$
Check

Independent check on the count: the outer loop can run at most len(data) - 1 which is 3 times, and it ran 2, so the flag really did cut it short rather than the bound doing it.

Any bubble sort question that asks for a number of passes wants one more pass than the number that did work, unless the last needed swap happens on the very last allowed pass.

⚠ Swapping without a temporary name

The two assignments look symmetric and the temporary looks like clutter. The first assignment overwrites the value the second one needs, so the list ends up with duplicates and no error is raised.

wrong$$\texttt{data[k] = data[k+1]};\;\texttt{data[k+1] = data[k]}$$
right$$\texttt{temp = data[k]};\;\texttt{data[k] = data[k+1]};\;\texttt{data[k+1] = temp}$$
⚠ Inner range written without the minus one

range(len(data) - j) looks like the right number of items to visit, and it is. The loop body reads one place past k, so the last turn reads one place past the end.

wrong$$\texttt{for k in range(len(data) - j):}$$
right$$\texttt{for k in range(len(data) - j - 1):}$$
⚠ Setting the flag once, outside the outer loop

It is declared before the loop like any other variable, so initialising it there feels natural. It has to be reset at the top of every pass, because it is a claim about that pass and not about the sort.

wrong$$\texttt{issorted = False}\;\text{once, before the while}$$
right$$\texttt{issorted = True}\;\text{as the first line inside the while}$$

10.6Merge sort: split, sort each half the same way, merge the two sorted halves

Split the list, sort both halves by the same function, and build one sorted list by taking the smaller front item repeatedly.

The two swapping sorts pay n squared, which on ten thousand items is a hundred million comparisons. Here is the sort that does the same job in about a hundred and thirty thousand, and it needs the recursion from the first box on this page.

RuleRule 10.6: merge sort and its merge step
Conditions
  • Merge assumes both of the lists it is given are already sorted. Handed unsorted lists it returns a list that is not sorted, and raises nothing.

  • Merge sort returns a new list and never changes the list it was given. The base case returns L[:] and not L, so even a one item list comes back as a copy.

  • The two halves must be L[:middle] and L[middle:] with the same middle. Any other pair either loses an item or keeps one twice.

$$\boxed{\begin{aligned}&\textbf{merge sort}&&\texttt{len(L) < 2} \Rightarrow \texttt{return L[:]}\\& &&\texttt{middle = len(L) // 2}\\& &&\texttt{return merge(sort(L[:middle]), sort(L[middle:]))}\\&\textbf{merge}&&\text{take the smaller of the two front items, repeatedly};\\& &&\text{then append whatever is left of the other list}\\&\textbf{cost}&&n\log_{2} n \text{ comparisons} = O(n\log n)\end{aligned}}$$

A list of one item is already sorted, so hand back a copy of it. Otherwise cut the list in half, sort each half by asking this same function, and then walk the two sorted halves side by side, always taking whichever front item is smaller, until one of them runs out; then pour the rest of the other one on the end.

Proof

Where n log n comes from. The splitting stops when the pieces hold one item, and each split halves the size, so the number of levels is the number of times n can be halved, which is log base 2 of n.

At every level, merging touches each of the n items exactly once: the pieces at one level together hold the whole list, and merging two pieces of total size m costs at most m comparisons.

So the total is about n comparisons per level times log n levels, which is n log n. The lecture puts it in numbers: for a list of 10000 the selection sort figure is 100 million and the merge sort figure about 130000. Those are n squared and n log base 2 of n, since 10000 times 13.29 is 132877; the actual comparison counts are about half of each, because both formulas ignore the factor of one half.

Checked against a run rather than left as algebra: on a reversed list of 1024 items the measured counts were 523776 for bubble sort and 5120 for merge sort. The bubble figure is 1024 times 1023 over 2 exactly. The merge figure is half of 1024 times 10, and the halving is real rather than sloppiness: on a reversed list every merge empties one side after exactly half of its comparisons and drains the other side for free.

Looks like this, but is not

A merge that looks finished. The loop walks both lists, takes the smaller front item each time, and stops when one list runs out.

def merge(left, right, compare):
    """Broken: the two drain loops at the end are missing."""
    result = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if compare(left[i], right[j]):
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    return result

def before(a, b):
    return a < b

print(merge([40, 61, 90], [55, 72], before))

Run:

[40, 55, 61, 72]

Five items went in and four came out. The loop condition is i < len(left) and j < len(right), so the moment either side is exhausted the loop ends, and whatever is still in the other side is simply never appended. Here the right list ran out after 55 and 72, and the 90 left in the left list was dropped.

nbubble sortmerge sortratio

8

28

12

2.3

64

2016

192

10.5

256

32640

1024

31.9

1024

523776

5120

102.3

The ratio in the last column is the whole argument for merge sort, and it is not a constant: it grows with the list. At 8 items merge sort saves a bit more than half the work and at 1024 items it saves ninety nine per cent of it. The bubble column is exactly n times n minus one over two every time, which confirms the counter is counting comparisons.

merge of [40, 61, 90] and [55, 72] into one sorted list

Write the merge step on its own, before any recursion. Two sorted lists go in, one sorted list comes out, and neither input is changed. The comparison is passed in as a function so that the same merge can be used for ascending and descending order.

def merge(left, right, compare):
    """Assumes left and right are sorted lists and compare(a, b) is True when
    a belongs before b. Returns a NEW sorted list with every item of both."""
    result = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if compare(left[i], right[j]):
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    while i < len(left):
        result.append(left[i])
        i += 1
    while j < len(right):
        result.append(right[j])
        j += 1
    return result

def before(a, b):
    """Returns True when a belongs before b in ascending order."""
    return a < b

left = [40, 61, 90]
right = [55, 72]
print(merge(left, right, before))
print('left is untouched: ', left)
print('right is untouched:', right)

Sample Run:

[40, 55, 61, 72, 90]
left is untouched:  [40, 61, 90]
right is untouched: [55, 72]
FindThe merged list, and why two loops follow the first one.
Given
  • left is [40, 61, 90] and right is [55, 72], both sorted ascending.

  • compare(a, b) is True when a belongs before b.

  • i walks the left list and j walks the right one.

Solution

Take the smaller front item, repeatedly

$$\texttt{while i < len(left) and j < len(right):}$$

This loop is only safe while both lists still have a front item, because the body reads left[i] and right[j].

$$40 < 55 \Rightarrow \texttt{result = [40]},\ i = 1$$

Only the index of the side that gave an item moves. Moving both would skip an item on the other side.

$$55 < 61 \Rightarrow \texttt{result = [40, 55]},\ j = 1$$

Now the right list is the smaller one at the front.

$$61 < 72 \Rightarrow [40, 55, 61],\ i = 2$$

Left again.

$$72 < 90 \Rightarrow [40, 55, 61, 72],\ j = 2$$

Right, and now j equals len(right), so the loop condition fails with 90 still unplaced.

Pour the rest of whichever list is left

$$\texttt{while i < len(left): append left[i]}$$

Runs here and appends the 90. Without this loop the merge returns four items out of five, which is the box above.

$$\texttt{while j < len(right): append right[j]}$$

Does nothing on this data and is still required: with [55, 72] and [40, 61] the other side would be the one left over. Exactly one of the two loops ever runs.

Answer $$\boxed{[40, 55, 61, 72, 90]}$$
Check

Two independent checks. Lengths: 3 plus 2 is 5 and the result has 5 items, so nothing was lost or doubled. Inputs: both printed unchanged afterwards, so the result really is a new list.

Four comparisons for five items. A merge of two lists holding m items together costs at most m minus 1 comparisons, because every comparison places one item and the last item needs none.

The two drain loops are not tidying up, they are part of the algorithm. Any merge you write from memory should be checked by counting the items in and the items out.

merge_sort around that merge, with the comparison as a default parameter

Now the recursion. Two lines do the splitting and one line does the merging. The comparison has a default value so that the usual call takes one argument, and a caller who wants the other order can pass a different comparison.

def merge(left, right, compare):
    """Assumes left and right are sorted. Returns a new sorted list."""
    result = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if compare(left[i], right[j]):
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    while i < len(left):
        result.append(left[i])
        i += 1
    while j < len(right):
        result.append(right[j])
        j += 1
    return result

def merge_sort(L, compare=lambda x, y: x < y):
    """Assumes L is a list. Returns a NEW sorted list; L is not changed."""
    if len(L) < 2:
        return L[:]
    middle = len(L) // 2
    left = merge_sort(L[:middle], compare)
    right = merge_sort(L[middle:], compare)
    return merge(left, right, compare)

marks = [55, 90, 40, 72, 61, 18]
result = merge_sort(marks)
print('sorted copy: ', result)
print('original:    ', marks)
print('descending:  ', merge_sort(marks, lambda x, y: x > y))

Sample Run:

sorted copy:  [18, 40, 55, 61, 72, 90]
original:     [55, 90, 40, 72, 61, 18]
descending:   [90, 72, 61, 55, 40, 18]

The same function with the splits and merges printed, which is the picture above in text form.

def merge(left, right):
    """Assumes left and right are sorted ascending. Returns a new sorted list."""
    result = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if left[i] < right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    while i < len(left):
        result.append(left[i])
        i += 1
    while j < len(right):
        result.append(right[j])
        j += 1
    return result

def merge_sort(L, depth):
    """Prints the split and the merge at every depth."""
    if len(L) < 2:
        print('  ' * depth + 'base ' + str(L))
        return L[:]
    middle = len(L) // 2
    print('  ' * depth + 'split ' + str(L))
    left = merge_sort(L[:middle], depth + 1)
    right = merge_sort(L[middle:], depth + 1)
    merged = merge(left, right)
    print('  ' * depth + 'merge ' + str(left) + ' and ' + str(right)
          + ' into ' + str(merged))
    return merged

merge_sort([55, 90, 40, 72], 0)

Sample Run:

split [55, 90, 40, 72]
  split [55, 90]
    base [55]
    base [90]
  merge [55] and [90] into [55, 90]
  split [40, 72]
    base [40]
    base [72]
  merge [40] and [72] into [40, 72]
merge [55, 90] and [40, 72] into [40, 55, 72, 90]
FindThe sorted copy, the state of the original, and what the second call with a different comparison gives.
Given
  • marks is [55, 90, 40, 72, 61, 18], six items in no particular order.

  • The default comparison is lambda x, y: x < y, which is the one line form of def before(x, y): return x < y.

  • merge is the function from the previous example.

Solution

Write the base case as a copy

$$\texttt{if len(L) < 2: return L[:]}$$

len(L) < 2 covers both the empty list and the one item list, so a list of any length reaches it. L[:] and not L, so that the caller can never end up with a second name for its own list.

Split and recurse

$$\texttt{middle = len(L) // 2}$$

Whole division, so middle is an int usable as a slice bound. For six items it is 3.

$$\texttt{L[:middle]},\ \texttt{L[middle:]}$$

The same middle in both, once as a stop and once as a start, which is what makes the two pieces fit together with nothing lost and nothing duplicated.

$$\texttt{merge\_sort(L[:middle], compare)}$$

The comparison is passed down, otherwise the deeper calls would fall back on the default and half the list would be sorted the other way.

Read the three printed lines

$$\text{sorted copy } [18, 40, 55, 61, 72, 90]$$

The default comparison gives ascending order.

$$\text{original still } [55, 90, 40, 72, 61, 18]$$

Nothing was assigned into marks, and every piece the recursion touched was a slice, which is a new list.

$$\texttt{lambda x, y: x > y} \Rightarrow \text{descending}$$

The only change is which of the two front items counts as belonging first, so the same merge produces the reverse order.

Answer $$\boxed{[18, 40, 55, 61, 72, 90];\ \text{the original unchanged};\ [90, 72, 61, 55, 40, 18]}$$
Check

Independent check against the built in: sorted(marks) gives the same six numbers in the same order, and sorted(marks, reverse=True) gives the descending one.

Eleven comparisons for six items. The same list was run through the two in place sorts with counters: bubble sort made 15 and the course's selection sort made 21.

The printed tree is worth reading twice. Every split line appears before its two children and every merge line after them, which is the same top down then bottom up pattern as the factorial stack.

Checkpoint
§10.6 — a merge with ties in it

Thirty seconds. Two sorted lists, both containing a 2, merged by the function from this block.

def merge(left, right):
    """Assumes both lists are sorted ascending. Returns one sorted list."""
    out = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if left[i] < right[j]:
            out.append(left[i])
            i += 1
        else:
            out.append(right[j])
            j += 1
    while i < len(left):
        out.append(left[i])
        i += 1
    while j < len(right):
        out.append(right[j])
        j += 1
    return out

a = [2, 2, 9]
b = [1, 2, 4]
print(merge(a, b))
print(len(merge(a, b)))
Find(a) Write both lines of output.
Given
  • a is [2, 2, 9] and b is [1, 2, 4], both sorted ascending.

  • The comparison inside the loop is left[i] < right[j], strictly less than.

  • The second print asks for the length of the result.

IPython console
Hint 1/4

Two questions: the order of the six items, and how many items come out.

Hint 2/4

The test is strictly less than, so when the two front items are equal it is False and the item from the right list is taken first.

Hint 3/4

The lists are [2, 2, 9] and [1, 2, 4]. The 1 goes first, then the two front items are both 2.

Hint 4/4

Six items on the first line and the number 6 on the second.

Show solution

Walk both fronts

$$2 < 1?\ \text{no} \Rightarrow \text{take } 1$$

The right front is smaller, so j moves and i stays.

$$2 < 2?\ \text{no} \Rightarrow \text{take the right } 2$$

Strictly less than is False on a tie, so the else branch runs and the right list's item is appended.

$$2 < 4?\ \text{yes} \Rightarrow \text{take both left } 2\text{s}$$

Two turns in a row take from the left, because both of its 2s are below the right's 4.

$$9 < 4?\ \text{no} \Rightarrow \text{take } 4,\ j \text{ runs out}$$

The right list is now exhausted, so the main loop stops with 9 still unplaced.

Drain and count

$$\text{drain left} \Rightarrow \text{append } 9$$

The first drain loop runs. Without it the answer would have five items.

$$3 + 3 = 6$$

Nothing is created or lost by a merge, so the length is the sum of the two inputs.

Answer $$\boxed{[1, 2, 2, 2, 4, 9]\;/\;6}$$
Check

Independent check by sorting the concatenation: the six values 2, 2, 9, 1, 2, 4 sorted ascending are 1, 2, 2, 2, 4, 9, which is what merge returned.

Whenever you write a merge, count the items in and the items out before you look at the order. A missing drain loop shows up in the count immediately.

⚠ Stopping the merge when one side runs out

The main loop looks like the whole algorithm, and it is the part that does the comparing. The leftovers need no comparisons at all, which is exactly why they are easy to forget.

wrong$$\texttt{while i < len(left) and j < len(right)}\;\text{only}$$
right$$\text{then }\texttt{while i < len(left)}\;\text{and }\texttt{while j < len(right)}$$
⚠ Expecting merge sort to change the list it was given

Bubble sort and selection sort were written two boxes earlier and both work in place, so the habit is fresh. Merge sort cannot work in place: it builds result as a new list.

wrong$$\texttt{merge\_sort(weights)}\;\text{then read }\texttt{weights}$$
right$$\texttt{weights = merge\_sort(weights)}$$
⚠ Splitting with middle in one half and middle plus one in the other

Bisection search moves its window ends past mid, because there the item at mid has been dealt with. In merge sort nothing has been dealt with, so the item at middle still has to be in one of the halves.

wrong$$\texttt{L[:middle]},\;\texttt{L[middle + 1:]}$$
right$$\texttt{L[:middle]},\;\texttt{L[middle:]}$$

10.7Paying for a sort once: when it is worth it, and when a dictionary makes it unnecessary

Sorting costs more than a single search, so it pays only when many searches follow, and sometimes nothing needs sorting.

Every box so far has priced one operation. The last question of the chapter is the one a program actually has to answer: given this list and this many lookups, is sorting worth it at all?

RuleRule 10.7: the break even point for sorting first
Conditions
  • k is the number of lookups the program will make and n is the length of the list. Both costs are counted in comparisons of list items.

  • The list must not change between the lookups. One insertion puts it back out of order, and then either the sort is paid again or the bisection search is no longer legal.

$$\boxed{\begin{aligned}&\text{leave it unsorted, } k \text{ linear searches:}&&k\,n\\&\text{sort once, then } k \text{ bisection searches:}&&n\log_{2} n + k(\log_{2} n + 1)\\&\text{sorting wins from about}&&k = \log_{2} n\end{aligned}}$$

Searching an unsorted list k times costs k times the list length. Sorting it costs about n log n once, and then each of the k searches costs only the number of halvings. Setting those two against each other, the sort has paid for itself by about the log n th lookup, which for twenty thousand items means from the fifteenth lookup onward.

Looks like this, but is not

Sorting makes every later search nearly free, so sorting first sounds like it can only help. Here are the measured totals for 20000 items, sorting cost included.

def linear_cost(n, lookups):
    """Comparisons for `lookups` failed linear searches in a list of n items."""
    return n * lookups

def binary_cost(n, lookups):
    """Comparisons for `lookups` bisection searches, plus the sort that
    makes them possible. Merge sort does about n * log2(n) comparisons."""
    halvings = 0
    size = n
    while size > 1:
        size = size // 2
        halvings += 1
    return n * halvings + (halvings + 1) * lookups

n = 20000
for lookups in [1, 5, 20, 100, 1000]:
    print(lookups, linear_cost(n, lookups), binary_cost(n, lookups))

Run, printing lookups, then the unsorted total, then the sorted total:

1 20000 280015
5 100000 280075
20 400000 280300
100 2000000 281500
1000 20000000 295000

At one lookup the unsorted route costs 20000 and the sorted route costs 280015, fourteen times more. At five lookups it is still losing. A program that sorts a 20000 item list to answer one question has done fourteen times the work for nothing, and it will not look slow in testing because both numbers are small on a modern machine.

lookupsleave unsortedsort first, then bisectwhich wins

1

20000

280015

unsorted

5

100000

280075

unsorted

15

300000

280225

sorted, just

100

2000000

281500

sorted

1000

20000000

295000

sorted

The middle row is the break even point, found by a loop rather than by algebra: at 15 lookups the sorted route becomes the cheaper one and it never looks back. Notice how flat the third column is. Going from 1 lookup to 1000 raises it by five per cent, because almost all of it is the one sort.

Finding the break even lookup count for a 20000 item list

A program holds 20000 records and will look up k of them. Find the smallest k for which sorting first is cheaper, using the halving count and a loop rather than algebra.

# Where does paying for the sort start to pay off?
n = 20000
halvings = 0
size = n
while size > 1:
    size = size // 2
    halvings += 1
lookups = 1
while n * lookups <= n * halvings + (halvings + 1) * lookups:
    lookups += 1
print('halvings:', halvings)
print('sorting pays off from lookup number', lookups)

Sample Run:

halvings: 14
sorting pays off from lookup number 15
FindThe smallest number of lookups at which sorting first wins.
Given
  • The list holds 20000 items and can be halved 14 times.

  • Merge sort on n items costs about n times the halving count, so about 280000 comparisons here.

  • Each bisection search costs 15 comparisons at worst, and each linear search 20000.

Solution

Write both totals as a cost in k

$$\text{unsorted} = 20000k$$

Each lookup is a separate full walk, and the walks do not help each other.

$$\text{sorted} = 280000 + 15k$$

The sort is paid once whatever k is, which is the whole point of the word amortise.

Find where the lines cross

$$20000k > 280000 + 15k$$

Ask when the unsorted route becomes the more expensive one; that is the moment sorting starts paying.

$$19985k > 280000$$

Subtracting 15k from both sides. The 15 barely matters, which is the mathematical version of the flat third column in the table.

$$k > 14.01 \Rightarrow k = 15$$

k counts lookups, so it must be a whole number, and the first whole number above 14.01 is 15. The loop in the program found the same 15 by trying k = 1, 2, 3 and so on.

Answer $$\boxed{k = 15}$$
Check

Independent check against the measured table: at 5 lookups the totals are 100000 and 280075, so unsorted wins; at 15 they are 300000 and 280225, so sorted wins.

The break even count came out at 15, and the halving count is 14. That is not a coincidence: the crossing point is about log base 2 of n whatever n is, because the sort costs n log n and each saved search saves about n.

Carry the shape rather than the number: sorting pays off after about log n lookups. For a list of a thousand that is ten lookups, for a million it is twenty, and for a list of ten it is basically never worth the trouble.

sort, sorted, sorting your own objects, and the lookup that needs no search

Three questions a lab answer has to get right. First, which of the two built in sorts copies.

marks = [55, 90, 40, 72, 61]
copy = sorted(marks)
print('sorted() gives back a list:', copy)
print('the original is untouched: ', marks)

answer = marks.sort()
print('sort() gives back:         ', answer)
print('but the original is now:   ', marks)

Sample Run:

sorted() gives back a list: [40, 55, 61, 72, 90]
the original is untouched:  [55, 90, 40, 72, 61]
sort() gives back:          None
but the original is now:    [40, 55, 61, 72, 90]

Second, a list of objects. The sort code does not change at all; the only new thing is __lt__ in the class, which decides what the less than sign means and therefore what sorted means.

class Track:
    """A song with a title and a length in seconds."""

    def __init__(self, title, seconds):
        self.__title = title
        self.__seconds = seconds

    def get_title(self):
        """Returns the title."""
        return self.__title

    def get_seconds(self):
        """Returns the length in seconds."""
        return self.__seconds

    def __lt__(self, other):
        """A track is less than another if it is shorter. Ties go by title."""
        if self.__seconds == other.get_seconds():
            return self.__title < other.get_title()
        return self.__seconds < other.get_seconds()

    def __repr__(self):
        """Returns the title and the length."""
        return self.__title + '(' + str(self.__seconds) + ')'

playlist = [Track('Oda', 210), Track('Bir', 185), Track('Cam', 210),
            Track('Ada', 240)]
playlist.sort()
print(playlist)

Sample Run:

[Bir(185), Cam(210), Oda(210), Ada(240)]

Third, the case where no search is needed. A dictionary answers a lookup without walking anything, which is why sorting is sometimes the wrong question.

def list_looks(names, wanted):
    """Returns how many items a walk of the list examines before it answers."""
    looks = 0
    for i in range(len(names)):
        looks += 1
        if names[i] == wanted:
            return looks
    return looks

names = []
for i in range(1000):
    names.append('id' + str(i))

lookup = {}
for i in range(1000):
    lookup['id' + str(i)] = i

print('list walk for the last name:', list_looks(names, 'id999'))
print('list walk for a missing name:', list_looks(names, 'id9999'))
print('dictionary answer:', 'id999' in lookup, lookup['id999'])
print('dictionary answer for missing:', 'id9999' in lookup)

Sample Run:

list walk for the last name: 1000
list walk for a missing name: 1000
dictionary answer: True 999
dictionary answer for missing: False
FindWhich operations copy, what the tie rule does to Oda and Cam, and how many items the dictionary examines.
Given
  • The marks list is [55, 90, 40, 72, 61].

  • Track.__lt__ compares seconds first and titles when the seconds are equal.

  • The playlist holds Oda at 210, Bir at 185, Cam at 210 and Ada at 240 seconds.

  • The list and the dictionary both hold a thousand names built as 'id' + str(i).

Solution

Separate copying from rearranging

$$\texttt{sorted(marks)} \to \text{new list, original intact}$$

A function that returns a value and leaves its argument alone. Use it when the original order is still needed.

$$\texttt{marks.sort()} \to \texttt{None}$$

A method that rearranges and reports nothing. The printed None is the return value, not the list.

Read the tie in the object sort

$$\texttt{Bir}(185) < \texttt{Cam}(210)$$

Different lengths, so the first branch of __lt__ is skipped and the seconds decide.

$$\texttt{Cam}(210) < \texttt{Oda}(210)$$

Equal seconds, so the titles decide, and 'Cam' < 'Oda' is True. Without the tie branch the two would keep whichever order they started in, which is not what the specification asked for.

Compare a walk with a lookup

$$\text{list walk} \to 1000 \text{ looks}$$

Both for the last name and for a name that is not there, because a walk can only rule an item out by looking at it.

$$\texttt{'id999' in lookup} \to \texttt{True}$$

A dictionary computes where the key would be and looks only there, so the answer costs the same whether the dictionary holds ten keys or a million. That is the O(1) at the top of the list of complexity classes.

Answer $$\boxed{\texttt{sorted}\ \text{copies};\ \texttt{sort}\ \text{returns None};\ [\texttt{Bir, Cam, Oda, Ada}];\ 1000\ \text{against}\ 1}$$
Check

Independent check on the object sort against the definition rather than the output: the printed order 185, 210, 210, 240 is ascending in seconds, and inside the tie Cam comes before Oda alphabetically.

The dictionary version builds a thousand entry dictionary once, at a cost of one insertion per name, and then every lookup is free. It is the amortisation argument again with a different one time cost.

Before writing a search, ask what the lookups are keyed on. If it is one field and that field is unique, a dictionary beats both searches and needs no sort at all.

Checkpoint
§10.7 — one copy, then two changes

Thirty seconds. A copy is taken, then the original is appended to and sorted.

sizes = [38, 41, 36]
copy = sorted(sizes)
sizes.append(30)
print(copy)
print(sizes)
sizes.sort()
print(sizes)
Find(a) Write all three lines.
Given
  • sizes starts as [38, 41, 36].

  • copy is made with sorted, before the append.

  • append adds 30 at the end, and then sort is called on sizes.

IPython console
Hint 1/4

Three questions: what the copy holds, what the original holds after the append, and what it holds after the sort.

Hint 2/4

sorted builds a separate list at the moment it is called. Later changes to the original cannot reach it, because they are two objects.

Hint 3/4

At the moment of the copy the list is [38, 41, 36]. The append then puts 30 at the end of the original only.

Hint 4/4

The copy has three sorted items; the original has four, first unsorted then sorted.

Show solution

Take the copy

$$\texttt{copy = sorted(sizes)} \to [36, 38, 41]$$

A new object, built now, from the three values present now.

Change the original twice

$$\texttt{sizes.append(30)} \to [38, 41, 36, 30]$$

Appends to the original object only. copy is a different object and is unaffected.

$$\texttt{sizes.sort()} \to [30, 36, 38, 41]$$

In place, so the same object now holds the sorted order, and there is no second list to print.

Answer $$\boxed{[36, 38, 41]\;/\;[38, 41, 36, 30]\;/\;[30, 36, 38, 41]}$$
Check

Independent check that the two really are separate objects: the copy still has three items after the append, and a second name for one object could not have a different length from the first.

A sorted list stays sorted only until something is added. Any program that keeps a sorted list has to insert in the right place or sort again.

⚠ Sorting text that holds numbers

A file gives strings, and the strings look like numbers on the screen. Sorting them compares character by character, so 104 comes before 11 because 0 comes before 1.

wrong$$\texttt{['11','9','104','23'].sort()} \to \texttt{['104','11','23','9']}$$
right$$\texttt{int(item)}\;\text{for each item first, then }\texttt{sort}$$
⚠ Walking a list when the lookup key is unique

The data arrives as a list of records and a list is what you have, so a loop is the first thought. If every lookup is on one unique field, a dictionary built once turns every later lookup from n looks into one.

wrong$$\texttt{for i in range(len(names)): if names[i] == w}$$
right$$\texttt{if w in lookup: lookup[w]}$$
⚠ Searching a list with bisection after appending to it

The list was sorted and the search worked, so the search is trusted. An append goes on the end, and one item in the wrong place is enough to make the search return -1 for things that are there.

wrong$$\texttt{L.sort()};\;\texttt{L.append(x)};\;\texttt{binary\_search(L, x)}$$
right$$\texttt{L.append(x)};\;\texttt{L.sort()};\;\texttt{binary\_search(L, x)}$$
Choosing the search and the sort for a given task

Any lab step or exam part that says find, or search, or in order, without naming an algorithm.

  1. Count the lookups

    One or two lookups on a list that is not already sorted: linear search, and do not sort. Many lookups: the sort is worth paying for from about the log n th lookup.

  2. Ask what the lookups are keyed on

    If every lookup is by one unique field, build a dictionary from that field once and stop thinking about searching. If the lookups are by range, or order, or the smallest few, sorting is the right tool.

  3. Check whether the list is allowed to change

    If the caller still needs the original order, use sorted or merge sort. If not, sort or one of the in place sorts is cheaper because it copies nothing.

  4. Pick the sort by what the sheet says and how long the list is

    If a specification names an algorithm, write that one; marks are for the named algorithm and not for a faster substitute. Otherwise: short list, bubble or selection; long list, merge sort; and in real code the built in sort.

  5. Write the precondition in the docstring

    Bisection search and merge both assume sorted input. Writing it down is what stops the next reader from calling them on an unsorted list.

Where it goes wrong
  • Sorting a list to answer one question, which on the measured 20000 item list is fourteen times the work of just walking it.

  • Substituting the built in sort where a sheet asked for bubble sort. The output is right and the marks are for the algorithm.

  • Using bisection search on a list that is sorted by a different field from the one being searched.

Tracing a search or a sort loop on paper

Exam questions that give a short program and ask what it prints, which on the two real papers checked for this page were worth 30 and 20 marks.

  1. Write the variable names as column headings

    For a search: first, last, mid, L[mid]. For a sort: the list itself, plus the loop counters. One row per turn of the loop.

  2. Write the list once, with the indices above it

    Almost every mistake in these questions is reading the wrong index. Writing 0 to n minus 1 above the values, once, removes most of them.

  3. Fill one row per turn and never skip ahead

    Compute the new values from the row above, not from your idea of where the algorithm is going. A loop that is about to end looks exactly like one that is not.

  4. Mark where each print happens

    Put the printed text in its own column as you go. The answer asked for is the print column read downwards, not the final value of the variables.

  5. Check the loop's exit before writing the answer

    For a while loop, state the condition that became False. For a for over range, check the last value the counter actually took, which is one less than the stop.

Where it goes wrong
  • Giving the final value of the variables instead of the printed lines.

  • Losing a blank line or a trailing space. The exam asks for the exact output, and print with several arguments puts one space between them.

  • Tracing the algorithm you remember instead of the code on the paper, which is how a planted off by one goes unnoticed.

linear search on fifteen sorted ids, counted

The same sorted list of fifteen ids, searched by walking it. The counter reports how many items were looked at.

def linear_search(L, e):
    """Assumes L is a list. Returns (index, comparisons), index -1 if absent."""
    comparisons = 0
    for i in range(len(L)):
        comparisons += 1
        if L[i] == e:
            return i, comparisons
    return -1, comparisons

ids = [102, 118, 125, 137, 140, 156, 171, 188, 190,
       204, 211, 226, 233, 245, 260]
print('looking for 245:', linear_search(ids, 245))
print('looking for 250:', linear_search(ids, 250))

Sample Run:

looking for 245: (13, 14)
looking for 250: (-1, 15)
FindThe index and the comparison count for both targets.
Given
  • The list holds fifteen ids in ascending order.

  • 245 is the fourteenth of them, at index 13.

  • 250 is not in the list.

Solution

Walk to the target

$$245 \text{ at index } 13 \Rightarrow 14 \text{ comparisons}$$

The counter goes up before the test, so an item at index 13 has cost 14 looks by the time it is found.

$$250 \Rightarrow 15 \text{ comparisons}$$

Absence costs the whole list, because this version has no early stop even though the list is sorted.

Answer $$\boxed{(13, 14)\;/\;(-1, 15)}$$
Check

Independent check: the absent target costs 15 and the list has 15 items, so the counter is counting one look per item.

bisection search on the same fifteen ids, counted

The same list, the same two targets, the halving search. The counter is in the same place, just inside the loop.

def binary_search(L, e):
    """Assumes L is sorted ascending. Returns (index, comparisons)."""
    comparisons = 0
    first = 0
    last = len(L) - 1
    while first <= last:
        mid = (first + last) // 2
        comparisons += 1
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid, comparisons
    return -1, comparisons

ids = [102, 118, 125, 137, 140, 156, 171, 188, 190,
       204, 211, 226, 233, 245, 260]
print('looking for 245:', binary_search(ids, 245))
print('looking for 250:', binary_search(ids, 250))

Sample Run:

looking for 245: (13, 3)
looking for 250: (-1, 4)
FindThe index and the comparison count for both targets.
Given
  • The list is the same fifteen ids in ascending order.

  • The window starts as index 0 to index 14.

  • mid is (first + last) // 2.

Solution

Halve down to the target

$$\texttt{mid} = 7 \to 11 \to 13$$

Three windows: the whole list, the upper half, then the upper quarter, which lands on 245.

$$245 \Rightarrow 3 \text{ comparisons}$$

Fifteen items can be halved three times, so three is also the worst case here.

$$250 \Rightarrow 4 \text{ comparisons}$$

Absence is found when the window closes, which takes one more turn than landing on the value.

Answer $$\boxed{(13, 3)\;/\;(-1, 4)}$$
Check

Independent check against the other version: both returned index 13 for 245 and -1 for 250, so the cheap one is not cheap by being wrong.

Same list, same targets, same answers: 14 and 15 comparisons one way, 3 and 4 the other. The only difference in the code is which index is examined next, and the only thing that makes the cheap version legal is that the list is sorted.

How to tell them apart

If the code reads L[i] for i going 0, 1, 2 in order, it is linear search and it works on any list. If the code computes an index from two other indices, it is bisection search and it requires a sorted list.

Scaffolding comes off
The common skeleton
  1. Decide what shrinks and make it a parameter, usually an index i.

  2. Write the base case first: if i == len(L): and the answer for no items at all.

  3. Assume the same function already returns the right answer for i + 1.

  4. Write one line that combines L[i] with that assumed answer, and put return in front of it.

  5. Call it on the full list and on the empty or one item list, and check both.

1 · fully worked

Recursive total of a list of prices, from index i onward

Write total_from(prices, i) which returns the sum of prices from index i to the end. No loop.

def total_from(prices, i):
    """Assumes prices is a list of numbers and i an index into it.
    Returns the sum of prices from index i to the end."""
    if i == len(prices):
        return 0
    return prices[i] + total_from(prices, i + 1)

basket = [12.5, 4.0, 7.25]
print('all three:', total_from(basket, 0))
print('last two: ', total_from(basket, 1))
print('nothing:  ', total_from(basket, 3))

Sample Run:

all three: 23.75
last two:  11.25
nothing:   0
FindThe three printed sums.
Given
  • prices is [12.5, 4.0, 7.25].

  • The function is called with i equal to 0, then 1, then 3.

Solution

What shrinks

$$\texttt{total\_from(prices, i)}$$

The index shrinks the problem while the list stays whole, so no slicing and no copying. Chosen over prices[1:] because a sum over a long list would then copy the tail on every call.

The base case

$$\texttt{if i == len(prices): return 0}$$

The sum of no numbers is 0, and 0 is the right answer rather than a placeholder: adding it changes nothing. Using len(prices) - 1 here would drop the last price.

Combine one item with the rest

$$\texttt{return prices[i] + total\_from(prices, i + 1)}$$

Assume the call for i + 1 is right; then the whole answer is this one price plus that. The return is in front because the value has to travel back up.

$$12.5 + (4.0 + (7.25 + 0)) = 23.75$$

The additions happen on the way back up, innermost first, exactly as in the factorial figure.

$$\texttt{total\_from(basket, 3)} = 0$$

i already equals len, so the base case answers immediately. That is the call that proves the base case is right.

Answer $$\boxed{23.75 \;/\; 11.25 \;/\; 0}$$
Check

Independent check by adding the three prices by hand: 12.5 plus 4.0 is 16.5, plus 7.25 is 23.75.

Four calls for three prices: one per item plus the base case. O(n) in time and O(n) in call frames.

Every rung below uses the same four lines. Only the base case value and the combining line change.

2 · you write the reasoning

Same skeleton, simpler combining step. Write count_below(counts, limit, i), which returns how many items of counts from index i on are below limit. The steps are given; write the reason for each one yourself before you open the answers.

def count_below(counts, limit, i):
    """Assumes counts is a list of ints, limit an int, i an index.
    Returns how many items of counts from index i on are below limit."""
    if i == len(counts):
        return 0
    if counts[i] < limit:
        return 1 + count_below(counts, limit, i + 1)
    return count_below(counts, limit, i + 1)

stock = [4, 15, 5, 23, 7]
print(count_below(stock, 10, 0))
print(count_below(stock, 4, 0))

Sample Run:

3
0
  1. reasoning

    The count of items below the limit in an empty stretch is 0, and 0 is also the neutral value for the additions that follow.

  2. reasoning

    The test looks at one item only. Whether any other item is below the limit is the recursive call's business.

  3. reasoning

    This item counts for one, and the rest of the answer comes from the call. The 1 + is the whole combining step, which is why this rung is easier than a sum: there is no arithmetic on the data at all.

  4. reasoning

    This item counts for nothing, so the answer for the whole stretch is exactly the answer for the rest. Notice both branches end in a return and both make the same recursive call; leaving the return off either one would give None.

  5. reasoning

    The items below 10 are 4, 5 and 7, so three of the five, and the run agrees. The second call in the program, with limit 4, gives 0, which checks the other end: nothing is below 4.

3 · find the buried error

Harder: the answer is now an index rather than a number, and there has to be a way of saying there is none. A student wrote first_below(counts, limit, i), which should return the index of the first item from i on that is below limit, or -1 if there is none. It is meant to print 2 and then -1 for the list [15, 23, 7, 30] with limits 10 and 5. It prints None and then 0. Exactly two of the three steps are wrong.

def first_below(counts, limit, i):
    """Returns the index of the first item from i on that is
    below limit, or -1 if there is none."""
    if i == len(counts) - 1:              # Step 1
        return -1
    if counts[i] < limit:                 # Step 2
        return i
    first_below(counts, limit, i + 1)     # Step 3
the two buried errors (2)
⚠ step 1

The base case fires one item too early: it is reached when i is the index of the last item, so that last item is never tested against the limit.

len(counts) - 1 is the correct bound in range and in the last of a bisection search, so the hand writes it automatically. Here i is compared with the position one past the end, not with the last valid index.

right

Write if i == len(counts): return -1.

⚠ step 3

The recursive call has no return in front of it, so every answer computed by a deeper call is thrown away and the function returns None.

The same line in the printing version of a recursive search correctly has no return, because there is nothing to hand back. Here there is.

right

Write return first_below(counts, limit, i + 1).

4 · the bare problem
§10.1 — a recursive largest value, from a bare specification

No skeleton this time. A weather log holds one temperature reading per hour and you need the highest one, written recursively because the specification says so.

Find
  1. (a) Write the function and the two test prints.

  2. (b) Say why the base case here is i == len(values) - 1 and not i == len(values), which is the opposite of every other rung on this ladder.

Given
  • Write max_from(values, i) in a file called readings.py, with a docstring in the course's form.

  • It assumes values is a list of numbers with at least one item and i is an index inside it.

  • It returns the largest value from index i to the end. No loop anywhere in the function.

  • Test it with readings = [18, 25, 21, 25, 9], printing the largest of the whole list and the largest from index 2.

Hint 1/4

The answer for the whole list is either the first value or the largest of the rest, so the shape is the same as the other rungs: one item combined with an assumed answer.

Hint 2/4

The combining step is a comparison rather than an addition: compute the answer for the rest, then return whichever of that and values[i] is bigger.

Hint 3/4

For [18, 25, 21, 25, 9] the call at i equal to 4 has only the 9 left. Ask what the largest of an empty stretch would even be, and that tells you where the base case has to sit.

Hint 4/4

The base case returns values[i] when i is the last index, and the recursive line returns the larger of values[i] and max_from(values, i + 1).

Show solution

Choose the base case that can answer

$$\texttt{if i == len(values) - 1: return values[i]}$$

The last item on its own is the largest of the stretch that holds only it. Chosen over i == len(values) because that stretch is empty and has no largest to return.

Combine by comparing

$$\texttt{rest = max\_from(values, i + 1)}$$

Assume it is right. Storing it in a name rather than calling twice matters: two calls would double the work at every level and turn an O(n) function into an exponential one.

$$\texttt{if values[i] > rest: return values[i]}$$

Strictly greater, so a tie returns rest. Either is correct for a largest value, and consistency matters if the values were objects.

$$\texttt{return rest}$$

The other branch, and it must be a return too. Both paths out of the recursive case hand a value back.

Read the two tests

$$\texttt{max\_from(readings, 0)} = 25$$

The largest of all five readings.

$$\texttt{max\_from(readings, 2)} = 25$$

From index 2 the stretch is 21, 25, 9, whose largest is still 25. Same answer from a different route, which is a small check that the index parameter is really being used.

Answer $$\boxed{25 \;/\; 25}$$
Check

Independent check with a different function on the same data: max(readings) is 25 and max(readings[2:]) is 25.

When the answer for an empty input does not exist, the base case moves up one and the docstring has to say the input is non empty. That is the one place this ladder's skeleton needs adjusting.

Full exam-style question

A Playlist class with a bubble sort over its objects and a recursive reportexam format

This is the shape of the eighth lab and of the long question on the final: a class given an ordering, a container class whose data is private, a sort over the objects that uses that ordering, and a recursive search that prints a report. Nothing here is new; every part came from a box above or from the classes week.

Write playlist.py containing:

  1. A class Track with private title, seconds and plays, one get method per attribute, a __lt__ that makes a track less than another if it is shorter and compares titles when the lengths are equal, and a __repr__ printing the three fields one per line.
  2. A class Playlist with private name, owner and tracks, an __init__ taking the name and the owner and starting with an empty list, get_track(index), get_num_tracks(), add_track(track), a bubble_sort() that orders the tracks shortest first using __lt__, a recursive linear_search(limit, i=0) that prints every track whose play count is below limit, and a __repr__ giving the name, the owner and every track.
  3. A function create_playlist(lines) taking a list of strings, the first holding the name and the owner and the rest holding a title, a length in seconds and a play count, and returning the filled Playlist.
  4. A script that builds the playlist, sorts it, displays it, and then reports the tracks with fewer than 10 plays.
class Track:
    """One song: a title, a length in seconds and how many times it was played."""

    def __init__(self, title, seconds, plays):
        self.__title = title
        self.__seconds = seconds
        self.__plays = plays

    def get_title(self):
        """Returns the title."""
        return self.__title

    def get_plays(self):
        """Returns the number of plays."""
        return self.__plays

    def get_seconds(self):
        """Returns the length in seconds."""
        return self.__seconds

    def __lt__(self, other):
        """A track is less than another if it is shorter. If the lengths are
        equal the titles are compared."""
        if self.__seconds == other.get_seconds():
            return self.__title < other.get_title()
        return self.__seconds < other.get_seconds()

    def __repr__(self):
        """Returns the title, the length and the plays, one per line."""
        return ('Title: ' + self.__title + '\nSeconds: ' + str(self.__seconds)
                + '\nPlays: ' + str(self.__plays) + '\n')


class Playlist:
    """A named list of Track objects."""

    def __init__(self, name, owner):
        self.__name = name
        self.__owner = owner
        self.__tracks = []

    def get_track(self, index):
        """Returns the Track at the given index."""
        return self.__tracks[index]

    def get_num_tracks(self):
        """Returns how many Tracks the playlist holds."""
        return len(self.__tracks)

    def add_track(self, track):
        """Adds one Track to the end of the playlist."""
        self.__tracks.append(track)

    def bubble_sort(self):
        """Sorts the tracks in place, shortest first, using __lt__."""
        issorted = False
        j = 0
        while j < len(self.__tracks) - 1 and not issorted:
            issorted = True
            for k in range(len(self.__tracks) - j - 1):
                if self.__tracks[k + 1] < self.__tracks[k]:
                    issorted = False
                    temp = self.__tracks[k]
                    self.__tracks[k] = self.__tracks[k + 1]
                    self.__tracks[k + 1] = temp
            j = j + 1

    def linear_search(self, limit, i=0):
        """Displays every track from index i on whose play count is below
        limit. Uses a recursive linear search."""
        if i == self.get_num_tracks():
            return
        track = self.__tracks[i]
        if track.get_plays() < limit:
            print(track.get_title(), 'has only', track.get_plays(), 'plays')
        self.linear_search(limit, i + 1)

    def __repr__(self):
        """Returns the name, the owner and every track."""
        out = self.__name + '  ' + self.__owner + '\n\n'
        for track in self.__tracks:
            out = out + str(track) + '\n'
        return out


def create_playlist(lines):
    """Assumes lines is a list of strings. The first holds the name and the
    owner; each later line holds a title, seconds and plays. Returns a
    Playlist holding one Track per later line."""
    head = lines[0].strip().split(',')
    playlist = Playlist(head[0], head[1])
    for line in lines[1:]:
        parts = line.strip().split(',')
        playlist.add_track(Track(parts[0], int(parts[1]), int(parts[2])))
    return playlist


LINES = ['Sabah,Ankara',
         'Oda,210,14',
         'Bir,185,3',
         'Cam,210,41',
         'Ada,240,7']

playlist = create_playlist(LINES)
playlist.bubble_sort()
print('Bubble sorted playlist:')
print(playlist)
print('Tracks nobody listens to:')
playlist.linear_search(10)

Sample Run:

Bubble sorted playlist:
Sabah  Ankara

Title: Bir
Seconds: 185
Plays: 3

Title: Cam
Seconds: 210
Plays: 41

Title: Oda
Seconds: 210
Plays: 14

Title: Ada
Seconds: 240
Plays: 7


Tracks nobody listens to:
Bir has only 3 plays
Ada has only 7 plays
FindThe sorted display and the report, and which parts of the answer carry the marks.
Given
  • The data lines are Sabah,Ankara, then Oda,210,14, Bir,185,3, Cam,210,41 and Ada,240,7.

  • Track.__lt__ compares seconds first and titles only when the seconds are equal.

  • The report limit is 10 plays.

Solution

Put the ordering in the class, not in the sort

$$\texttt{def \_\_lt\_\_(self, other):}$$

The sort below compares two Track objects with the less than sign and nothing else, so all the knowledge about what shorter means lives here. Putting it in the sort instead would mean writing a new sort for every ordering.

$$\texttt{if self.\_\_seconds == other.get\_seconds():}$$

The tie branch reads the other object through its get method rather than through other.__seconds, because the two underscores rename the attribute and the direct read is the fragile version.

$$\texttt{return self.\_\_title < other.get\_title()}$$

Text compared with the same sign. 'Cam' < 'Oda' is True, which is what puts Cam before Oda among the two 210 second tracks.

Bubble sort over objects is bubble sort unchanged

$$\texttt{if self.\_\_tracks[k + 1] < self.\_\_tracks[k]:}$$

The only line that touches the data, and it is the same comparison as data[k] > data[k+1] written the other way round, because a class is only required to define __lt__.

$$\texttt{temp = self.\_\_tracks[k]}$$

The same three line swap. Objects are swapped by moving the references, and no Track is copied or changed.

$$\texttt{while j < len(...) - 1 and not issorted}$$

The flag is kept, so a playlist that arrives already in order costs one pass.

The recursive search reports and returns nothing

$$\texttt{def linear\_search(self, limit, i=0):}$$

The index is a parameter with a default, so the script calls playlist.linear_search(10) and the recursion supplies the index itself. This is the detail the lab sheet does not spell out and the one that makes the call site read normally.

$$\texttt{if i == self.get\_num\_tracks(): return}$$

Base case on the container's own count. A bare return because the method reports by printing.

$$\texttt{self.linear\_search(limit, i + 1)}$$

Last line, no return needed, and note it is called on self: inside a method the recursive call still needs the object.

Read the two blocks of output

$$185, 210, 210, 240$$

Ascending in seconds, and inside the 210 tie Cam comes before Oda, so both branches of __lt__ fired.

$$\texttt{Bir}(3),\ \texttt{Ada}(7)$$

The only two play counts below 10, reported in the sorted order because the sort ran first and the search walks the list as it now stands.

Answer $$\boxed{\text{Bir, Cam, Oda, Ada};\ \text{report: Bir with 3 and Ada with 7}}$$
Check

Two independent checks. The tie: Cam and Oda both run 210 seconds and Cam is printed first, which only the title branch of __lt__ can explain. The report: the five play counts are 14, 3, 41, 7, and exactly two of them are under 10, so two lines is right.

Four tracks, so the sort makes at most six comparisons and the search makes four calls. The whole answer is about eighty lines and none of it is new code; it is the classes week plus two boxes from this page.

One thing changes between this page and the lab: the lab gives you the data in a file rather than in a list. That is two lines, and the rest of the answer is untouched.

Practice

A · concept 3 questions
1§10.2 — when the early stop is allowed

A helper is written for a list of room numbers. It stops as soon as it meets a number larger than the one it is looking for, which on a sorted list saves about half the work.

The claim: adding that line can only make the search faster, never wrong, because it only ever stops early on a value that is not there.

Find(a) True or false, with the reason.
Given
  • The line added is if L[i] > e: return False.

  • The list it is used on is [34, 6, 16, 35, 1].

  • The value looked for is 16, which is in that list.

Hint 1/4

The claim has a hidden assumption in it. Find the assumption before judging the claim.

Hint 2/4

The reasoning everything after this item is bigger is only valid when the list is sorted ascending. Nothing in the line itself checks that.

Hint 3/4

On [34, 6, 16, 35, 1] the first item is 34, and the value looked for is 16.

Hint 4/4

False: on that list the search reports 16 as absent after one comparison.

Show solution

Run the first comparison

$$\texttt{L[0] = 34},\ 34 \neq 16$$

No match, so the next test runs.

$$34 > 16 \Rightarrow \texttt{return False}$$

The early stop fires immediately, and four items are never looked at.

Compare with the truth

$$\texttt{16 in L} \to \texttt{True}$$

in walks the whole list, so it is right and the helper is wrong.

Answer $$\boxed{\text{False}}$$
Check

Independent check: the same line on the sorted list [1, 6, 16, 34, 35] answers True for 16, so the fault is in the list and not in the line.

Any claim of the form this can only help should be tested on the input the claim's reasoning quietly assumed away.

2§10.1 — what a base case guarantees

A student is told their recursive function never stops and adds a base case at the top of it.

The claim: once a recursive function has a base case, it is guaranteed to finish, because the base case is the line that ends the recursion.

Find(a) True or false, with the reason.
Given
  • The base case added is if i == len(L): return 0.

  • The recursive line is return L[i] + f(L, i).

  • The first call is f(L, 0) on a list of three items.

Hint 1/4

Two separate things have to be true for a recursion to finish. The claim names one of them.

Hint 2/4

A base case says what the answer is at the end. Something else has to guarantee the end is reached, and that something is the argument getting closer to it on every call.

Hint 3/4

Here the recursive line passes i unchanged, so the second call has i equal to 0 just like the first, on a list of length 3.

Hint 4/4

False: with i never increasing, i == len(L) is never true.

Show solution

Trace the argument

$$f(L, 0) \to f(L, 0) \to f(L, 0)$$

The second argument is passed through unchanged, so the sequence of calls is constant.

$$i = 0 \neq 3 = \texttt{len(L)}$$

The base case test is False on every call.

Name what is missing

$$f(L, i + 1)$$

The fix is in the recursive call and not in the base case. A base case is a statement about the end; the shrinking is what gets you there.

Answer $$\boxed{\text{False}}$$
Check

Independent check on the direction of the fix: with i + 1 the values of i are 0, 1, 2, 3, and the fourth call matches len(L).

3§10.7 — which line leaves the caller's list alone

A report needs the three smallest marks and must also print the marks in the order they were entered, later in the same program. So the original order has to survive.

Find(a) Which single line gives you a sorted list and leaves marks in entry order?
Given
  • marks is [55, 90, 40, 72, 61], in entry order.

  • merge_sort is the version from this page, which returns a new list.

  • bubble_sort is the version from this page, which sorts in place and returns nothing.

Hint 1/4

Two requirements, and each line has to be checked against both: does it give you a sorted list, and is marks still in entry order afterwards.

Hint 2/4

Two of the four operations return a new list and leave their input alone: sorted and merge_sort. The other two rearrange the input and return None.

Hint 3/4

The list is [55, 90, 40, 72, 61] and it must still read in that order after the line. Look at which name each line assigns to.

Hint 4/4

The line that both returns a list and does not touch marks is the merge sort one.

Show solution

Returns a list, changes nothing

$$\texttt{merge\_sort(marks)}$$

Returns a new list, leaves the argument alone. Both boxes ticked.

$$\texttt{sorted(marks)}$$

Also returns a new list, but the offered line assigns it back over marks, which fails the second requirement.

Returns None, changes the list

$$\texttt{marks.sort()},\ \texttt{bubble\_sort(marks)}$$

Both rearrange marks and evaluate to None, so they fail both requirements at once.

Answer $$\boxed{\texttt{lowest = merge\_sort(marks)}}$$
Check

Independent check: after the chosen line, marks[0] is still 55.

B · computation 6 questions
1§10.1 — a print before the call and a print after it

A recursive function with a print on each side of the recursive call.

def show(n):
    """Prints on the way down and again on the way back up."""
    if n == 0:
        return
    print('down', n)
    show(n - 1)
    print('up', n)

show(3)
Find(a) Write exactly what this prints.
Given
  • The call is show(3).

  • The base case is n == 0 and returns without printing.

  • There is a print before the recursive call and another after it.

IPython console
Hint 1/4

Six lines come out of three calls. The question is the order, not the count.

Hint 2/4

Everything before a recursive call runs on the way down, and everything after it runs on the way back up, once the deeper call has returned.

Hint 3/4

The calls are made with n equal to 3, 2, 1 and then 0. The 0 call prints nothing and returns.

Hint 4/4

Three down lines with n falling, then three up lines with n rising.

Show solution

On the way down

$$\texttt{show(3)} \to \texttt{down 3}$$

Printed before the call, so it comes first.

$$\texttt{show(2)} \to \texttt{down 2}$$

The frame for n equal to 3 is still waiting.

$$\texttt{show(1)} \to \texttt{down 1}$$

Three frames now waiting.

$$\texttt{show(0)} \to \text{returns}$$

The base case prints nothing, which is why there are no down 0 or up 0 lines.

On the way back up

$$\texttt{up 1},\ \texttt{up 2},\ \texttt{up 3}$$

The innermost waiting frame resumes first, so the n values come out in the reverse of the order they went in.

Answer $$\boxed{\text{down }3,2,1\ \text{then up }1,2,3}$$
Check

Independent check on the count: three calls print, each prints twice, so six lines, and the base case contributes none.

If an exam asks you to print a list forwards and backwards with one recursive function, this is the whole trick.

2§10.1 — a recursive count with two answers

A recursive count over a list, called twice with different values.

def count_value(L, value, i):
    """Assumes L is a list and i an index. Returns how many times value
    appears in L from index i onwards."""
    if i == len(L):
        return 0
    if L[i] == value:
        return 1 + count_value(L, value, i + 1)
    return count_value(L, value, i + 1)

seats = [3, 5, 3, 3, 8]
print(count_value(seats, 3, 0))
print(count_value(seats, 9, 0))
Find(a) Write exactly what this prints.
Given
  • The list is [3, 5, 3, 3, 8].

  • The base case is i == len(L) and returns 0.

  • The two calls look for 3 and for 9, both starting at index 0.

IPython console
Hint 1/4

Two numbers come out. Each is a count over the whole list.

Hint 2/4

The matching branch returns 1 + the call for the rest, and the other branch returns just the call for the rest, so the answer is built by adding one per match on the way back up.

Hint 3/4

The list [3, 5, 3, 3, 8] holds three 3s and no 9s.

Hint 4/4

Three, then zero.

Show solution

Looking for 3

$$\text{indices } 0, 2, 3 \text{ match}$$

Three of the five items equal 3.

$$1 + 1 + 1 + 0 = 3$$

Each matching frame adds one to what comes back from below, and the base case starts the sum at 0.

Looking for 9

$$\text{no index matches}$$

Every frame takes the pass it on branch, so nothing is added.

$$0$$

The base case's 0 travels back up unchanged, which is the correct answer for none.

Answer $$\boxed{3 \;/\; 0}$$
Check

Independent check with a different tool: [3, 5, 3, 3, 8].count(3) is 3 and .count(9) is 0.

3§10.3 — a bisection loop with the wrong comparison

A bisection search whose loop condition is missing its equals sign. Three targets are tried.

def binary_search(L, e):
    """Broken: the loop stops while one item is still unchecked."""
    first = 0
    last = len(L) - 1
    while first < last:
        mid = (first + last) // 2
        if e < L[mid]:
            last = mid - 1
        elif e > L[mid]:
            first = mid + 1
        else:
            return mid
    return -1

ids = [11, 22, 33, 44, 55]
print(binary_search(ids, 33))
print(binary_search(ids, 55))
print(binary_search(ids, 11))
Find(a) Write exactly what this prints.
Given
  • The list is [11, 22, 33, 44, 55], sorted ascending, five items.

  • The loop condition is while first < last, without the equals sign.

  • The targets are 33, 55 and 11, and all three are in the list.

IPython console
Hint 1/4

All three targets are present, so any -1 in the output is the bug showing itself. Work out which of the three the loop can still reach.

Hint 2/4

The loop stops when first equals last, that is when the window has shrunk to exactly one item. That last item is therefore never compared.

Hint 3/4

The list is [11, 22, 33, 44, 55]. For 33 the first mid is 2; for 55 and for 11 the window has to shrink to one item before the target is reached.

Hint 4/4

One of the three is found at the first comparison, one is found at index 0, and one is reported absent.

Show solution

Target 33

$$\texttt{first 0, last 4, mid 2} \to \texttt{L[2] = 33}$$

Equal on the first turn, so the missing equals sign never matters.

Target 55

$$\texttt{mid 2}:\ 55 > 33 \Rightarrow \texttt{first = 3}$$

Upper half kept.

$$\texttt{first 3, last 4, mid 3}:\ 55 > 44 \Rightarrow \texttt{first = 4}$$

Now first and last are both 4.

$$4 < 4\ \text{is False} \Rightarrow \texttt{-1}$$

The window holds exactly the target and the loop will not enter.

Target 11

$$\texttt{mid 2}:\ 11 < 33 \Rightarrow \texttt{last = 1}$$

Lower half kept.

$$\texttt{first 0, last 1, mid 0} \to \texttt{L[0] = 11}$$

first is still below last, so the loop runs and finds it.

Answer $$\boxed{2 \;/\; -1 \;/\; 0}$$
Check

Independent check on which item the bug loses: the only target reported absent is the one whose window closes to a single item, and 11 shows that reaching index 0 is not the problem.

Test a search on the first item, the last item and an absent item. Those three calls catch every off by one this algorithm has.

4§10.5 — a swap written without a temporary

A bubble sort whose swap is written as two plain assignments, with no temporary name and no simultaneous assignment.

def broken_sort(data):
    """The swap is written without a temporary name."""
    for j in range(len(data) - 1):
        for k in range(len(data) - j - 1):
            if data[k] > data[k + 1]:
                data[k] = data[k + 1]
                data[k + 1] = data[k]

marks = [55, 90, 40, 72, 61]
broken_sort(marks)
print(marks)
Find(a) Write exactly what this prints.
Given
  • The list is [55, 90, 40, 72, 61].

  • The swap is data[k] = data[k + 1] followed by data[k + 1] = data[k].

  • The loop bounds themselves are correct.

IPython console
Hint 1/4

The loops are fine, so trace what the two assignment lines do to a single pair before worrying about the whole sort.

Hint 2/4

The first assignment overwrites data[k]. The second one then reads data[k], which no longer holds the value it held a line earlier.

Hint 3/4

Take the pair 90 and 40 at indices 1 and 2 of [55, 90, 40, 72, 61] and run the two lines on it.

Hint 4/4

Every swap copies the smaller value into both places, so values are duplicated and others are lost.

Show solution

One pair, two assignments

$$\texttt{data[1] = data[2]} \Rightarrow \texttt{[55, 40, 40, 72, 61]}$$

The 90 is overwritten and now exists nowhere. This is the line that loses it.

$$\texttt{data[2] = data[1]} \Rightarrow \texttt{[55, 40, 40, 72, 61]}$$

data[1] is now 40, so this writes 40 back over the 40 that was already there and achieves nothing.

What the whole sort then does

$$\text{every swap duplicates the smaller value}$$

So the smallest value spreads leftwards through the list, and the result is ascending and wrong.

Answer $$\boxed{[40, 40, 40, 61, 61]}$$
Check

Independent check that does not need the trace: the input held five different values and the output holds two, so the function cannot be a sort whatever the order looks like.

Check a sort by the multiset of values first and the order second. Order alone cannot detect a broken swap.

5§10.6 — a merge sort called and then ignored

A merge sort is called on a list, and then both the original list and a fresh call are printed.

def merge_sort(L):
    """Returns a NEW sorted list built from L."""
    if len(L) < 2:
        return L[:]
    middle = len(L) // 2
    left = merge_sort(L[:middle])
    right = merge_sort(L[middle:])
    out = []
    i = 0
    j = 0
    while i < len(left) and j < len(right):
        if left[i] < right[j]:
            out.append(left[i])
            i += 1
        else:
            out.append(right[j])
            j += 1
    while i < len(left):
        out.append(left[i])
        i += 1
    while j < len(right):
        out.append(right[j])
        j += 1
    return out

weights = [8, 2, 5]
merge_sort(weights)
print(weights)
print(merge_sort(weights))
Find(a) Write exactly what this prints.
Given
  • weights is [8, 2, 5].

  • merge_sort returns a new list and contains no assignment to its parameter.

  • The first call's return value is not stored anywhere.

IPython console
Hint 1/4

Two lines come out. They are not the same, and the difference is the whole point of the question.

Hint 2/4

Merge sort builds its answer in a new list and hands it back. A call whose value is not assigned has no lasting effect at all.

Hint 3/4

weights is [8, 2, 5]. The first call's result is discarded; the second call's result is printed directly.

Hint 4/4

The original order first, then the sorted order.

Show solution

The discarded call

$$\texttt{merge\_sort(weights)} \to [2, 5, 8]$$

Computed, returned, and not assigned, so the only reference to it disappears at the end of the statement.

$$\texttt{weights} \to [8, 2, 5]$$

Unchanged, because the function only ever read from it and wrote into slices and a new list.

The printed call

$$\texttt{print(merge\_sort(weights))} \to [2, 5, 8]$$

Here the return value is used immediately, so it survives long enough to be printed.

Answer $$\boxed{[8, 2, 5] \;/\; [2, 5, 8]}$$
Check

Independent check that the function is not in place: the second print shows a sorted list while the first shows the original, so both orders exist at once and must be two objects.

6§10.3 — a window that stops shrinking

A bisection search whose window ends are moved to mid rather than past it. A step counter stops the run after five turns so the repetition is visible.

def binary_search(L, e):
    """Broken: the window ends are set to mid instead of mid - 1 and mid + 1.
    A step counter stops the run so the repetition is visible."""
    first = 0
    last = len(L) - 1
    steps = 0
    while first <= last and steps < 5:
        mid = (first + last) // 2
        print('step', steps, 'first', first, 'last', last, 'mid', mid)
        if e < L[mid]:
            last = mid
        elif e > L[mid]:
            first = mid
        else:
            return mid
        steps += 1
    return -1

ids = [11, 22, 33, 44]
print('answer:', binary_search(ids, 30))
Find(a) Write exactly what this prints.
Given
  • The list is [11, 22, 33, 44] and the target is 30, which is absent.

  • The window updates are last = mid and first = mid, without the minus one and plus one.

  • The loop also stops when steps reaches 5.

IPython console
Hint 1/4

Six lines come out. Read the last three of them together and ask what is different between them.

Hint 2/4

The item at mid has already been compared, so a window that keeps it can fail to get smaller. When first and last stay put, mid stays put too.

Hint 3/4

The list is [11, 22, 33, 44] and the target 30 lies between L[1] and L[2]. Compute mid from each printed pair yourself.

Hint 4/4

Five step lines, three of them identical, and then -1 from the counter rather than from the algorithm.

Show solution

The first two turns make progress

$$\texttt{first 0, last 3, mid 1}:\ 30 > 22 \Rightarrow \texttt{first = 1}$$

first = mid rather than mid + 1, so index 1 stays in the window even though it has been ruled out.

$$\texttt{first 1, last 3, mid 2}:\ 30 < 33 \Rightarrow \texttt{last = 2}$$

Same fault on the other side.

Then it repeats

$$\texttt{first 1, last 2, mid 1}:\ 30 > 22 \Rightarrow \texttt{first = 1}$$

The state after this turn is identical to the state before it, so the next turn must do the same thing.

$$\text{steps } 3, 4:\ \text{identical}$$

Three identical lines, and only the counter ends the loop.

$$\texttt{answer: -1}$$

Returned because steps < 5 failed, not because the window closed.

Answer $$\boxed{\text{5 step lines, the last 3 identical, then }-1}$$
Check

Independent check on the diagnosis: with mid - 1 and mid + 1 the same search on the same data prints two step lines and then -1, so the repetition really comes from the two changed lines.

If a trace ever prints the same state twice, stop tracing: the loop cannot escape, and the bug is in whatever was supposed to change.

C · exam level 3 questions
1§10.7 — 50000 records and 200 lookups

A courier program reads 50000 delivery records into a list, in the order they arrive, so the list is in no particular order. It then has to answer 200 queries. Each query gives one tracking code and asks for the matching record, and tracking codes are unique.

Find(a) Which plan does the least work in total?
Given
  • The list holds 50000 records in no particular order.

  • There are 200 queries, each on a unique tracking code.

  • 50000 can be halved 15 times, so merge sort costs about 750000 comparisons and each bisection about 16.

  • Bubble sort on 50000 items costs about 1.2 billion comparisons.

Hint 1/4

Total work means the one time setup plus the per query cost times 200. Write that sum for each plan before comparing anything.

Hint 2/4

Sorting is worth paying for from about log n lookups onward, which here is 16, and 200 is well past that. But a lookup on a single unique field does not need order at all, and the structure that provides that costs one pass over the records.

Hint 3/4

The numbers in the given: 200 times 50000 for the walks, 750000 plus 200 times 16 for merge sort, 1.2 billion for bubble sort, and one pass over 50000 records to build a dictionary.

Hint 4/4

The dictionary costs about 50000 operations and then answers every query without searching.

Show solution

The two plans that sort

$$750000 + 200 \times 16 \approx 753000$$

Merge sort dominates and the searching is almost free, which is the flat column from the amortisation table again.

$$1.2 \times 10^{9} + 3200$$

Bubble sort's n squared is what makes this the worst plan, even though its searching is identical to the plan above.

The two plans that do not sort

$$200 \times 50000 = 10^{7}$$

Every query pays the full walk because the list is unsorted and nothing is remembered between queries.

$$50000 \text{ insertions, then } 0$$

One pass over the records builds the dictionary, and after that a lookup examines one place rather than searching. This is the O(1) row of the complexity table.

Answer $$\boxed{\text{the dictionary, about } 50000}$$
Check

Independent check on the size of the claim: the measured list against dictionary run in the amortisation block had a thousand names, where the walk cost 1000 looks and the dictionary answered from one place, in both the present and the absent case.

Ask what the queries are keyed on before deciding whether to sort. Order is only worth buying when the queries are about order.

2§10.5 — the line that raises the IndexError

A student's bubble sort crashes. The three candidate lines are numbered in the comments, and the error message is IndexError: list index out of range.

def bubble_sort(data):
    """The inner range is written without the -1."""
    for j in range(len(data) - 1):          # Step 1
        for k in range(len(data) - j):      # Step 2
            if data[k] > data[k + 1]:       # Step 3
                data[k], data[k + 1] = data[k + 1], data[k]

marks = [55, 90, 40]
bubble_sort(marks)
print(marks)
Find(a) Which line has to change, and to what?
Given
  • The list is [55, 90, 40], three items, so the valid indices are 0, 1 and 2.

  • The error is raised on the comparison line, which reads both data[k] and data[k + 1].

  • With j equal to 0 the inner loop's stop value is 3.

Hint 1/4

The error names an index, so find the largest value of k the loops allow and then ask which index the body reads with it.

Hint 2/4

The body reads data[k + 1], so the loop must stop k one place earlier than the last index. That makes the inner stop value len(data) - j - 1.

Hint 3/4

The list is [55, 90, 40], so len(data) is 3 and the valid indices are 0, 1, 2. With j equal to 0 the inner range is range(3), whose last value is 2.

Hint 4/4

With k equal to 2 the body reads data[3], which does not exist, so Step 2 needs its minus one.

Show solution

Find the largest k

$$j = 0 \Rightarrow \texttt{range(3 - 0)} = \texttt{range(3)}$$

range stops before its argument, so k takes 0, 1, 2 and its largest value is 2.

$$\texttt{data[k + 1]} = \texttt{data[3]}$$

One past the last valid index, so this read raises the IndexError.

Fix the bound, not the body

$$\texttt{range(len(data) - j - 1)}$$

Now the largest k is 1 and the largest index read is 2. Changing the body to data[k - 1] instead would compare each item with its left neighbour and never look at the last item.

Answer $$\boxed{\texttt{range(len(data) - j - 1)}}$$
Check

Independent check on the fixed version: the bubble sort with the minus one, run earlier on the five item list, sorted it without an error and its inner loop's largest k was 3 on a list whose last index is 4.

Any loop whose body reads L[k + 1] must stop one short. Read the body first, then set the bound from it.

3§10.5 — a shelf program, sorted then searched

Exam length, in the lab's own format. A stock program reads a line of stock counts, puts them in order, and then answers one lookup. The user's input and the program's output are both shown in the Sample Run, with the input appearing after each prompt because the terminal echoes what is typed.

Find
  1. (a) Write the whole file.

  2. (b) Say why binary_search may be called on the list at that point in the script but not one line earlier.

Given
  • Write shelf.py with three functions and a script that uses them.

  • read_counts(text) assumes text is numbers separated by single spaces and returns them as a list of ints.

  • selection_sort(L) assumes L is a list of ints and sorts it in place, ascending. Do not use sort or sorted.

  • binary_search(L, e) assumes L is sorted ascending and returns the index of e, or -1 if it is absent.

  • The script reads one line of counts, sorts them, prints the sorted list, reads one target, and prints either the index it was found at or a message saying it is not on the shelf.

  • Every function needs a docstring in the course's form.

Hint 1/4

Three separate jobs and they do not interact: turn text into ints, order the ints, find one int. Write and test them one at a time.

Hint 2/4

text.split(' ') gives a list of strings and int(field) turns one into a number. Selection sort is the suffix_start loop from this page. Bisection search is the first, last, mid loop.

Hint 3/4

The Sample Run types 12 4 9 30 7 and then 9. After sorting, that list is [4, 7, 9, 12, 30] and 9 sits at index 2.

Hint 4/4

The sorted list prints as [4, 7, 9, 12, 30] and the lookup for 9 reports index 2.

Show solution

Text to numbers

$$\texttt{text.split(' ')} \to \texttt{['12','4','9','30','7']}$$

Splitting gives strings. Converting here rather than later means the rest of the program never has to think about it.

$$\texttt{counts.append(int(field))}$$

int of each field, appended one at a time, which is the course's loop rather than a comprehension.

Order, then look up

$$\texttt{selection\_sort(counts)}$$

In place, called on its own line and not assigned, because it returns nothing.

$$[4, 7, 9, 12, 30]$$

The sorted order, which is what the search needs.

$$\texttt{binary\_search(counts, 9)} \to 2$$

mid is 2 on the first turn and L[2] is 9, so this particular lookup takes one comparison.

Report either outcome

$$\texttt{if where == -1:}$$

The sentinel has to be tested for explicitly, because -1 is also a legal index in Python and printing it would be misleading.

Answer $$\boxed{\texttt{sorted: [4, 7, 9, 12, 30]};\ \texttt{found at index 2}}$$
Check

Independent check on the search against the printed list: index 2 of [4, 7, 9, 12, 30] is 9, which is what was asked for.

Every lab answer that searches has a sort somewhere above it, and the marks are as much in the order of the two calls as in either function.

D · interleaved 4 questions
1§10.7 — a helper that sorts what it was handed

A menu program asks a helper for its three cheapest prices and then prints the menu.

def cheapest_three(prices):
    """Returns the three lowest prices."""
    prices.sort()
    return prices[:3]

menu = [24.0, 9.5, 31.0, 12.5, 18.0]
print(cheapest_three(menu))
print(menu)
Find(a) Write exactly what this prints.
Given
  • menu is [24.0, 9.5, 31.0, 12.5, 18.0], in menu order.

  • cheapest_three calls sort on its parameter and returns a slice.

  • The caller prints the returned value and then its own list.

IPython console
Hint 1/4

Two lines come out. The second one is the interesting one: ask what the caller's list looks like after the call.

Hint 2/4

A parameter is another name for the same object, and sort works in place, so sorting through the parameter sorts the caller's list. A slice, by contrast, is a new list.

Hint 3/4

menu starts as [24.0, 9.5, 31.0, 12.5, 18.0] and the helper sorts it before slicing.

Hint 4/4

The three cheapest, and then the menu in price order rather than in menu order.

Show solution

The return value

$$\texttt{prices.sort()} \to [9.5, 12.5, 18.0, 24.0, 31.0]$$

In place, through the shared object.

$$\texttt{prices[:3]} \to [9.5, 12.5, 18.0]$$

A slice, so this is a new list and the caller can do what it likes with it.

The damage

$$\texttt{menu} \to [9.5, 12.5, 18.0, 24.0, 31.0]$$

The caller's own name now shows price order. Nothing in the call site suggested that the list would be touched.

Answer $$\boxed{[9.5, 12.5, 18.0]\;/\;[9.5, 12.5, 18.0, 24.0, 31.0]}$$
Check

Independent check that the returned list is a separate object: the two printed lists have different lengths, three and five, so they cannot be two names for one list.

A function that takes a list and does not promise to change it should sort a copy. sorted inside a helper costs one copy and removes a whole class of bug.

2§10.5 — sorting rows, then changing one of them

A table of [name, mark] rows is sorted by the mark, and then the first row of the sorted table is changed through a second name.

def sort_rows(table):
    """Sorts a list of [name, mark] rows in place by the mark."""
    for j in range(len(table) - 1):
        for k in range(len(table) - j - 1):
            if table[k][1] > table[k + 1][1]:
                table[k], table[k + 1] = table[k + 1], table[k]

rows = [['ada', 70], ['bora', 45], ['cem', 88]]
sort_rows(rows)
print(rows)
first = rows[0]
first[1] = 0
print(rows)
Find(a) Write exactly what this prints.
Given
  • The table is [['ada', 70], ['bora', 45], ['cem', 88]].

  • sort_rows swaps whole rows, comparing table[k][1].

  • After the sort, first = rows[0] and then first[1] = 0.

IPython console
Hint 1/4

Two lines come out, and the difference between them is caused by two lines of the program, not by the sort.

Hint 2/4

Sorting a list of lists moves the inner lists around; it does not copy them. So rows[0] after the sort is the very same inner list that used to be somewhere else in the table.

Hint 3/4

The rows are ['ada', 70], ['bora', 45] and ['cem', 88], and sorting by the mark puts bora first.

Hint 4/4

The sorted table, and then the same table with bora's mark at 0.

Show solution

Sort by the second field

$$\texttt{table[k][1] > table[k+1][1]}$$

The outer index picks a row and the inner one picks the mark, so the comparison is between marks while the swap moves whole rows.

$$[['bora', 45], ['ada', 70], ['cem', 88]]$$

45, 70, 88 ascending.

Write through the handed out row

$$\texttt{first = rows[0]}$$

A second name for bora's row. No copy was made, because indexing a list gives you the item and not a clone of it.

$$\texttt{first[1] = 0}$$

Changes the one inner list, which the table still holds, so the table shows 0.

Answer $$\boxed{[['bora', 45], ...]\;/\;[['bora', 0], ...]}$$
Check

Independent check on the claim that the row was not copied: the second printed table shows the 0, and a copy would have left the table's own row at 45.

When a sort moves whole rows, every reference anyone still holds to a row stays valid and stays writable. That is useful and it is also a leak.

3§10.6 — a class counter, a tie, and a sort

An auction class counts how many bids were made and defines what one bid being less than another means. Three bids are made, two of them for the same amount.

class Bid:
    """One bid in an auction. The class counts how many were made."""
    count = 0

    def __init__(self, bidder, amount):
        self.bidder = bidder
        self.amount = amount
        Bid.count = Bid.count + 1

    def __lt__(self, other):
        """A bid is less than another if its amount is smaller."""
        return self.amount < other.amount

    def __repr__(self):
        return self.bidder + ':' + str(self.amount)

bids = [Bid('ada', 300), Bid('bora', 150), Bid('cem', 300)]
bids.sort()
print(bids)
print(Bid.count)
Find(a) Write exactly what this prints.
Given
  • The bids are ada 300, bora 150 and cem 300, created in that order.

  • Bid.count is a class variable increased by one in __init__.

  • __lt__ compares only the amount, with no tie branch.

IPython console
Hint 1/4

Two lines come out: the sorted list of bids and one number. They test different things and neither depends on the other.

Hint 2/4

The built in sort calls __lt__, and when two items compare as neither less than the other it leaves them in the order it found them. A class variable is one slot shared by every object, increased once per construction.

Hint 3/4

The bids in creation order are ada 300, bora 150, cem 300, and __repr__ prints the bidder and the amount joined by a colon.

Hint 4/4

bora first because 150 is smallest, then the two 300s in creation order, and the count is 3.

Show solution

The sort

$$150 < 300 \Rightarrow \texttt{bora}\ \text{first}$$

The only strict comparison in the data.

$$\texttt{ada}\ \text{and}\ \texttt{cem}\ \text{compare equal}$$

__lt__ is False in both directions, so the built in sort keeps the order it received: ada was created first.

The counter

$$\texttt{Bid.count} = 3$$

Written as Bid.count = Bid.count + 1 inside __init__, so it counts constructions. Written as self.count it would have created a separate instance attribute on each object and printed 1.

Answer $$\boxed{[\texttt{bora:150},\ \texttt{ada:300},\ \texttt{cem:300}]\;/\;3}$$
Check

Independent check on the tie claim: reversing the creation order of ada and cem changes the printed order of the two 300s, which a comparison based only on the amount could not do.

If ties matter, put the tie rule in __lt__. Relying on the sort to leave them alone works for the built in sort and not for the ones you write.

4§10.1 — a recursive collector with a default list

A recursive function collects the items of a list into an accumulator, which is given a default value so that the first caller does not have to supply one. It is called twice, on two different lists.

def collect(L, i=0, seen=[]):
    """Appends every item of L from index i on to seen, then returns seen."""
    if i == len(L):
        return seen
    seen.append(L[i])
    return collect(L, i + 1, seen)

print(collect([1, 2]))
print(collect([3, 4]))
Find(a) Write exactly what this prints.
Given
  • The first call is collect([1, 2]) and the second is collect([3, 4]).

  • The signature is def collect(L, i=0, seen=[]):.

  • The function appends to seen and returns it.

IPython console
Hint 1/4

Two lines come out and the second one is longer than the list that produced it. That is the question.

Hint 2/4

A default parameter value is evaluated once, when the def line runs, not once per call. So a default that is a list is one list shared by every call that does not supply its own.

Hint 3/4

The first call appends 1 and 2 to that shared list. The second call is made with no seen argument, so it gets the same object, which already holds [1, 2].

Hint 4/4

The first line is the first list and the second line is both lists joined.

Show solution

The first call

$$\texttt{seen}\ \text{bound to the default object}$$

Created once, when the def was executed, and stored with the function.

$$\texttt{append(1)},\ \texttt{append(2)} \to [1, 2]$$

append changes the object in place, so the change outlives the call.

$$\texttt{return seen} \to [1, 2]$$

Returned, printed, and still the function's default object.

The second call

$$\texttt{collect([3, 4])}\ \text{with no seen}$$

So the parameter is bound to the same object again, which already holds two items.

$$[1, 2, 3, 4]$$

Four items from a two item list, which is the visible symptom of the shared default.

Answer $$\boxed{[1, 2]\;/\;[1, 2, 3, 4]}$$
Check

Independent check on the diagnosis: passing the accumulator explicitly, as collect([3, 4], 0, []), prints [3, 4], which isolates the fault to the default rather than to the recursion.

Never give a parameter a list, a dictionary or any other changeable object as its default. Use None and build the real default on the first line.

Mistake ledger (25 entries)
⚠ No base case at all

The recursive line is the idea, so the stopping line feels like boilerplate.

wrong$$\texttt{def total(prices):}\;\texttt{return prices[0] + total(prices[1:])}$$
right$$\texttt{if len(prices) == 0: return 0}\;\text{first, then the recursive line}$$
⚠ The recursive call with no return in front of it

In a printing recursion it correctly has no return. In a computing one it must.

wrong$$\texttt{count\_down(n - 1)}$$
right$$\texttt{return count\_down(n - 1)}$$
⚠ A call that does not get closer to the base case

The index is copied from the parameter list without the change, so the base case is unreachable.

wrong$$\texttt{return f(L, i)}$$
right$$\texttt{return f(L, i + 1)}$$
⚠ Answering no from inside the loop

One match settles the question; one mismatch settles nothing, so the branches are not symmetric.

wrong$$\texttt{if L[i] == e: return True}\;\texttt{else: return False}$$
right$$\texttt{if L[i] == e: return True}\;\text{inside, }\texttt{return False}\;\text{outside}$$
⚠ Using the early stop on a list that is not sorted

The precondition lives in the docstring, and the docstring is what gets dropped when code is copied.

wrong$$\texttt{if L[i] > e: return False}\;\text{on }\texttt{[34, 6, 16, 35, 1]}$$
right$$\text{sort first, or drop the line and pay the full }n$$
⚠ Setting the flag and carrying on when the answer is already known

The lecture's own first version does this, so it gets copied where the cost matters.

wrong$$\texttt{found = True}\;\text{then keep looping}$$
right$$\texttt{return True}\;\text{at the match}$$
⚠ last starting at len(L) instead of len(L) - 1

len(L) is the count everyone says aloud; here the name holds an index.

wrong$$\texttt{last = len(L)}$$
right$$\texttt{last = len(L) - 1}$$
⚠ while first < last, without the equals sign

It works for most targets, so a quick test passes and the one item window is never looked at.

wrong$$\texttt{while first < last:}$$
right$$\texttt{while first <= last:}$$
⚠ Moving a window end to mid rather than past it

It looks like the cautious choice, and it lets the window stop shrinking.

wrong$$\texttt{last = mid}\;/\;\texttt{first = mid}$$
right$$\texttt{last = mid - 1}\;/\;\texttt{first = mid + 1}$$
⚠ Writing O(n/2) or O(2n)

The measured average really is n over 2, so writing it feels more precise. O already dropped it.

wrong$$O(n/2)\;\text{or}\;O(2n)$$
right$$O(n)$$
⚠ Calling the whole task O(log n) when the list had to be sorted first

True about the search, and the sort that made it legal belongs in the same bill.

wrong$$\text{sort} + \text{one search} = O(\log n)$$
right$$\text{sort} + \text{one search} = O(n\log n)$$
⚠ Counting the lines of the loop body instead of the turns

Not wrong arithmetic, but a constant multiple reported as a growth class.

wrong$$O(5\log n)$$
right$$O(\log n)$$
⚠ Swapping without a temporary name

The two assignments look symmetric; the first destroys the value the second needs.

wrong$$\texttt{data[k] = data[k+1]};\;\texttt{data[k+1] = data[k]}$$
right$$\texttt{temp = data[k]};\;\texttt{data[k] = data[k+1]};\;\texttt{data[k+1] = temp}$$
⚠ Inner range written without the minus one

It is the right number of items to visit, and the body reads one place past k.

wrong$$\texttt{for k in range(len(data) - j):}$$
right$$\texttt{for k in range(len(data) - j - 1):}$$
⚠ Setting the flag once, outside the outer loop

It is a claim about one pass, so it has to be reset at the top of every pass.

wrong$$\texttt{issorted = False}\;\text{once, before the while}$$
right$$\texttt{issorted = True}\;\text{as the first line inside the while}$$
⚠ Stopping the merge when one side runs out

The leftovers need no comparisons, which is exactly why they are easy to forget.

wrong$$\texttt{while i < len(left) and j < len(right)}\;\text{only}$$
right$$\text{then }\texttt{while i < len(left)}\;\text{and }\texttt{while j < len(right)}$$
⚠ Expecting merge sort to change the list it was given

The two in place sorts were written two boxes earlier and the habit is fresh.

wrong$$\texttt{merge\_sort(weights)}\;\text{then read }\texttt{weights}$$
right$$\texttt{weights = merge\_sort(weights)}$$
⚠ Splitting with middle in one half and middle plus one in the other

Bisection search moves past mid because mid is dealt with. Merge sort has dealt with nothing.

wrong$$\texttt{L[:middle]},\;\texttt{L[middle + 1:]}$$
right$$\texttt{L[:middle]},\;\texttt{L[middle:]}$$
⚠ Sorting text that holds numbers

A file gives strings that look like numbers, and 104 sorts before 11.

wrong$$\texttt{['11','9','104','23'].sort()} \to \texttt{['104','11','23','9']}$$
right$$\texttt{int(item)}\;\text{for each item first, then }\texttt{sort}$$
⚠ Walking a list when the lookup key is unique

A list is what you were handed, so a loop is the first thought.

wrong$$\texttt{for i in range(len(names)): if names[i] == w}$$
right$$\texttt{if w in lookup: lookup[w]}$$
⚠ Searching a list with bisection after appending to it

The search worked before, so it is trusted; one item in the wrong place is enough.

wrong$$\texttt{L.sort()};\;\texttt{L.append(x)};\;\texttt{binary\_search(L, x)}$$
right$$\texttt{L.append(x)};\;\texttt{L.sort()};\;\texttt{binary\_search(L, x)}$$
⚠ Removing items from a list while a for loop walks it

Removing an item shifts everything after it down one, so the for loop skips the next item. On [4, 7, 4, 4, 9] it removes two of the three 4s and reports 2, with no error.

wrong$$\texttt{for c in codes: if c == 4: codes.remove(c)}$$
right$$\texttt{i = 0};\;\texttt{while i < len(codes):}\;\texttt{... codes.pop(i) else i += 1}$$
⚠ Assigning the result of an in place sort

The commonest fault here, in two faces: L = L.sort() and L = bubble_sort(L). Both store None.

wrong$$\texttt{marks = marks.sort()}$$
right$$\texttt{marks.sort()}\;\text{on its own line}$$
⚠ Calling the recursive function twice in the combining line

A tidy looking two branch if that doubles the work at every level, turning an O(n) function into 2 to the n calls: 30 items becomes a billion. Store the recursive answer in a name and use the name twice.

wrong$$\texttt{if values[i] > max\_from(values, i+1): return values[i]}\;\texttt{else: return max\_from(values, i+1)}$$
right$$\texttt{rest = max\_from(values, i + 1)}\;\text{once, then compare}$$
⚠ Bisection search on a list sorted by a different field

A list sorted by __lt__ is in the order __lt__ looks at. Bisecting it by another field returns -1 for objects that are there.

wrong$$\texttt{tracks.sort()}\;\text{by length, then bisect by title}$$
right$$\text{sort by the field you will search, or search it }\text{linearly}$$
Formula card
The two parts of a recursive function
$$\texttt{if }\langle\text{smallest}\rangle\texttt{: return }\langle\text{answer}\rangle;\ \ \texttt{return }\langle\text{one item}\rangle \oplus f(\text{rest})$$

Every call must get strictly closer to the base case, and every path must end in a return.

Linear search
$$\texttt{for i in range(len(L)): if L[i] == e: return True}$$

None. Works on a list in any order.

The early stop, sorted lists only
$$\texttt{if L[i] > e: return False}$$

The list must be sorted ascending, otherwise the answer can be wrong with no error raised.

Bisection search
$$\texttt{mid = (first + last) // 2};\ \texttt{last = mid - 1}\ \text{or}\ \texttt{first = mid + 1}$$

Sorted ascending; last starts at len(L) - 1; the loop condition is first <= last.

Cost of the two searches
$$\text{linear } O(n),\ \text{worst } n;\quad \text{bisection } O(\log n),\ \text{worst } \log_{2} n + 1$$

Counted in comparisons; the worst case unless another case is named.

Bubble sort and selection sort
$$\tfrac{n(n-1)}{2}\ \text{comparisons} = O(n^{2}),\ \text{in place, returns nothing}$$

Bubble sort's inner bound is len(data) - j - 1; the swap needs a temporary or a simultaneous assignment.

Merge sort
$$n\log_{2} n\ \text{comparisons} = O(n\log n),\ \text{returns a new list}$$

The halves are L[:middle] and L[middle:]; the base case returns L[:]; merge needs both drain loops.

The break even point for sorting first
$$k\,n\ \ \text{against}\ \ n\log_{2} n + k(\log_{2} n + 1),\quad k \approx \log_{2} n$$

k is the number of lookups; the list must not change between them.

Check yourself

Close the page and write, from memory: the two searches with their preconditions and their worst case costs; the three sorts with their costs and whether each one returns a list or changes yours; the two parts of a recursive function; and the one sentence that decides whether to sort at all. Then open the formula card and see which of the eight rows you could not produce.

  • Write a recursive function over a list from a bare specification, with the base case first and a return on every path, and say what it prints if it prints before the call rather than after it?

    c-recursion

  • Write both linear searches and say, without looking, which one of them is wrong on the list [34, 6, 16, 35, 1] and why?

    c-linear-search

  • Write the bisection loop from memory with the right last, the right loop condition and the right window updates, and trace it on a seven item list for a target at index 0?

    c-binary-search

  • Say what happens to each search's cost when the list length is multiplied by ten, and give the best, average and worst case for linear search in one line each?

    c-cost-of-search

  • Write bubble sort with the flag and selection sort with the suffix index, name the two places bubble sort's bounds go wrong, and say which of the two can finish early?

    c-bubble-selection

  • Write merge and merge sort, say why merge needs two loops after its main loop, and say what merge_sort(L) leaves L looking like?

    c-merge-sort

  • Decide, for a list of 50000 records and a given number of lookups, whether to sort, and say when a dictionary makes the question go away?

    c-amortize

Glossary (22 terms)
algorithmalgoritma

A method for solving a problem, written so that each step is unambiguous and the method ends. It is independent of the language it is later written in, which is why the same bubble sort appears in every course.

arama algoritması

A method for finding an item with a wanted property inside a collection, or reporting that there is none. The collection can be explicit, like a list of records, or implicit, like the numbers whose square is near a given value.

linear searchdoğrusal arama

Examining the items of a list one after another from one end until the wanted item is found or the list runs out. Requires nothing of the order of the list, and costs n comparisons at worst.

bisection searchikili arama

Searching a sorted list by comparing with the item halfway along and discarding the half that cannot contain the target, then repeating on what is left. Costs the number of halvings, and gives wrong answers on a list that is not sorted.

recursionözyineleme

A function calling itself, directly or through another function, on a smaller version of its own problem. It needs a base case that answers without a further call and a recursive case that gets closer to it.

base casetaban durum

The branch of a recursive function that answers without calling itself, covering the smallest input. For a list it is usually the empty list or the single last item.

recursive caseözyinelemeli durum

The branch that combines the work for one item with the result of calling the same function on the rest. It must make the input strictly smaller and must return its value.

The pile of call frames that are waiting for the calls they made to return. A recursion n levels deep keeps n frames alive, which is the memory cost a loop does not have.

sortingsıralama

Rearranging a list so that its items are in order under some comparison. On this page in order means ascending unless a block says otherwise.

bubble sort

Repeatedly passing over the list and swapping any two neighbours that are out of order, so that each pass carries one more largest item to the end. In place, and able to stop early when a pass makes no swaps.

selection sortseçmeli sıralama

Repeatedly finding the smallest item of the part not yet settled and putting it at the front of that part. In place, and its comparison count does not depend on the order it started in.

merge sort

Sorting by splitting the list in half, sorting each half by the same method, and merging the two sorted halves. Returns a new list and costs about n log n comparisons.

mergebirleştirme

Building one sorted list out of two sorted lists by repeatedly taking whichever front item comes first, then appending whatever is left of the other list.

in placeyerinde

An operation that rearranges the object it was given rather than building a new one. An in place sort is visible to every name that points at the list, and it usually returns nothing.

best caseen iyi durum

The smallest number of steps an algorithm takes over all inputs of a given size. Almost never reported, because it usually describes one lucky input.

average caseortalama durum

The number of steps averaged over all inputs of a given size. For linear search on a list that contains the target it is about half the list.

worst caseen kötü durum

The largest number of steps over all inputs of a given size. It is the case a complexity in O form reports, and the case an exam means when it does not say otherwise.

A cost that grows like log n, meaning it goes up by a fixed amount each time the input is multiplied by a fixed factor. Doubling the list adds one comparison to a bisection search.

A cost that grows like n log n, the product of a term that grows with the input and one that grows like its logarithm. Merge sort is the example this course gives.

amortisation

Spreading the cost of one expensive operation over the many cheap operations it makes possible. Sorting once and then bisecting many times is the example: the sort is worth its price from about the log n th lookup onward.

lambda

A function written as a single expression with no name, as in lambda x, y: x < y. It takes any number of parameters and holds exactly one expression, whose value it returns.

The structure behind the dictionary, which computes from a key the one place its value could be, so that a lookup examines that place and nothing else. This is what makes a dictionary lookup O(1) and independent of how many keys there are.

What comes next
§11 · Plotting (Chapter 11)

The measured tables on this page were built by hand, printed as rows of numbers, and read with a sentence of explanation underneath. Next week the same tables become pictures, and the first thing plotted will be a growth curve like the one in this section's cost figure.

Sources
  • kitapJohn Guttag, Introduction to Computation and Programming Using Python, with Application to Understanding Data, Second Edition, Chapter 10 The chapter this week's syllabus line names. Its own worked code is not reproduced here; the exercises and examples on this page were written for it.
  • ders malzemesiThe lecture material for searching and sorting The versions of linear search, bisection search, bubble sort, selection sort and merge sort written out here follow the course's own, including the `issorted` flag, the `suffixStart` name and the `compare` parameter with its lambda default, so that what you reproduce in an exam matches what was taught. The material states it was adapted from the MIT introductory course and licensed under Creative Commons.
  • ders malzemesiThe eighth lab sheet and its sample run The shape of the exam example, a class with an ordering inside a container class with a bubble sort and a recursive search, comes from this sheet. No question text was reused; the data and the domain are different and the exercises here were written from scratch.
  • ders malzemesiTwo past papers from this course The weight given to output prediction questions was read off two real papers: a midterm where that question was 30 marks of 100, and a final where it was 20 of 100. The cover sheet of the midterm also lists the functions you are given in a closed book exam, which is why this page says what each list method returns rather than only what it does.
  • sabitMeasured runs on Python 3 Every output block and every comparison count on this page came out of an interpreter. Where a formula and a count disagree, the count is printed and the formula is called approximate.

Spotted something missing or wrong? tell us · share your own notes or an old exam.

Last updated .