All through the course we have been leaving claims on account: that nesting two loops quadruples the work when the data doubles (03-03), that binary search resolves in twenty comparisons what costs a linear search a million (06-01), that sorted is 3,600 times faster than our bubble sort (06-02) and that naive Fibonacci "explodes" (06-03). They all talk about the same thing: how much a program costs according to the size of its data. This lesson finally gives you the vocabulary to say it precisely.
That vocabulary is called Big-O notation, and it is one of those tools that change the way you look at code: once you have it, you can open a function you did not write and know, without running it, whether it will hold up with a thousand items or two hundred thousand. You will also learn the opposite, which matters just as much: when nothing needs optimising at all.
Contents
- Why it is not measured in seconds
- What gets counted instead
- Big-O notation and how it is simplified
- A catalogue of complexities
- The growth table
- How to analyse a piece of code
- Three EasyTask fragments analysed
- Worst case, best case and average case
- Space complexity and the time-memory trade-off
- Cost of the usual operations in Python
- When to optimise and when not to
- EasyTask with 20 tasks and with 200,000
- Common mistakes and tips
- Exercises
- Module conclusion
- Why it is not measured in seconds
In 06-02 we measured bubble sort with time.perf_counter() and got 11 seconds for 10,000 elements. It is a useful figure, but it communicates nothing outside that specific moment, because it depends on:
- The machine: an eight-year-old laptop and a modern server differ by a factor of ten.
- The language and its version: the same bubble sort in C is about fifty times faster than in Python.
- Whatever the computer was doing at that instant: another program, the antivirus, the browser.
- The specific data it happened to get: an almost-sorted list and a backwards one give different times.
If I tell you "my function takes 0.4 seconds", you do not know whether that is good. If I tell you "my function doubles its time every time the number of tasks doubles", you already know everything that matters: you know that with 200,000 tasks it will be 10,000 times slower than with 20, and that is true on any computer and in any language.
That is the change of mindset: instead of measuring how long it takes, we describe how what it takes grows when the data grows. It is a property of the algorithm, not of the machine.
- What gets counted instead
What gets counted is elementary operations as a function of the input size, which by convention is called n. An elementary operation is one whose cost does not depend on the size of the data: an addition, a comparison, an assignment, accessing my_list[i], calling a function.
def count_pending(agenda): # n = len(agenda)
total = 0 # 1 operation, done once
for task in agenda: # the loop runs n times
if not task["completed"]: # 1 comparison per pass -> n
total += 1 # 1 addition per pass (at most) -> n
return total # 1 operationAdding up: 1 + n + n + 1, that is, 2n + 2 operations. What matters is not the exact number —it depends on how you count— but the shape of the expression: there is a term that grows with n and some extras that do not. And the first thing to understand is that n is not "how much data there is" in the abstract, but the size of whatever makes the work grow: here, the number of tasks in the agenda.
- Big-O notation and how it is simplified
Big-O notation (or asymptotic notation) describes how the number of operations grows when n gets large, ignoring everything that stops mattering at that scale. It is written O(...) and read "of the order of".
Formally it is an upper bound: saying that an algorithm is O(n) means that its cost does not grow faster than n, except for a constant factor. And from that come the two simplification rules:
- Multiplicative constants are ignored.
3nandnare bothO(n): an algorithm three times slower still doubles its time when the data doubles. The constant depends on the machine; the way it grows does not. - Lower-order terms are ignored. In
n² + 500n + 1000, whennis a million,n²is a trillion and500nis five hundred million: the second term is 0.05% of the total. You are left withO(n²).
| Operations counted | Big-O | Why |
|---|---|---|
2n + 2 |
O(n) |
The constant 2 and the +2 go away |
3n + 5 |
O(n) |
Same: it grows in proportion to n |
n² + 500n + 1000 |
O(n²) |
n² dominates everything else |
100 |
O(1) |
It does not depend on n at all |
5n log n + 3n |
O(n log n) |
n log n grows more than n |
The practical consequence of this generosity is that Big-O does not compare two algorithms of the same shape: between two O(n) algorithms, one can be five times faster than the other and Big-O will not say so. That is what time.perf_counter() is for. Big-O answers a different, far more important question: what will happen when the data grows?
- A catalogue of complexities
These six cover practically everything you will meet, and you already have an example of every one of them in this course:
| Complexity | Name | Example from the course | When n doubles… |
|---|---|---|---|
O(1) |
Constant | my_list[5], d["Marta"], .append() |
nothing changes |
O(log n) |
Logarithmic | Binary search (06-01) | adds one operation |
O(n) |
Linear | Sweeping the agenda, linear search | it doubles |
O(n log n) |
Linearithmic | sorted, mergesort (06-02, 06-03) |
a little more than double |
O(n²) |
Quadratic | Nested loops (03-03), bubble sort | it quadruples |
O(2ⁿ) |
Exponential | Naive Fibonacci (06-03) | it is squared |
Two of them are worth pausing on. O(1) does not mean "fast", it means "the same cost whatever the size": accessing my_list[999999] costs the same as my_list[0], because Python computes the memory address instead of sweeping. And O(log n) is almost as good as O(1): the base-2 logarithm of a million is 20, and of a billion is 30. A logarithmic algorithm barely notices the data growing.
At the other end, O(2ⁿ) is a wall: every new element doubles the total work. With n = 60 there is no computer in the world that will finish, and that is why naive Fibonacci was useless. Ordered from best to worst:
graph LR
A["O(1)"] --> B["O(log n)"] --> C["O(n)"] --> D["O(n log n)"]
D --> E["O(n squared)"] --> F["O(2 to the n)"]
- The growth table
Numbers are more eloquent than curves. Approximate operations for each size:
n |
O(1) |
O(log n) |
O(n) |
O(n log n) |
O(n²) |
O(2ⁿ) |
|---|---|---|---|---|---|---|
| 10 | 1 | 3 | 10 | 33 | 100 | 1,024 |
| 1,000 | 1 | 10 | 1,000 | 10,000 | 1,000,000 | unreachable |
| 1,000,000 | 1 | 20 | 1,000,000 | 20,000,000 | 1 trillion | unreachable |
Let us translate that last row into time, assuming ten million operations per second, which is a reasonable order of magnitude for Python:
| Complexity | With n = 1,000,000 |
|---|---|
O(log n) |
instant |
O(n) |
0.1 seconds |
O(n log n) |
2 seconds |
O(n²) |
more than a day |
There is the whole lesson in one table. With a million items, an O(n log n) algorithm answers while you wait and an O(n²) one never finishes in practice. And notice something crucial: with n = 10 all of them are instant. Complexity only matters when the data grows, and that observation will be section 11.
- How to analyse a piece of code
Three practical rules are enough for 95% of cases:
- Individual statements (assignments, comparisons, index or key accesses):
O(1). - Sequential loops add up; nested loops multiply. Two consecutive loops of
npasses aren + n = 2n→O(n). A loop ofnpasses inside another ofnpasses isn × n→O(n²). - Take the dominant term. If a fragment does a
sorted(O(n log n)) and then a sweep (O(n)), the total isO(n log n), because the larger one rules.
def analyse_me(agenda):
total = 0 # O(1)
for t in agenda: # loop 1: O(n)
total += t["days"]
for t in agenda: # loop 2: O(n), SEQUENTIAL -> it adds up
print(t["title"])
for a in agenda: # outer loop: n passes
for b in agenda: # inner loop: n passes -> it multiplies
if a["assignee"] == b["assignee"]:
pass
return totalThe calculation is 1 + n + n + n² = n² + 2n + 1, which simplifies to O(n²). And this is the most profitable design lesson of the analysis: the pair of nested loops dominates everything else. Optimising the first two loops would achieve nothing; the only change that matters is removing the nesting, which you have known how to do since 06-01 with an index.
There are two frequent traps when analysing. The first: a function call costs whatever the function costs, not O(1). If inside a loop of n passes you call sorted on the agenda, the total is n × n log n, not n. The second: the in operator over a list is O(n), so an if x in my_list inside a loop is a nested loop in disguise.
- Three EasyTask fragments analysed
Fragment 1: summary_by_assignee (since v0.10), which counts each person's tasks with a dictionary.
counts = {}
for task in agenda: # n passes
name = task["assignee"] # O(1)
counts[name] = counts.get(name, 0) + 1 # O(1): hash tableA loop of n passes with O(1) work inside: O(n). It is optimal, because to count you have to look at every task at least once. The key is that counts.get(...) is O(1); if instead of a dictionary we used a list of names and an .index(), each pass would be O(n) and the total O(n²).
Fragment 2: show_list (v0.13), which sorts before printing.
sorted is O(n log n) and the sweep is O(n); the dominant term rules: O(n log n). You cannot do better while sorting is required, and that is the honest argument for not obsessing: the expensive part is not your code, it is the sorting, and the sorting is already done by Timsort.
Fragment 3: detecting duplicate titles, written the naive way.
duplicates = []
for i, a in enumerate(agenda): # n passes
for b in agenda[i + 1:]: # up to n passes -> nested
if a["title"] == b["title"]:
duplicates.append(a["title"])Nested loops: O(n²). With 20 tasks that is about 190 comparisons, nothing. With 200,000 it would be twenty billion: hours of waiting. The set version, which you have known how to write since 05-03, solves the same thing in O(n):
seen, duplicates = set(), []
for task in agenda: # n passes
if task["title"] in seen: # O(1): set membership
duplicates.append(task["title"])
seen.add(task["title"]) # O(1)Same result, a single loop. This is the perfect example of why the lesson matters: the second piece of code is not cleverer, it is cheaper, and the difference is only visible if you know how to count.
- Worst case, best case and average case
The very same algorithm can cost wildly different amounts depending on the specific data it gets. Three scenarios are distinguished, and you already saw them in the linear search of 06-01:
| Case | What it is | Linear search | Insertion sort (06-02) |
|---|---|---|---|
| Best | The most favourable data | O(1): it is first |
O(n): already sorted |
| Average | Typical random data | O(n): half the list |
O(n²) |
| Worst | The least favourable data | O(n): it is not there |
O(n²): backwards |
When somebody says "this algorithm is O(x)" without qualifying, they mean the worst case. It is the convention, and it has a solid reason: the worst case is a guarantee. If the worst case is O(n log n), you know it will never take longer, whatever the data does. The average case is an expectation, not a promise, and there are famous algorithms —quicksort from 06-02— with an excellent average case and a bad worst case.
- Space complexity and the time-memory trade-off
Time is not the only thing that gets consumed: there is also space complexity, which measures how much extra memory an algorithm needs on top of the input data.
| Algorithm | Extra space | Why |
|---|---|---|
| Bubble, selection, insertion | O(1) |
They sort in place, only loose variables |
sorted() |
O(n) |
It builds a new list |
| Mergesort (06-03) | O(n) |
It needs auxiliary lists to merge |
Recursion of depth n |
O(n) |
One stack entry per pending call |
| Inverted index (06-01) | O(n) |
A dictionary holding every task |
| Fibonacci memoisation (06-03) | O(n) |
One stored result for each n |
The last two rows are the famous time-memory trade-off: you spend memory to gain time. The memoisation case is devastating: it takes Fibonacci from O(2ⁿ) to O(n) in time, in exchange for O(n) in space. It is one of the best bargains in programming.
The inverted index is the same deal on a small scale: index_by_assignee costs O(n) to build and O(n) of memory, and in exchange every "what does Luis have?" query goes from O(n) to O(1). With one query it does not pay off; with fifteen a day, it pays for itself. And there is a third dimension the deal ignores and is worth not forgetting: the index has to be kept up to date, and that cost is code maintenance, not execution time.
- Cost of the usual operations in Python
This table is one of the most useful things you will take away from the module. With n = number of elements:
| Operation | List | Dictionary / Set |
|---|---|---|
Access by position x[i] |
O(1) |
— |
Access by key d[k] |
— | O(1) |
x in collection |
O(n) |
O(1) |
.append(x) / .add(x) |
O(1) |
O(1) |
.insert(0, x) |
O(n) |
— |
.pop() (the last one) |
O(1) |
— |
.pop(0) (the first one) |
O(n) |
— |
del d[k] / .remove(x) |
O(n) |
O(1) |
.sort() / sorted() |
O(n log n) |
— |
Sweeping with for |
O(n) |
O(n) |
len() |
O(1) |
O(1) |
The rows in bold are the ones that cause real problems. insert(0, x) and pop(0) are O(n) because a list stores its elements consecutively in memory: putting something in or taking it out at the front forces every other element to shift. A loop that does pop(0) n times is O(n²) without looking like it. The standard library's answer is collections.deque, a doubly linked list where both ends are O(1).
And x in my_list is O(n) whereas x in my_set is O(1), which is the translation into this vocabulary of everything we saw in 06-01. Swapping a list for a set is the cheapest and most profitable optimisation there is: one line of code and a change of complexity.
- When to optimise and when not to
Here comes the part that is almost never told, and it is as important as the rest. Most optimisations are not needed.
- While
nis small, clarity rules. With 20 tasks, anO(n²)algorithm does 400 operations: microseconds. Rewriting that code to make itO(n)makes it harder to read in exchange for a saving nobody will notice. - Measure before you touch. Intuition about where the slowness lives is notoriously bad. Use
time.perf_counter()around the suspicious chunks and discover the bottleneck instead of assuming it. In most real programs, the time goes on the network or the disk, not on your loop. - Optimise the complexity, not the constants. If something has to be improved, going from
O(n²)toO(n)by swapping a list for a set is worth a thousand times more than saving three operations inside a loop. - But choose well from the start when it is free. Writing
if x in my_setinstead ofif x in my_listcosts no extra effort and no readability. That is not premature optimisation: it is not spoiling the program through carelessness.
The rule that sums all this up is Donald Knuth's and it is half a century old: premature optimization is the root of all evil. Write clear, correct code; measure; and optimise only what the measurement points at. Big-O is not there so you optimise everything, but so you know what is going to happen when the data grows and spot in time what will not hold up.
- EasyTask with 20 tasks and with 200,000
Let us analyse the whole program, now at v0.14, in both scenarios.
| Operation | Complexity | With n = 20 |
With n = 200,000 |
|---|---|---|---|
Registering a task (.append) |
O(1) |
instant | instant |
| Showing the listing (it sorts) | O(n log n) |
instant | ≈ 1 second |
| Searching by title (linear) | O(n) |
instant | ≈ 0.05 s |
| Searching by assignee with an index | O(n) to build + O(1) to query |
instant | ≈ 0.1 s each time |
| Summary by assignee | O(n) |
instant | ≈ 0.1 s |
| Saving / loading the JSON | O(n) |
instant | several seconds |
| Detecting duplicates (naive version) | O(n²) |
190 comparisons | 20 billion |
With 20 tasks, everything is fine and there is nothing to change. That is the honest verdict, not a concession: the program is clear, correct and answers instantly. Optimising it would be wasted work.
With 200,000 tasks, on the other hand, the analysis says exactly what to touch and in what order:
- Eliminate any
O(n²), starting with the duplicate detection: the set version from section 7 fixes it in four lines. It is the only truly urgent thing. - Build the index just once at startup and keep it up to date on every addition and removal, instead of rebuilding it on every query. It takes assignee searches from
O(n)toO(1). - Do not sort the whole agenda to show twenty lines. If the listing is paginated,
heapq.nsmallest(20, agenda, key=sort_key)isO(n log 20)instead ofO(n log n). - Stop loading and saving the whole file. A JSON of 200,000 tasks gets read entirely into memory every time; at that scale the answer is not a better algorithm, it is a database, which indexes, searches and saves only what changed.
Notice the order: first the bad complexities are removed, then time-memory trade-offs are applied and only at the end does the tool change. And notice what does not appear: rewriting sorted, micro-optimising the f-strings or removing functions to save calls. That would not move the needle.
Common Mistakes and Tips
Confusing "fast" with "good complexity". A well-written O(n²) algorithm can beat an O(n log n) one for small n, because the constants Big-O ignores do genuinely exist. Timsort, to look no further, sorts short stretches with insertion sort for exactly that reason.
Forgetting the cost of what you call. if title in [t["title"] for t in agenda] looks like an innocent line and is O(n) every time: it builds a whole list and sweeps it. Inside a loop, O(n²).
Believing that O(1) means instant and O(n²) means unacceptable. They are ways of growing, not speeds. O(n²) with n = 20 is perfect; O(n) with an expensive operation inside can be a disaster.
Analysing the best case without saying so. "My search is O(1) because it is normally near the front" is a half-truth. By convention we talk about the worst case, which is the one that gives guarantees.
Optimising without measuring. Changing code "because it looks slow" usually damages readability without improving the time. Measure, locate and only then act.
Tip: learn to spot nested loops. They are not always indented one inside the other. An in over a list, an .index(), a comprehension or a call to another function that sweeps are all hidden loops. Always ask yourself: does this line sweep anything?
Tip: the best change is usually of data structure, not of algorithm. List → set for membership; list → dictionary for key lookup. One line, and the complexity drops a whole step.
Exercises
Exercise 1: Determine the complexity
State the time complexity of each fragment, as a function of n = len(agenda), and justify the answer in one sentence.
# (a)
first = agenda[0]["title"]
# (b)
for t in agenda:
if t["title"] in [x["title"] for x in agenda]:
print(t["title"])
# (c)
names = {t["assignee"] for t in agenda}
for name in names:
print(name)
# (d)
copy = sorted(agenda, key=lambda t: t["days"])
for t in copy[:5]:
print(t["title"])Exercise 2: Bring the complexity down
This function checks which tasks from a list of new jobs already exist in the agenda. State its complexity, rewrite it so it is O(n + m) (with m being the number of new jobs) and state how much extra memory the new version uses.
def already_registered(agenda, jobs):
repeated = []
for job in jobs:
for task in agenda:
if task["title"] == job:
repeated.append(job)
break
return repeatedExercise 3: Measure and check the prediction
Write a function that measures with time.perf_counter() how long x in my_list takes against x in my_set when looking for an element that is not there, for n = 10,000 and n = 100,000. Predict before running what will happen to each time when n is multiplied by ten, and check whether you were right.
Solutions
Solution 1.
| Fragment | Complexity | Reason |
|---|---|---|
| (a) | O(1) |
Access by index and by key, sweeping nothing |
| (b) | O(n²) |
The comprehension builds and sweeps a list of n elements on every pass |
| (c) | O(n) |
Building the set is O(n) and sweeping it, O(n): they add up |
| (d) | O(n log n) |
sorted dominates; the slice of 5 and its loop are O(1) |
The (b) is the most instructive case: there are no two indented loops in sight, but the comprehension inside is the inner loop. The correct version builds the set of titles just once, outside the loop, and comes out O(n).
Solution 2.
The original version is O(n × m): for every job it sweeps the whole agenda. The rewrite replaces the inner loop with a set:
def already_registered(agenda, jobs):
"""Return the jobs whose title already exists in the agenda."""
titles = {task["title"] for task in agenda} # O(n), just once
return [j for j in jobs if j in titles] # O(m): each 'in' is O(1)Building the set costs O(n) and the comprehension O(m), so the total is O(n + m). With 200,000 tasks and 100 jobs, you go from 20 million comparisons to 200,100 operations: about a hundred times fewer. The cost is O(n) of extra memory —the set of titles—, and it is the time-memory trade-off in its cleanest form: one extra line, one step of complexity less.
Solution 3.
import time
def compare_membership(n):
"""Compare the cost of looking for an absent element in a list and in a set."""
my_list = list(range(n))
my_set = set(my_list)
missing = n + 1 # guaranteed not to be there
start = time.perf_counter()
missing in my_list # sweeps the n elements
t_list = time.perf_counter() - start
start = time.perf_counter()
missing in my_set # computes the slot and done
t_set = time.perf_counter() - start
print(f"n={n:>7} list={t_list:.6f}s set={t_set:.8f}s")
for n in (10_000, 100_000):
compare_membership(n)The prediction follows straight from the table in section 10: in over a list is O(n), so multiplying n by ten multiplies the time by ten; over a set it is O(1), so it does not change. Running it you will see exactly that: the list goes from a few tenths of a millisecond to a few milliseconds, while the set stays nailed to the order of a millionth of a second, whatever its size. Looking for the absent element is deliberate: it is the list's worst case, forcing a full sweep, and for the set nothing changes. Keep this experiment: it is the practical demonstration of the whole module in twenty lines.
Module conclusion
Efficiency is not measured in seconds because seconds depend on the machine, on the language and on luck. It is described by counting elementary operations as a function of n and keeping how they grow: that is Big-O notation, an upper bound in which multiplicative constants and lower-order terms are ignored, so that 3n + 5 is O(n) and n² + 500n is O(n²). The catalogue fits on one line —O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ)— and every one of them has its example in this course, from a dictionary access to naive Fibonacci. To analyse code, three rules are enough: individual statements are O(1), sequential loops add up and nested ones multiply, and the dominant term rules. By convention we talk about the worst case, because it is the only one that offers guarantees. And alongside time there is space complexity, with its time-memory trade-off: memoisation and inverted indexes spend O(n) of memory to bring the time down a whole step. The cost table for lists, dictionaries and sets —with in at O(n) against O(1), and the insert(0, x) and pop(0) that are O(n) without looking like it— is the cheat sheet you will use daily. But the most important conclusion is the opposite one: while n is small, clarity rules; measure before you touch and optimise only what the measurement points at.
That closes module 6, and with it the black boxes we had been dragging along. You know how to search: linearly with an early exit, binarily by discarding halves over sorted data and by key thanks to the hash table, as well as preparing inverted indexes when a query repeats. You know how to sort: selection, insertion and bubble with its sentinel, the difference between stable and unstable, the divide strategy behind mergesort and quicksort, and the professional use of sorted/.sort() with key, reverse and tuples for several criteria. You know how to use recursion with its base case and its recursive case, to recognise when it wins —tree-shaped data, algorithms that divide— and to fix its repeated work with memoisation. And now you know how to say how much all of that costs. EasyTask has reached v0.14: it searches, sorts by two criteria, hands out tomorrow's plan and adds up projects broken down to any depth.
And yet, look at what holds all of that up: a list of dictionaries. Nothing stops one dictionary from missing the completed key, another from having priority written as "High", or a third from dragging along an invented field nobody else understands; the program will not protest until it blows up with a KeyError halfway through the listing. And the functions that operate on tasks —show_card, total_days, sort_key— are scattered around the file, separated from the data they manipulate, with nothing saying that they form a set. In From data to objects: classes and instances module 7 begins, where data and behaviour stop travelling separately: you will learn to define what exactly a task is, guarantee that they are all born complete and keep the operations that belong to them right next to them.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
