python/volume-3/chapter-34ch 34 / 64 · 10 min

34Amortized analysis — the honest average

In Chapter 33 we dropped Big-O through to the metal and watched two algorithms with the same cost run a hundred times apart. Here we stay at the level of the count, but we make the count harder. Most algorithms don't have one cost at all. They have a best day, a worst day, and a typical day, and the gap between them can be the gap between "instant" and "the site is down." So here's the plan. We'll learn to price all three, and then meet a subtler idea that trips up almost everyone: how a single operation can be wildly expensive on one call and still be, honestly and provably, cheap. The whole way, we keep asking the one thing that matters — if an operation is sometimes O(n) and sometimes O(1), what is it really costing you? By the end you'll look at list.append, an operation that occasionally copies your entire list, and prove with a stopwatch and a derivation that it costs a constant. That proof is called amortized analysis, and it's one of the most useful ways of thinking an engineer owns.

One algorithm, three honest questions Why the case you measure decides the algorithm you choose Amortized: cheap over a lifetime, not by luck The canonical case: why append is O(1) amortized
scroll to begin

01One algorithm, three honest questions

Let's start with the simplest search there is: walk a list left to right, compare each item to your target, and stop the instant you hit it (you stepped exactly this in trace T22). Now watch the obvious question fall apart — how many comparisons does it cost? The honest answer is it depends on where the target sits. So we don't ask for one number. We ask three separate questions, and each one has a precise, non-negotiable meaning.

These are three different measurements of the same codenothing about the algorithm changed, only the input did. Best case is a fact about luck, and it's rarely worth designing around. Average case needs an assumption about which inputs show up, and that assumption can be wrong. Worst case needs no assumptions at all. It is a promise: "no matter what you throw at me, I will not cost more than this." That's why, when an engineer says an algorithm "is O(n)" with no qualifier, they almost always mean the worst case. It's the only one you can hand a stranger.

linear search for a target in a list of n = 8 best 1 comparison → O(1) average n/2 ≈ 4 → O(n) worst n = 8 → O(n) same code, three inputs — coloured boxes are the ones actually examined
Fig — Best, average, and worst are three questions about one algorithm. Only the worst case is a promise you can make without knowing the input.

Fine for a toy search. But does the gap between "typical" and "worst" ever actually change which algorithm you'd pick? Oh, it decides careers. →

02Why the case you measure decides the algorithm you choose

Here is where this stops being bookkeeping. Quicksort (stepped in trace T28) is, on typical data, one of the fastest sorts ever written: on random input it does about n·log₂n comparisons. But that speed comes from a gamble. It picks a pivot and splits the list around it, hoping for two roughly equal halves. Feed it an input that always splits into "one item" and "everything else," and every split does real work while the problem barely shrinks. The cost collapses to O(n²). And the input that triggers this? For the textbook "pivot = last element" version, it's an already-sorted list — the most innocent-looking data imaginable. Let's not take that on faith; let's count the comparisons.

which_case.pypython
def quicksort_count(a):
    comps = 0
    def qs(lo, hi):
        nonlocal comps
        if lo >= hi: return
        pivot = a[hi]                 # naive: last element is the pivot
        i = lo
        for j in range(lo, hi):
            comps += 1                # count every key comparison
            if a[j] < pivot:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        qs(lo, i-1); qs(i+1, hi)
    qs(0, len(a)-1)
    return comps

# n = 2000
# random input : 25,335 comparisons    (~ n·log2 n = 21,932)
# sorted input : 1,999,000 comparisons (~ n^2 / 2  = 2,000,000)   -> 79x worse

Line by line: comps tallies each comparison. qs partitions the slice a[lo..hi] around pivot, sweeping j across it and counting one comparison per step, then recursing on the two sides. On 2,000 random numbers it spent 25,335 comparisons — right in the n·log n neighbourhood. On the same 2,000 numbers already sorted, it spent 1,999,000. That is exactly 2000·1999/2, the sum 1 + 2 + … + 1999, the fingerprint of a fully lopsided split every single time. Same algorithm, same n, 79× more work — because the case changed.

"It ran fine on my test data"
Test data is often small, random, or hand-made — precisely the average case. Production data is sorted exports, repeated values, and inputs an attacker chose on purpose — the worst case. An algorithm judged only on its good days ships a time bomb. Real quicksort defuses this by choosing the pivot at random (the randomization chapter) so no fixed input is its enemy.

Now the mirror image, and the reason your dict and set feel like magic. A hash lookup is O(1) on average: it jumps straight to a bucket (Volume 1, chapter 7). Its worst case, when every key collides into one bucket, degrades to an O(n) scan. Usually that worst case is a fairy tale you never meet. But you can force it, and watch O(1) rot into O(n) in real time:

which_case.pypython
class Bad:
    def __hash__(self): return 0     # EVERY key lands in the same bucket
    def __eq__(self, o): return self.v == o.v

# average nanoseconds per membership test ("x in the_set"):
# size 1000:  normal set     72 ns      colliding set    69,024 ns
# size 2000:  normal set     72 ns      colliding set   113,662 ns
# size 4000:  normal set     74 ns      colliding set   354,515 ns

Read the two columns. The normal set answers in ~72 nanoseconds whether it holds a thousand items or four thousandflat, the signature of O(1). The sabotaged set, where every key hashes to 0, gets steadily slower as it grows: the signature of O(n). (Timings are from this laptop; the shape, not the exact digits, is the point.) This isn't academic. Feeding a web server keys crafted to collide is a real denial-of-service attack called hash flooding, which is exactly why modern languages randomize their hash seed at startup.

InteractiveAverage vs worst — slide n and watch them tear apart
quicksort comparisons at n = 64 average 384 ≈ n·log₂n worst 2016 = n²/2 bars use a log scale — even so, worst pulls away as n climbs
The readout is worst ÷ average. Small n hides the danger; large n makes it a chasm.
good pivots → balanced log₂n levels · n·log n work depth stays shallow bad pivots (sorted input) → skewed n levels · n² work one long stick — depth = n
Fig — The same partition step, two fates. Balanced splits give log n shallow levels; lopsided splits give a stick n levels deep. Depth times per-level work is the whole story.
Wait —
if worst-case is the promise we design around, then list.append is in trouble. Its worst case copies the entire list — that's O(n). So why does everyone call append "cheap"? Are they wrong?

03Amortized: cheap over a lifetime, not by luck

They're not wrong. They're using a third kind of accounting, and it's the star of this chapter. Amortized cost is the total cost of a whole sequence of operations, divided by the number of operations. The word comes from finance. To amortize a cost is to spread a big one-time expense across many periods, so each period carries a small, even share. A £1,200 annual subscription isn't "£1,200 expensive"; amortized over the year it's £100 a month. The £1,200 charge is real, but no single month feels it.

The magic move is this. Some operations are occasionally expensive but usually trivial, and the expensive ones are rare enough that when you add up the whole bill and divide, the average is small — often a constant. When we can prove that, we say the operation is O(1) amortized. And here's the part that makes it powerful rather than hand-wavy: this is still a worst-case statement. It doesn't say "append is usually fast." It says "any sequence of m appends costs at most c·m total — guaranteed, for every possible sequence." No luck, no distribution, no coin flips. A promise.

operation number → cost → resize: copy all n amortized average — low and flat
Fig — Most operations (green) cost almost nothing; a rare one (red) pays for a huge job. Spread the tall bars over all the short ones and the average (amber dashes) sits low and steady.

Abstract enough. Let's pin it to the one operation you've typed ten thousand times — and finally cash the promissory note the Big-O chapter wrote when it said "append is O(1)." →

04The canonical case: why append is O(1) amortized

You met the machinery in Volume 1, chapter 6, so we'll use it, not rebuild it. A Python list is a dynamic array: a contiguous block of 8-byte reference slots, deliberately over-allocated with a few spare seats at the end. Watch what append normally does. It just drops one reference into the next spare seat — one write, cost O(1), no matter how long the list is. But when the spare seats run out, there's no room to grow in place, because the neighbouring memory is already taken. So CPython rents a bigger block elsewhere, copies every existing reference across, frees the old block, and then writes your new item. That copy touches all n references: cost O(n). Watch the spare seats refill with sys.getsizeof, which reports the container's own byte size:

append.pypython
import sys
lst = []
for i in range(1, 33):
    lst.append(i)
    # container bytes jump ONLY at len 1, 5, 9, 17, 25 …
# capacity sequence as it grows one at a time:
#   4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128 …
list length (items appended) → allocated capacity → length = capacity (full) flat tread = free appends riser = relocation (copy all n) 4 8 16 24 32
Fig — Capacity climbs in a staircase. You coast across each flat tread on spare seats (cheap); you pay only at the red risers, where the block is full and everything relocates — and the treads get longer as the list grows, so paying gets rarer.

The bytes don't climb smoothly. They jump, then hold flat for several appends, then jump again. Each jump is a relocation (an O(n) copy); each flat stretch is a run of cheap O(1) appends landing in spare seats. The relocations get rarer as the list grows, because each new block is proportionally bigger than the last. That "rare, and rarer over time" is the whole trick, and it's exactly the tall-red-bar-among-green picture from a moment ago. This is the concrete shape of a per-operation cost that is mostly constant with occasional spikes. Turn the knob and feel it:

InteractiveAppend cost — spikes rise, the average holds flat
append number (1 … n) → cost this append → avg
16
Green = cheap (1 write). Red = a resize copying the whole array. The amber line — total cost ÷ n — stays in a tight band near 2 and never climbs as n grows.

Drag n and notice the two things happening at once. The red spikes get taller — a resize at length 32 copies 32 references, at length 64 copies 64. Yet the amber average line refuses to climb with n. It just wobbles in a tight band around 2, nudged up the instant a resize lands, then settling back as the next run of cheap appends dilutes the cost. Rising spikes, bounded average. That contradiction — the individual worst case grows without bound while the per-operation average stays pinned inside a constant band — is amortized analysis in one picture. The next section proves why the line can't rise.

A labelled simplification
The widget uses the classic doubling model (capacity ×2 when full), which keeps the amortized average a small constant that settles around 2 (and provably never exceeds 3). Real CPython grows by only about 1/8 each time (≈1.125×), not 2×. Same Big-O — O(1) amortized — but a bigger constant, which we'll measure exactly in the next section. The refinement is coming; the model is just the clean version of the same idea.

Two nagging questions remain. Why is the average exactly 2 and not creeping upward forever? And what does the real, non-doubling CPython actually cost? Time to add up the whole bill. →

05The aggregate method: add up the whole bill, then divide

The cleanest way to prove an amortized bound is the aggregate method: compute the total cost of a sequence of operations, then divide by how many there were. No per-operation cleverness, just one big sum. Take m appends into a list that doubles when full. The cheap part is easy: every append does exactly one write, so that's m writes total. The expensive part is all the copying at resizes. With doubling, resizes happen at lengths 1, 2, 4, 8, …, up to m, and each copies the block it's leaving. So the copies are 1 + 2 + 4 + 8 + … up to roughly m.

That sum is the punchline. A doubling geometric series always adds up to less than twice its largest term: 1 + 2 + 4 + … + m < 2m. So all the copying across the entire sequence costs under 2m, and the writes cost m. That's a grand total under 3m for m appends. Divide by m: under 3 per append, a constant. The list can grow to a billion; the average never budges. That is the derivation behind "append is O(1) amortized."

successive resizes — each copies the block it leaves 1 2 4 8 16 32 1+2+4+…+32 = 63 < 2 × 32 = 64 all copying < 2m total → under 3 writes per append
Fig — The copy costs double, but a doubling series sums to under twice its last term. All the relocations in an entire lifetime cost less than 2m — so each append is a constant on average.

Enough theory. Let's put a real stopwatch on a real million appends and check the promise against CPython: spares, non-doubling growth, and all.

aggregate.pypython
import time
N = 1_000_000
lst = []
t0 = time.perf_counter()
for i in range(N):
    lst.append(i)
print((time.perf_counter() - t0) * 1000, "ms")   # ~= 55 ms  -> ~55 ns / append

# counting the ACTUAL relocations over the same run:
#   resizes            : 86
#   total slots copied : 8,445,096   ->  8.4 copies per append
#   writes per append  : ~9.4        (constant - does NOT grow with N)

A million appends finished in about 55 milliseconds — roughly 55 nanoseconds each (this laptop; yours will vary). For comparison, writing into a list that's already the right size — pure O(1) writes, zero growth — clocked ~50 ns each. So all of CPython's growing, relocating, and freeing added only about 5 ns per append on average. The counters explain why. Across a million appends there were only 86 relocations, and the total copying came to ~8.4 slot-copies per append. That's the honest CPython constant: about 9 writes per append, not the model's 2, because CPython grows by ~1/8 rather than doubling. Smaller growth means more frequent resizes means a bigger constant. But constant it is: independent of N, exactly as promised. The doubling model told the right story; the machine just uses a different constant.

InteractiveWhy doubling and not one-at-a-time — the strategy is the whole ballgame
Build N = 1,048,576 items — total element-copies 1 1K 1M 1B 1T total copies — log scale → Doubling ×2 O(1) amortized 1.0 million Grow +1 O(n) amortized 550 billion 524,288× more copying the gap is exactly N/2 — it doesn't shrink, it grows with your data
1,048,576
Same appends, two growth rules. Doubling copies ~N items across a whole lifetime — a constant per append. Grow-by-one (add a single seat each time it fills) relocates on every append, copying 0+1+2+…+(N−1) ≈ N²/2. Slide N up and watch them tear apart: the naive rule isn't a little slower, it's N/2 times slower — and that multiplier has no ceiling. Choosing ×2 over +1 is the entire difference between O(1) and O(n) amortized. (Doubling model: ×2 vs +1.)
Where you meet this — everywhere you grow a collection
This exact grow-and-copy trick is the beating heart of the growable array in every major language: Python's list, C++ std::vector, Java ArrayList, Go's append on slices, JavaScript arrays, Rust's Vec. The same idea makes hash tables fast: a dict or set that fills up rehashes into a bigger table — one occasional O(n) job that keeps every insert O(1) amortized. Databases amortize too: log-structured stores (the tech under Cassandra, RocksDB, and your phone's storage engine) do cheap appends and pay for order in rare, batched compactions. Even a garbage collector is amortized thinking — many cheap allocations, an occasional expensive sweep.

06The trap: amortized is not average-case

This is the distinction that separates people who've memorized the words from people who understand them, and the myth is worth killing out loud.

The myth

"Amortized O(1) and average-case O(1) are two phrasings of the same idea — both mean usually fast, occasionally slow, so on average fast."

The reality

Average-case invokes probability over inputs — a bet on which data shows up. Amortized invokes no probability at all — it's a guaranteed total over any sequence. One is a forecast; the other is a contract.

Line them up. Quicksort's average O(n log n) is a statement about a distribution of inputs: "if your input is random, expect n log n." Hand it a sorted array and the promise evaporates: you get n². The good behaviour was conditional on luck. Now take append's amortized O(1). There is no "if your appends are random." Every sequence of a million appends costs about 9 million writes — the sorted case, the adversarial case, the you-chose-the-inputs-to-hurt-me case, all identical. You cannot construct a bad sequence, because the guarantee is over the sum, not over a typical draw. Average-case can be defeated by a clever enemy; amortized cannot.

AMORTIZED guaranteed total ÷ count holds for ANY sequence — no dice AVERAGE-CASE typical bad input expected over random input a bet on typical data — beatable
Fig — Amortized averages over the sequence you run (a contract). Average-case averages over the inputs you might get (a forecast). Same word "average," opposite kinds of promise.
↺ The thing people get backwards
People hear "amortized O(1)" and relax as if the operation is always fast. It isn't — one append in the sequence really does stall while it copies the whole array, and if you're writing hard real-time code (audio, control loops) that single spike can miss a deadline. Amortized doesn't promise every operation is cheap; it promises the total is. Knowing which promise you hold tells you whether that lurking spike is harmless (a web request) or fatal (a pacemaker) — and when to pre-size the list to dodge it entirely.
The deeper cut — the accounting method, and why copies are provably bounded

The aggregate sum is one proof; here's the more portable one, the accounting (banker's) method, developed alongside amortized analysis by Robert Tarjan. Overcharge the cheap operations and save the surplus as credit, then let the expensive one spend that saved credit. Charge every append 3 units: 1 pays for its own write, and 2 go into a savings account attached to that slot. When the array is full and must copy, every element being moved has already banked credit since the last resize. With doubling, exactly half the elements are "new" since the last resize, each holding 2 credits. That is precisely enough to pay 1 to copy itself and 1 to copy an "old" element whose credit was already spent. The bank balance never goes negative, so no operation ever effectively costs more than its 3 prepaid units. Amortized cost ≤ 3 = O(1). Here's the beauty: this argument needs no probability and no summation. It's a local invariant, "the bank is always solvent," which is why the method generalizes to data structures where the aggregate sum is hard to write down.

One honest footnote on CPython specifically: its growth request is roughly new = size + (size >> 3) + 6 (the exact constants are an implementation detail and have shifted across versions). The >> 3 is the ≈12.5% headroom. Because the block still grows proportionally to its size, each element is still copied only a bounded number of times over the list's life. So the accounting argument goes through with a larger charge (~9 rather than 3), and append stays O(1) amortized. The language spec only promises the amortized bound; the constant is CPython's business.

The 1% move: judge the sequence, not the instance
Amortized thinking is a lens you can point at anything. Don't ask "how bad is the worst single operation?" — ask "how bad is the total, spread over everything I'll actually do?" It's why you batch database writes, buffer file I/O, prepay an annual plan, cook once and reheat all week, and tolerate a slow occasional cleanup that keeps every ordinary day fast. The expensive step isn't a bug to fear; it's an investment that makes a thousand cheap steps possible. Spot that pattern — rare big cost buys many small ones — and you'll design systems, and a schedule, that stay fast under load.

You can now price an algorithm three honest ways and prove a spiky operation is secretly cheap. But pricing is diagnosis, not cure. Next: the design loop itself — start with brute force, then ask the one question that turns a coder into an engineer, "where is the wasted work?" — and watch an O(n²) two-sum collapse to O(n) before your eyes. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 34, in working code

A single append can copy the entire list, and we still call it cheap. These twelve deterministic programs count every write and every copy, then spread the rare expensive spikes across the many cheap ops, until the honest average falls flat and stays there.

One append, three honest answers
The very same append is O(1) in the lucky case, O(n) in the full-block case, and a small constant when you average an entire lifetime. Amortized analysis is that third answer.
The dynamic array, simulated
The reason append is amortized O(1) is the doubling growth policy. Watch the capacity staircase, the per-append cost spikes, and why growing by a fixed one seat instead would be a catastrophe.
The aggregate method — add up the whole bill
The cleanest proof: total every operation over a lifetime, then divide. A doubling series of copy costs sums to under twice its last term, so the average is a constant. The same trick nails the binary counter.
Other ledgers, and the trap
Two more ways to prove a constant: the banker's prepaid account and the multipop stack. Then the distinction that separates people who memorized the word from people who understand it — amortized is a contract, not a forecast.
end of chapter 34 · six sections
iolinked.com
Written by Ajai Raj