Big-O and Complexity Deep Dive
Big-O is the language interviewers and contest setters use to talk about speed. It answers one question: as the input grows, how does the work grow?
What you’ll learn
Section titled “What you’ll learn”- What Big-O, Big-Omega, and Big-Theta actually mean.
- The complexity hierarchy and how to read it off code.
- Best vs average vs worst case.
- Amortized analysis (why
list.appendis "" despite resizing). - The Python constant-factor reality that decides TLE.
Visual intuition
Section titled “Visual intuition”Big-O as arithmetic rather than vibes. The dashed line is roughly what an online judge accepts in a second:
Step through the n values and watch which curves cross the budget line. This is the calculation to do BEFORE choosing an approach: n <= 20 means exponential is intended, n <= 5000 means quadratic is fine, n <= 100000 means you need O(n log n).
The core idea
Section titled “The core idea”Big-O describes an upper bound on growth, ignoring constants and lower-order terms. Formally, if there exist constants and such that:
So — the term dominates, constants drop.
Two companions complete the picture:
- — a lower bound (the work is at least this).
- — a tight bound (upper and lower).
Watch the growth rates race
Section titled “Watch the growth rates race”Small inputs hide everything — every algorithm looks fast. The gap explodes as grows. This is why beats so decisively on large data:
The hierarchy (fastest to slowest)
Section titled “The hierarchy (fastest to slowest)”| Big-O | Name | Example |
|---|---|---|
| constant | array index, dict lookup | |
| logarithmic | binary search | |
| linear | one pass over a list | |
| linearithmic | efficient sorts, most divide-and-conquer | |
| quadratic | nested loops over the same data | |
| exponential | naive subsets / recursion | |
| factorial | brute-force permutations |
Reading complexity off code
Section titled “Reading complexity off code”Count the loops and how the input drives them.
# O(1) — no loop over input
def first(nums):
return nums[0] if nums else None
# O(n) — one pass
def total(nums):
s = 0
for x in nums: # runs n times
s += x
return s
# O(n^2) — loop inside a loop over the same data
def any_pair_sums_zero(nums):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == 0:
return True
return False
print(first([9, 2]), total([1, 2, 3]), any_pair_sums_zero([3, -3, 5]))Best, average, worst
Section titled “Best, average, worst”The same algorithm can have different costs depending on the input.
- Best case — luckiest input (e.g., target is the first element).
- Worst case — the input interviewers care about most.
- Average case — expected over random inputs.
Linear search: best (found first), worst (found last / absent).
Amortized analysis
Section titled “Amortized analysis”Some operations are occasionally expensive but cheap on average over a
sequence. list.append is the classic: usually , but when the underlying
array is full Python allocates a bigger one and copies everything ().
Because resizes double capacity, those costs spread out to amortized .
The Python constant-factor reality
Section titled “The Python constant-factor reality”Big-O drops constants — but on a real judge, constants decide TLE. Python is ~10–100× slower per operation than C++. A rough safe budget per second:
graph LR
A["n up to 1e8
O(n) only, tight loops"] --> B["n up to 1e6-1e7
O(n) / O(n log n)"]
B --> C["n up to 1e4
O(n^2) ok"]
C --> D["n up to ~20
O(2^n) ok"]
D --> E["n up to ~11
O(n!) ok"]
We cover concrete TLE-beating tricks (fast I/O, stdlib, PyPy) in Phase 2: Python for DSA & CP.
Dry run
Section titled “Dry run”Reading complexity off code, three cases that get misread
Section titled “Reading complexity off code, three cases that get misread”1. A while inside a for is not automatically .
The monotonic-stack shape — each element pushed once and popped at most once — runs the inner loop times in total, not per iteration. The bound comes from a potential argument, not from counting nesting depth. Amortised .
2. list.append is amortised, and here is the evidence.
Watching the length at which CPython reallocates a growing list:
1, 5, 9, 17, 25, 33, 41, 53, 65, 77, 93, 109, 129, 149, …The gaps grow — 4, 4, 8, 8, 8, 8, 12, 12, 12, 16, 16, 20, 20 — because the new capacity is
proportional to the current size. Geometric growth means the total copying across n appends is a
constant multiple of n, so the average is even though individual appends are .
That is what “amortised” means, and it is the honest figure to quote for a loop of appends.
3. The operation that silently squares your complexity.
n | x in list | x in set | Ratio |
|---|---|---|---|
| 1,000 | 4.90 µs | 0.026 µs | 186x |
| 100,000 | 659 µs | 0.048 µs | 13,649x |
The set is flat — 0.026 µs to 0.048 µs as n grows 100x — while the list scales linearly.
Putting x in some_list inside a loop turns an algorithm into without changing a line
of the visible logic, and it is the single most common accidental blow-up in Python.
Same story for the front of a list:
n | list.insert(0, x) | deque.appendleft(x) | Ratio |
|---|---|---|---|
| 10,000 | 7.06 µs | 0.032 µs | 220x |
| 50,000 | 20.48 µs | 0.037 µs | 546x |
list.insert(0, …) shifts every element; deque is at both ends. The ratio grows with n
because one is linear and the other is constant — which is exactly what the asymptotics predict, and
the measurement makes it concrete.
One more drill: name the class from the shape
Section titled “One more drill: name the class from the shape”Practice
Section titled “Practice”Drill 1 — name the complexity. Complete the function so it runs in , not : sum every element using a single pass.
Drill 2 — pick the fast structure. Counting frequencies with a nested scan
is . A dict makes it . Fill the blank.
Complexity
Section titled “Complexity”The reference table, with the Python-specific numbers that the pure asymptotics hide.
| Operation | Bound | Note |
|---|---|---|
list[i], len(x) | — | |
list.append | amortised | on the reallocation; geometric growth makes the total linear |
list.pop() (end) | — | |
list.insert(0, x) / list.pop(0) | measured 220-546x slower than deque | |
list.insert/del at an index | shifts the tail | |
x in list | measured 186x slower than a set at , 13,649x at | |
x in set / x in dict | average | worst case under adversarial hashing |
dict[k], set.add | average | — |
deque.append / appendleft / pop / popleft | use for a queue, always | |
heapq.heappush / heappop | heapify is | |
bisect.bisect_* | but insort is — the shift | |
sorted / list.sort | best case, Timsort is adaptive | |
min / max / sum | C-level loop, small constant | |
"".join(parts) | the right way to build a string | |
String slicing s[a:b] | copies — a slice in a loop is a hidden | |
set union / intersection | of the sizes | — |
Best, average, worst — and which one to quote:
| Algorithm | Best | Average | Worst | Quote |
|---|---|---|---|---|
| Quicksort | worst, then say randomisation makes it unlikely | |||
| Timsort | worst, and mention the adaptive best case | |||
| Hash lookup | average — the worst case needs an adversary | |||
| Binary search | worst |
The Python constant factor. Roughly 10-100x slower than C for interpreted loops, so the safe
working budget is about simple operations per second rather than . Work pushed into C —
sum, sorted, heapq, set operations, join — does not pay that tax, which is why the fix for
a slow Python loop is usually “express it as a built-in” rather than “change the algorithm”.
Pitfalls
Section titled “Pitfalls”x in some_listinside a loop. Measured 13,649x slower than a set at . This turns into with no visible change to the logic, and it is the most common accidental blow-up in Python.list.pop(0)for a queue. per call — measured 546x slower thandeque.popleftat . Usecollections.deque.- Reading a
whileinside aforas . If each element is pushed and popped at most once, the total inner work is — monotonic stack, sliding-window deque, KMP. The nesting is not the bound; the potential argument is. - Quoting
appendas worst case. It is amortised and on the reallocation. Overnappends the total is , so amortised is the honest figure for a loop — but say the word. - Ignoring the variable the bound is over. Binary search on the answer is in the
value range; knapsack is in the capacity. Both are routinely misquoted as functions of
nalone, and both blow up when the other variable is large. - Forgetting recursion’s space. stack frames, invisible in the source, and a hard ~1,000 ceiling in CPython.
- Slicing inside a loop.
s[i:]copies, so a loop of slices is even though each line looks constant. Pass indices instead. - Assuming hashing is unconditional. It is average; degenerate hashing is . It matters only against an adversary, which is why competitive judges hack fixed hash functions.
- Comparing asymptotics without the constant. Python beats C only when
nis large enough — andsortedis C. Measured elsewhere:heapq.nlargestbeats sorting by 13x atk = 5of 200,000 and loses by 4x atk = n/2.
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
“What is the complexity of list.insert(0, x)?” | Whether you know the data model | — every element shifts. Measured 220x slower than deque.appendleft at and 546x at 50,000. That is why a queue uses deque |
“in on a list versus a set?” | The most common Python performance bug | against average — measured 186x at and 13,649x at . Inside a loop it silently squares the whole algorithm |
| “Amortised or worst case?” | Precision with words | append is amortised, on the resize that copies. Because the capacity grows geometrically, n appends total — so amortised is the honest figure for a loop |
“There’s a while inside your for. Isn’t that ?” | Whether you can defend an amortised bound | Not if each element enters and leaves the structure once. Total inner-loop work is bounded by total pushes, which is n. That is the argument for monotonic stacks, sliding-window deques and KMP |
| “Your solution is but it times out” | Diagnosis order | Either the class is wrong, or the bound is over a variable you assumed (, ), or the constant is the problem — Python-level loops, per-iteration allocation, slicing. Check the first two before rewriting |
| “Space complexity of your recursion?” | The invisible cost | frames, degenerate, and a ~1,000-frame ceiling in CPython — so it is a crash risk, not just memory |
| “Can you ever beat for sorting?” | Knowing the model | Only by leaving the comparison model: counting or radix sort is for bounded integer keys. The bound is about comparison sorts specifically |
| “Which bounds do people quote wrongly?” | Judgement | Pseudo-polynomial ones — knapsack’s , digit DP — because they are polynomial in a value rather than an input length. And naive Fibonacci, which is , not |
| “How many operations per second should you assume?” | Practical sizing | ~ for C, and about for pure-Python loops — an order of magnitude down. Work pushed into built-ins does not pay that tax |
Self-check
Section titled “Self-check”-
`x in some_list` inside a loop over n elements. What is the measured cost?
The set timings were flat as n grew 100x (0.026 to 0.048 microseconds) while the list scaled linearly. Nothing about the code looks quadratic, which is what makes it the most common accidental blow-up in Python. One `set(...)` conversion fixes it.
pch.quizShowAnswer
B — O(n) per test: measured 186x slower than a set at n = 1000 and 13,649x at n = 100,000, turning an O(n) algorithm into O(n^2) — The set timings were flat as n grew 100x (0.026 to 0.048 microseconds) while the list scaled linearly. Nothing about the code looks quadratic, which is what makes it the most common accidental blow-up in Python. One `set(...)` conversion fixes it.
-
Why is `list.append` described as O(1) amortised rather than O(1)?
Measured reallocation lengths: 1, 5, 9, 17, 25, 33, 41, 53, 65, 77, 93... with gaps that widen because new capacity is proportional to current size. Geometric growth is exactly what makes the total copying a constant multiple of n. Saying "amortised" when you mean it, and "worst case" when you mean that, is the precision being tested.
pch.quizShowAnswer
B — An individual append can be O(n) when the list reallocates and copies; capacity grows geometrically, so n appends total O(n) and the average is constant — Measured reallocation lengths: 1, 5, 9, 17, 25, 33, 41, 53, 65, 77, 93... with gaps that widen because new capacity is proportional to current size. Geometric growth is exactly what makes the total copying a constant multiple of n. Saying "amortised" when you mean it, and "worst case" when you mean that, is the precision being tested.
-
A `while` loop nested inside a `for` loop. Is it necessarily O(n^2)?
This is the monotonic stack, sliding-window deque and KMP argument. The bound comes from a potential function -- total pushes bound total pops -- rather than from counting nesting. Reading the nesting as the bound is the standard misread, and being able to give the potential argument is what "prove your complexity" is asking for.
pch.quizShowAnswer
B — No -- if each element enters and leaves the structure at most once, total inner work is bounded by n, giving O(n) amortised — This is the monotonic stack, sliding-window deque and KMP argument. The bound comes from a potential function -- total pushes bound total pops -- rather than from counting nesting. Reading the nesting as the bound is the standard misread, and being able to give the potential argument is what "prove your complexity" is asking for.
-
Why does a queue use `collections.deque` rather than a list?
A Python list is a dynamic array, so the front is the expensive end -- removing from it moves every remaining element. deque is a doubly linked list of blocks and is O(1) at both ends. Measured 220x at n = 10,000 and 546x at 50,000: the ratio grows with n, which is what linear-versus-constant looks like.
pch.quizShowAnswer
B — `list.pop(0)` and `list.insert(0, x)` are O(n) because every element shifts -- measured 546x slower than deque at n = 50,000 — A Python list is a dynamic array, so the front is the expensive end -- removing from it moves every remaining element. deque is a doubly linked list of blocks and is O(1) at both ends. Measured 220x at n = 10,000 and 546x at 50,000: the ratio grows with n, which is what linear-versus-constant looks like.
-
Your O(n log n) solution times out. What do you check first?
Cheapest checks first, and the middle one is the sneaky case: binary search on the answer is O(n log R) in the *value* range and knapsack is O(nW) in the *capacity*, both routinely misquoted as functions of n. Rewriting for constant factor when the complexity class is wrong is the most expensive mistake available.
pch.quizShowAnswer
B — Whether the class is actually wrong, and whether the bound is over the variable you assumed (O(nW), O(n log R)) -- only then the constant — Cheapest checks first, and the middle one is the sneaky case: binary search on the answer is O(n log R) in the *value* range and knapsack is O(nW) in the *capacity*, both routinely misquoted as functions of n. Rewriting for constant factor when the complexity class is wrong is the most expensive mistake available.
-
What operations-per-second budget should you assume for pure-Python loops?
Interpreted loops run 10-100x slower than compiled ones, so the standard 10^8 figure needs shifting down for Python-level iteration. The corollary matters more than the number: `sum`, `sorted`, `heapq`, set operations and `join` all run in C, which is why the fix for a slow Python loop is often to express it as a built-in rather than to change the algorithm.
pch.quizShowAnswer
B — About 10^7 -- roughly an order of magnitude below C, though work pushed into built-ins does not pay that tax — Interpreted loops run 10-100x slower than compiled ones, so the standard 10^8 figure needs shifting down for Python-level iteration. The corollary matters more than the number: `sum`, `sorted`, `heapq`, set operations and `join` all run in C, which is why the fix for a slow Python loop is often to express it as a built-in rather than to change the algorithm.
-
Which of these bounds is most often quoted wrongly?
An amount of 10^9 is ten characters of input but a billion table entries, so calling O(nW) "polynomial" hides an exponential in the input size. Naive Fibonacci is the other classic: Theta(phi^n), not 2^n. Hash lookup's O(1)-average is a fair simplification since the worst case needs an adversary.
pch.quizShowAnswer
B — Pseudo-polynomial ones -- knapsack's O(nW) and digit DP -- because they are polynomial in a VALUE, not in the input's length — An amount of 10^9 is ten characters of input but a billion table entries, so calling O(nW) "polynomial" hides an exponential in the input size. Naive Fibonacci is the other classic: Theta(phi^n), not 2^n. Hash lookup's O(1)-average is a fair simplification since the worst case needs an adversary.
Recall card
Section titled “Recall card”- The Python budget is ~ simple operations per second for interpreted loops — an order of
magnitude below the usual . Work in C (
sum,sorted,heapq, sets,join) does not pay it. x in listis ;x in setis . Measured 186x at , 13,649x at . In a loop this silently squares the algorithm — the commonest Python blow-up.list.insert(0, …)/pop(0)are — 220-546x slower thandeque. Queues usedeque.appendis amortised, on the resize. Reallocation lengths 1, 5, 9, 17, 25, 33… — geometric growth is why the total is linear. Say “amortised”.- A
whileinside aforis not automatically . Pushed-once/popped-once gives total — monotonic stack, deque window, KMP. - Name the variable the bound is over: is in the capacity, in the value range. Pseudo-polynomial is not polynomial.
- Slicing copies —
s[i:]in a loop is a hidden . - Recursion costs frames, invisible in the source, with a ~1,000 ceiling.
- Quote the worst case for quicksort, the average for hashing, and mention Timsort’s best.
- applies to comparison sorts — counting and radix escape it for bounded keys.
- Big-O = growth of work vs input; drop constants and lower-order terms.
- Know the hierarchy cold: .
- Sequential adds, nested multiplies; keep the dominant term.
- Amortized cost spreads rare expensive ops across many cheap ones.
- In Python, constants matter — use to guess the intended complexity.
Next: Recurrences & the Master Theorem — complexity for divide-and-conquer.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading