Complexity Analysis: Big-O from first principles
Every other topic gets judged through the lens you build here. Interviewers don't just want the right answer — they want to hear you reason about cost, out loud. This is the grammar of that conversation.
01The problem Big-O actually solves
You're on-call. A batch job that processed 10K records in 2 seconds suddenly gets 10M. Will it take ~30 minutes (linear — annoying), ~3 weeks (quadratic — page someone), or ~heat death of the universe (exponential — redesign)?
You can't answer by timing code on your laptop — wall-clock depends on hardware, load, cache temperature. So we ask a sharper question:
The one-sentence definition (know it): f(n) is O(g(n)) if, past some input size, f(n) is bounded above by a constant multiple of g(n). Translation: "eventually, my cost grows no faster than g(n), ignoring constants."
Why we legally drop constants and small terms
3n² + 50n + 1000 → O(n²). At n = 1,000,000 the n² term is 3·10¹², while 50n is 5·10⁷ — the linear term contributes 0.001% of the work. Big-O describes the large-n regime, which is exactly the regime where you care.
02The seven curves, with real intuition
| Big-O | Name | Gut feeling | You've already met it |
|---|---|---|---|
| O(1) | constant | "look it up" | Redis GET, dict access, array index |
| O(log n) | logarithmic | "halve what's left each step" | Postgres B-tree index, binary search |
| O(n) | linear | "touch everything once" | Full table scan, replaying a Kafka partition |
| O(n log n) | linearithmic | "sort-shaped work" | ORDER BY without an index, merge sort |
| O(n²) | quadratic | "everything vs everything" | Naive JOIN, nested loops over one list |
| O(2ⁿ) | exponential | "try every subset" | Unmemoized recursion, brute-force search |
| O(n!) | factorial | "try every ordering" | Brute-force traveling salesman |
O(1) — flat, not fast
The work doesn't depend on input size. A dict lookup in a 10-element dict and a 10-million-element dict cost roughly the same. "Constant" can still be a big constant — it just doesn't grow.
O(log n) — the most underappreciated curve in computing
Every step cuts the remaining problem by a constant fraction. That's the signature. Hear "halve", "binary", "tree height", "divide the range" → think log.
def binary_search(a, target):
lo, hi = 0, len(a) - 1
while lo <= hi: # runs ~log2(n) times
mid = (lo + hi) // 2
if a[mid] == target: return mid
if a[mid] < target: lo = mid + 1
else: hi = mid - 1
return -1
O(n) — one pass (or two, or five)
Touch each element a constant number of times. Two passes is still O(n) — but say "I make two passes" out loud anyway; it signals you know and chose to ignore the constant.
O(n log n) — "sort-shaped"
Two ways to get here: O(log n) levels of O(n) work each (merge sort), or n operations at O(log n) each (n pushes through a heap). Rule of thumb: if your solution sorts, you're at least n log n. Comparison sorting cannot beat it — n! orderings, each comparison halves the candidates, so you need ≥ log₂(n!) ≈ n log n comparisons.
O(n²) — the all-pairs shape
for i in range(n):
for j in range(i + 1, n): # every pair: n(n-1)/2 → O(n²)
...
n = 10⁴ → 10⁸ comparisons → seconds in Python. n = 10⁵ → 10¹⁰ → minutes-to-hours. Heuristic: if n can reach 10⁵, quadratic times out — the interviewer wants n log n or n.
O(2ⁿ) and O(n!) — combinatorial explosions
"Every subset" = 2ⁿ. "Every ordering" = n!. Only acceptable when n ≤ ~20 (subsets) or n ≤ ~10 (orderings) — which is exactly the range where interviewers expect backtracking (file 07).
03Reading complexity off code — three rules
Sequential blocks add → take the max. O(n) then O(n²) = O(n²).
Nested blocks multiply. Loops over different inputs → say O(n·m), not "n²". Precision reads as skill.
Know the cost of what you call. The classic slip: a linear operation hiding inside a loop.
for x in items: # O(n) iterations…
if x in some_list: # …× O(n) scan
... # total: O(n²)for x in items: # O(n) iterations…
if x in some_set: # …× O(1) hash
... # total: O(n)Python costs you must know cold
| Operation | Cost | Note |
|---|---|---|
lst[i], lst.append(x) | O(1) | append is amortized — §5 |
lst.insert(0,x), lst.pop(0) | O(n) | shifts everything — use deque |
x in lst | O(n) | linear scan |
x in set_ / d[k] | O(1) avg | hashing (file 02) |
lst.sort() / sorted() | O(n log n) | Timsort (file 08) |
slice lst[a:b] | O(b−a) | copies! |
s += t in a loop | O(n²) total | strings immutable — use ''.join |
heapq.heappush/pop | O(log n) | file 06 |
min(lst) / max(lst) | O(n) | full scan every call |
04Space — the forgotten sibling
Same idea, applied to extra memory (by convention, the input doesn't count).
When an interviewer asks "can you do better?", your first two thoughts, in order:
- Trade space for time? — hashmap, prefix sums, memoization
- Exploit structure? — sorted input → binary search / two pointers
05Amortized analysis — why append is O(1) even when it isn't
Python lists are dynamic arrays: contiguous memory with spare capacity. Usually append drops into a free slot — O(1). When full, Python allocates bigger and copies all n elements — that one call is O(n).
capacity 4: [a][b][c][d] append(e) → FULL!
│
▼ allocate 8 · copy 4 · insert
capacity 8: [a][b][c][d][e][ ][ ][ ] ← next 3 appends: cheap O(1)
The banker's argument (say this one out loud): charge every append 3 coins. One pays for the insert; two go into the bank. When a resize must copy n elements, the bank — filled by the n/2 appends since the last resize — pays for the whole copy. No operation ever goes into debt → amortized O(1).
Equivalently: total copy work to reach size n with doubling is n/2 + n/4 + … ≈ n. So n appends cost ≈ 2n → O(1) each.
Where amortized thinking wins interviews
list.appendand hashmap inserts (resize at load factor) — amortized O(1)- Queue-from-two-stacks: each element moves at most twice → amortized O(1) per op
- Monotonic stack loops (file 03): the inner
whilelooks nested, but each element is pushed and popped once → total O(n)
06Best / average / worst — which to report
Default to worst case unless told otherwise. The three classic splits:
| Algorithm | Average | Worst | Why the worst happens |
|---|---|---|---|
| Quicksort | O(n log n) | O(n²) | adversarial pivots (sorted input + naive pivot) |
| Hashmap ops | O(1) | O(n) | all keys collide into one bucket |
| BST ops | O(log n) | O(n) | sorted inserts → a stick (hence self-balancing trees) |
07Narrating analysis — the meta-skill being graded
Interviewers grade the narration as much as the answer. The rhythm:
- Before coding, state the target. "Brute force is O(n²) — all pairs. n is up to 10⁵ so that's 10¹⁰ ops — too slow. I'll aim for O(n) with a hashmap." Thirty seconds; enormous signal.
- While coding, price what matters. "This
inis against a set, so O(1)… this slice copies, but it's outside the loop." - After coding, state both bounds unprompted. "Time O(n) — one pass, O(1) per element. Space O(n) worst case for the map."
Phrases that buy goodwill
- "Let me get a correct brute force first, then optimize." (then actually do it)
- "The bottleneck is this membership test — a set fixes it."
- "This is amortized O(1) — one call can resize, but n calls total O(n)."
- "Recursion depth is O(n) worst case; I can convert to iterative if stack limits worry us."
08The decoder ring — constraints reveal the intended answer
The constraint in the problem statement is a hint about the intended complexity. Read it first, always:
| Constraint | Intended complexity | Smells like |
|---|---|---|
| n ≤ 10–12 | O(n!), O(2ⁿ·n) | permutations, brute force |
| n ≤ 20–25 | O(2ⁿ) | subsets, bitmask, backtracking |
| n ≤ ~500 | O(n³) | Floyd–Warshall, interval DP |
| n ≤ ~5,000 | O(n²) | 2D DP, all-pairs |
| n ≤ ~10⁵–10⁶ | O(n log n) or O(n) | sort, heap, sliding window, hashmap |
| n ≤ ~10⁹+ | O(log n) or O(1) | binary search on the answer, math |
Calibration: Python does roughly ~10⁷ simple ops/second pessimistically; judges budget a few seconds.
09Feel the curves — interactive
10Rapid-fire interview Q&A
while i < n: i *= 2 — complexity?for i in range(n): for j in range(i): … ?x in my_list slow but x in my_set fast?s += t copies everything accumulated so far. Copies of size 1+2+…+n ⇒ O(n²). Fix: collect parts in a list, ''.join(parts).11Self-test — answer before you peek
- Define Big-O in one sentence a junior engineer would understand.
- Simplify: O(5n² + 3n log n + 200n + 7).
lst.pop()vslst.pop(0)— costs and why?- Explain amortized O(1) append using the doubling argument, ≤ 3 sentences.
- n ≤ 10⁵: what complexities are viable? What does n ≤ 20 hint instead?
- Extra space of recursive DFS on a balanced tree? On a degenerate stick?
- Why can't any comparison sort beat O(n log n)?
- One worst-case scenario each: quicksort, hashmap lookup, BST insert.
- What's wrong with
while target not in seen_list:inside a loop, and the fix? - Say your solution's time and space in two sentences, interviewer-style.
Spot-check answers
- "As input grows, the work grows no faster than this function, ignoring constants."
- O(n²).
pop()O(1) (end);pop(0)O(n) — shifts every element left.- Doubling makes resizes rare; total copy work to reach n is ≈ n; spread over n appends ⇒ O(1) each.
- O(n log n) or better; n ≤ 20 hints exponential/backtracking is intended.
- O(log n) balanced; O(n) stick.
- n! orderings; each comparison at most halves candidates ⇒ ≥ log₂(n!) ≈ n log n comparisons.
- Sorted input + first-element pivot; all keys in one bucket; inserting sorted keys.
- List membership is O(n), making the loop O(n²); use a set.
- "Time O(…) because …; space O(…) for the …" — practice the sentence shape.
Source: 01-dsa/01-complexity-analysis.mdNext: Arrays, Strings & Hashing →