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 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 is a convention for , and the equals sign only reads left to right: is true, and means nothing.
For functions that are non-negative for large :
| Notation | Definition | Reads as |
|---|---|---|
| there exist and with for all | f grows no faster than g | |
| there exist and with for all | f grows at least as fast as g | |
| there exist and with for all | f grows at the same rate as g | |
| for every there is an with for all | f grows strictly slower than g | |
| for every there is an with for all | f grows strictly faster than g |
The difference between big-O and little-o is the quantifier on . Big-O needs one constant that works; little-o needs every constant to work eventually. So but , while .
holds exactly when both and .
Finding the constants
Take . The claim is .
Upper bound. Pick . Then is the same as . At the left side is 105 and the right is 100, so it fails. At it is 115 against 121, and for larger the gap only widens. So works.
Lower bound. Pick . Then for every , so works for this side.
Both hold for , so with . The constants are not unique. and also works, because for . Any valid pair proves the claim.
Showing a bound fails. To show , suppose for all . Dividing by gives for all large , which is false for . No constant can work.
The limit test
When exists, it settles the relationship directly:
| Limit of f/g | Conclusion |
|---|---|
| 0 | , and so but not |
| a constant | |
| , and so but not |
For example, by L’Hôpital’s rule, so any power of , however small, eventually beats a logarithm. The limit need not exist. For and the ratio oscillates between about and , so neither nor holds. Asymptotic notation does not impose a total order on functions.
Useful facts
- Logarithm bases do not matter inside O. , which differs by a constant factor. Writing O(log n) without a base is correct.
- Exponential bases do matter. , so . Likewise is not .
- Sums take the maximum. .
- Products multiply. .
- Polynomials. A degree- polynomial with positive leading coefficient is .
- Factorials. . From Stirling’s approximation, . This is the number behind the sorting lower bound.
- Harmonic numbers. . It appears in the analysis of quicksort, hashing and the coupon collector problem.
The usual hierarchy, each strictly slower than the next:
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 , 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 ” and “quicksort’s best case is ” 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.
| Term | What varies | What it bounds |
|---|---|---|
| Worst case | The input | The maximum cost over all inputs of size |
| Best case | The input | The minimum cost over all inputs of size |
| Average case | The input, drawn from an assumed distribution | The mean cost over that distribution |
| Expected | The algorithm’s own random choices | The mean cost for every fixed input |
| Amortized | Nothing random | The 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 . On reversed input each element walks the whole prefix, giving . So insertion sort is in the best case and 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 elements has inversions on average, because each of the pairs is inverted with probability 1/2. That is where 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 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 comparisons on average. If the key is absent, every search makes . 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 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 behavior is vanishingly small for large .
This is a stronger guarantee than an average-case bound. Deterministic quicksort with the first element as pivot has average-case time over random permutations, but sorted input, which is common, triggers its worst case every time.
Amortized time
Amortized analysis bounds the total cost of any sequence of operations, in the worst case, and divides by . Nothing is random, and no distribution is assumed. “Appending to a Python list is amortized O(1)” means any sequence of 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 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 deep, so its auxiliary space is . 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:
| Algorithm | Recursion depth | Note |
|---|---|---|
| Binary search, recursive | An iterative version uses O(1) | |
| Merge sort | Plus for the merge buffer | |
| Quicksort, recursing on both sides | worst case | Unbalanced partitions stack up |
| Quicksort, recursing on the smaller side and looping on the larger | worst case | Each recursive call is at most half the size |
| DFS on a graph | Up to | A path graph reaches the full depth |
| Tree traversal | for height | is if balanced and 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 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 . Everything depends on how often that happens, which depends on the growth policy. Three policies, measured:
| Growth policy | n | Element copies | Copies per append | Time for n appends |
|---|---|---|---|---|
| Double (×2) | 100,000 | 131,071 | 1.31 | 8 ms |
| Double (×2) | 400,000 | 524,287 | 1.31 | 34 ms |
| Double (×2) | 1,600,000 | 2,097,151 | 1.31 | 134 ms |
| ×1.125 + 6 (CPython’s rule) | 100,000 | 845,726 | 8.46 | 18 ms |
| ×1.125 + 6 (CPython’s rule) | 400,000 | 3,485,389 | 8.71 | 75 ms |
| ×1.125 + 6 (CPython’s rule) | 1,600,000 | 14,335,787 | 8.96 | 291 ms |
| Add 1,000 | 100,000 | 4,950,100 | 49.50 | 70 ms |
| Add 1,000 | 400,000 | 79,800,400 | 199.50 | 1,079 ms |
| Add 1,000 | 1,600,000 | 1,279,201,600 | 799.50 | 17,323 ms |
With either multiplicative policy, copies per append stay flat as grows, and total time grows linearly. With additive growth, copies per append grow linearly with , 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 appends the copying costs
and the writes cost , so the total is under and the amortized cost per append is O(1). The measured 131,071 copies for 100,000 appends is , the sum of the powers of two up to 65,536.
With growth factor the copies form a geometric series whose sum is about to , depending on where falls between resizes. For that is between 1 and 2 copies per element, matching the measured 1.31. For 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 , resizes happen at sizes and copy that many elements:
That is total, amortized per append. For and the formula gives 80,000,000, and the measurement was 79,800,400. A larger 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 , the array holds elements and no credit. The next appends each deposit 2 units, so when the array is full again it holds units of credit, exactly enough to copy all elements. Credit never goes negative, so appends cost at most .
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 on the state of the structure, with always and at the start. The amortized cost of an operation is its actual cost plus the change in potential:
Summing over a sequence, the potential terms telescope:
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 and capacity , take . Right after a resize the array is just over half full and is about 2; just before the next one, and . If the array starts empty with capacity 0 and the first append allocates one slot, then , and because the array is at least half full from then on, throughout.
- Append without resize. Actual cost 1. rises by 1, so rises by 2. Amortized cost .
- Append with resize. Before: , . The append copies elements and writes one, actual cost . After: , capacity , . Amortized cost .
Both cases cost 3, so any sequence of appends costs at most .
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 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 . 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 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 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
| Structure | Expensive operation | Amortized cost | Argument |
|---|---|---|---|
| Binary counter increment | Flipping a long run of trailing 1s | O(1) per increment | Bit flips every increments, so increments flip fewer than bits in total |
| Stack with multipop | Popping items at once | O(1) per operation | Each item is popped at most once after being pushed |
| Queue from two stacks | Moving the whole input stack to the output stack | O(1) per operation | Each item is moved at most once (chapter 7) |
| Union-find with union by rank and path compression | A long find | per operation | Potential argument; is the inverse Ackermann function, below 5 for any realistic (chapter 13) |
| Splay tree | Splaying a deep node | O(log n) per operation | Potential 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 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 shape | Recurrence |
|---|---|
| Halve the range and recurse on one half, O(1) other work | |
| Split into two halves, recurse on both, merge in linear time | |
| Remove one element, recurse on the rest, linear work | |
| Two recursive calls on , constant work |
Base cases are usually and are left implicit. Floors and ceilings in 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, :
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 levels, each costing , so .
For the tree is a path. Level costs , and the total is . This is quicksort’s worst case, when every partition peels off one element.
For the tree is complete binary with depth , so it has nodes and . Towers of Hanoi has exactly this recurrence, and the minimal number of moves is .
The substitution method
Guess the answer and prove it by induction. To show is , assume for all . Then
for any . The induction must prove the exact form of the hypothesis. Proving for “works” only if one writes , which hides a constant that grows at each level; the correct calculation gives , which is not .
The Master Theorem
Most divide-and-conquer recurrences have the form
with subproblems, each of size for a constant , and work outside the recursive calls. The Master Theorem solves them by comparing to , which is the number of leaves in the recursion tree: the tree has depth and branching factor , so it has leaves. Call this the watershed function.
The version in the fourth edition of Cormen, Leiserson, Rivest and Stein states three cases:
| Case | Condition on f(n) | Solution | Where the work is |
|---|---|---|---|
| 1 | for some | The leaves dominate | |
| 2 | for some | Every level costs about the same | |
| 3 | for some , and for some and large | The root dominates |
The intuition is a geometric series over the levels of the recursion tree. Level has nodes each doing work. In case 1 the per-level cost grows geometrically toward the leaves, so the leaves’ dominates. In case 3 it shrinks geometrically away from the root, so the root’s dominates. In case 2 it is roughly constant across levels, which multiplies by one more log factor.
The extra condition in case 3, , is the regularity condition. It says the work actually shrinks from one level to the next. It holds automatically for polynomials such as , but can fail for oscillating functions.
Note the : case 1 and case 3 need 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 recurrence | a | b | Watershed | f(n) | Case | Solution |
|---|---|---|---|---|---|---|
| Binary search, | 1 | 2 | 1 | 2, k = 0 | ||
| Merge sort, | 2 | 2 | 2, k = 0 | |||
| Tree traversal of a balanced tree, | 2 | 2 | 1 | 1 | ||
| Karatsuba multiplication, | 3 | 2 | 1 | |||
| Naive divide-and-conquer matrix multiply, | 8 | 2 | 1 | |||
| Strassen’s matrix multiply, | 7 | 2 | 1 | |||
| 4 | 2 | 2, k = 1 | ||||
| 2 | 2 | 3 | ||||
| , e.g. quickselect with perfect pivots | 1 | 2 | 1 | 3 |
The theorem’s predictions can be checked by evaluating a recurrence exactly. With and a power of 2, the ratio should settle to a constant when 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 , and the term shrinks relative to 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 , which is case 1 with solution . Doubling the number of bits should multiply the time by :
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 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. shrinks by subtraction, not division. Use the recursion tree: . Likewise is . There is a separate “master theorem for decreasing functions” for , but unrolling is usually quicker.
The gap between cases 1 and 2. has watershed , and is smaller than but not by a polynomial factor, so case 1 does not apply; and it is not for any , so case 2 does not either. The recursion tree solves it. Level has nodes of size , each costing , so the level costs . Summing from 0 to gives , which is .
A non-constant number of subproblems. (van Emde Boas trees have recurrences of this kind) has neither constant nor constant . Substituting turns it into , so and .
Unequal splits. has two subproblems of different sizes. The recursion tree gives : each full level costs , and the tree is between and 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
with constants and , and of polynomial growth. Find the unique real with
Then
Small perturbations such as floors, ceilings and in the subproblem sizes do not change the result.
Two examples:
- . Solve : . The integral is , so .
- , the recurrence of the median-of-medians selection algorithm. Here gives , which is less than 1. The integral is , so . The algorithm is linear because the subproblem fractions sum to . Chapter 3 explains where those fractions come from.
The second example generalizes into a rule worth remembering: if and , then . If , then .
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.
| n | x in list (C, O(n)) | Binary search (Python, O(log n)) | bisect (C, O(log n)) |
|---|---|---|---|
| 8 | 59 ns | 150 ns | 64 ns |
| 32 | 240 ns | 258 ns | 78 ns |
| 128 | 935 ns | 325 ns | 82 ns |
| 512 | 3,508 ns | 458 ns | 93 ns |
| 2,048 | 13,930 ns | 468 ns | 99 ns |
| 8,192 | 54,038 ns | 517 ns | 107 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 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 , 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 and , 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 and a slow memory accessed in blocks of size , and counts only block transfers. Scanning items costs transfers, searching a B-tree costs , and sorting costs . Cache-oblivious algorithms achieve these bounds without knowing or , 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 is prime takes steps, which looks polynomial. The input is only 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, for capacity , is the standard example (chapter 14).
- Hidden lower-order terms. An algorithm that runs in is , but the linear term dominates until exceeds a million.
- Parallelism. Work (total operations) and span (the longest chain of dependent operations) are separate measures. A parallel sum has work and 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
| Concept | What it is for |
|---|---|
| O, Ω, Θ | Upper, lower and tight bounds on a function, each with explicit constants and |
| o, ω | Strict bounds: grows strictly slower or strictly faster |
| Limit test | Deciding the relationship between two functions when the ratio converges |
| Worst, best, average case | Bounds over inputs: maximum, minimum, and mean under an assumed distribution |
| Expected time | Mean over the algorithm’s random choices, for every input |
| Smoothed analysis | Worst input plus small random noise; explains algorithms that are fast in practice despite bad worst cases |
| Auxiliary space | Extra memory beyond the input, including the recursion stack |
| Aggregate method | Sum the cost of the whole sequence and divide |
| Accounting method | Overcharge cheap operations and store credit for expensive ones |
| Potential method | Credit as a function of state; amortized cost is actual cost plus change in potential |
| Deamortization | Spreading expensive work across operations to bound single-operation latency |
| Recursion tree | Solving any recurrence by summing work level by level |
| Master Theorem | Solving by comparing to |
| Akra–Bazzi | Solving divide-and-conquer recurrences with unequal subproblem sizes |
| External memory and cache-oblivious models | Predicting cost when memory transfers, not operations, dominate |