Program performance

We’ve talked about making programs more readable and concise. We have not yet talked very much about how to make programs fast. Today, we’ll start to learn how to reason carefully about program performance.

Here’s a Python program:

def sum(l: list) -> int:
    s = 0
    for x in l:
        s += x
    return s

What’s the running time of this Python program? As we talked about in the first lecture, one way of answering this question would be to pull out our pocket watch and measure it. As we showed in that lecture, though, this approach has flaws. This function’s running time is going to depend on the machine it is run on, what other programs are running, and a fair amount of random chance! Moreover, its running time is going to depend on the data it’s passed in. We probably can’t run it on all possible lists of numbers, so how can we predict how fast it will run on an arbitrary list?

Let’s see if we can figure out how long it’s going to take without running it. We can start by counting the “basic operations,” like adding numbers or assigning to a variable, executed in the function:

def sum(l: list) -> int: 
    s = 0 # 1 operation
    for x in l: # loop executes len(l) times
        s += x # 2 operations
    return s # 1 operation

So for a list of length L, the program executes (2 * L) + 2 operations. If we assume that each operation takes the same amount of time, we can say that its running time is proportional to (2 * L) + 2.

Notice how many assumptions we’re making here! For one, we’re not counting the for-loop itself as an operation–do we need to? Python’s operations probably don’t actually take the same amount of time–does it matter?

For some purposes, the answers to these questions absolutely matter. Some code is run inside Google every time a user does a web search–the exact running time of that code is very important to Google! For our purposes in this class, though–and for most of the code I’ve ever written–more precision than we’ve talked about here just isn’t important.

In fact, even this much precision is more than we’ll generally use going forward! Given that we’re not really sure about either of the constants in (2 * L) + 2–after all, if returning from a function takes a while it could be more like (2 * L) + 10, and if addition takes a while it could be (10 * L) + 2–we’ll just ignore them and say that our function’s running time is linear in L –that is, it does some constant number of operations for each element of L. Next class we’ll introduce some commonly-used notation for expressing this idea.

Before that, let’s look at a couple more functions. How about this one?

def member(l: list, el) -> bool:
    for x in l:
        if x == el:
            return True
    return False

How long does this function take, for a list of length L?

The running time of member depends on its arguments even more than the running time of sum did! If we call it on a list whose first element is el, it really doesn’t matter how long the list is–it’s going to take the same amount of time. This is the best-case running time: constant time.

Computer programming is an engineering discipline, though. If I’m building a bridge, I’m probably not all that interested in the maximum weight it can tolerate in the best case! What we’re usually interested in (though we’ll see some exceptions, later in the course) is worst-case running time: how long will the function take on the worst possible input or inputs?

In the case of member, it will take the longest when el isn’t actually in l. In that case, it runs in linear time.

We’ll take a look at one last function:

def distinct(l: list) -> list:
    d = []
    for x in l:
        if x not in d:
            d.append(x)
    return d

(As it turns out, x not in d is basically equivalent, in Python, to calling not member(d, x). So, it takes linear time in the length of d.)

What’s the best case? Well, if all of the elements in l are the same, x not in d never takes more than a constant number of operations. So in that case, the function runs in linear time.

How about the worst case? What if the elements are all different? See the lecture capture for details, but in this case, the function takes this many operations:

(0 + 1 + 2 + ... + (L - 1)) + <some constant>

As it happens, this can be simplified to:

(L * (L - 1)) / 2 + <some constant>

So in this worst-case scenario, our function’s running time is quadratic in L.