Skip to content

Analysis of data structures

You’ve now seen two implementations of two ADTs: a stack built on a growing array, and a queue built on a linked list. Here we compare them directly, so you can reason about why each implementation was built the way it was — and so you’re ready to measure it yourself.

The first part of this page states the costs of the two data structures. The rest of the page shows where such costs come from: how to analyze the running time of a piece of code, loop by loop, and how to summarize the result with big-O, big-Ω, and big-Θ notation. The analysis of loops and of searching is a review of material from CS 1; the amortized analysis of a growing array near the end is new.

We describe an operation’s cost using O(⋅)O(\cdot) (“big-O”) notation, which describes how the number of steps grows as the size of the data, nn, grows: O(1)O(1) (constant — doesn’t grow with nn), O(log⁡n)O(\log n), O(n)O(n) (grows proportionally with nn), and so on. The costs in this first part are worst-case costs unless they say otherwise, and they use big-O loosely. The second half of the page makes both ideas precise: best and worst cases, and big-O as an upper bound.

  • data[i] (indexing): O(1)O(1) — constant time. A growing array is one contiguous block of memory; the address of element ii is computed directly from ii, with no searching needed.
  • data.append(value): O(1)O(1) amortized. Most of the time, there’s spare room at the end of the underlying array, and appending is just writing into the next slot — O(1)O(1). Occasionally, the array is full, and Python allocates a new, larger array and copies every element over — an O(n)O(n) operation. But Python grows the array geometrically (multiplying its capacity by a constant factor each time it resizes, rather than adding a fixed number of slots), so those expensive resizes happen less and less often as the list grows. Spread out (“amortized”) over many appends, the average cost per append works out to O(1)O(1). Amortized analysis, near the end of this page, shows why.
  • data.pop() (from the end): O(1)O(1) — no resizing, no shifting; just remove the last element.
  • data.pop(0) or data.insert(0, value) (from the front): O(n)O(n) — every other element has to shift over by one position to close (or open) the gap.
  • Adding or removing at the front or back (given a reference to that end, as LinkedQueue keeps in self.front and self.back): O(1)O(1) — just re-point a couple of references, no shifting.
  • Indexing, data[i]: O(n)O(n) in the worst case — there’s no way to jump directly to node ii; you have to follow ii links from the front. (The best case, i=0i = 0, is O(1)O(1).)
  • Adding or removing in the middle, given a reference to the right spot: O(1)O(1) to actually splice in or out — but O(n)O(n) to find that spot in the first place, since finding it means walking the list from one end.
Operation (worst case)Growing arrayLinked list
Add / remove at the endO(1)O(1) amortizedO(1)O(1)
Add / remove at the frontO(n)O(n)O(1)O(1)
Access by indexO(1)O(1)O(n)O(n)

This is why a growing array makes a great stack (all the action is at one end — the end where arrays are fast) but a poor queue (queues need fast access at both ends), and why a linked list makes a great queue but a poor choice if you need fast random access by index.

Neither implementation is better in general — they’re better at different things. Picking a data structure means matching its strengths to the operations your problem actually needs.

The costs above were stated, not derived. The rest of this page shows how to derive them, and costs like them, from code. Whatever the problem, we are concerned with two aspects of an algorithm:

  • Correctness: showing that the algorithm produces the correct answer.
  • Efficiency: understanding how much of some resource the algorithm uses. The resource we analyze most often is time.

The input size n, and the steps of an analysis

Section titled “The input size n, and the steps of an analysis”

Running times are written as functions of nn, where nn stands for whatever characteristic of the input determines the running time. It might be the length of a list passed to the function, the value of a numeric parameter, or the number of nodes in a linked list. Writing nn lets us talk about rates of growth in general terms, but before discussing a running time, always know what nn refers to.

Given an algorithm or a piece of code, you should be able to:

  1. Identify the quantity nn that the running time depends on.
  2. Decide which case to analyze, the best case or the worst case, and describe the inputs of size nn that produce it.
  3. Compute the running time of that case in terms of constants and nn, section by section.
  4. Only then, describe that running time using big-O, big-Ω, or big-Θ notation, and say which case the bound is for.

Steps 2 and 4 answer different questions. Step 2 chooses which function of nn we are talking about. Step 4 describes how fast that function grows. Keeping them apart avoids a common mistake: thinking that big-O means “worst case.” It does not, as the section on cases and bounds near the end explains. The rest of this page follows these steps in order.

The method for finding a running time starts from code. Here is linear search, which checks the items of a list one at a time, starting at the front:

def linear_search(the_list, key):
index = 0
while index < len(the_list):
if key == the_list[index]:
return index
else:
index += 1
return None

Let the_list have length nn. The method is to divide the code into sections, where every line in a section runs the same number of times, give each section a constant for the time it takes to run once, count how many times each section runs, and add up the products. How many times the loop runs depends on where the key is, so start with the slowest possibility, the worst case: the key is not in the list at all. (The next section defines the worst case precisely.)

c1 1 c1 c2 n c2n c3 1 c3 def linear_search(the_list, key):     index = 0     while index < len(the_list):         if key == the_list[index]:             return index         else:             index += 1     return None constant runs (worst case) time + the final, failing loop test total c2n + (c1 + c3)
  • First, Python creates the local variable index. That is fast, and it happens only once, but we account for it anyway: call its time c1c_1, a constant that does not depend in any way on nn.
  • Next, the while loop. In the worst case, the key is not in the list at all, and the body of the loop runs nn times. It is reasonable to assume that each iteration, the loop test plus the body, takes a constant amount of time (or at most a constant); call it c2c_2. The loop costs c2nc_2 n in total.
  • Finally, in the worst case the loop test is made one last time, when index equals len(the_list), and comes up False. That is the (n+1)(n+1)st test, since the first nn tests were for index equal to 0,1,…,n−10, 1, \ldots, n-1. Then the function returns None. The last test and the return together take some constant time; call it c3c_3 (again, independent of nn).

Adding up the sections, the worst-case running time of linear search is

c1+c2n+c3=c2n+(c1+c3).c_1 + c_2 n + c_3 = c_2 n + (c_1 + c_3).

The running time of an algorithm usually depends on the size of its input: searching a list of a million items takes longer than searching a list of ten. But it also depends on which input of that size the algorithm gets. Linear search, above, checks the items of a list one at a time. If the key is at index 0, it finishes after one check. If the key is not in the list, it has to check every item before it can report that. Two inputs of the same size can take different amounts of time.

So for each size nn, there is not one running time but many: one for each input of size nn. To get a single function of nn to analyze, we choose which input of each size to consider:

  • The worst-case running time is, for each nn, the longest running time of any input of size nn.
  • The best-case running time is, for each nn, the shortest running time of any input of size nn.
  • The average-case running time is, for each nn, the average running time over all inputs of size nn. Computing it requires knowing how likely each input is, and we won’t use it on this page.

The figure below shows the idea for linear search. Each dot is one input: a list of length nn with the key at some index, or with the key missing. For each nn, the dots form a column. The worst-case running time is the top dot of each column, and the best-case running time is the bottom dot.

1 2 3 4 5 6 7 8 n,  the length of the list running time worst case: key not in the list c2n + (c1 + c3) best case: key at index 0 c1 + c2 other inputs: key at index 1, 2, …, n − 1 each dot is one input of size n

Two points about these definitions are easy to get wrong:

  • The best case is not a small input. The best case is the best input of each size nn. The best case of linear search is a list of any length whose first item is the key. It is not a list of length 1.
  • Each case is a function of nn. In the figure, the worst case of linear search grows with nn, and the best case does not. Both are functions that we can analyze, and later we will describe each of them with big-O or big-Θ notation.

The worst case is usually of most interest, because it is a guarantee: “this algorithm is fast enough, as long as the list is not too long” is useful information. “This algorithm might be fast, if you are lucky and give it the right input” is much less useful. The best case is still worth knowing: it tells you what the algorithm does on favorable inputs, and for some algorithms, such as insertion sort below, favorable inputs are common in practice.

Sometimes the best case and the worst case are the same. To find the smallest item in an unsorted list of length nn, the obvious (and best) algorithm looks at every item, keeping track of the smallest seen so far. Even if the smallest item happens to be first, you can’t know that it’s the smallest until you’ve looked at all the others. Every input of size nn takes the same time.

And some running times do not depend on nn at all: returning a randomly selected item from a list, or the smallest item of a sorted list, takes the same time however long the list is.

The best case uses the same sections with different counts. When the key is at index 0, section c1c_1 runs once, section c2c_2 runs once (the first iteration finds the key and returns), and section c3c_3 does not run at all. The best-case running time is

c1+c2,c_1 + c_2,

a constant that does not depend on nn. These two running times are the two lines in the figure in the previous section.

The values of c1c_1, c2c_2, and c3c_3 depend on the speed of the computer and the efficiency of the Python implementation. The form of each expression does not: in the worst case, a constant times nn plus a constant; in the best case, a constant.

One of the most basic problems in computer science is finding the index of an item in a list. Suppose the list is sorted, so that binary search is an option: a list of country names, sorted alphabetically.

countries = ["Afghanistan", "Albania", "Algeria", "Andorra", "Angola",
"Anguilla", "Argentina", "Armenia", "Aruba", ..., "Zimbabwe"]

(The ... stands for the rest of the country names; assume that every country is in the list.) To find the index of a country, such as "Yemen" or "Afghanistan", there are at least three approaches:

  • Random search. Pick a random index. Check whether that location contains the item you are looking for. If not, repeat.
  • Linear search. Loop over the items in the list, starting at index 0, then index 1, index 2, and so on, until you either find the item or run out of list.
  • Binary search. This approach requires the list to be sorted. Keep track of the indices bounding the sublist still under consideration. If the sublist is ever empty, the item is not in the list. Otherwise, check the item at the midpoint of the sublist. If it is the item, return its index. If not, compare: if the midpoint item is greater than the item sought, discard the second half of the sublist; if smaller, discard the first half. Repeat with the half that remains.

Best and worst cases for the three searches

Section titled “Best and worst cases for the three searches”

Which method finds the item most quickly? It depends on the input. Consider the best and worst inputs for each approach.

  • Random search. In the best case, the first index picked holds the item, and the time taken is the time to examine a single item. In the worst case, random indices are picked forever, and the item is never found. (The probability of this outcome approaches 0 as time goes on, but probabilistic analysis of running time is beyond this page.) And if the item is not in the list at all, random search never discovers that fact.
  • Linear search. In the best case, the item is first in the list, as in the best case for random search. In the worst case, the item is at the end of the list or not present at all. If the list contains nn items, all nn must be examined.
  • Binary search. In the best case, the item is at the midpoint of the whole list, and it is found in the first place we look. In the worst case, the algorithm halves the list over and over until the remaining sublist is empty. A list of length nn can be halved about log⁡2n+1\log_2 n + 1 times before the sublist is empty, and this number is much less than nn for large nn.

All three have the same best case: one look. They differ in the worst case, and the rest of this section makes the worst cases of linear and binary search precise.

Binary search is an example of repeated halving. We divide the list in half. The item cannot be in one of the halves, so we discard that half entirely; the item might be in the other half, so we divide that half in half, and so on, until we either find the item or the sublist is empty. The worst-case running time therefore depends on how many times a list can be halved before the sublist is empty. The answer is the base-2 logarithm of the length of the list.

Halve a number nn, getting n/2n/2. Halve it again, getting n/4n/4, and again, getting n/8n/8. Continue until the number is less than 1. A sublist cannot contain half an item, so a sublist size less than 1 must be 0, and to reach size 0, the sublist must have had size 1 the time before. So consider this question:

How many times do we halve nn until we get down to 1?

16 0 8 1 4 2 2 3 1 4 sublist size halvings 16 → 8 → 4 → 2 → 1:  4 halvings,  and  log2 16 = 4

To answer it, try the opposite operation: start with 1 and keep doubling until reaching nn. Doubling undoes halving, so the number of doublings needed to get from 1 up to nn is the same as the number of halvings needed to get from nn down to 1.

DoublingsResult
01
12
24
38
416
532
664

Each doubling multiplies by another factor of 2, so after xx doublings the result is 2x2^x. The number of doublings needed to reach nn is therefore the value of xx for which 2x=n2^x = n. That number is log⁡2n\log_2 n: the exponent to which 2 must be raised to get nn.

2x=n2^x = n if and only if log⁡2n=x\log_2 n = x.

Starting at 1 and doubling log⁡2n\log_2 n times gives nn; so starting at nn and halving log⁡2n\log_2 n times gives 1. This argument assumes that nn is a power of 2. When it isn’t, the count is rounded, a detail the notation introduced later lets us ignore. It also counts halvings down to 1, not to 0, which is what the worst case of binary search (item not found) needs; but once the sublist has size 1, the next halving gives size 0, so the number of halvings to reach 0 is one more: log⁡2n+1\log_2 n + 1.

When computer scientists write logarithms, they almost always mean base-2 logarithms.

Binary search is analyzed the same way. Here it is written with a loop: left and right are the indices at the two ends of the sublist still under consideration, and each iteration of the loop discards half of that sublist.

def binary_search(the_list, key):
left = 0
right = len(the_list) - 1
while left <= right:
midpoint = (left + right) // 2
if the_list[midpoint] == key:
return midpoint # found it
elif key < the_list[midpoint]:
right = midpoint - 1 # discard the second half
else:
left = midpoint + 1 # discard the first half
return None # empty sublist: not found
c5 1 c5 c4 log2 n + 1 c4 (log2 n + 1) c6 1 c6 def binary_search(the_list, key):     left = 0     right = len(the_list) - 1     while left <= right:         midpoint = (left + right) // 2         if the_list[midpoint] == key:             return midpoint         elif key < the_list[midpoint]:             right = midpoint - 1         else:             left = midpoint + 1     return None constant runs (worst case) time + the final, failing loop test total c4 log2 n + (c4 + c5 + c6)
  • Setting up left and right happens once; call its time c5c_5.
  • One iteration of the loop tests whether the sublist is empty, computes the midpoint, compares the key with the item at the midpoint, and moves left or right. Each of these steps takes constant time; call the total for one iteration c4c_4. In the worst case, the key is not in the list, and the loop runs once for each sublist size from nn down through 1: log⁡2n+1\log_2 n + 1 times, as the halving figure above shows.
  • Finally, the loop test fails, when the sublist is empty, and the function returns None. Call the time for these c6c_6.

So the worst-case running time of binary search is

c4(log⁡2n+1)+c5+c6=c4log⁡2n+(c4+c5+c6).c_4 (\log_2 n + 1) + c_5 + c_6 = c_4 \log_2 n + (c_4 + c_5 + c_6).

In the best case, the key is at the midpoint of the whole list. The setup runs once, the loop runs once and returns, and the final section does not run, for a best-case running time of c5+c4c_5 + c_4, a constant.

(Binary search can also be written recursively, as it was in CS 1, and the running time is the same.)

Run-time analysis of find_node on a linked list

Section titled “Run-time analysis of find_node on a linked list”

The same method applies to operations on data structures. Here is find_node, a method of a circular, doubly linked list with a sentinel. It walks the list from the sentinel and returns the first node holding value, or None. Let nn be the number of items in the list.

c1 1 1 c2 1 n c3 0 1 def find_node(self, value):     node = self.sentinel.next     while node is not self.sentinel:         if node.value == value:             return node         node = node.next     return None constant runs: best case runs: worst case + the final, failing loop test best (value in the first node) c1 + c2 worst (value not in the list) c2n + (c1 + c3)

This is linear search on a linked list, and the analysis has the same shape. In the best case, the first node holds the value: the loop runs once and returns, for a running time of c1+c2c_1 + c_2. In the worst case, no node holds the value: the loop runs once for each of the nn nodes, and then the loop test fails at the sentinel, for c2n+(c1+c3)c_2 n + (c_1 + c_3). The walk is what costs time; any linked-list operation that has to find a node by value or by index, starting from one end, has the same worst case.

In some code, the counts do not depend on which input of size nn is given, and the best and worst cases are the same. Here nn is a number passed to the function:

def do_something(n):
for i in range(n):
for j in range(n):
print(i, j)
c1 n c1n c2 n · n = n² c2n² def do_something(n):     for i in range(n):         for j in range(n):             print(i, j) constant runs time total c2n² + c1n

The inner loop’s body, the print, takes some constant time. How many times does it run? For each of the nn iterations of the outer loop, the inner loop iterates nn times, so n⋅n=n2n \cdot n = n^2 times in all. The outer loop’s own step runs only nn times. The running time is c2n2+c1nc_2 n^2 + c_1 n, for every call with argument nn.

At the other extreme, some code takes the same time regardless of its input:

def do_something_else(n):
print("My name is Inigo Montoya")

Its running time is a constant cc, whatever nn is.

In linear search and binary search, the best case is a constant: the key is found on the first look. Insertion sort, from CS 1, is an example where the best and worst cases are different and both grow with nn.

def insertion_sort(the_list):
n = len(the_list)
for i in range(1, n):
key = the_list[i] # the value to insert
j = i - 1 # look to the left of position i
while j >= 0 and the_list[j] > key:
the_list[j + 1] = the_list[j]
j -= 1
the_list[j + 1] = key
c1 1 1 c2 n − 1 n − 1 c3 0 1 + 2 + ⋯ + (n−1) c2 part of the same c₂ run as above def insertion_sort(the_list):     n = len(the_list)     for i in range(1, n):         key = the_list[i]         j = i - 1         while j >= 0 and the_list[j] > key:             the_list[j + 1] = the_list[j]             j -= 1         the_list[j + 1] = key constant runs: best case runs: worst case best (already sorted) c2(n − 1) + c1 worst (reverse sorted) c3 n(n−1)/2 + c2(n − 1) + c1

Only the while loop’s section, c3c_3, runs a number of times that depends on the values in the list, not only on nn.

Each iteration of the outer loop inserts one value into the sorted part of the list, but it does not work like inserting into a growing array. An array insertion needs a free slot at the end, and possibly a doubling, before it can shift values right to open a gap at a known index. Insertion sort needs neither. When it copies key = the_list[i] out of the list, position i becomes a hole, right next to the sorted part. Each iteration of the while loop copies one value one slot right, into the hole, which moves the hole one slot left. When the loop stops, key is written into the hole:

0 1 2 3 4 5 i = 4: the next value 10 20 40 50 30 60 lift out key = 30 10 20 40 50 60 hole key 30 50 > 30: shift right 10 20 40 50 60 hole key 30 40 > 30: shift right 10 20 40 50 60 hole key 30 20 ≤ 30: drop key in 10 20 30 40 50 60 sorted so far

The hole also combines two jobs that an array insertion does separately: finding the position and shifting. Insertion sort does not know in advance where key belongs. It finds out by walking left from the end of the sorted part, and every comparison that does not stop the loop pays for one shift. Each shift is a single assignment, not a three-assignment swap.

In the worst case, the list starts out sorted in reverse order, and each item has to move all the way to the left: the item at position 1 moves 1 place, the item at position 2 moves 2 places, and so on, up to n−1n-1 places. The total number of moves is 1+2+⋯+(n−1)1 + 2 + \cdots + (n-1). To add this up, write the sum forward and then backward, and add the two rows column by column:

1+2+⋯+(n−1)+(n−1)+(n−2)+⋯+1n+n+⋯+n\begin{array}{rcccccc} & 1 & + & 2 & + \cdots + & (n-1) \\ + & (n-1) & + & (n-2) & + \cdots + & 1 \\ \hline & n & + & n & + \cdots + & n \end{array}

Each of the n−1n - 1 columns adds up to nn, so twice the sum is n(n−1)n(n-1), and the sum is n(n−1)2\dfrac{n(n-1)}{2}. The worst-case running time is

c3n(n−1)2+c2(n−1)+c1.c_3 \frac{n(n-1)}{2} + c_2 (n - 1) + c_1.

In the best case, the list starts out already sorted. Each time the while loop is reached, the_list[j] is already less than or equal to key, so the loop body runs 0 times, and the best-case running time is

c2(n−1)+c1.c_2 (n - 1) + c_1.

The best case here is a list of any length nn that happens to be sorted, not a short list. And it is a realistic input: lists that are already sorted, or nearly sorted, come up often in practice.

We want to compare running times: is binary search faster than linear search, and by how much? Every analysis so far ended with a function of nn with constants in it, one for each case. (The constants belong to each analysis separately: c2c_2 in one row is not the same constant as c2c_2 in another.)

AlgorithmBest-case running timeWorst-case running time
Linear searchc1+c2c_1 + c_2c2n+(c1+c3)c_2 n + (c_1 + c_3)
Binary searchc4+c5c_4 + c_5c4log⁡2n+(c4+c5+c6)c_4 \log_2 n + (c_4 + c_5 + c_6)
find_nodec1+c2c_1 + c_2c2n+(c1+c3)c_2 n + (c_1 + c_3)
do_somethingc2n2+c1nc_2 n^2 + c_1 nc2n2+c1nc_2 n^2 + c_1 n
do_something_elsecccc
Insertion sortc2(n−1)+c1c_2 (n-1) + c_1c3n(n−1)2+c2(n−1)+c1c_3 \frac{n(n-1)}{2} + c_2(n-1) + c_1

These functions are cumbersome to compare, and the constants depend on the computer and the Python implementation. As the next example shows, once nn is large enough, the constants matter much less than how fast each function grows. Asymptotic analysis compares functions by their growth as nn grows large, ignoring constant factors. It applies to any function of nn: a best case, a worst case, or a function that has nothing to do with running time.

Which is faster, linear search or binary search? It depends on the input (linear search finds "Afghanistan" faster than binary search does, in the list above), on the length of the list, and on the constants. For the worst case, the question is: when is

c4log⁡2n+(c4+c5+c6)smaller thanc2n+(c1+c3) ?c_4 \log_2 n + (c_4 + c_5 + c_6) \quad\text{smaller than}\quad c_2 n + (c_1 + c_3)\,?

The claim is that, whatever the constants, binary search is faster for large enough nn. Suppose linear search runs on a blazingly fast, ultra-modern HAL 9000 that executes each iteration of the while loop in 0.00001 seconds, so c2=0.00001c_2 = 0.00001, and let c1+c3=0c_1 + c_3 = 0. Binary search runs on a vintage Apple II, which takes 0.01 seconds per iteration of its loop, so c4=0.01c_4 = 0.01: a thousand times slower per step. Let c5+c6=0c_5 + c_6 = 0 as well.

nnlog⁡2n\log_2 nHAL 9000, linear search: 0.00001 n0.00001\,nApple II, binary search: 0.01(log⁡2n+1)0.01(\log_2 n + 1)
1,024100.01 s0.11 s
16,384140.16 s0.15 s
1,000,000about 2010 sabout 0.21 s
1,000,000,000about 30about 2.8 hoursabout 0.31 s

At n=16,384=214n = 16{,}384 = 2^{14}, the Apple II already wins. It might look as though the constants were rigged to make that happen, but lengthening the list makes the point: no difference between c2c_2 and c4c_4 can overcome the Apple II’s advantage of a log⁡2n\log_2 n factor where the HAL 9000 has a factor of nn.

The two functions grow in different ways. Linear search’s worst case grows linearly: if the list doubles in length, the running time about doubles. Binary search’s worst case grows logarithmically: if the list doubles in length from nn to 2n2n, the loop runs one more time, and the running time grows by only c4c_4. With equations,

c4log⁡2(2n)=c4log⁡2n+c4log⁡22=c4log⁡2n+c4,c_4 \log_2 (2n) = c_4 \log_2 n + c_4 \log_2 2 = c_4 \log_2 n + c_4,

since 21=22^1 = 2 means log⁡22=1\log_2 2 = 1. The function log⁡2n\log_2 n grows much more slowly than nn, and for large enough nn that difference outweighs any difference in the constants.

The three notations below, big-O, big-Ω, and big-Θ, make “grows like” precise. Each compares a function f(n)f(n), such as a running time, with a simpler function g(n)g(n), such as nn or n2n^2.

f(n)f(n) is O(g(n))O(g(n)) (pronounced “big-O of g(n)g(n)”) if, for large enough nn, f(n)f(n) is at most a constant times g(n)g(n). Precisely: there are positive constants cc and n0n_0 such that

f(n)≤c⋅g(n)for every n≥n0.f(n) \le c \cdot g(n) \quad \text{for every } n \ge n_0.

The constant cc is what lets big-O ignore constant factors, and n0n_0 is what lets it ignore small nn. In the figure, g(n)=n2g(n) = n^2 and c=2c = 2. The wiggly function f(n)f(n) crosses 2n22n^2 several times while nn is small, but after about n=8.2n = 8.2 it never goes back above. With n0=9n_0 = 9, f(n)≤2n2f(n) \le 2n^2 for every n≥n0n \ge n_0, so f(n)f(n) is O(n2)O(n^2):

0 2 4 6 8 10 12 n n0 = 9 c · g(n) = 2n² c = 2,  g(n) = n² f(n) before n₀, the curves may cross; the definition allows it for every n ≥ n₀, f(n) ≤ c · g(n)

For example, the worst-case running time of linear search, c2n+(c1+c3)c_2 n + (c_1 + c_3), is O(n)O(n): for every n≥1n \ge 1, it is at most c2n+(c1+c3)n=(c1+c2+c3)nc_2 n + (c_1 + c_3) n = (c_1 + c_2 + c_3) n. The worst-case running time of binary search is O(log⁡2n)O(\log_2 n). The best-case running time of linear search, c1+c2c_1 + c_2, is a constant, at most (c1+c2)⋅1(c_1 + c_2) \cdot 1, so it is O(1)O(1).

When a function is a sum, such as

c1n2+c2n+c3,c_1 n^2 + c_2 n + c_3,

only the fastest-growing term matters: it is O(n2)O(n^2). The definition shows why. For every n≥1n \ge 1, n≤n2n \le n^2 and 1≤n21 \le n^2, so each lower-order term is at most its coefficient times n2n^2:

c1n2+c2n+c3≤c1n2+c2n2+c3n2=(c1+c2+c3) n2.c_1 n^2 + c_2 n + c_3 \le c_1 n^2 + c_2 n^2 + c_3 n^2 = (c_1 + c_2 + c_3)\, n^2.

Choosing c=c1+c2+c3c = c_1 + c_2 + c_3 and n0=1n_0 = 1 satisfies the definition. The constant cc absorbs every coefficient, which is why both the lower-order terms and the leading constant can be dropped. So the running time of do_something, c2n2+c1nc_2 n^2 + c_1 n, is O(n2)O(n^2), and the running time of do_something_else, a constant cc, is O(1)O(1).

The base of a logarithm doesn’t matter either, because for any constants aa and bb,

log⁡an=log⁡bnlog⁡ba.\log_a n = \frac{\log_b n}{\log_b a}.

Logarithms to base aa and base bb differ only by the constant factor log⁡ba\log_b a, and constant factors don’t matter in big-O notation. So the worst case of binary search is written O(log⁡n)O(\log n).

Big-O is only an upper bound. It says that a function grows no faster than g(n)g(n), and nothing about how much slower it might grow. Any function that is O(log⁡n)O(\log n) is also O(n)O(n), and also O(n2)O(n^2). It is true that the worst case of binary search is O(n)O(n), but it is not the most informative statement; O(log⁡n)O(\log n) is.

f(n)f(n) is Ω(g(n))\Omega(g(n)) (Ω is the Greek letter omega; “big-omega of g(n)g(n)”) if, for large enough nn, f(n)f(n) is at least a constant times g(n)g(n). Precisely: there are positive constants cc and n0n_0 such that

f(n)≥c⋅g(n)for every n≥n0.f(n) \ge c \cdot g(n) \quad \text{for every } n \ge n_0.

This is big-O with the inequality reversed. In the figure, g(n)=n2g(n) = n^2 and c=1/2c = 1/2. The wiggly function f(n)f(n) dips below n2/2n^2/2 twice while nn is small, but from n0=10n_0 = 10 on it stays at or above, so f(n)f(n) is Ω(n2)\Omega(n^2):

0 2 4 6 8 10 12 14 n n0 = 10 c · g(n) = n²/2 c = 1/2,  g(n) = n² f(n) before n₀, the curves may cross; the definition allows it for every n ≥ n₀, f(n) ≥ c · g(n)

For example, the running time of do_something, c2n2+c1nc_2 n^2 + c_1 n, is at least c2n2c_2 n^2 for every nn, so it is Ω(n2)\Omega(n^2). The worst-case running time of linear search is at least c2nc_2 n, so it is Ω(n)\Omega(n).

Big-Ω is only a lower bound, in the same way that big-O is only an upper bound. The worst case of linear search is also Ω(log⁡n)\Omega(\log n) and Ω(1)\Omega(1), true statements that say less than Ω(n)\Omega(n).

f(n)f(n) is Θ(g(n))\Theta(g(n)) (Θ is the Greek letter theta) if it is both O(g(n))O(g(n)) and Ω(g(n))\Omega(g(n)): for large enough nn, it is at least one constant times g(n)g(n) and at most another constant times g(n)g(n). It grows like g(n)g(n), no faster and no slower.

The examples above already show both halves. The running time of do_something is O(n2)O(n^2) and Ω(n2)\Omega(n^2), so it is Θ(n2)\Theta(n^2). The worst-case running time of linear search is O(n)O(n) and Ω(n)\Omega(n), so it is Θ(n)\Theta(n).

When a function is known exactly, as all the functions in the table above are, finding its big-Θ is the same work as finding its big-O: keep the fastest-growing term and drop its constant. Use big-Θ when you know the growth rate exactly, and big-O or big-Ω when you can bound it from only one side.

For large enough nn, any function that is Θ(n2)\Theta(n^2) is smaller than any function that is Θ(n3)\Theta(n^3), whatever the constants. So functions can be grouped into families by growth rate, and the families can be ranked:

1<log⁡n<n<n<nlog⁡n<n2<n3<2n<n!1 < \log n < \sqrt n < n < n \log n < n^2 < n^3 < 2^n < n!

A table of values shows how quickly these families separate:

nnlog⁡2n\log_2 nnlog⁡2nn \log_2 nn2n^22n2^n
10about 3about 331001,024
100about 7about 66410,000about 1.3×10301.3 \times 10^{30}
1,000about 10about 9,9661,000,000about 1.1×103011.1 \times 10^{301}

An algorithm whose running time grows like n!n! runs for a long time, even for moderate nn.

The take-home method: choose the case, and compute its running time section by section, using constants. Then keep only the fastest-growing term and drop its constant. That case’s running time is big-Θ of that term.

Putting the two steps together gives one tight bound for each case:

AlgorithmBest caseWorst case
Linear searchΘ(1)\Theta(1)Θ(n)\Theta(n)
Binary searchΘ(1)\Theta(1)Θ(log⁡n)\Theta(\log n)
find_nodeΘ(1)\Theta(1)Θ(n)\Theta(n)
do_somethingΘ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)
do_something_elseΘ(1)\Theta(1)Θ(1)\Theta(1)
Insertion sortΘ(n)\Theta(n)Θ(n2)\Theta(n^2)

Read each entry as a sentence that names a case and a bound: “the best-case running time of linear search is Θ(1)\Theta(1).” The case says which function; the bound says how fast it grows. Any case can be paired with any kind of bound:

  • “The best-case running time of insertion sort is O(n)O(n)” is true. Big-O is used here for a best case.
  • “The worst-case running time of linear search is O(n2)O(n^2)” is also true. It is an upper bound, but not a tight one; Θ(n)\Theta(n) says more.
  • “The worst-case running time of insertion sort is Ω(n2)\Omega(n^2)” is true. Big-Ω is used here for a worst case.
  • “The worst-case running time of binary search is Θ(log⁡n)\Theta(\log n)” is a tight bound on a worst case.

So big-O does not mean worst case, big-Ω does not mean best case, and big-Θ does not mean average case. Big-O, big-Ω, and big-Θ describe a function; best case and worst case pick the function.

A bound can also be stated for all inputs at once, with no case named. Since no input takes longer than the worst case, a big-O bound on the worst case holds for every input. “Insertion sort runs in O(n2)O(n^2) time” is therefore true: no input of size nn takes more than a constant times n2n^2. But “insertion sort runs in Θ(n2)\Theta(n^2) time” is false as a statement about every input, because a sorted list takes time proportional to only nn. For insertion sort, the strongest statement about all inputs is O(n2)O(n^2). Linear search is similar: it runs in O(n)O(n) time on every input, but not in Θ(n)\Theta(n) time on every input. For do_something, whose best and worst cases are the same, “do_something runs in Θ(n2)\Theta(n^2) time” is true for every input.

So a statement such as “linear search is Θ(n)\Theta(n)” is ambiguous: it is true of the worst case but false of the best case. Name the case, unless one bound holds for every input.

Amortized analysis: why doubling makes append constant-time

Section titled “Amortized analysis: why doubling makes append constant-time”

A growing array stores a list’s values in a block of fixed capacity, and when the block fills up, it allocates a larger block and copies the values into it. Why should the new block have double the capacity, rather than some fixed number of additional slots?

First, the individual costs. An array stores its items in order in one contiguous block of memory. Inserting at the front of an nn-item array moves every item one slot to the right, so the worst-case cost of inserting is Θ(n)\Theta(n); deleting from the front is Θ(n)\Theta(n) for the same reason. Deleting from the end moves nothing, so pop() is Θ(1)\Theta(1). Appending at the end usually moves nothing either. But if the block is full, and something else is stored in memory right after it, the whole list must be copied to a new, larger block, which takes Θ(n)\Theta(n) time. So in the worst case, a single append takes Θ(n)\Theta(n) time, much worse than the Θ(1)\Theta(1) you might have expected.

The trick is to make that worst case rare. When the block of nn items fills up, the list requests room not for n+1n+1 items but for 2n2n. Overallocating like this may seem wasteful, since up to half of the block may be unused, but it means that a long sequence of appends copies the list only occasionally. Starting from capacity 1, appending 128 items resizes the block at capacity 1, 2, 4, 8, 16, 32, and 64, ending at 128.

To add up the cost, follow one block from one doubling to the next. Say that writing a value into a free slot costs 1, and that copying a value into a new block costs some constant cc per value. Suppose the block has just doubled to capacity 8, so it holds 4 values and has 4 free slots. Each of the next 4 appends finds a free slot and costs 1:

just doubled: capacity 8, 4 values 10 20 30 40 0 1 2 3 4 5 6 7 4 free slots append 50, 60, 70, 80 full: capacity 8, 8 values 10 20 30 40 50 1 60 1 70 1 80 1 cost of each append: 4 cheap appends, total cost: 4 × 1 = 4

Now the block is full. The next append, of 90, must first copy all 8 values into a new block of capacity 16, at a cost of 8c8c, and then write 90 into slot 8:

full block, capacity 8 10 20 30 40 50 60 70 80 append 90: the block is full, so copy all 8 values first: 8 × c = 8c new block, capacity 16 10 20 30 40 50 60 70 80 90 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Charge the copy to the 4 appends that filled the block (50, 60, 70, 80): 4 + 8c  for 4 appends, so  1 + 2c  per append

The append of 90 is expensive only because the 4 appends before it filled the block, so charge the cost of the copy to those 4 appends. Together they cost 4+8c4 + 8c: 44 to write their own values and 8c8c to copy the block they filled. That is 1+2c1 + 2c per append.

The same calculation works at every size. Right after a block doubles from capacity mm to capacity 2m2m, it holds mm values and has mm free slots. The next mm appends cost 1 each, and the copy that follows costs 2m⋅c2m \cdot c. Charged to those mm appends, that is m+2mcm + 2mc in total, or 1+2c1 + 2c per append, whatever mm is. (The very first appends, into blocks of capacity 1 and 2, don’t start half full, but they add only a constant to the total.)

The next figure shows the cost of each of the first 16 appends, starting from capacity 1, with c=1c = 1. Most appends cost 1. The appends that find the block full, the 2nd, 3rd, 5th, and 9th, also copy every value, and cost 2, 3, 5, and 9. A single append can be expensive, but spread over the appends that filled each block, the copying never raises the average above 1+2c=31 + 2c = 3:

0 3 6 9 cost 1 copy 1 2 copy 2 3 4 copy 4 5 6 7 8 copy 8 9 10 11 12 13 14 15 16 append number 1 + 2c = 3 writing the new value: 1 copying: c per value (c = 1 here) appends 1–16 cost 16 + 1 + 2 + 4 + 8 = 31, less than 3 × 16 = 48

Since the appends cost at most the constant 1+2c1 + 2c each, on average, appending takes Θ(1)\Theta(1) amortized time.

Doubling is not the only growth factor that works. Any constant factor greater than 1 gives Θ(1)\Theta(1) amortized appends; a smaller factor wastes less memory but leaves each append with a larger share of the copying. Python’s own list grows by only about 12.5% at a time. After the block grows from capacity mm to 1.125m1.125m, only 0.125m0.125m cheap appends happen before the next copy of 1.125m1.125m values, so each of them is charged 1+9c1 + 9c rather than 1+2c1 + 2c. That is still a constant.

This kind of analysis is called an amortized running-time analysis: the occasional expensive append is paid for by many cheap appends, so that averaged over any long sequence of operations, each append costs Θ(1)\Theta(1). It is a statement about the whole sequence, not about any one call: a single append can still take Θ(n)\Theta(n) time.

Growing by a fixed number of slots instead, say 10, breaks this argument. After the block grows from capacity mm to m+10m + 10, only 10 cheap appends happen before the next copy, and that copy moves all m+10m + 10 values. Charged to those 10 appends, that is 1+(m+10)c/101 + (m + 10)c/10 per append, which grows with mm. Once nn is large, nearly every tenth append copies the whole list, and nn appends cost Θ(n2)\Theta(n^2) in total: Θ(n)\Theta(n) each, on average.

The analyses of linear and binary search, the review of logarithms, and the introduction to big-O are adapted from the CS 1 notes by Balkcom et al., chapter 14, “Analyzing algorithms”.

The rest of these notes were drafted by Claude (Anthropic) at Balkcom’s direction and then edited by Balkcom. This includes the comparison of the growing array and the linked list, the discussion of best, worst, and average cases, the analyses of find_node, nested loops, and insertion sort, big-Ω and big-Θ, the ranking of growth rates, the amortized analysis of doubling, the figures, and the practice problems. Claude’s drafts reflect the standard treatment of these topics, which can also be found in Cormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, chapters 2 and 3 and the chapter on amortized analysis, and in Sedgewick and Wayne, Algorithms, section 1.4.

  • TimeComplexity: the running times of operations on Python’s built-in data structures
  • timeit: for measuring the running time of small pieces of code

Try these on your own before checking the solutions. For each function, first compute the running time in terms of constants (c1c_1, c2c_2, and so on) and nn, section by section. Then give the tightest big-Θ bound you can. If the best and worst cases differ, analyze both. (Every running time in these problems is O(n!)O(n!), but you can say something much more informative about each.)

def f(n):
i = 0
while i < n:
j = 0
while j < n:
print(i, j)
j += 1
i += 1

(Solution)

def f(n):
i = 0
while i < n:
j = 0
while j < 1000:
print(i, j)
j += 1
i += 1

(Solution)

def g(x):
for i in range(x):
print(i)
def f(n):
i = n
while i > 0:
g(n)
i -= 1

(Solution)

def f(n):
i = 1
while i < n:
i *= 2
print(i)

(Solution)

from math import sqrt
def f(n):
i = 1
while i <= sqrt(n):
if n % i == 0:
print(i, n // i)
i += 1
print("And that's all the integer factors of", n)

(Solution)

For a circular, doubly linked list with a sentinel and nn items, give the worst-case running time of __init__, insert_after, prepend, append, deleting a given node, get(i), and a length() that walks the list to count its items. For each, say in a sentence why.

(Solution)

A growing array that adds 10 slots each time it fills up, instead of doubling, starts empty with capacity 10. How many item copies does it make, in total, over the course of nn appends, where nn is a multiple of 10? Give an exact formula and a big-Θ bound, and compare with doubling.

(Solution)

Let nn be a positive integer.

def count_digits(n):
count = 0
while n > 0:
n = n // 10
count += 1
return count

(Solution)

def f(n):
for i in range(n):
for j in range(i, n):
print(i, j)

(Solution)

Let nn be the length of the_list.

def has_duplicate(the_list):
n = len(the_list)
for i in range(n):
for j in range(i + 1, n):
if the_list[i] == the_list[j]:
return True
return False

(Solution)

Python’s list is a growing array. result.insert(0, i) shifts every value already in result one slot to the right before writing i into slot 0.

def countdown(n):
result = []
for i in range(n):
result.insert(0, i)
return result

How would you build the same list in Θ(n)\Theta(n) time?

(Solution)

def f(n):
for i in range(n):
j = 1
while j < n:
j *= 2

(Solution)

Say whether each statement is true or false, and why. has_duplicate is the function from Problem 10.

  1. The worst-case running time of has_duplicate is Ω(n)\Omega(n).
  2. The best-case running time of has_duplicate is Ω(n)\Omega(n).
  3. has_duplicate runs in O(n2)O(n^2) time on every input.
  4. has_duplicate runs in Ω(n2)\Omega(n^2) time on every input.
  5. If the worst-case running time of an algorithm is O(nlog⁡n)O(n \log n), then so is its best-case running time.
  6. If the best-case running time of an algorithm is Ω(n)\Omega(n), then every input of size nn takes Ω(n)\Omega(n) time.

(Solution)

A growing array doubles its capacity when an append finds the block full. To avoid wasting memory after many deletions, it also shrinks: whenever a pop from the end leaves the block exactly half full, it copies the remaining values into a new block of half the capacity.

  1. The array is full, with mm values and capacity mm, where mm is a power of 2. Find a sequence of operations in which every operation copies mm values. What does this say about the amortized cost of appends and pops?
  2. Suppose instead that a pop shrinks the block to half its capacity only when it leaves the block a quarter full. Explain why the sequence from part 1 no longer causes a copy on every operation. How many operations must happen, at least, between one copy and the next?

(Solution)