All A Level 8 50 min read 10 question quiz 20 exam marks

Big-O and Algorithm Complexity

How to measure the cost of an algorithm without a stopwatch: counting steps, the growth classes from constant to factorial, reading the class straight from code, space complexity, and why some problems cannot be brute forced.

Counting steps, not seconds

How long a program takes depends on the computer, the language, what else is running, and the input. None of that tells you whether the algorithm is any good. Complexity measures it directly: how the number of basic steps grows as the size of the input, n, grows.

Big-O notation writes down that growth, using two simplifications:

  1. Drop constant factors. Doing 3 steps per item or 5 steps per item is the same shape of growth.
  2. Keep only the fastest growing term. For large n the biggest term swamps the rest.

So an algorithm that takes 3n² + 5n + 2 steps is O(n²), said "order n squared". Big-O describes an upper bound, and unless it says otherwise, for the worst case.

The growth classes

ClassNameExampleDoubling n
O(1)ConstantReading items[5], a dictionary lookupNo change
O(log n)LogarithmicBinary searchOne extra step
O(n)LinearLinear search, one loop through the dataTwice as long
O(n log n)LinearithmicMerge sort, quick sort on averageA little over twice
O(n²)QuadraticBubble sort, a loop inside a loopFour times as long
O(2ⁿ)ExponentialNaive recursive Fibonacci, trying every subsetThe square of the time
O(n!)FactorialTrying every ordering, as in a brute force salesmanUnimaginable

Rather than quote this table, count it. Each function below does one thing in a way that gives the growth in its name, and returns the number of steps it took.

Python
def constant(n):
    return 1

def logarithmic(n):
    steps = 0
    while n > 1:
        n = n // 2
        steps += 1
    return steps

def linear(n):
    steps = 0
    for i in range(n):
        steps += 1
    return steps

def linearithmic(n):
    steps = 0
    for level in range(logarithmic(n)):
        for i in range(n):
            steps += 1
    return steps

def quadratic(n):
    steps = 0
    for i in range(n):
        for j in range(n):
            steps += 1
    return steps

def exponential(n):
    if n == 0:
        return 1
    return exponential(n - 1) + exponential(n - 1)

def row(*values):
    print("".join(str(v).ljust(11) for v in values))

row("n", "O(1)", "O(log n)", "O(n)", "O(n log n)", "O(n sq.)", "O(2 to n)")
for n in [4, 8, 12, 16]:
    row(n, constant(n), logarithmic(n), linear(n), linearithmic(n), quadratic(n), exponential(n))

Read down the last column. By n = 16 the exponential algorithm has taken 65 536 steps, where the linear one took 16. Add four more to n and it is a million.

How algorithm costs growA graph of the number of steps against the size of the input, n, from 0 to 12. Constant time stays flat at 1. Logarithmic time rises very slowly, to about 3.6. Linear time rises steadily to 12. n log n rises faster, to about 43. n squared rises steeply, reaching 100 at n equals 10 and leaving the top of the graph at about 11. Exponential time, 2 to the power n, leaves the top at about n equals 7, when it reaches 128, and keeps climbing far beyond the graph.Steps taken, as the size of the input growsinput size nsteps024681012020406080100120O(1)O(log n)O(n)O(n log n)O(n²)O(2ⁿ)O(1) and O(log n) are almost flat. O(2ⁿ) leaves the picture at n = 7.
Past n = 7, the exponential curve is already off the page. Past n = 40 it is off the page of any computer.

Where the big ones come from

Pattern in the codeComplexity
A statement that runs onceO(1)
A loop over the n itemsO(n)
A loop that halves (or doubles) the problem each timeO(log n)
A loop inside a loop, both over nO(n²)
A loop over n items, each step doing an O(log n) jobO(n log n)
A recursive function that calls itself twice on a problem one smallerO(2ⁿ)
A recursive function that halves the problem and does n work to combineO(n log n)
Big O: how the work grows as the input growsA chart of the six common growth rates, with the size of the input along the bottom and the amount of work up the side. O of 1 is a flat line: the work does not depend on the size of the input. O of log n rises very slowly. O of n is a straight line: double the input and you double the work. O of n log n is slightly steeper than that. O of n squared curves up sharply and O of 2 to the n shoots off the top of the chart almost at once. A panel on the right gives each one a feel: O of 1 is opening the first page, O of log n is halving a phone book again and again, O of n is reading every page, O of n log n is merge sorting a pile of cards, O of n squared is comparing every pair of people, and O of 2 to the n is trying every combination. Big O counts steps, not seconds, and describes how the work grows, not how long a particular run takes.How the work grows with the inputsize of the input, nwork1What each one feels likeO(1)open the first pageO(log n)halve a phone book,again and againO(n)read every pageO(n log n)merge sort a pile ofcardsO(n²)compare every pairof peopleO(2ⁿ)try every combination2it counts steps, not seconds
  1. Each curve shows how the work grows as the input gets bigger.
  2. From flat, to slow, to straight, to steep, to explosive: O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ).
Big O compares algorithms by how they scale. It ignores the speed of any particular machine.

Reading complexity from code

In the exam you are given code, or a description, and you state its complexity. The method is to find the part that repeats, and ask how many times it repeats for an input of size n.

Python
def first_item(items):                 # O(1)
    return items[0]

def total(items):                      # O(n): one pass
    result = 0
    for item in items:
        result += item
    return result

def has_duplicate(items):              # O(n squared): compares every pair
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            if items[i] == items[j]:
                return True
    return False

def has_duplicate_fast(items):         # O(n): a set remembers what has been seen
    seen = set()
    for item in items:
        if item in seen:
            return True
        seen.add(item)
    return False

data = [3, 8, 1, 9, 4, 8]
print(first_item(data), total(data), has_duplicate(data), has_duplicate_fast(data))

Both duplicate checkers give the same answer. The first compares every pair, so for a million items it makes about 500 billion comparisons. The second looks at each item once, relying on the set's constant time membership test, so it makes a million. Same task, completely different cost, and the only difference is the data structure.

Best, average and worst case

The same algorithm can behave differently on different inputs of the same size.

AlgorithmBest caseWorst case
Linear searchO(1): the item is firstO(n): last or missing
Binary searchO(1): the item is in the middleO(log n)
Bubble sort with a swapped flagO(n): already sorted, one passO(n²): reversed
Quick sortO(n log n)O(n²): bad pivots, such as an already sorted list
Merge sortO(n log n)O(n log n)
The worst case is the default

When the question just says "state the time complexity of" an algorithm, give the worst case, and write it as O( ). If it asks for the best case, or the average, it will say so.

Space complexity

Space complexity is how the memory an algorithm needs grows with n, apart from the input itself. A time saving is often paid for in space, and the exam asks you to say which.

AlgorithmExtra spaceWhy
Bubble sort, insertion sortO(1)Swaps items in the same list
Merge sortO(n)Builds new lists as it merges
Recursive factorialO(n)One stack frame for each pending call
Iterative factorialO(1)One variable
Memoised FibonacciO(n)Stores every result, to save time

Python's own operations

The built in types have costs too, and the wrong choice is a common cause of slow programs.

OperationCost
items.append(x)O(1)
items.insert(0, x)O(n): everything after shifts
x in items for a listO(n)
x in some_set, key in some_dictO(1) on average
sorted(items)O(n log n)
len(items)O(1)

Measuring, and the problems that cannot be solved by trying everything

A stopwatch confirms it

Timing a program is no substitute for analysis, because the result varies from run to run and machine to machine. It can confirm the difference between two growth classes when it is large:

Python
import time

def linear_search(items, target):
    for i in range(len(items)):
        if items[i] == target:
            return i
    return -1

def binary_search(items, target):
    low = 0
    high = len(items) - 1
    while low <= high:
        mid = (low + high) // 2
        if items[mid] == target:
            return mid
        elif items[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1

data = list(range(200000))

start = time.perf_counter()
linear_search(data, 199999)
linear_time = time.perf_counter() - start

start = time.perf_counter()
binary_search(data, 199999)
binary_time = time.perf_counter() - start

print(linear_time > binary_time, linear_time > 50 * binary_time)

Rather than print the times themselves, which are different on every machine, the program asks two yes or no questions: is linear search slower, and is it more than fifty times slower? The answer is True to both, and the gap grows with the data.

Tractable and intractable problems

A problem is tractable if there is an algorithm that solves it in polynomial time, such as O(n) or O(n²) or O(n³): the cost grows quickly but stays manageable. It is intractable if the only known algorithms are exponential or worse: for any but small inputs they cannot finish in a useful time, however fast the computer.

The classic example is the travelling salesman problem: find the shortest route that visits every one of n cities once and returns. Checking every route is the obvious algorithm, and for n cities there are (n - 1)! divided by 2 different routes.

Python
import math

def readable(seconds):
    for name, size in [("years", 31_557_600), ("days", 86_400), ("hours", 3_600), ("minutes", 60)]:
        if seconds >= size:
            amount = seconds / size
            if amount > 1000:
                return format(round(amount, -3), ",.0f") + " " + name
            return format(amount, ",.3g") + " " + name
    return "under a minute"

for cities in [5, 10, 15, 20, 25]:
    routes = math.factorial(cities - 1) // 2
    print(cities, "cities:", format(routes, ","), "routes, at a billion a second:", readable(routes / 1_000_000_000))

Going from 20 cities to 25 takes the time from about two years to about ten million years. A computer a thousand times faster would not help.

When a problem is intractable, programmers use a heuristic: a method that finds a good answer, not necessarily the best, in reasonable time. For the salesman, "always go to the nearest city not yet visited" is fast, and is usually within a quarter of the best route. A* search, which you have already met, is another way of using a heuristic to avoid exploring everything.

Why this matters for design

A better algorithm beats a better computer. Moving from O(n²) to O(n log n) on a million items is a gain of about fifty thousand times. At the historic rate of hardware doubling every two years, that is about thirty years of progress, gained from one rewrite.

Key terms you must be able to define

Complexity
A measure of how the resources an algorithm needs, time or memory, grow as the size of its input grows.
Big-O notation
A way of describing the upper bound on the growth of an algorithm's cost, ignoring constants and lower order terms.
Time complexity
How the number of steps an algorithm takes grows as the size of the input grows.
Space complexity
How the amount of memory an algorithm needs grows as the size of the input grows.
Constant time
A cost that does not depend on the size of the input, written O(1).
Logarithmic time
A cost that grows by a fixed amount each time the input doubles, written O(log n), as in binary search.
Polynomial time
A cost that grows as a power of n, such as n or n squared, which is considered tractable.
Exponential time
A cost that multiplies each time n increases by one, such as 2 to the power n, which is considered intractable.
Tractable
Describes a problem that can be solved in polynomial time or better.
Intractable
Describes a problem for which the only known algorithms take exponential time or worse, so only small cases can be solved.
Heuristic
A method that finds a good, not necessarily optimal, answer quickly, used when finding the best answer is intractable.

Flashcards

Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.

DefineComplexity
AnswerA measure of how the resources an algorithm needs, time or memory, grow as the size of its input grows.
DefineBig-O notation
AnswerA way of describing the upper bound on the growth of an algorithm's cost, ignoring constants and lower order terms.
DefineTime complexity
AnswerHow the number of steps an algorithm takes grows as the size of the input grows.
DefineSpace complexity
AnswerHow the amount of memory an algorithm needs grows as the size of the input grows.
DefineConstant time
AnswerA cost that does not depend on the size of the input, written O(1).
DefineLogarithmic time
AnswerA cost that grows by a fixed amount each time the input doubles, written O(log n), as in binary search.
DefinePolynomial time
AnswerA cost that grows as a power of n, such as n or n squared, which is considered tractable.
DefineExponential time
AnswerA cost that multiplies each time n increases by one, such as 2 to the power n, which is considered intractable.

Reach the top grade on this topic

Complexity questions are about shape, so show your reasoning rather than just the label.

  • State the complexity in the form O( ) and say which case it is, usually the worst case
  • Justify it from the code: name the loop, say how many times it runs, and what is inside it
  • Drop constants and lower terms, and say that you have done so
  • When comparing algorithms, give a time cost and a space cost for each, and choose with a reason
  • Describe an intractable problem by its exponential cost, and name the heuristic as the way round it

Mistakes that cost marks here

Avoid these
  • Writing O(2n) or O(n + 5), when constants and lower terms are dropped: O(n).
  • Giving the best case when the question means the usual one, the worst case.
  • Saying a nested loop is always O(n squared), when the inner loop may run a fixed number of times.
  • Confusing time and space complexity, or forgetting the stack space used by recursion.
  • Claiming a faster computer solves an exponential problem, when it adds only one more item.
Fun fact

Doubling a computer's speed lets an exponential algorithm handle exactly one more item. Nothing about faster hardware rescues it, which is why a cleverer algorithm is worth more than any amount of waiting for faster processors.

Knowledge check

0 of 10 answered
1What does Big-O notation describe?
2What is the time complexity of reading items[5] from a list?
3What is the worst case time complexity of a binary search?
4A function has one loop that runs n times, and inside it another loop that runs n times. What is its complexity?
5What is the complexity of an algorithm that takes 5n + 20 steps?
6Which of these growth rates is the worst for a large input?
7What is the average time complexity of merge sort?
8If an O(n) algorithm takes 2 seconds for an input of size n, roughly how long does it take for 2n?
9What does space complexity measure?
10What is an intractable problem?
Score 0 / 10

Exam-style questions

Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.

20 marks
1State what is meant by Big-O notation.2 marks
State

Mark scheme0 / 2
  • LIt describes how the time or memory an algorithm needs grows as the size of the input grows
  • LIt ignores constants and gives an upper bound, usually the worst case
Model answer

Big-O notation describes how the time, or the memory, that an algorithm needs grows as the size of its input grows. It ignores constant factors and smaller terms and gives an upper bound, which is usually the worst case.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
2State the time complexity, using Big-O notation, of each of the following. (a) Reading an item from an array when its index is known. (b) A linear search of a list of n items. (c) A binary search of a sorted list of n items. (d) A bubble sort of n items, in the worst case.4 marks
State

Mark scheme0 / 4
  • L(a) O(1)
  • L(b) O(n)
  • L(c) O(log n)
  • L(d) O(n squared)
Model answer

(a) O(1), constant time, because the position is calculated directly. (b) O(n), linear, because in the worst case every item is checked. (c) O(log n), logarithmic, because each comparison halves the items that remain. (d) O(n squared), quadratic, because there is a pass of up to n comparisons for each of up to n passes.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
3The subprogram shown is called with a list of n items. State its time complexity, and explain how you decided.4 marks
State
Python
def mystery(items):
    count = 0
    for a in items:
        for b in items:
            if a == b:
                count += 1
    return count

Mark scheme0 / 4
  • LThe time complexity is O(n squared)
  • LThere is a loop inside another loop
  • LBoth loops go through all n items
  • LSo the inner loop runs n times for each of the n passes of the outer loop, a total of n times n steps
Model answer

The time complexity is O(n squared). The subprogram contains one loop nested inside another, and both loops go through all n items. The inner loop runs n times for each of the n passes of the outer loop, so the total number of steps is n times n. This means that doubling the length of the list would make it take about four times as long.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
4Explain what is meant by an intractable problem, and describe one way in which programmers deal with such problems.4 marks
Explain

Mark scheme0 / 4
  • LA problem for which the only known algorithms take exponential time, or worse
  • LThe time needed grows so quickly that only small inputs can be solved in a reasonable time
  • LA heuristic can be used instead
  • LIt finds a good, but not necessarily the best, solution in a reasonable time
Model answer

An intractable problem is one for which the only known algorithms take exponential time or worse, so the time needed grows so quickly with the size of the input that only small cases can be solved in a reasonable time, however fast the computer. A programmer can use a heuristic instead, which is a rule of thumb that finds a good solution, though not necessarily the best one, in a reasonable time. For the travelling salesman problem, always travelling to the nearest unvisited city is an example.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
5Two subprograms check whether a list of n items contains a duplicate. The first compares every pair of items. The second adds each item to a set as it goes, and stops if an item is already in the set. Compare the two subprograms in terms of time and space complexity.6 marks
Compare

Mark scheme0 / 6
  • LThe first compares every pair, so takes O(n squared) time
  • LThe second looks at each item once, so takes O(n) time
  • LChecking whether an item is in a set takes constant time on average, which is what makes the second one fast
  • LThe first needs almost no extra memory, O(1) space
  • LThe second needs a set that could hold every item, O(n) space
  • LA conclusion: the second is better for a long list because the time saved outweighs the memory, though the first could suit a very small or a memory limited device
Model answer

The first subprogram compares every pair of items, so it takes O(n squared) time, but it needs almost no extra memory, O(1) space. The second looks at each item once, so it takes O(n) time, because checking whether an item is in a set takes constant time on average. However, the set may end up holding every item, so it needs O(n) space. The second is the better choice for a long list, because the saving in time is enormous and outweighs the cost in memory. The first could still suit a very short list, or a device with very little memory, where the extra memory is the greater concern.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
Machine mark 0 / 20