Big-O Mastery¶
Complete Complexity Analysis — Phase 0, Weeks 1–2¶
Complexity analysis is the difference between knowing that a solution works and knowing why it’s acceptable. This document gives you the formal intuition, the mechanical derivation rules, and the amortized analysis tool — not as abstract theory, but as things you will actively use every time you write or evaluate code.
1. The Formal Definition (No Epsilon-Delta Required)¶
Big-O is a mathematical relationship between two functions. Informally: f(n) is O(g(n)) if, from some input size N₀ onward, f(n) never exceeds c·g(n) for some constant c.
The core intuition: We care about what happens as n grows large. Below N₀, any algorithm can beat any other algorithm because constants dominate. Above N₀, the growth rate determines the winner.
f(n) = O(g(n)) ←→ ∃ c > 0, N₀ > 0 such that f(n) ≤ c·g(n) for all n ≥ N₀
The three notations:
Notation |
Meaning |
Analogy |
|---|---|---|
O(g(n)) |
Upper bound — algorithm does at most this much work |
“It costs no more than…” |
Ω(g(n)) |
Lower bound — algorithm does at least this much work |
“It costs no less than…” |
Θ(g(n)) |
Tight bound — algorithm does exactly this growth rate |
“It costs exactly…” |
Critical distinction: O is NOT “worst case.” O is an upper bound. You can say the best case of linear search is O(1) — it’s a valid upper bound on the best case. When people say “quicksort is O(n log n),” they mean average case. Merge sort’s worst case is Θ(n log n) — both upper and lower bound. Get this right.
Simplification rules (the ones you use every day):
Drop constants: O(3n) = O(n), O(1000) = O(1)
Drop lower-order terms: O(n² + n) = O(n²), O(n + log n) = O(n)
Different variables stay separate: O(n·m) ≠ O(n²) unless you know n = m
2. Deriving Big-O: The Mechanical Rules¶
Rule 1: Single loops¶
A loop that runs n times → O(n). The body’s constant work doesn’t change this.
for i in range(n): # n iterations
arr[i] = arr[i] * 2 # O(1) per iteration
Total: O(n)
Rule 2: Nested loops¶
Multiply the number of iterations. But read the actual bounds — don’t assume.
for i in range(n): # n iterations
for j in range(n): # n iterations per outer
process(i, j) # O(1)
Total: O(n²)
BUT:
for i in range(n): # n iterations
for j in range(m): # m iterations per outer (m ≠ n)
process(i, j)
Total: O(n·m), NOT O(n²)
AND:
for i in range(n):
for j in range(i): # i iterations, not n
process(i, j)
Total: 0 + 1 + 2 + … + (n-1) = n(n-1)/2 = O(n²). But recognize the pattern.
Rule 3: Loops that halve — O(log n)¶
If the loop variable is halved (or some fraction) each iteration, it runs log₂(n) times.
i = n
while i > 1:
process(i)
i = i // 2 # halved each time
How many times can you halve n before reaching 1? log₂(n) times. → O(log n)
The intuition: Every time you cut the problem in half, you add one step. Starting from n=1024, it takes 10 halvings to reach 1. log₂(1024) = 10.
Rule 4: Recursive calls — write the recurrence¶
Don’t eyeball recursive code. Write T(n) = [cost per call] + [recursive calls on subproblems].
def merge_sort(arr): # T(n)
if len(arr) <= 1: return
mid = len(arr) // 2
merge_sort(arr[:mid]) # T(n/2)
merge_sort(arr[mid:]) # T(n/2)
merge(arr) # O(n)
Recurrence: T(n) = 2T(n/2) + O(n) → see 02_recurrence_relations.md for solving this.
Rule 5: Sequential operations — add¶
for i in range(n): # O(n)
process(i)
for i in range(n): # O(n)
another(i)
Total: O(n) + O(n) = O(2n) = O(n). The loops run sequentially; you add their costs.
3. The 7 Complexity Classes¶
These cover 99% of what you’ll encounter. Know them cold.
Class |
Name |
Example |
n=10 |
n=100 |
n=1000 |
|---|---|---|---|---|---|
O(1) |
Constant |
Array index access, hash map lookup |
1 |
1 |
1 |
O(log n) |
Logarithmic |
Binary search, balanced BST ops |
3 |
7 |
10 |
O(n) |
Linear |
Linear scan, sum of array |
10 |
100 |
1000 |
O(n log n) |
Linearithmic |
Merge sort, heap sort |
33 |
664 |
9966 |
O(n²) |
Quadratic |
Bubble sort, naive nested loops |
100 |
10,000 |
1,000,000 |
O(2ⁿ) |
Exponential |
Recursive Fibonacci, subset enumeration |
1024 |
2¹⁰⁰ |
2¹⁰⁰⁰ |
O(n!) |
Factorial |
Permutation generation, TSP brute force |
3.6M |
incomputable |
incomputable |
The practical cliff: For n = 10⁶ (one million), O(n log n) is ~2×10⁷ operations (fine). O(n²) is 10¹² operations (won’t finish in your lifetime without a supercomputer). The difference between “solves in 0.1s” and “never finishes” is often just one complexity class.
Codeforces rule of thumb: Modern computers do ~10⁸ simple operations per second. If your time limit is 1s and n = 10⁶, you need O(n log n) or better.
4. Space Complexity¶
Space complexity follows the same O/Θ/Ω notation. The critical thing most people miss: the call stack counts.
What to count:
Variables you declare
Data structures you allocate
The recursive call stack (one frame per active call)
def factorial(n): # T(n) = O(n), S(n) = O(n)
if n == 0: return 1
return n * factorial(n - 1)
This creates n stack frames simultaneously. Space: O(n), not O(1).
def factorial_iterative(n): # S(n) = O(1)
result = 1
for i in range(1, n+1):
result *= i
return result
One variable, no recursion stack. Space: O(1).
Space-time tradeoff: Memorization/caching trades space for time. Prefix sums use O(n) extra space to answer range queries in O(1) instead of O(n) each. Always state both complexities.
5. Amortized Analysis: The Dynamic Array Example¶
Amortized analysis asks: what is the average cost per operation over a sequence of n operations, even when some individual operations are expensive?
The problem: A dynamic array (ArrayList in Java, vector in C++) has capacity. When you push_back beyond capacity, it reallocates and copies everything — an O(n) operation. Does this make push_back O(n)?
No. Here’s why, worked out:
Suppose the array doubles in capacity each time it fills. Starting with capacity 1:
Capacity |
Push cost |
|---|---|
1→2 |
copy 1 element (cost: 1) |
2→4 |
copy 2 elements (cost: 2) |
4→8 |
copy 4 elements (cost: 4) |
8→16 |
copy 8 elements (cost: 8) |
… |
… |
n/2 → n |
copy n/2 elements (cost: n/2) |
Total copy cost for n pushes: 1 + 2 + 4 + 8 + … + n/2 = n - 1 (geometric series sum).
Total work for n push_back operations: n (for the pushes themselves) + (n-1) (for all copies) = 2n - 1.
Amortized cost per push_back: (2n-1)/n ≈ 2 = O(1).
This is why ArrayList/vector advertises O(1) amortized push_back. Any single push might cost O(n), but that cost is “paid for” by the n/2 cheap pushes that preceded it.
The mental model: Imagine each cheap push deposits a “credit.” When the expensive copy happens, it withdraws those credits. The credit never goes negative, so the average is constant.
6. What Most Engineers Get Wrong¶
Mistake 1: Confusing worst-case with Big-O. “Quicksort is O(n log n)” is sloppy language. Quicksort’s average-case is O(n log n). Its worst-case is O(n²) on adversarial input (already-sorted arrays with naive pivot). On competitive programming platforms, adversarial test cases are common. Always distinguish average-case from worst-case explicitly.
Mistake 2: Assuming built-in operations are always O(1).
In C++, unordered_map (hash map) is O(1) average but O(n) worst-case due to hash collisions. There are documented competitive programming problems where adversaries construct inputs that trigger O(n) on every lookup, making your O(n) loop actually O(n²). This is not theoretical — it happens in Codeforces rounds.
Mistake 3: Dropping constants that matter in practice. O(n) with c=100 versus O(n log n) with c=1: for n < 10⁵, the “slower” O(n log n) may run faster. Big-O is asymptotic — it tells you what wins for large n, not for your specific n. Always sanity-check your constant factor when n is small.
Mistake 4: Treating nested loops as automatically O(n²).
Two nested loops over different ranges is O(n·m). Two nested loops where the inner loop’s bound depends on the outer variable (e.g., for j in range(i)) requires summing the series. Always read the actual bounds.
7. Practice Problems¶
Problem 1. What is the time complexity of this code?
for i in range(n):
for j in range(i, n):
print(i, j)
Answer: The inner loop runs (n-i) times. Total iterations = Σ(n-i) for i=0..n-1 = n + (n-1) + … + 1 = n(n+1)/2 = O(n²).
Problem 2. What is the time complexity?
i = 1
while i < n:
j = 1
while j < n:
print(i, j)
j *= 2
i *= 2
Answer: Outer loop: log n iterations. Inner loop: log n iterations. Total: O(log²n).
Problem 3. You have an algorithm: for each of n elements, run binary search on a sorted array of n elements. Total complexity? Answer: O(n log n). Binary search is O(log n), run n times.
Problem 4. What is the space complexity of this function?
def sum_array(arr, n):
if n == 0: return 0
return arr[n-1] + sum_array(arr, n-1)
Answer: O(n) space. Each recursive call adds one frame to the call stack, and there are n frames active simultaneously at the deepest point.
Problem 5. True or false: An O(n²) algorithm always runs slower than an O(n log n) algorithm. Answer: False. Big-O is asymptotic — for small n, constants dominate. An O(n²) algorithm with c=1 runs faster than an O(n log n) algorithm with c=1000 for n < 1000. Always consider the actual input size and constants.
Return to README.md · Next: 02_recurrence_relations.md