AI Knowledge Tree

Asymptotic complexity and Big-O

Analyze time and space with Big-O and Big-Theta, including amortized analysis.

⏱ 28 min

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

T(n)=O(f(n))T(n) = O(f(n)) if there exist constants c,n0>0c, n_0 > 0 such that

T(n)≤c f(n)for all n≥n0.T(n) \le c\, f(n) \quad \text{for all } n \ge n_0.

Big-O is an upper bound that hides constants and low-order terms. Saying T(n)=O(n2)T(n) = O(n^2) means the running time grows no faster than quadratically, eventually. A program with T(n)=100n2+50n+7T(n) = 100 n^2 + 50 n + 7 is O(n2)O(n^2); so is one with T(n)=n2T(n) = n^2.

The hidden constants matter in practice but not for distinguishing algorithms whose growth rates differ. An O(nlog⁡n)O(n \log n) algorithm with a large constant beats an O(n2)O(n^2) one for large enough nn, regardless of how large the constants are.

Big-Omega and Big-Theta

Big-Omega is the matching lower bound: T(n)=Ω(f(n))T(n) = \Omega(f(n)) if there exist c,n0c, n_0 with T(n)≥c f(n)T(n) \ge c\,f(n) for n≥n0n \ge n_0. Big-Theta is both at once, T(n)=Θ(f(n))T(n) = \Theta(f(n)) when T(n)=O(f(n))T(n) = O(f(n)) and T(n)=Ω(f(n))T(n) = \Omega(f(n)): a tight characterization, the actual growth rate.

The hierarchy of common complexity classes:

O(1)⊂O(log⁡n)⊂O(n)⊂O(nlog⁡n)⊂O(n2)⊂O(2n)⊂O(n!)O(1) \subset O(\log n) \subset O(n) \subset O(n \log n) \subset O(n^2) \subset O(2^n) \subset O(n!)

Each class is strictly smaller (so an O(n)O(n) algorithm is also O(n2)O(n^2) but not the reverse). The exponential jumps are the practical cliffs: n=100n = 100 takes microseconds at O(n2)O(n^2), milliseconds at O(n3)O(n^3), and longer than the age of the universe at O(n!)O(n!).

Worst, average, and best case

Big-O usually refers to the worst case: the maximum running time over all inputs of size nn. Average case averages over an input distribution, often uniform. Best case is the minimum. Quicksort is O(n2)O(n^2) worst case, O(nlog⁡n)O(n \log n) average case; binary search is O(log⁡n)O(\log n) worst, O(1)O(1) 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 nn operations divided by nn.

The canonical example is a dynamic array that doubles its capacity when it fills up. Most append calls are O(1)O(1), but every kk-th call (when full) is O(k)O(k) because all elements must be copied to a new larger buffer. Across nn inserts, the expensive ones at sizes 1,2,4,…,n/2,n1, 2, 4, \dots, n/2, n cost 1+2+4+⋯+n≤2n1 + 2 + 4 + \cdots + n \le 2n total. The cheap ones contribute nn more. The total is ≤3n\le 3n, so the amortized cost per insert is O(1)O(1), even though individual inserts can be O(n)O(n).

The complexity classes separate sharply as input grows: constant and logarithmic stay flat while quadratic and exponential explode, which is why asymptotics decide scalability.
The complexity classes separate sharply as input grows: constant and logarithmic stay flat while quadratic and exponential explode, which is why asymptotics decide scalability.

Check it on arrays

python · numpy
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 T(n)=3n2+10n+7T(n) = 3n^2 + 10n + 7 is O(n2)O(n^2). Give explicit constants cc and n0n_0.

Solution

Step 1. The definition asks for c,n0>0c, n_0 > 0 with T(n)≤c n2T(n) \le c\,n^2 for all n≥n0n \ge n_0. Bound each lower-order term by an n2n^2 term: for n≥1n \ge 1, 10n≤10n210n \le 10n^2 and 7≤7n27 \le 7n^2.

Step 2. Add the bounds:

T(n)=3n2+10n+7≤3n2+10n2+7n2=20n2for n≥1.T(n) = 3n^2 + 10n + 7 \le 3n^2 + 10n^2 + 7n^2 = 20n^2 \quad \text{for } n \ge 1.

Step 3. So the definition holds with c=20c = 20 and n0=1n_0 = 1. (Many valid pairs exist; a tighter one such as c=4,n0=11c = 4, n_0 = 11 also works, since for n≥11n \ge 11 the term 10n+7≤n210n + 7 \le n^2.)

Exercise 2. Show that O(n)O(n) is a strict subset of O(n2)O(n^2): prove every function in O(n)O(n) is in O(n2)O(n^2), then exhibit one function in O(n2)O(n^2) that is not in O(n)O(n).

Solution

Step 1. Let T(n)=O(n)T(n) = O(n). Then there are c,n0c, n_0 with T(n)≤c nT(n) \le c\,n for n≥n0n \ge n_0. For n≥max⁡(n0,1)n \ge \max(n_0, 1), n≤n2n \le n^2, so T(n)≤c n≤c n2T(n) \le c\,n \le c\,n^2. That is the definition of T(n)=O(n2)T(n) = O(n^2) with the same cc and n0′=max⁡(n0,1)n_0' = \max(n_0, 1). Every O(n)O(n) function is therefore O(n2)O(n^2).

Step 2. Take g(n)=n2g(n) = n^2. It is O(n2)O(n^2) trivially (c=1c = 1). Suppose it were O(n)O(n): then n2≤c nn^2 \le c\,n for all n≥n0n \ge n_0, hence n≤cn \le c for all large nn, which fails once n>cn > c. So n2∉O(n)n^2 \notin O(n).

Step 3. The containment holds in one direction only, so the inclusion is strict.

Exercise 3 (code). The lesson claims doubling makes nn appends cost ≤3n\le 3n total. Instrument a doubling dynamic array to count the total number of element copies (work done during resizes) across n=100000n = 100000 appends, and assert the count is below 2n2n (the geometric-series bound on copy work).

Solution
python · numpy
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 1+2+⋯+216=1310711 + 2 + \dots + 2^{16} = 131071 for n=100000n = 100000, which is below 2n=2000002n = 200000, confirming the amortized cost per append is constant.

The next lesson formalizes this dynamic array and the hash map.


References

  1. [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. [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).↩