Complexity of operations: O-notation, counted on built-in types
"Estimate the complexity of this code" is a question answered with the letter O and checked by counting. A run counts comparisons directly: x in on a list of a hundred thousand elements made a hundred thousand comparisons, and on a set without hash collisions — one, with one hash for all — on the order of n; bisect — seventeen; over a hundred thousand appends the list reallocated its memory 66 times; a list comprehension with x in on another list made a million comparisons at a thousand elements; and sorted() on already sorted data makes exactly n − 1.
Full technical treatment
TL;DR
O-notation describes not speed but how the work grows as the input grows. O(1) — the work does not depend on n, O(n) — it grows with n, O(log n) — it adds a little with each doubling, O(n²) — it grows as a square. Strictly, O is an upper bound ("no faster than"); a tight bound is written Θ. Work is easiest to count in comparisons: the run counts them directly.
Hence the main point: you estimate not a line of code but what it does
inside. x in list is a scan: a hundred thousand comparisons on a hundred
thousand elements. x in set is O(1) on average: in our set without hash
collisions it is one comparison at every n checked, but if all elements share
one hash, a lookup among a hundred elements called __eq__ 105 times — the
worst case, O(n). That is why the innocent list comprehension [x for x in a if x in b] with a list b is
O(n²): at a thousand elements it made a million comparisons, and with a set
b — none.
Beyond that comes what separates understanding from having read about it.
append is "amortized" O(1): over a hundred thousand appends the list
reallocated its memory 66 times, because it grows with a reserve. Binary
search with bisect on a hundred thousand takes 17 comparisons — but that is
an insertion position; whether the element was found takes one more check.
And sorted() is O(n log n) in the average and worst case, but on already
sorted data it made exactly n − 1 comparisons in our run: CPython's sort
looks for ready-made ordered runs.
- what a list, a set and a
forloop are; - that
x in ychecks membership; - the high-school notion of a logarithm: log2(1024) = 10;
- a class with the methods
__eq__,__lt__and__hash__— the lesson "Classes and OOP": the run counts comparisons through such methods; - what a hash is and why a set searches by it — the lesson "Built-in collections".
- O(1), O(n), O(log n), O(n log n), O(n²) and how they are estimated;
- amortized O(1) for
append,bisect; - the hidden square in a one-liner, the complexity of
sorted().
Base: what O-notation says
| notation | how the work grows | example in built-in types | which case |
|---|---|---|---|
| O(1) | does not depend on n | x in set, d[key] | average; worst — O(n) |
| O(1) | does not depend on n | lst.append(x) | amortized; an individual call — up to O(n) |
| O(log n) | +1 step when n doubles | bisect on a sorted list | worst |
| O(n) | together with n | x in list, lst.insert(0, x), min(lst) | worst: for x in list — the element is last or absent; for min — any |
| O(n log n) | slightly faster than linear | sorted(lst) | average and worst; best — O(n) |
| O(n²) | as the square of n | a loop inside a loop over the same data | — |
Here n is the number of elements in the collection, and the work is the
number of element comparisons (for append and insert — reference copies),
not time.
A junior-level answer to "what is O(n)": it is how an algorithm's running time grows with the size of the input; constants and lower-order terms are dropped. More strictly: O(f(n)) is an asymptotic upper bound in a chosen cost model: from some n on, the work is at most c·f(n). So a linear function is formally both O(n) and O(n²); a tight bound — grows exactly like f(n) up to a factor — is written Θ(f(n)), and in an interview "O(n)" usually means that. That is correct, and it is usually followed by questions: what is the complexity of specific list and set operations, what "amortized" means, and where a square hides in code that looks linear. The mechanisms below answer them by counting.
How the check works: the elements are objects that count calls to their
__eq__ and __lt__. For a scan, binary search and sorting the number of
comparisons is the measure of the work. It does not depend on the speed of the
machine and matched on CPython 3.11–3.14, but it is an observation of the
implementation for these objects, not a contract of the language: other data
— coinciding hashes, for example — give a different count. Computing hashes
is not counted, and one comparison is not one machine operation: for long
strings both hash and == go over the characters (the section "Deeper: the
cost of one comparison").
Mechanism 1: lookup — O(n), O(1) and O(log n) in numbers
Output of bench/complexity/basics.py, section 1 (labels translated):
1) lookup: how many comparisons, the element sought is the last one
n x in list x in set bisect log2(n)
10 10 1 3 3.3
100 100 1 7 6.6
1000 1000 1 10 10.0
10000 10000 1 13 13.3
100000 100000 1 17 16.6
bisect on the unsorted [5, 1, 4, 2, 3], looking for 1: index 0, while 1 is at position 1 — no error
Three columns — three kinds of growth:
x in listis O(n). A scan compares the elements one by one, and there are exactly n comparisons when the element sought is the last.x in setis O(1) on average. A set computes the hash, goes to the right slot and compares the element with those that have the same hash. For the run'sKeythe hashes of different values do not coincide, so__eq__was called once — with ten elements and with a hundred thousand. That is an observation for this set of keys, not a property of sets: below is the same lookup when all elements share one hash.bisectis O(log n). Binary search on a sorted list discards half with every comparison. n grew ten thousand times, and there are 14 more comparisons — the column keeps pace with log2(n).
If all elements share one hash — the output of bench/complexity/basics.py,
section 6 (labels translated):
6) a set where all elements have the same hash: __eq__ calls
n normal hashes one hash: lookup one hash: building
10 1 10 45
100 1 105 5982
1000 1 1336 605201
10000 1 11672 57720235
the same experiment with hash 0 instead of 1, n = 100: lookup — 114
normal hashes, the one sought is the same object, not an equal one: lookup — 0
With one hash for all, looking up the last element called __eq__ 105 times
at a hundred elements and 11,672 times at ten thousand — on the order of n:
this is a set's worst case, O(n). There are even more calls than n — so the
set compared some elements more than once; the exact number depends even on
the hash value itself: with zero instead of one it is 114. Building such a
set costs on the order of n²/2 comparisons. And an element found by identity
— the same object, not an equal one — calls no __eq__ at all. So "O(1) on
average" does not mean "one comparison", and the __eq__ counts themselves
are an observation of CPython, not a promise of the language.
bisect is a standard library module, and its condition is written right in
the docstring of bisect_left: "return the index where to insert item x in list a,
assuming a is sorted". Being sorted is the caller's responsibility: on an unsorted list
bisect returns a wrong index without an error — the last line of the run
above. That is why the O(log n) of
the lookup goes together with the O(n log n) of sorting: binary search pays
off when a list is sorted once and searched many times.
One more thing: bisect_left returns an insertion position, not an answer
to "is the element there". The 17 comparisons in the table above are only the
__lt__ calls up to that position. Whether the element was found is decided
by a separate check: the position is inside the list and the element there
equals the one sought — i < len(a) and a[i] == x. The output of
bench/complexity/basics.py, section 7 (labels translated):
7) bisect: an insertion position is not yet "found" — a check after it is needed
looking for 199998 in 0, 2, 4, …, 199998: position 99999, __lt__ — 17, __eq__ — 1, found: True
looking for 77777 in 0, 2, 4, …, 199998: position 38889, __lt__ — 17, __eq__ — 1, found: False
looking for 200001 in 0, 2, 4, …, 199998: position 100000, __lt__ — 16, __eq__ — 0, found: False
For 77,777, which is not in the list, bisect_left still returned a position
— 77,778 stands there; without the equality check it would look like a hit.
For 200,001 the position equals the length of the list, and there is nothing
to compare with — hence the bounds check.
The same in a short example that can be copied. The full file
bench/complexity/find.py:
import bisect
import sys
print("PY", sys.version.split()[0])
def contains(a, x):
i = bisect.bisect_left(a, x)
return i < len(a) and a[i] == x
a = [2, 4, 6, 8]
for x in (6, 5, 9):
print(f"x = {x}: bisect_left -> {bisect.bisect_left(a, x)}, contains -> {contains(a, x)}")Run it with python3.13 bench/complexity/find.py; the output on CPython
3.13.7 (the same on 3.11, 3.12 and 3.14 except for the version line), exactly
as printed:
PY 3.13.7
x = 6: bisect_left -> 2, contains -> True
x = 5: bisect_left -> 2, contains -> False
x = 9: bisect_left -> 4, contains -> False
For 6 and for 5 the position is the same — 2; only the equality check tells them apart.
Mechanism 2: append — amortized O(1)
A list keeps references to its elements in one block of memory. When the
space runs out, a bigger block is needed, and all the references may have to
be copied — that is O(n). If that
happened on every append, n appends would cost O(n²). So CPython allocates
memory with a reserve; the run catches every reallocation by a change in
sys.getsizeof — the output of bench/complexity/basics.py, section 2
(labels translated):
2) append: how many times the list reallocated memory
first capacities (slots for elements): [4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128]
1000 appends — reallocations: 28
10000 appends — reallocations: 47
100000 appends — reallocations: 66
The series of capacities matches the comment in the CPython sources —
0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ... — and the same comment says why
it is like that: the reserve "is enough to give linear-time amortized
behavior over a long sequence of appends()". The reserve grows with the size,
so a hundred thousand append calls took 66 reallocations, not a hundred
thousand.
This is what amortized O(1) means: an individual append is
occasionally expensive — it may copy the whole list — but n appends cost O(n)
in total, that is O(1) on average per call. The answer "append is O(1)" is
right, but with the expected caveat: in the worst case an individual call is
O(n).
Mechanism 3: growth of time — the same, by the clock
O-notation predicts how many times the time grows if n grows tenfold: O(1) —
roughly not at all, O(n) — roughly tenfold. A measurement at three sizes —
the output of bench/complexity/cost.py (CPython 3.13.7; labels translated):
ns per operation, medians of 7 rounds
operation 1000 10000 100000 ×10 ×100
list.append 35.1 26.3 28.4 0.8 0.8
list.insert(0, x) 308.2 2242.9 19988.0 7.3 64.9
x in list 5332.6 52136.7 643060.2 9.8 120.6
x in set 25.4 27.2 26.2 1.1 1.0
sorted(), per element 106.7 127.0 220.3 1.2 2.1
appendandx in set— the time per operation does not grow: O(1) (amortized and average;x in sethere is on integers without collisions).insert(0, x)andx in list— grow almost tenfold for each ×10: O(n).insert(0, x)shifts all the elements,x in listscans them.sorted()per element grows slowly — 1.2 times per ×10: that is the log n factor of O(n log n).
The numbers do not fit the prediction exactly. For insert(0, x) it is 7.3
instead of 10, but that is a property of the measurement: over a thousand
inserts the list grows to n + 1,000, so the average lengths are 1,500 and
10,500, whose ratio is about 7. For sorted() it is 2.1 at ×100 instead of
the 1.7 the logarithm suggests. O-notation drops fixed costs and says nothing
about what happens in the hardware; it describes the shape of growth, not the
exact time. That is why in an interview you say "grows linearly", not "ten
times slower".
Mechanism 4: the hidden square
The most common "estimate the complexity" question is about code where the
loop is not visible. [x for x in a if x in b] looks like one pass over a.
But if b is a list, each x in b is a pass too. The output of
bench/complexity/basics.py, section 3 (labels translated):
3) the hidden square: [x for x in a if x in b], no common elements
n b is a list b is a set
100 10000 0
1000 1000000 0
n grew tenfold — there are a hundred times more comparisons: that is O(n²).
With a set b there are no __eq__ calls at all: the hashes of the elements
of a match no hash in the set, and __eq__ is called only when hashes
match. Building the set is O(n) on average, a lookup is O(1) on average per
element, O(n) in total; with coinciding hashes both bounds are worse (section 6
above).
Where else a square hides: lst.remove(x) or lst.index(x) in a loop over
another collection — for x in to_delete: lst.remove(x) — lst.insert(0, x)
in a loop, x in lst for selecting unique items. There is one rule: multiply the number of loop iterations by
the cost of the operation inside it.
Deeper: sorting and n − 1 comparisons
Output of bench/complexity/basics.py, section 4 (labels translated):
4) sorted(): how many comparisons
n random lg(n!) n·log2(n) sorted reversed
1000 8655 8529 9966 999 999
10000 120184 118458 132877 9999 9999
100000 1531824 1516704 1660964 99999 99999
On random data there are about n·log2(n) comparisons — that is O(n log n).
The lg(n!) column is the theoretical minimum: no comparison sort can do
with fewer on average. On 3.13 CPython's sort is 1–1.5% above it.
On already sorted and strictly descending data the run gave exactly n − 1 comparisons, O(n). That is an observation on such an ordered input and this implementation, not a promise of the language; the way the sort is built explains it: CPython's sort looks for ready-made ordered runs in the input, reverses the descending ones and merges them; if the whole input is one such run, one pass over it is enough. So the answer "sorted() is O(n log n)" holds for the worst and the average case, while the best one is O(n).
Deeper: the cost of one comparison
The whole lesson counts comparisons as if each one cost the same. For
integers that is close to the truth, for strings it is not: the hash and the
equality check go over the characters. The output of
bench/complexity/strkeys.py (CPython 3.13.7; labels translated):
string keys: ns per operation, medians of 7 rounds
operation 10 1000 100000
hash(a new string) 35.3 431.8 29768.6
hash(the same string) 34.6 34.6 30.1
a == b, different objects 21.3 36.0 2567.6
The hash of a new string of a hundred thousand characters is about 30 µs, of
ten characters — 35 ns; comparing two equal but different strings also grows
with the length. Hashing the same string again is cheap at any length: the
string remembers its hash (the hash field in
Include/cpython/unicodeobject.h:101, CPython 3.13.7). So "a set lookup is
O(1)" is O(1) in the number of elements, while the cost of one check depends
on the key: a new long string has to be hashed, and an equal but different
one compared character by character.
How to answer in an interview
Short answer: O-notation describes how the work grows with the input;
strictly it is an upper bound. x in list, insert(0, x), remove are
O(n); x in set, d[key] are O(1) on average and O(n) in the worst case,
when hashes coincide; append is amortized O(1); bisect is O(log n);
sorted() is O(n log n) in the average and worst case. The complexity of
code is the number of iterations times the cost of the operation inside.
That is enough for a correct answer. What follows is what you add if the interviewer digs.
If the interviewer digs deeper
On "estimate [x for x in a if x in b]". "If b is a list, O(n·m): each
in scans b. At a thousand elements I got a million comparisons. Make b a
set and it becomes O(n + m)."
On "why is append O(1) if the list is sometimes copied". "It grows with a reserve proportional to its size. A hundred thousand appends took 66 reallocations. Expensive calls are rare; in total it is O(n), that is O(1) on average. That is amortized complexity."
On "what is the complexity of sorted()". "O(n log n) in the average and worst case. On already sorted data it is O(n): I got exactly n − 1 comparisons, CPython's sort finds ready-made runs."
On "why is binary search fast". "Each comparison discards half: on a
hundred thousand elements, 17 comparisons instead of a hundred thousand. But
the list has to be sorted, and sorting is O(n log n), so it pays off with
many lookups. And bisect_left gives an insertion position — whether the
element was found I check separately: i < len(a) and a[i] == x."
On "is x in set one comparison?" "O(1) on average, but not one comparison
by definition. In my run keys without collisions did give one, but when all
hundred elements shared one hash, a lookup called __eq__ 105 times — the
worst case, O(n). That is a CPython counter, not a language guarantee."
Next they ask
What is the complexity of dictionary operations?
Like a set's: lookup, insertion and deletion by key are O(1) on average (insertion is also amortized: the table is reallocated now and then), because a dictionary also finds a key by its hash. In the worst case, when the keys' hashes coincide, lookup degenerates into a scan — section 5 of the same run, identical on 3.11–3.14:
5) a dict where all keys have the same hash: comparisons to look up the last key
n = 10: normal hashes — 1, one hash for all — 10
n = 100: normal hashes — 1, one hash for all — 100
n = 1000: normal hashes — 1, one hash for all — 1000
So a bad __hash__ — a constant, for example — turns a dictionary into a
list. How a dictionary is built inside is covered in the article on
dictionaries.
Are "O(1) on average" and "amortized O(1)" the same thing?
No. "On average" is about different inputs: an individual dictionary lookup
can be slow if the hashes are unlucky. "Amortized" is about a sequence of
operations: an expensive append happens, but rarely, and the sum of n calls
is guaranteed to be O(n).
Why O-notation, if constants decide everything?
Constants decide at small n: with ten elements a list scan is only a few times slower than a set — 3.4 times in the lesson on collections. O-notation answers a different question — what happens when there is a hundred times more data. Quadratic code that goes unnoticed on test data becomes a hundred times slower on real data when the data grows tenfold.
Common misconceptions
A one-line list comprehension is O(n)
If there is an x in on a list inside, it is O(n²). At a thousand elements [x for x in a if x in b] with a list b made a million comparisons.
append is always O(1)
Amortized. An individual append that runs out of space copies the whole list — O(n). But such calls are few: 66 per hundred thousand.
Sorting always costs O(n log n)
O(n log n) is for the average and worst case. On already sorted and strictly descending data sorted() made exactly n − 1 comparisons in the run — O(n); that is an observation on such an input, not a promise.
x in set is exactly one comparison at any n
One comparison is an observation for keys without hash collisions. O(1) is an average estimate: when all hundred elements share one hash, a lookup called __eq__ 105 times, at ten thousand — 11,672. The worst case is O(n).
O(1) means fast, O(n) means slow
O-notation is about growth, not speed. x in list on ten elements is ten comparisons, and in the lesson on collections such a lookup was only 3.4 times slower than a set, while the first element was found by the list just as fast.
Version history
| Version | Change | What it means for code |
|---|---|---|
| 3.11 | The reference point of the lesson. The lookup comparison counts, the list's series of capacities and the n − 1 comparisons on sorted data are the same on 3.11–3.14. | |
| 3.13 | The number of sorted() comparisons on the same shuffled data became slightly different: 8,655 versus 8,632 on 3.11 and 3.12 for a thousand elements. The complexity is the same — O(n log n); the exact number of comparisons cannot be relied on. | |
| 3.15 | Python 3.15 was released on 9 October 2026. The lesson did not run it: all counts and measurements are CPython 3.11–3.14, the boundary was checked on 14.10.2026. This row claims no changes relevant to the lesson — on 3.15 the counts have to be taken again. |
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
n = 100
count = 0
for i in range(n):
for j in range(i + 1, n):
count += 1
print(count)Practice · estimate
Check yourself
What is the complexity of result = [x for x in a if x not in seen], where a has length n and seen is a list of length m?
How it was measured
The numbers of this lesson come from these scripts. Each one opens right from here — together with the record of its run: what it ran on, what came out and with what spread.
Comparison counts — CPython 3.11.15, 3.12.3, 3.13.7 and 3.14.7; time
measurements — 3.13.7 and 3.14.7 on one machine with two cores. Sections 6–7
of bench/complexity/basics.py, bench/complexity/strkeys.py and
bench/complexity/find.py were added and run on 14.10.2026 on linux/x86_64.
Contracts and quotes were checked on 14.10.2026. The lesson did not run
Python 3.15 (released on 9 October 2026).
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- O-notation describes not speed but how the work grows as the input grows. O(1) — the work does not depend on n, O(n) — it grows with n, O(log n) — it adds a little with each doubling, O(n²) — it grows as a square. Strictly, O is an upper bound ("no faster than"); a tight bound is written Θ. Work is easiest to count in comparisons: the run counts them directly.
- Hence the main point: you estimate not a line of code but what it does inside.
x in listis a scan: a hundred thousand comparisons on a hundred thousand elements.x in setis O(1) on average: in our set without hash collisions it is one comparison at every n checked, but if all elements share one hash, a lookup among a hundred elements called__eq__105 times — the worst case, O(n). That is why the innocent list comprehension[x for x in a if x in b]with a listbis O(n²): at a thousand elements it made a million comparisons, and with a setb— none. - Beyond that comes what separates understanding from having read about it.
appendis "amortized" O(1): over a hundred thousand appends the list reallocated its memory 66 times, because it grows with a reserve. Binary search withbisecton a hundred thousand takes 17 comparisons — but that is an insertion position; whether the element was found takes one more check. Andsorted()is O(n log n) in the average and worst case, but on already sorted data it made exactly n − 1 comparisons in our run: CPython's sort looks for ready-made ordered runs.
In fact
- If there is an
x inon a list inside, it is O(n²). At a thousand elements[x for x in a if x in b]with a listbmade a million comparisons. - Amortized. An individual
appendthat runs out of space copies the whole list — O(n). But such calls are few: 66 per hundred thousand. - O(n log n) is for the average and worst case. On already sorted and strictly descending data
sorted()made exactly n − 1 comparisons in the run — O(n); that is an observation on such an input, not a promise. - One comparison is an observation for keys without hash collisions. O(1) is an average estimate: when all hundred elements share one hash, a lookup called
__eq__105 times, at ten thousand — 11,672. The worst case is O(n). - O-notation is about growth, not speed.
x in liston ten elements is ten comparisons, and in the lesson on collections such a lookup was only 3.4 times slower than a set, while the first element was found by the list just as fast.
By version
- 3.11
- The reference point of the lesson. The lookup comparison counts, the list's series of capacities and the n − 1 comparisons on sorted data are the same on 3.11–3.14.<
- 3.13
- The number of
sorted()comparisons on the same shuffled data became slightly different: 8,655 versus 8,632 on 3.11 and 3.12 for a thousand elements. The complexity is the same — O(n log n); the exact number of comparisons cannot be relied on.< - 3.15
- Python 3.15 was released on 9 October 2026. The lesson did not run it: all counts and measurements are CPython 3.11–3.14, the boundary was checked on 14.10.2026. This row claims no changes relevant to the lesson — on 3.15 the counts have to be taken again.<
What is covered
- Base: what O-notation says
- Mechanism 1: lookup — O(n), O(1) and O(log n) in numbers
- Mechanism 2: append — amortized O(1)
- Mechanism 3: growth of time — the same, by the clock
- Mechanism 4: the hidden square
- Deeper: sorting and n − 1 comparisons
- Deeper: the cost of one comparison
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Check yourself
- How it was measured
Sources & further reading
4 SOURCES
- Objects/listobject.c — list_resizeCPython source code. Lines 130–136 at tag v3.13.7 (123–129 at v3.14.0, same text). The comment on allocating memory with a reserve: «This over-allocates proportional to the list size, making room for additional growth. The over-allocation is mild, but is enough to give linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc()». And the series of capacities: «The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...».https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
- Objects/listsort.txt — the description of CPython's sortCPython source code. At tag v3.13.7 (the file is the same at v3.14.0). Lines 59–60 name descending and ascending data; lines 68–69: on them the sort is trivial «because it naturally works on runs»; lines 74–77 are the table where for n = 32,768 that is 32,767 comparisons; lines 100–101 note one compare fewer on descending data than the previous algorithm. Lines 104–106: «lg(n!) is the information-theoretic limit for the best any comparison-based sorting algorithm can do on average (across all permutations)». Lines 215–216, on how runs are found: «count_run() returns the # of elements in the next run, and, if it's a descending run, reverses it in-place».https://github.com/python/cpython/blob/v3.13.7/Objects/listsort.txt
- bisect — the docstring of bisect_leftOfficial documentation. The docstring of bisect.bisect_left in the interpreter: «Return the index where to insert item x in list a, assuming a is sorted». Checked by calling bisect.bisect_left.__doc__ on 3.13.7 and 3.14.7.https://docs.python.org/3.13/library/bisect.html
- What’s New In Python 3.15Official documentation. Python 3.15 was released on 9 October 2026. The lesson did not run it: all counts and measurements are CPython 3.11–3.14; the boundary was checked on 14.10.2026.https://docs.python.org/3.15/whatsnew/3.15.html