Asymptotic complexity and Big-O
Analyze time and space with Big-O and Big-Theta, including amortized analysis.
When the input doubles, does the running time double, square, or stay the same? Asymptotic complexity answers this with a precise notation that ignores constants and low-order terms, the only ones that matter once inputs are large[1]. Amortized analysis extends the same idea to operations whose individual costs vary but whose average over a long sequence is what counts[2].
Big-O notation
if there exist constants such that
Big-O is an upper bound that hides constants and low-order terms. Saying means the running time grows no faster than quadratically, eventually. A program with is ; so is one with .
The hidden constants matter in practice but not for distinguishing algorithms whose growth rates differ. An algorithm with a large constant beats an one for large enough , regardless of how large the constants are.
Big-Omega and Big-Theta
Big-Omega is the matching lower bound: if there exist with for . Big-Theta is both at once, when and : a tight characterization, the actual growth rate.
The hierarchy of common complexity classes:
Each class is strictly smaller (so an algorithm is also but not the reverse). The exponential jumps are the practical cliffs: takes microseconds at , milliseconds at , and longer than the age of the universe at .
Worst, average, and best case
Big-O usually refers to the worst case: the maximum running time over all inputs of size . Average case averages over an input distribution, often uniform. Best case is the minimum. Quicksort is worst case, average case; binary search is worst, best (key at the middle). Worst-case bounds are the safe default for guarantees; average-case bounds reflect typical behavior under stated input assumptions.
Amortized analysis
Some operations are usually cheap but occasionally expensive. The right cost is the amortized cost: total cost of a sequence of operations divided by .
The canonical example is a dynamic array that doubles its capacity when it fills
up. Most append calls are , but every -th call (when full) is
because all elements must be copied to a new larger buffer. Across inserts, the
expensive ones at sizes cost
total. The cheap ones contribute more. The total is , so the
amortized cost per insert is , even though individual inserts can be
.
Check it on arrays
import time, random, sys
sys.setrecursionlimit(10_000)
# Empirical growth rates: time three algorithms over increasing n.
def linear_sum(n):
s = 0
for x in range(n):
s += x
return s
def quadratic_pairs(n):
s = 0
for i in range(n):
for j in range(n):
s += 1
return s
def n_log_n(n): # merge-sort style work
if n <= 1: return 0
return n + n_log_n(n // 2) + n_log_n(n - n // 2)
def timeit(f, n):
t = time.perf_counter()
f(n)
return time.perf_counter() - t
# Growth-rate fingerprint: t(2n)/t(n) approaches 2 for O(n), 4 for O(n^2), ~2.2 for n log n
sizes = [200, 400, 800, 1600]
print("size | linear quad n log n")
for n in sizes:
print(f"{n:>5} | {timeit(linear_sum, n):.5f} {timeit(quadratic_pairs, n):.5f} {timeit(n_log_n, n):.5f}")
# Amortized O(1) append: total work for n appends is < 3n, not n^2
class DynArray:
def __init__(self): self.cap = 1; self.size = 0; self.data = [None]
def append(self, x):
if self.size == self.cap: # double on full
new = [None] * (self.cap * 2)
for i in range(self.size): new[i] = self.data[i]
self.data = new; self.cap *= 2
self.data[self.size] = x; self.size += 1
n = 100_000
arr = DynArray()
t = time.perf_counter()
for i in range(n): arr.append(i)
elapsed = time.perf_counter() - t
print(f"{n} appends: {elapsed:.4f}s per-op {elapsed/n*1e6:.2f} us")The doubling fingerprint matches the predicted growth (around 2, 4, and a bit over 2), and 100,000 dynamic-array appends complete in linear time despite individual expensive resizes.
Exercises
Exercise 1. Prove directly from the definition that is . Give explicit constants and .
Solution
Step 1. The definition asks for with for all . Bound each lower-order term by an term: for , and .
Step 2. Add the bounds:
Step 3. So the definition holds with and . (Many valid pairs exist; a tighter one such as also works, since for the term .)
Exercise 2. Show that is a strict subset of : prove every function in is in , then exhibit one function in that is not in .
Solution
Step 1. Let . Then there are with for . For , , so . That is the definition of with the same and . Every function is therefore .
Step 2. Take . It is trivially (). Suppose it were : then for all , hence for all large , which fails once . So .
Step 3. The containment holds in one direction only, so the inclusion is strict.
Exercise 3 (code). The lesson claims doubling makes appends cost total. Instrument a doubling dynamic array to count the total number of element copies (work done during resizes) across appends, and assert the count is below (the geometric-series bound on copy work).
Solution
import numpy as np
def total_copies(n):
cap, size, copies = 1, 0, 0
for _ in range(n):
if size == cap: # resize: copy all current elements
copies += size
cap *= 2
size += 1
return copies
n = 100_000
c = total_copies(n)
# Copies are 1+2+4+...+the last power of two below n, a geometric sum < 2n.
assert c < 2 * n
print("total copies:", c, " bound 2n =", 2 * n)The copy count is the sum of powers of two for , which is below , confirming the amortized cost per append is constant.
The next lesson formalizes this dynamic array and the hash map.
References
- [1]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Asymptotic notation and growth of functions (Ch. 3).↩
- [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Amortized analysis, including the aggregate and potential methods (Ch. 17).↩