Data Structures and Algorithms

1 — Complexity analysis

Complexity analysis predicts how the cost of an algorithm grows with the size of its input. The essay Big-O complexity covers the everyday version: what O(n) means, the common complexity classes with timings, how to read a loop nest, and the usual ways to make code faster. This chapter assumes that material and goes underneath it. It gives the exact definitions with their constants, separates the different kinds of “case” that get blurred together, works through amortized analysis three ways on a real dynamic array, solves divide-and-conquer recurrences with the Master Theorem and with Akra–Bazzi where the Master Theorem does not apply, and measures where the model breaks down on real hardware.

Timings in this chapter were measured with Python 3.14 on one Mac (an Apple M3 Pro). Exact numbers will differ on other machines and versions. The ratios and the shapes of the curves are what carry over.

The cost model

Every complexity statement is relative to a model of what one “step” costs. The standard one is the word RAM model:

  • Memory is an array of words, and reading or writing any word costs one step regardless of its address.
  • Arithmetic, comparison and branching on a single word cost one step.
  • A word holds about log⁡n\log n bits, enough to store an index into the input.

Under this model, a[i] is O(1), adding two machine integers is O(1), and comparing two keys is O(1). Each of these assumptions can fail in practice, and later sections show where:

  • Python integers are arbitrary precision, so multiplying two 100,000-digit numbers is not one step.
  • Comparing two strings costs time proportional to their common prefix.
  • Memory access is not uniform. A read that hits the L1 cache and one that goes to DRAM differ by roughly two orders of magnitude.

When comparison is the expensive operation, as in sorting and searching, algorithms are often analyzed by counting comparisons instead of all steps. That is the model behind the n log n lower bound for sorting in chapter 3.

Asymptotic notation, precisely

All five symbols describe sets of functions. Writing f(n)=O(g(n))f(n) = O(g(n)) is a convention for f∈O(g)f \in O(g), and the equals sign only reads left to right: n=O(n2)n = O(n^2) is true, and O(n2)=nO(n^2) = n means nothing.

For functions that are non-negative for large nn:

NotationDefinitionReads as
f=O(g)f = O(g)there exist c>0c > 0 and n0n_0 with f(n)≤c⋅g(n)f(n) \le c \cdot g(n) for all n≥n0n \ge n_0f grows no faster than g
f=Ω(g)f = \Omega(g)there exist c>0c > 0 and n0n_0 with f(n)≥c⋅g(n)f(n) \ge c \cdot g(n) for all n≥n0n \ge n_0f grows at least as fast as g
f=Θ(g)f = \Theta(g)there exist c1,c2>0c_1, c_2 > 0 and n0n_0 with c1g(n)≤f(n)≤c2g(n)c_1 g(n) \le f(n) \le c_2 g(n) for all n≥n0n \ge n_0f grows at the same rate as g
f=o(g)f = o(g)for every c>0c > 0 there is an n0n_0 with f(n)<c⋅g(n)f(n) < c \cdot g(n) for all n≥n0n \ge n_0f grows strictly slower than g
f=ω(g)f = \omega(g)for every c>0c > 0 there is an n0n_0 with f(n)>c⋅g(n)f(n) > c \cdot g(n) for all n≥n0n \ge n_0f grows strictly faster than g

The difference between big-O and little-o is the quantifier on cc. Big-O needs one constant that works; little-o needs every constant to work eventually. So 2n=O(n)2n = O(n) but 2n≠o(n)2n \ne o(n), while n=o(nlog⁡n)n = o(n \log n).

f=Θ(g)f = \Theta(g) holds exactly when both f=O(g)f = O(g) and f=Ω(g)f = \Omega(g).

Finding the constants

Take f(n)=3n2+10n+5f(n) = 3n^2 + 10n + 5. The claim is f=Θ(n2)f = \Theta(n^2).

Upper bound. Pick c2=4c_2 = 4. Then 3n2+10n+5≤4n23n^2 + 10n + 5 \le 4n^2 is the same as 10n+5≤n210n + 5 \le n^2. At n=10n = 10 the left side is 105 and the right is 100, so it fails. At n=11n = 11 it is 115 against 121, and for larger nn the gap only widens. So c2=4,n0=11c_2 = 4, n_0 = 11 works.

Lower bound. Pick c1=3c_1 = 3. Then 3n2≤3n2+10n+53n^2 \le 3n^2 + 10n + 5 for every n≥0n \ge 0, so n0=0n_0 = 0 works for this side.

Both hold for n≥11n \ge 11, so f=Θ(n2)f = \Theta(n^2) with c1=3,c2=4,n0=11c_1 = 3, c_2 = 4, n_0 = 11. The constants are not unique. c2=18c_2 = 18 and n0=1n_0 = 1 also works, because 3n2+10n+5≤3n2+10n2+5n23n^2 + 10n + 5 \le 3n^2 + 10n^2 + 5n^2 for n≥1n \ge 1. Any valid pair proves the claim.

Showing a bound fails. To show n2≠O(n)n^2 \ne O(n), suppose n2≤cnn^2 \le cn for all n≥n0n \ge n_0. Dividing by nn gives n≤cn \le c for all large nn, which is false for n>cn > c. No constant can work.

The limit test

When lim⁡n→∞f(n)/g(n)\lim_{n \to \infty} f(n)/g(n) exists, it settles the relationship directly:

Limit of f/gConclusion
0f=o(g)f = o(g), and so f=O(g)f = O(g) but not Θ(g)\Theta(g)
a constant 0<L<∞0 < L < \inftyf=Θ(g)f = \Theta(g)
∞\inftyf=ω(g)f = \omega(g), and so f=Ω(g)f = \Omega(g) but not Θ(g)\Theta(g)

For example, lim⁡(log⁡n)/n0.01=0\lim (\log n)/n^{0.01} = 0 by L’Hôpital’s rule, so any power of nn, however small, eventually beats a logarithm. The limit need not exist. For f(n)=n1+sin⁡nf(n) = n^{1 + \sin n} and g(n)=ng(n) = n the ratio oscillates between about 1/n1/n and nn, so neither f=O(g)f = O(g) nor f=Ω(g)f = \Omega(g) holds. Asymptotic notation does not impose a total order on functions.

Useful facts

  • Logarithm bases do not matter inside O. log⁡an=log⁡bn/log⁡ba\log_a n = \log_b n / \log_b a, which differs by a constant factor. Writing O(log n) without a base is correct.
  • Exponential bases do matter. 3n/2n=1.5n→∞3^n / 2^n = 1.5^n \to \infty, so 3n≠O(2n)3^n \ne O(2^n). Likewise 22n=4n2^{2n} = 4^n is not O(2n)O(2^n).
  • Sums take the maximum. O(f)+O(g)=O(max⁡(f,g))O(f) + O(g) = O(\max(f, g)).
  • Products multiply. O(f)⋅O(g)=O(fg)O(f) \cdot O(g) = O(f g).
  • Polynomials. A degree-dd polynomial with positive leading coefficient is Θ(nd)\Theta(n^d).
  • Factorials. log⁡(n!)=Θ(nlog⁡n)\log(n!) = \Theta(n \log n). From Stirling’s approximation, log⁡2n!=nlog⁡2n−nlog⁡2e+O(log⁡n)\log_2 n! = n \log_2 n - n \log_2 e + O(\log n). This is the number behind the sorting lower bound.
  • Harmonic numbers. 1+1/2+⋯+1/n=ln⁡n+O(1)1 + 1/2 + \dots + 1/n = \ln n + O(1). It appears in the analysis of quicksort, hashing and the coupon collector problem.

The usual hierarchy, each strictly slower than the next:

1  <  log⁡log⁡n  <  log⁡n  <  n  <  n  <  nlog⁡n  <  n2  <  n3  <  2n  <  n!  <  nn1 \;<\; \log \log n \;<\; \log n \;<\; \sqrt{n} \;<\; n \;<\; n \log n \;<\; n^2 \;<\; n^3 \;<\; 2^n \;<\; n! \;<\; n^n

Several variables

Inputs with more than one size need more than one variable. A graph algorithm is O(V + E), a string search is O(n + m), a matrix product is O(n m p). Collapsing these to O(n) loses information: O(V + E) on a dense graph is O(V2)O(V^2), and on a sparse one it is O(V). The definitions extend by requiring the inequality once every variable is past its threshold.

Common misstatements

“Big-O means worst case.” It does not. O, Ω and Θ are bounds on a function. The function can be the worst-case running time, the best-case running time, the average, or the memory use. “Quicksort’s worst case is Θ(n2)\Theta(n^2)” and “quicksort’s best case is Θ(nlog⁡n)\Theta(n \log n)” are both precise statements. What people usually mean by “the complexity of X is O(f)” is “the worst-case running time of X is O(f)”.

“O(n) means it takes about n steps.” O(n) is only an upper bound. A function that always runs in constant time is also O(n), and O(n²), and O(2ⁿ). When the bound is tight, Θ is the accurate symbol. Most everyday use of O means Θ.

“O(1) means fast.” It means bounded by a constant. A hash lookup that computes SHA-256 over a 1 MB key is O(1) in the number of stored keys.

“An O(n log n) algorithm beats an O(n²) one.” Only past some input size, and the crossover can be large. Insertion sort beats merge sort on small arrays, which is why production sorts switch to it for short ranges.

Best, worst, average, expected and amortized

These five terms describe different quantities. Mixing them up is the most frequent source of wrong complexity claims.

TermWhat variesWhat it bounds
Worst caseThe inputThe maximum cost over all inputs of size nn
Best caseThe inputThe minimum cost over all inputs of size nn
Average caseThe input, drawn from an assumed distributionThe mean cost over that distribution
ExpectedThe algorithm’s own random choicesThe mean cost for every fixed input
AmortizedNothing randomThe worst-case total cost of a sequence of operations, divided by its length

Worst and best case

Insertion sort shows all three input cases cleanly. It inserts each element into the sorted prefix to its left, scanning backward until it finds a smaller element. Counting comparisons on 1,000 elements:

n = 1000
sorted input:        999 comparisons   (n - 1 = 999)
random input:    250,253 comparisons   (about n^2/4 = 250,000)
reversed input:  499,500 comparisons   (n(n-1)/2 = 499,500)

On sorted input each new element stops after one comparison, giving n−1n - 1. On reversed input each element walks the whole prefix, giving ∑i=1n−1i=n(n−1)/2\sum_{i=1}^{n-1} i = n(n-1)/2. So insertion sort is Θ(n)\Theta(n) in the best case and Θ(n2)\Theta(n^2) in the worst.

The best case is rarely useful as a guarantee, because an adversary or an unlucky input never has to supply it. It is useful when real inputs are known to be close to it: insertion sort’s linear best case is why it works well on nearly sorted data.

Average case

The random-input row above is an average-case result. Each pair of elements that is out of order (an inversion) costs one shift. A uniformly random permutation of nn elements has n(n−1)/4n(n-1)/4 inversions on average, because each of the (n2)\binom{n}{2} pairs is inverted with probability 1/2. That is where n2/4n^2/4 comes from, and 250,253 is within 0.1% of it.

An average-case result is only as good as its distribution. Insertion sort’s average is Θ(n2)\Theta(n^2) on random permutations, but if real inputs are logs with occasional late arrivals, the number of inversions is small and the real cost is close to linear.

Linear search for a key that is present, at a uniformly random position, makes (n+1)/2(n + 1)/2 comparisons on average. If the key is absent, every search makes nn. The “average case” depends on what fraction of searches miss, which the algorithm has no control over.

Expected time of a randomized algorithm

Quicksort with a pivot chosen uniformly at random has expected running time O(nlog⁡n)O(n \log n) on every input, including sorted and reversed ones. The expectation is over the coin flips the algorithm makes, not over inputs. No input is bad; only unlucky coin flips are, and the probability of Θ(n2)\Theta(n^2) behavior is vanishingly small for large nn.

This is a stronger guarantee than an average-case bound. Deterministic quicksort with the first element as pivot has O(nlog⁡n)O(n \log n) average-case time over random permutations, but sorted input, which is common, triggers its Θ(n2)\Theta(n^2) worst case every time.

Amortized time

Amortized analysis bounds the total cost of any sequence of mm operations, in the worst case, and divides by mm. Nothing is random, and no distribution is assumed. “Appending to a Python list is amortized O(1)” means any sequence of mm appends costs O(m) in total, even though a single append can cost O(n). It is covered in detail below.

Smoothed analysis

A fifth notion sits between worst and average. Smoothed analysis, introduced by Daniel Spielman and Shang-Hua Teng in 2001, takes the worst input and then adds small random noise to it, and measures the expected cost of the perturbed input. It explains why the simplex method for linear programming, which has exponential worst-case examples, runs in polynomial time in practice: its bad inputs are fragile, and a small perturbation destroys them.

Space complexity

Space complexity measures the memory an algorithm uses as a function of input size. Two conventions are in use:

  • Total space counts the input as well.
  • Auxiliary space counts only the extra memory beyond the input.

“Merge sort uses O(n) space” refers to auxiliary space; its total space is also O(n), so the distinction looks pointless there. It matters for algorithms like binary search, whose total space is O(n) because of the array, but whose auxiliary space is O(1). Algorithm descriptions almost always mean auxiliary space, and in-place means O(1) auxiliary space, or sometimes O(log n) to allow a recursion stack.

Output is usually excluded too. An algorithm that returns all subsets of a set must use Θ(n2n)\Theta(n 2^n) memory to hold its output, which says nothing about the algorithm.

Measured: a list against a generator

These two functions compute the same sum and have the same time complexity:

def squares_list(n):
    return sum([i * i for i in range(n)])

def squares_gen(n):
    return sum(i * i for i in range(n))

Peak memory allocated during each call, measured with tracemalloc:

squares_list  n=   10,000  peak     394.4 KiB
squares_list  n=  100,000  peak    3903.7 KiB
squares_list  n=1,000,000  peak   39497.1 KiB
squares_gen   n=   10,000  peak       0.4 KiB
squares_gen   n=  100,000  peak       0.4 KiB
squares_gen   n=1,000,000  peak       0.4 KiB

The list version is O(n) auxiliary space, about 40 bytes per element: an 8-byte pointer in the list plus a 28-byte integer object, rounded up by the allocator. The generator version is O(1). This difference often decides whether a job fits in memory.

The recursion stack

Every active function call holds a stack frame with its arguments, local variables and return address. A recursive algorithm’s space includes the deepest chain of simultaneous calls, even when it allocates no data structure.

def total(xs, i=0):
    if i == len(xs):
        return 0
    return xs[i] + total(xs, i + 1)

This function uses O(1) memory per call and recurses nn deep, so its auxiliary space is Θ(n)\Theta(n). CPython caps Python-level recursion depth:

1000
404550
RecursionError: maximum recursion depth exceeded

The first line is sys.getrecursionlimit(). Summing 900 elements works; summing 5,000 raises RecursionError. CPython does not eliminate tail calls, so rewriting the function in tail-recursive form does not help. The fix is a loop.

The stack depth of the common recursive algorithms:

AlgorithmRecursion depthNote
Binary search, recursiveΘ(log⁡n)\Theta(\log n)An iterative version uses O(1)
Merge sortΘ(log⁡n)\Theta(\log n)Plus Θ(n)\Theta(n) for the merge buffer
Quicksort, recursing on both sidesΘ(n)\Theta(n) worst caseUnbalanced partitions stack up
Quicksort, recursing on the smaller side and looping on the largerO(log⁡n)O(\log n) worst caseEach recursive call is at most half the size
DFS on a graphUp to Θ(V)\Theta(V)A path graph reaches the full depth
Tree traversalΘ(h)\Theta(h) for height hhhh is log⁡n\log n if balanced and nn if degenerate

The quicksort row is a standard technique: after partitioning, recurse into the smaller part and handle the larger part by updating the loop bounds. Since the recursive call always gets at most half the elements, the depth is at most log⁡2n\log_2 n regardless of pivot quality. Chapter 3 uses it.

Time and space trade off

Many speedups spend memory: a hash set to make membership O(1), a memo table to avoid recomputation, a prefix-sum array to answer range queries in O(1). Some go the other way: recomputing a value instead of storing it, or streaming data instead of loading it. When memory is the binding constraint, the right question is often “what is the least I need to keep?”, and the answer leads to streaming algorithms, reservoir sampling (chapter 17) and the two-pointer and sliding-window techniques of chapter 4.

Amortized analysis

Some data structures do a little work on most operations and a lot of work on a few. Amortized analysis shows that the expensive operations are rare enough that the average over any sequence is small. The guarantee is about sequences, in the worst case: no input can make the total exceed the bound.

The standard example is the dynamic array, the structure behind Python’s list, C++‘s std::vector and Java’s ArrayList. It stores elements in a fixed-size block. When the block is full, appending allocates a larger block, copies every element across, and frees the old one.

A dynamic array with a growth policy

class DynamicArray:
    def __init__(self, grow):
        self._grow = grow          # function: old capacity -> new capacity
        self._cap = 1
        self._n = 0
        self._data = [None]
        self.copies = 0            # element copies done by resizing

    def append(self, x):
        if self._n == self._cap:
            new_cap = self._grow(self._cap)
            new = [None] * new_cap
            for i in range(self._n):       # the expensive part
                new[i] = self._data[i]
            self.copies += self._n
            self._data, self._cap = new, new_cap
        self._data[self._n] = x
        self._n += 1

An append when the array is full costs Θ(n)\Theta(n). Everything depends on how often that happens, which depends on the growth policy. Three policies, measured:

Growth policynElement copiesCopies per appendTime for n appends
Double (×2)100,000131,0711.318 ms
Double (×2)400,000524,2871.3134 ms
Double (×2)1,600,0002,097,1511.31134 ms
×1.125 + 6 (CPython’s rule)100,000845,7268.4618 ms
×1.125 + 6 (CPython’s rule)400,0003,485,3898.7175 ms
×1.125 + 6 (CPython’s rule)1,600,00014,335,7878.96291 ms
Add 1,000100,0004,950,10049.5070 ms
Add 1,000400,00079,800,400199.501,079 ms
Add 1,0001,600,0001,279,201,600799.5017,323 ms

With either multiplicative policy, copies per append stay flat as nn grows, and total time grows linearly. With additive growth, copies per append grow linearly with nn, so total time is quadratic: four times the appends took about 16 times as long. The three methods below explain both results.

The aggregate method

Sum the actual cost of the whole sequence and divide.

With doubling from capacity 1, resizes happen when the size reaches 1, 2, 4, 8, and so on, copying that many elements. Over nn appends the copying costs

1+2+4+⋯+2⌊log⁡2(n−1)⌋<2n1 + 2 + 4 + \dots + 2^{\lfloor \log_2 (n-1) \rfloor} < 2n

and the writes cost nn, so the total is under 3n3n and the amortized cost per append is O(1). The measured 131,071 copies for 100,000 appends is 217−12^{17} - 1, the sum of the powers of two up to 65,536.

With growth factor r>1r > 1 the copies form a geometric series whose sum is about n/(r−1)n/(r - 1) to rn/(r−1)rn/(r-1), depending on where nn falls between resizes. For r=2r = 2 that is between 1 and 2 copies per element, matching the measured 1.31. For r=1.125r = 1.125 it is between 8 and 9, matching 8.46 to 8.96. Any constant factor above 1 gives O(1) amortized appends. Smaller factors waste less memory and copy more.

With additive growth of kk, resizes happen at sizes k,2k,3k,…k, 2k, 3k, \dots and copy that many elements:

k+2k+3k+⋯+⌊n/k⌋k≈n22kk + 2k + 3k + \dots + \lfloor n/k \rfloor k \approx \frac{n^2}{2k}

That is Θ(n2)\Theta(n^2) total, Θ(n)\Theta(n) amortized per append. For n=400,000n = 400{,}000 and k=1,000k = 1{,}000 the formula gives 80,000,000, and the measurement was 79,800,400. A larger kk only moves the constant.

The accounting method

Charge each operation a fixed amortized cost, possibly more than its actual cost. The surplus is stored as credit on specific elements and spent later on expensive operations. If the credit never goes negative, the total charged is an upper bound on the total actual cost.

For doubling, charge 3 units per append:

  • 1 unit pays for writing the new element.
  • 1 unit is stored on the new element, to pay for copying it at the next resize.
  • 1 unit is stored on an element in the older half of the array, which has already spent its own credit on a previous copy.

Just after a resize to capacity 2k2k, the array holds kk elements and no credit. The next kk appends each deposit 2 units, so when the array is full again it holds 2k2k units of credit, exactly enough to copy all 2k2k elements. Credit never goes negative, so mm appends cost at most 3m3m.

capacity 8, after resize from 4:   [a b c d . . . .]   credit 0
4 more appends, 2 credit each:      [a b c d e f g h]   credit 8
resize to 16, copy 8 elements:      [a b c d e f g h . . . . . . . .]   credit 0

The potential method

Define a potential function Φ\Phi on the state of the structure, with Φ≥0\Phi \ge 0 always and Φ=0\Phi = 0 at the start. The amortized cost of an operation is its actual cost plus the change in potential:

c^i=ci+Φi−Φi−1\hat{c}_i = c_i + \Phi_i - \Phi_{i-1}

Summing over a sequence, the potential terms telescope:

∑i=1mc^i=∑i=1mci+Φm−Φ0≥∑i=1mci\sum_{i=1}^m \hat{c}_i = \sum_{i=1}^m c_i + \Phi_m - \Phi_0 \ge \sum_{i=1}^m c_i

so the total amortized cost bounds the total actual cost. Potential plays the role of stored credit, but it is a function of the state rather than an assignment of coins to elements, which makes it easier to apply to complicated structures.

For a doubling array with size ss and capacity kk, take Φ=2s−k\Phi = 2s - k. Right after a resize the array is just over half full and Φ\Phi is about 2; just before the next one, s=ks = k and Φ=k\Phi = k. If the array starts empty with capacity 0 and the first append allocates one slot, then Φ0=0\Phi_0 = 0, and because the array is at least half full from then on, Φ≥0\Phi \ge 0 throughout.

  • Append without resize. Actual cost 1. ss rises by 1, so Φ\Phi rises by 2. Amortized cost 1+2=31 + 2 = 3.
  • Append with resize. Before: s=ks = k, Φ=k\Phi = k. The append copies kk elements and writes one, actual cost k+1k + 1. After: s=k+1s = k + 1, capacity 2k2k, Φ=2(k+1)−2k=2\Phi = 2(k+1) - 2k = 2. Amortized cost (k+1)+(2−k)=3(k + 1) + (2 - k) = 3.

Both cases cost 3, so any sequence of mm appends costs at most 3m3m.

What CPython actually does

CPython’s list resizing code in Objects/listobject.c computes the new capacity as

new_allocated = (newsize + (newsize >> 3) + 6) & ~3

that is, the requested size plus one eighth plus 6, rounded down to a multiple of 4. The capacities can be observed through sys.getsizeof, which reports 8 more bytes for each additional slot:

len at resize -> new capacity
   1 -> 4
   5 -> 8
   9 -> 16
  17 -> 24
  25 -> 32
  33 -> 40
  41 -> 52
  53 -> 64
  65 -> 76
  77 -> 92
  93 -> 108
 109 -> 128
 129 -> 148
 149 -> 172
 173 -> 200

For small lists the ”+ 6” dominates, so growth looks like doubling. For large lists the factor approaches 1.125. The comment in the source explains the choice: the growth is mild, but enough to give linear-time amortized behavior over a long sequence of appends.

Appending to a real list, the per-append cost is flat as the list grows by a factor of 1,000, which is what amortized O(1) predicts:

n=     10,000  total     0.11 ms  per append  10.6 ns
n=    100,000  total     1.09 ms  per append  10.9 ns
n=  1,000,000  total    12.77 ms  per append  12.8 ns
n= 10,000,000  total   148.98 ms  per append  14.9 ns

The slight rise at 10 million is the memory hierarchy, covered in the last section, not the algorithm.

The copy is often not a copy. The analysis above charges Θ(n)\Theta(n) for each resize, but CPython grows the block with realloc, and the allocator can frequently extend a block in place or remap pages without moving the data. Timing each of 2,000,000 appends individually, the 92 appends that triggered a resize had a median latency of 417 ns against 83 ns for ordinary appends (both figures include the cost of reading the timer). Resizing a list of over a million elements, which in theory moves 8 MB of pointers, typically took 5 to 10 µs. The single slowest append in the run was a resize at about 257,000 elements, at 139 µs, but the next nine slowest were ordinary appends that resized nothing; those most likely came from the memory allocator and the operating system supplying fresh pages for new integer objects. The amortized bound is a ceiling, and real resizes often come in well under it, while other sources of latency that Big-O does not model can be just as large.

Shrinking without thrashing

Removing elements raises a trap. If the array halves its capacity whenever it becomes half full, then at the boundary an alternating sequence of append, pop, append, pop triggers a grow and a shrink on every operation, each costing Θ(n)\Theta(n). The fix is hysteresis: grow when full, but shrink only when the array falls to a quarter full. After any resize the array is half full, so at least k/4k/4 operations must happen before the next one, and the cost is again amortized O(1). CPython’s list shrinks only when the size falls below half the allocation (and, in 3.14, not at all for very small lists), and then reallocates with the same one-eighth slack, which leaves room in both directions.

When amortized is not enough

An amortized bound says nothing about the latency of a single operation. A system that must answer every request within a deadline, such as an audio callback, a trading engine or a garbage collector pause budget, cannot afford an occasional Θ(n)\Theta(n) step even if the average is excellent. The remedy is deamortization: spreading the expensive work across many cheap operations. A deamortized dynamic array allocates the new block early and copies two old elements on each subsequent append, so it is finished by the time the old block is full. Redis resizes its hash tables this way, moving a few buckets on each command rather than rehashing everything at once (Redis internals).

Other classic amortized results

StructureExpensive operationAmortized costArgument
Binary counter incrementFlipping a long run of trailing 1sO(1) per incrementBit ii flips every 2i2^i increments, so nn increments flip fewer than 2n2n bits in total
Stack with multipopPopping kk items at onceO(1) per operationEach item is popped at most once after being pushed
Queue from two stacksMoving the whole input stack to the output stackO(1) per operationEach item is moved at most once (chapter 7)
Union-find with union by rank and path compressionA long findO(α(n))O(\alpha(n)) per operationPotential argument; α\alpha is the inverse Ackermann function, below 5 for any realistic nn (chapter 13)
Splay treeSplaying a deep nodeO(log n) per operationPotential is the sum of logs of subtree sizes

Recurrence relations

A recursive algorithm’s running time is naturally described by a recurrence: the cost on input size nn in terms of the cost on smaller sizes. Writing it down is mechanical: count the recursive calls and their sizes, and add the non-recursive work.

Code shapeRecurrence
Halve the range and recurse on one half, O(1) other workT(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1)
Split into two halves, recurse on both, merge in linear timeT(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n)
Remove one element, recurse on the rest, linear workT(n)=T(n−1)+O(n)T(n) = T(n-1) + O(n)
Two recursive calls on n−1n-1, constant workT(n)=2T(n−1)+O(1)T(n) = 2T(n-1) + O(1)

Base cases are usually T(1)=O(1)T(1) = O(1) and are left implicit. Floors and ceilings in n/2n/2 rarely change the asymptotic answer and are normally dropped.

There are three ways to solve a recurrence.

The recursion tree

Draw the calls as a tree, write the non-recursive work at each node, sum each level, then sum the levels. For merge sort, T(n)=2T(n/2)+cnT(n) = 2T(n/2) + cn:

level 0:                 cn                         = cn
level 1:         cn/2          cn/2                 = cn
level 2:     cn/4  cn/4    cn/4  cn/4               = cn
  ...
level k:   2^k nodes, each c n / 2^k                = cn
  ...
leaves:    n nodes of size 1                        = cn

There are log⁡2n+1\log_2 n + 1 levels, each costing cncn, so T(n)=Θ(nlog⁡n)T(n) = \Theta(n \log n).

For T(n)=T(n−1)+cnT(n) = T(n-1) + cn the tree is a path. Level ii costs c(n−i)c(n - i), and the total is c(n+(n−1)+⋯+1)=Θ(n2)c(n + (n-1) + \dots + 1) = \Theta(n^2). This is quicksort’s worst case, when every partition peels off one element.

For T(n)=2T(n−1)+cT(n) = 2T(n-1) + c the tree is complete binary with depth nn, so it has 2n−12^n - 1 nodes and T(n)=Θ(2n)T(n) = \Theta(2^n). Towers of Hanoi has exactly this recurrence, and the minimal number of moves is 2n−12^n - 1.

The substitution method

Guess the answer and prove it by induction. To show T(n)=2T(n/2)+nT(n) = 2T(n/2) + n is O(nlog⁡n)O(n \log n), assume T(m)≤c mlog⁡2mT(m) \le c\, m \log_2 m for all m<nm < n. Then

T(n)≤2cn2log⁡2n2+n=cnlog⁡2n−cn+n≤cnlog⁡2nT(n) \le 2c \tfrac{n}{2} \log_2 \tfrac{n}{2} + n = cn \log_2 n - cn + n \le cn \log_2 n

for any c≥1c \ge 1. The induction must prove the exact form of the hypothesis. Proving T(n)≤cnT(n) \le cn for T(n)=2T(n/2)+nT(n) = 2T(n/2) + n “works” only if one writes 2c(n/2)+n=O(n)2c(n/2) + n = O(n), which hides a constant that grows at each level; the correct calculation gives cn+ncn + n, which is not ≤cn\le cn.

The Master Theorem

Most divide-and-conquer recurrences have the form

T(n)=a T(n/b)+f(n)T(n) = a\,T(n/b) + f(n)

with a≥1a \ge 1 subproblems, each of size n/bn/b for a constant b>1b > 1, and f(n)f(n) work outside the recursive calls. The Master Theorem solves them by comparing f(n)f(n) to nlog⁡ban^{\log_b a}, which is the number of leaves in the recursion tree: the tree has depth log⁡bn\log_b n and branching factor aa, so it has alog⁡bn=nlog⁡baa^{\log_b n} = n^{\log_b a} leaves. Call this the watershed function.

The version in the fourth edition of Cormen, Leiserson, Rivest and Stein states three cases:

CaseCondition on f(n)SolutionWhere the work is
1f(n)=O(nlog⁡ba−ϵ)f(n) = O(n^{\log_b a - \epsilon}) for some ϵ>0\epsilon > 0T(n)=Θ(nlog⁡ba)T(n) = \Theta(n^{\log_b a})The leaves dominate
2f(n)=Θ(nlog⁡balog⁡kn)f(n) = \Theta(n^{\log_b a} \log^k n) for some k≥0k \ge 0T(n)=Θ(nlog⁡balog⁡k+1n)T(n) = \Theta(n^{\log_b a} \log^{k+1} n)Every level costs about the same
3f(n)=Ω(nlog⁡ba+ϵ)f(n) = \Omega(n^{\log_b a + \epsilon}) for some ϵ>0\epsilon > 0, and af(n/b)≤cf(n)a f(n/b) \le c f(n) for some c<1c < 1 and large nnT(n)=Θ(f(n))T(n) = \Theta(f(n))The root dominates

The intuition is a geometric series over the levels of the recursion tree. Level jj has aja^j nodes each doing f(n/bj)f(n/b^j) work. In case 1 the per-level cost grows geometrically toward the leaves, so the leaves’ nlog⁡ban^{\log_b a} dominates. In case 3 it shrinks geometrically away from the root, so the root’s f(n)f(n) dominates. In case 2 it is roughly constant across log⁡bn\log_b n levels, which multiplies by one more log factor.

The extra condition in case 3, af(n/b)≤cf(n)a f(n/b) \le c f(n), is the regularity condition. It says the work actually shrinks from one level to the next. It holds automatically for polynomials such as f(n)=n2f(n) = n^2, but can fail for oscillating functions.

Note the ϵ\epsilon: case 1 and case 3 need ff to be smaller or larger than the watershed by a polynomial factor, not just any factor. That leaves gaps, discussed below.

Worked examples

Algorithm or recurrenceabWatershedf(n)CaseSolution
Binary search, T(n/2)+1T(n/2) + 112n0=1n^0 = 112, k = 0Θ(log⁡n)\Theta(\log n)
Merge sort, 2T(n/2)+n2T(n/2) + n22nnnn2, k = 0Θ(nlog⁡n)\Theta(n \log n)
Tree traversal of a balanced tree, 2T(n/2)+12T(n/2) + 122nn11Θ(n)\Theta(n)
Karatsuba multiplication, 3T(n/2)+n3T(n/2) + n32nlog⁡23≈n1.585n^{\log_2 3} \approx n^{1.585}nn1Θ(n1.585)\Theta(n^{1.585})
Naive divide-and-conquer matrix multiply, 8T(n/2)+n28T(n/2) + n^282n3n^3n2n^21Θ(n3)\Theta(n^3)
Strassen’s matrix multiply, 7T(n/2)+n27T(n/2) + n^272nlog⁡27≈n2.807n^{\log_2 7} \approx n^{2.807}n2n^21Θ(n2.807)\Theta(n^{2.807})
4T(n/2)+n2log⁡n4T(n/2) + n^2 \log n42n2n^2n2log⁡nn^2 \log n2, k = 1Θ(n2log⁡2n)\Theta(n^2 \log^2 n)
2T(n/2)+n22T(n/2) + n^222nnn2n^23Θ(n2)\Theta(n^2)
T(n/2)+nT(n/2) + n, e.g. quickselect with perfect pivots121nn3Θ(n)\Theta(n)

The theorem’s predictions can be checked by evaluating a recurrence exactly. With T(1)=1T(1) = 1 and nn a power of 2, the ratio T(n)/g(n)T(n)/g(n) should settle to a constant when gg is the predicted solution:

8T(n/2) + n^2  -> n^3      (case 1)
   n=2^10: T(n)/g(n) = 1.9990
   n=2^15: T(n)/g(n) = 2.0000
   n=2^20: T(n)/g(n) = 2.0000
2T(n/2) + n    -> n log n  (case 2)
   n=2^10: T(n)/g(n) = 1.1000
   n=2^15: T(n)/g(n) = 1.0667
   n=2^20: T(n)/g(n) = 1.0500
2T(n/2) + n^2  -> n^2      (case 3)
   n=2^10: T(n)/g(n) = 1.9990
   n=2^15: T(n)/g(n) = 2.0000
   n=2^20: T(n)/g(n) = 2.0000

Case 2 converges slowly because the exact solution is nlog⁡2n+nn \log_2 n + n, and the +n+n term shrinks relative to nlog⁡2nn \log_2 n only logarithmically.

Measured: Karatsuba in CPython

CPython multiplies large integers with Karatsuba’s algorithm, which splits each number in half and replaces four half-size products with three. The recurrence is T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n), which is case 1 with solution Θ(nlog⁡23)\Theta(n^{\log_2 3}). Doubling the number of bits should multiply the time by 21.585=32^{1.585} = 3:

      bits      x * y  ratio
    20,000    160.7 µs 
    40,000    492.8 µs   3.07
    80,000   1480.5 µs   3.00
   160,000   4449.5 µs   3.01
   320,000  13402.1 µs   3.01
   640,000  40320.5 µs   3.01

The ratio is 3.00 to two decimal places. Schoolbook multiplication would give 4. This is also a reminder that the word RAM’s “arithmetic is O(1)” applies only to numbers that fit in a machine word.

Where the Master Theorem does not apply

The theorem needs equal-sized subproblems, a constant number of them, a constant shrink factor, and an f(n)f(n) that is polynomially separated from the watershed or matches it up to a log power. Common recurrences that fail one of these:

Subtract-and-conquer. T(n)=T(n−1)+nT(n) = T(n-1) + n shrinks by subtraction, not division. Use the recursion tree: Θ(n2)\Theta(n^2). Likewise T(n)=2T(n−1)+1T(n) = 2T(n-1) + 1 is Θ(2n)\Theta(2^n). There is a separate “master theorem for decreasing functions” for T(n)=aT(n−b)+f(n)T(n) = aT(n-b) + f(n), but unrolling is usually quicker.

The gap between cases 1 and 2. T(n)=2T(n/2)+n/log⁡nT(n) = 2T(n/2) + n/\log n has watershed nn, and n/log⁡nn / \log n is smaller than nn but not by a polynomial factor, so case 1 does not apply; and it is not Θ(nlog⁡kn)\Theta(n \log^k n) for any k≥0k \ge 0, so case 2 does not either. The recursion tree solves it. Level jj has 2j2^j nodes of size n/2jn/2^j, each costing (n/2j)/log⁡2(n/2j)(n/2^j)/\log_2(n/2^j), so the level costs n/(log⁡2n−j)n/(\log_2 n - j). Summing jj from 0 to log⁡2n−1\log_2 n - 1 gives n(1+1/2+⋯+1/log⁡2n)=nHlog⁡2nn(1 + 1/2 + \dots + 1/\log_2 n) = n H_{\log_2 n}, which is Θ(nlog⁡log⁡n)\Theta(n \log \log n).

A non-constant number of subproblems. T(n)=n T(n)+nT(n) = \sqrt{n}\,T(\sqrt{n}) + n (van Emde Boas trees have recurrences of this kind) has neither constant aa nor constant bb. Substituting S(m)=T(2m)/2mS(m) = T(2^m)/2^m turns it into S(m)=S(m/2)+1S(m) = S(m/2) + 1, so S(m)=Θ(log⁡m)S(m) = \Theta(\log m) and T(n)=Θ(nlog⁡log⁡n)T(n) = \Theta(n \log \log n).

Unequal splits. T(n)=T(n/3)+T(2n/3)+nT(n) = T(n/3) + T(2n/3) + n has two subproblems of different sizes. The recursion tree gives Θ(nlog⁡n)\Theta(n \log n): each full level costs nn, and the tree is between log⁡3n\log_3 n and log⁡3/2n\log_{3/2} n deep. For the general case there is the Akra–Bazzi method.

The Akra–Bazzi method

Mohamad Akra and Louay Bazzi published a generalization in 1998 that handles recurrences of the form

T(n)=∑i=1kai T(bin)+g(n)T(n) = \sum_{i=1}^{k} a_i\, T(b_i n) + g(n)

with constants ai>0a_i > 0 and 0<bi<10 < b_i < 1, and gg of polynomial growth. Find the unique real pp with

∑i=1kaibi p=1\sum_{i=1}^{k} a_i b_i^{\,p} = 1

Then

T(n)=Θ ⁣(np(1+∫1ng(u)up+1 du))T(n) = \Theta\!\left(n^p \left(1 + \int_1^n \frac{g(u)}{u^{p+1}}\, du\right)\right)

Small perturbations such as floors, ceilings and +O(n/log⁡2n)+O(n/\log^2 n) in the subproblem sizes do not change the result.

Two examples:

  • T(n)=T(n/3)+T(2n/3)+nT(n) = T(n/3) + T(2n/3) + n. Solve (1/3)p+(2/3)p=1(1/3)^p + (2/3)^p = 1: p=1p = 1. The integral is ∫1nu/u2 du=ln⁡n\int_1^n u/u^2\,du = \ln n, so T(n)=Θ(nlog⁡n)T(n) = \Theta(n \log n).
  • T(n)=T(n/5)+T(7n/10)+nT(n) = T(n/5) + T(7n/10) + n, the recurrence of the median-of-medians selection algorithm. Here (1/5)p+(7/10)p=1(1/5)^p + (7/10)^p = 1 gives p≈0.84p \approx 0.84, which is less than 1. The integral is ∫1nu−p du=Θ(n1−p)\int_1^n u^{-p}\,du = \Theta(n^{1-p}), so T(n)=Θ(np⋅n1−p)=Θ(n)T(n) = \Theta(n^p \cdot n^{1-p}) = \Theta(n). The algorithm is linear because the subproblem fractions sum to 1/5+7/10=9/10<11/5 + 7/10 = 9/10 < 1. Chapter 3 explains where those fractions come from.

The second example generalizes into a rule worth remembering: if T(n)=∑T(bin)+Θ(n)T(n) = \sum T(b_i n) + \Theta(n) and ∑bi<1\sum b_i < 1, then T(n)=Θ(n)T(n) = \Theta(n). If ∑bi=1\sum b_i = 1, then T(n)=Θ(nlog⁡n)T(n) = \Theta(n \log n).

Where Big-O is incomplete

Asymptotic analysis deliberately discards constant factors and assumes uniform memory cost. On real machines, both matter, and sometimes they decide the outcome.

Constants: a measured crossover

Searching a sorted list for its last element three ways: Python’s in operator, which is a linear scan written in C; a binary search written in Python; and bisect.bisect_left, which is binary search written in C. Times are per lookup and include about 30 ns of call overhead common to all three.

nx in list (C, O(n))Binary search (Python, O(log n))bisect (C, O(log n))
859 ns150 ns64 ns
32240 ns258 ns78 ns
128935 ns325 ns82 ns
5123,508 ns458 ns93 ns
2,04813,930 ns468 ns99 ns
8,19254,038 ns517 ns107 ns

The O(n) scan in C beats the O(log n) search in Python up to about 32 elements, because each step of the C loop costs a couple of nanoseconds while each step of the Python loop costs tens. Past that, the growth rates take over, and at 8,192 elements the Python binary search is about 100 times faster. Implementing the same algorithm in C (bisect) gives roughly another factor of 5. Asymptotics decide what happens at scale; constants decide the small cases and the crossover point.

This is why library sorts switch to insertion sort below a cutoff, why hash tables with a handful of entries are often replaced by linear arrays, and why “use the algorithm with the better Big-O” is advice for large inputs only.

The memory hierarchy

The RAM model charges one step for any memory access. Real hardware has a hierarchy: registers, several levels of cache, main memory, and storage, each larger and slower than the one before. The machine used here reports a 64 KiB L1 data cache per core and a 16 MiB L2 cache shared by its performance cores. As rough orders of magnitude on current hardware, an L1 hit costs about a nanosecond and a DRAM access around 100 ns.

Caches load memory in lines of 64 or 128 bytes, and hardware prefetchers detect sequential access and fetch ahead. Access patterns that walk memory in order are therefore much cheaper per element than patterns that jump around, even when the operation count is identical.

The experiment below sums a list of Python integers in two orders. In both cases sum() reads the same nn integer objects, in a single pass, with the same number of operations. In the first list, the objects are laid out in memory in the order the list visits them, because they were allocated in that order. The second list holds the same objects in shuffled order, so each step jumps to an unrelated address.

xs = [i + 1000 for i in range(n)]      # int objects allocated in order
ys = xs[:]
random.shuffle(ys)                     # same objects, scattered order
           n   in order   shuffled  ratio   (ns per element, sum())
       1,000       2.22       2.12    1.0
      10,000       2.36       2.88    1.2
     100,000       2.83       3.35    1.2
   1,000,000       2.65      13.57    5.1
  10,000,000       2.67      15.93    6.0

Up to 100,000 elements the two orders cost about the same. Each integer object occupies 32 bytes, so 100,000 of them take about 3 MB and fit in the 16 MiB L2 cache once loaded. At 1,000,000 elements the objects take about 32 MB, the working set no longer fits, and the shuffled order becomes 5 times slower per element. The in-order version stays flat because the prefetcher keeps feeding it. Both versions are Θ(n)\Theta(n), and Big-O correctly predicts that the total time grows linearly in each; it cannot predict the factor of 6 between them.

The same effect explains several standard engineering choices:

  • Arrays usually beat linked lists, even for operations where the linked list has a better bound, because the array is contiguous and a list traversal is a chain of dependent cache misses (chapter 6).
  • B-trees beat binary search trees on disk and in memory. A node holding hundreds of keys costs one block read, so the tree is shallow in terms of block transfers. PostgreSQL’s default index is a B-tree for this reason (Postgres indexes).
  • Open addressing hash tables often beat chained ones, because probing neighboring slots stays within a cache line (chapter 8).
  • Merge sort and quicksort both access memory sequentially during partition and merge, which is part of why they are fast in practice; heap sort jumps between index ii and 2i2i, and is usually slower despite the same bound.

Models that account for memory

When memory transfers dominate, a different cost model gives better predictions. The external memory model (also called the I/O model or disk access model) has a fast memory of size MM and a slow memory accessed in blocks of size BB, and counts only block transfers. Scanning nn items costs Θ(n/B)\Theta(n/B) transfers, searching a B-tree costs Θ(log⁡Bn)\Theta(\log_B n), and sorting costs Θ((n/B)log⁡M/B(n/B))\Theta\big((n/B) \log_{M/B} (n/B)\big). Cache-oblivious algorithms achieve these bounds without knowing MM or BB, typically by recursive divide-and-conquer that eventually produces subproblems that fit in each level of cache.

Other things Big-O does not capture

  • Input size in bits. Trial division to test whether nn is prime takes O(n)O(\sqrt{n}) steps, which looks polynomial. The input is only log⁡2n\log_2 n bits long, so in terms of input size the algorithm is exponential. Algorithms that are polynomial in the numeric value but not in the number of bits are called pseudo-polynomial. The dynamic programming solution to 0/1 knapsack, O(nW)O(nW) for capacity WW, is the standard example (chapter 14).
  • Hidden lower-order terms. An algorithm that runs in nlog⁡n+106nn \log n + 10^6 n is Θ(nlog⁡n)\Theta(n \log n), but the linear term dominates until log⁡n\log n exceeds a million.
  • Parallelism. Work (total operations) and span (the longest chain of dependent operations) are separate measures. A parallel sum has Θ(n)\Theta(n) work and Θ(log⁡n)\Theta(\log n) span.
  • Variance. Two algorithms with the same expected time can have very different tail latencies. Quicksort with random pivots has a tiny but nonzero probability of quadratic behavior; heap sort has none.

Summary

ConceptWhat it is for
O, Ω, ΘUpper, lower and tight bounds on a function, each with explicit constants cc and n0n_0
o, ωStrict bounds: grows strictly slower or strictly faster
Limit testDeciding the relationship between two functions when the ratio converges
Worst, best, average caseBounds over inputs: maximum, minimum, and mean under an assumed distribution
Expected timeMean over the algorithm’s random choices, for every input
Smoothed analysisWorst input plus small random noise; explains algorithms that are fast in practice despite bad worst cases
Auxiliary spaceExtra memory beyond the input, including the recursion stack
Aggregate methodSum the cost of the whole sequence and divide
Accounting methodOvercharge cheap operations and store credit for expensive ones
Potential methodCredit as a function of state; amortized cost is actual cost plus change in potential
DeamortizationSpreading expensive work across operations to bound single-operation latency
Recursion treeSolving any recurrence by summing work level by level
Master TheoremSolving T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n) by comparing f(n)f(n) to nlog⁡ban^{\log_b a}
Akra–BazziSolving divide-and-conquer recurrences with unequal subproblem sizes
External memory and cache-oblivious modelsPredicting cost when memory transfers, not operations, dominate