Amortized Analysis

By the end you will be able to bound the total cost of a whole sequence of operations — even when a few individual operations are expensive — using the three standard techniques: aggregate analysis, the accounting method, and the potential method. You will apply all three to a stack with MULTIPOP, a binary counter with INCREMENT, and a dynamic table that grows and shrinks itself, and see exactly why a careless resizing rule can quietly turn an O(1) operation into an O(n) one.

1. What "amortized" means (and what it does not mean)

Think of a gym membership that costs $50/month but includes one free "induction session" worth $200 the very first month. If you look only at month 1, the gym seems to cost $250 — terrifying. But averaged over a full year, you paid $50×12 + $200 = $800 for 12 months, i.e. about $67/month. Amortized analysis is exactly this: instead of panicking about one expensive operation in a sequence, you prove a bound on the total cost of the whole sequence, then divide by the number of operations to get a per-operation "amortized" cost.

This is easy to confuse with two other ideas, so let's separate them clearly:

The single most common beginner mistake: assuming "amortized O(1)" means "every operation actually takes O(1) time." It doesn't. It means the sum of costs over any sequence of n operations is O(n) — a handful of operations inside that sequence can genuinely cost O(n) each, as long as they're rare enough that the total stays O(n). You will see this happen concretely in the very first animation below.

There are three standard techniques for proving an amortized bound, all producing valid (though sometimes different-looking) bounds on the same sequence of operations:

We will apply all three to two running examples — a stack with MULTIPOP, and a binary counter with INCREMENT — before moving to dynamic tables.

2. Aggregate analysis: MULTIPOP on a stack

Picture a stack of plates on a spring-loaded plate dispenser. PUSH puts one plate on top; POP removes the top plate. MULTIPOP(k) grabs up to k plates off the top in one go — like scooping several plates at once instead of one at a time. A single MULTIPOP can look expensive (scooping 1,000 plates!), but notice: you can only ever scoop plates that were placed there by a PUSH first. If there have only been n PUSHes total, there can never be more than n plates scooped, ever, no matter how the POPs and MULTIPOPs are distributed.

The multipop operation in pseudocode (push and pop are the O(1) primitives of any stack):

multipop(k)

A single multipop(k) call costs min(size of S, k) — in the worst case, that's Θ(n) if the stack holds n items. Doing this n times naively suggests Θ(n²) total. But aggregate analysis looks at the whole sequence at once: every object popped (whether by POP or by a POP inside MULTIPOP) must have been pushed at some point, and each object can only be popped once. So across any sequence of n PUSH/POP/MULTIPOP operations, the total number of POPs performed can never exceed the total number of PUSHes, which is at most n. Total actual cost ≤ n (pushes) + n (pops) = 2n, so the amortized cost per operation is O(1), even though some individual MULTIPOP calls cost Θ(n).

The aggregate argument as running code: the worst single call and the whole-sequence total side by side.

Dart implementation (a stack via a growable List; “is the stack empty?” is _s.isEmpty, and PUSH/POP map directly to List.add/List.removeLast):
class Stack17 {
  final List<int> _s = [];
  int get size => _s.length;
  int push(int x) { _s.add(x); return 1; }
  int pop() {
    if (_s.isEmpty) return 0;
    _s.removeLast();
    return 1;
  }
  int multipop(int k) {
    var popped = 0;
    while (_s.isNotEmpty && popped < k) {
      _s.removeLast();
      popped++;
    }
    return popped;
  }
}
Each method returns its actual cost (the number of elementary PUSH/POP steps it performed) so the amortized-analysis experiments in this lesson can sum them up and check the bounds proved here.
Aggregate analysis's signature move: don't bound one operation — bound the whole sequence, using a global argument (here: "total pops ≤ total pushes") that no single operation's local view could see. The amortized cost (here, O(1)) is then just T(n)/n, assigned equally to every operation, regardless of its actual cost.

Input size → what’s feasible: n = 106 mixed PUSH/POP/MULTIPOP operations → at most 2n = 2·106 elementary steps in total (milliseconds), even though the “each MULTIPOP could cost n” view suggests n² = 1012.

3. Aggregate analysis: INCREMENT on a binary counter

Picture an odometer with k dials, each showing 0 or 1 (instead of 0–9). Turning it up by one "click" (INCREMENT) sometimes just flips the rightmost dial from 0 to 1 (cheap). But sometimes — like going from 0111 to 1000 — a whole run of dials has to flip from 1 back to 0 before the next dial can flip to 1 (expensive, one click but many dial-turns).

A k-bit counter is an array bits[0..k−1] of bits, where bits[0] is least significant. Its value is Σ bits[i]·2i. Here is the increment operation (0-indexed, bits[0] is the least significant bit):

increment()

A single INCREMENT can flip all k bits (going from all-1s to all-0s, e.g. 111→000 with the carry conceptually overflowing past the array — the final guard "if i < bits.length" simply drops that overflow bit). So the naive worst-case bound for n INCREMENTs is Θ(nk). But look at how often each individual bit flips: bit bits[0] flips on every single INCREMENT (n times); bits[1] flips every 2nd INCREMENT (⌊n/2⌋ times); bits[i] flips every 2i-th INCREMENT (⌊n/2i⌋ times). Summing a geometric series:

total flips = Σi=0k−1 ⌊n/2i⌋ < n·Σi=0∞ 1/2i = 2n

So n INCREMENTs cost less than 2n bit-flips total — amortized O(1) per INCREMENT, even though individual calls can cost up to k.

The geometric-series bound as code: count how often each bit flips and compare with floor(n / 2i) and with 2n:

Dart implementation (bits[0] = least significant bit):
class Counter17 {
  final List<int> bits;
  Counter17(int k, [int start = 0]) : bits = List.filled(k, 0) {
    var x = start;
    for (var i = 0; i < k && x > 0; i++) {
      bits[i] = x & 1;
      x >>= 1;
    }
  }
  int increment() {
    var i = 0, cost = 0;
    while (i < bits.length && bits[i] == 1) {
      bits[i] = 0;
      i++;
      cost++;
    }
    if (i < bits.length) {
      bits[i] = 1;
      cost++;
    }
    return cost;
  }
}
increment() returns its actual cost (number of bit writes), letting us total it across many calls and check it against the 2n bound.
A classic off-by-one bug: writing while (i <= bits.length ...) instead of < . That reads/writes bits[bits.length], which throws a RangeError in Dart the moment the counter first overflows (all bits are 1) — see the debugging question about this in the interview bank.
Same pattern as MULTIPOP: don't bound one INCREMENT — bound how many times each bit flips across the whole sequence, then sum. This "charge to the object, not the operation" trick (counting flips per bit, pops per plate) is the heart of aggregate analysis.

Input size → what’s feasible: k = 64-bit counter, n = 109 INCREMENTs → fewer than 2·109 bit writes in total (about 2 per call), not k·n = 6.4·1010.

4. The accounting method: MULTIPOP

Instead of totalling everything at the end (aggregate analysis), imagine a toll booth that charges a fixed, made-up "amortized price" per operation type, and keeps the overpayments in a bank. PUSH is charged $2: $1 pays for the actual push, and the other $1 is taped as a credit onto that exact plate. POP and MULTIPOP are charged $0 — they're "free," paid entirely out of the credit already taped onto the plates being removed.

This works as long as the bank balance (total credit) never goes negative — and it can't, here, because the credit taped to a plate is always still there (equal to $1) until that exact plate is popped, so the bank balance always exactly equals the current number of plates on the stack (≥ 0, always).

The accounting invariant as code (credit is the bank balance, charged the made-up price):

This example deliberately shows two dangerous inputs for MULTIPOP: calling it on an empty stack (“stack is empty” is immediately true, so it costs 0 — no crash, no negative size), and calling MULTIPOP(k) with k larger than the stack size (it just pops whatever exists and stops — the worst-case actual cost of one call is bounded by the current stack size, not by k). Both are exercised in the animation.
The accounting-method invariant, stated as an assertion you can test: after every operation, credit == stack.size. Here it is expressed as a check (see it exercised on random operation sequences in verify/c17.dart's testStackAccounting):
var credit = 0;
// after each op: charged - actual is added to credit
// PUSH: charged=2; POP/MULTIPOP: charged=0
credit += charged - actual;
// invariant, must ALWAYS hold:
// credit == s.size  &&  credit >= 0
The accounting method's signature move: pick amortized "prices" ĉ for each operation type (here PUSH=2, POP=MULTIPOP=0) such that the running total of (ĉ − actual) — the credit balance — never goes negative. If it never goes negative, then Σĉ ≥ Σ(actual cost) always, so the chosen ĉ values really do bound the true total cost.

5. The accounting method: INCREMENT

Same toll-booth idea, applied to bits instead of plates: setting a bit from 0 to 1 is charged $2 ($1 pays the actual write, $1 is taped onto that bit as credit). Resetting a bit from 1 to 0 is charged $0 — paid entirely from the $1 already taped onto that exact bit (which must be there, since the bit is 1 right now, and every 1-bit always carries exactly $1 of credit by this scheme).

One INCREMENT resets some number of bits (each paid for free, from their own credit) and then sets at most one new bit to 1 (charged $2). So the amortized cost of any single INCREMENT, under this pricing, is at most 2 — regardless of how many bits it resets. The bank balance always equals the number of 1-bits currently in the counter, which is never negative.

The same idea for the counter, run for 40 INCREMENTs of a 5-bit counter (so it includes an overflow):

This example starts the counter already all 1-bits (the worst possible starting state) so the very first INCREMENT resets every bit and overflows — the single most expensive possible call. Watch that this worst-case call is charged only $0 amortized, not $2: an overflowing INCREMENT never sets a new bit (there is nowhere left to set it), so the $2 "set a bit" charge never applies — every one of its resets is paid entirely from credit banked long before this animation even started. (A normal, non-overflowing INCREMENT — most of the calls you'll see elsewhere in this lesson — is the one charged $2, since it does set exactly one new bit.)
The accounting-method invariant here: credit == number of 1-bits, always. Verified on hundreds of random (k, start, n) combinations in verify/c17.dart (testCounterPotential's telescoping check is the potential-method twin of this same invariant).
Both accounting-method examples use the exact same trick: find a resource (a plate, a bit) that is created once, destroyed once, and carries exactly $1 of credit the whole time in between. The credit banked at creation is exactly enough to pay for the eventual destruction — that's why the invariant never breaks.

6. The potential method: MULTIPOP & the correctness argument

Instead of tracking individual dollar bills taped to individual plates, imagine a single fuel gauge Φ for the whole stack, reading "number of plates currently stacked." PUSH raises the gauge by 1 (it "fills the tank" a little); POP and MULTIPOP lower it (they "spend fuel" that was stored earlier). The potential method formalizes this with one function Φ(D) of the whole data structure's state.

The recipe: pick Φ so that Φ(D₀) = 0 (start) and Φ(Dᵢ) ≥ 0 always. Then define each operation's amortized cost as:

ĉᵢ = cᵢ + Φ(Dᵢ) − Φ(Di−1)

Summing over all n operations, the Φ terms telescope (each Φ(Dᵢ) cancels the next term's −Φ(Dᵢ)):

Σᵢ ĉᵢ = Σᵢ cᵢ + Φ(Dn) − Φ(D₀)

Since Φ(D₀) = 0 and Φ(Dn) ≥ 0, this proves Σ cᵢ ≤ Σ ĉᵢ — the real total cost is bounded by the sum of amortized costs. This telescoping identity is itself the whole correctness proof — there is no separate "loop invariant" needed the way a sorting algorithm needs one; proving Φ(D₀)=0 and Φ(Dᵢ)≥0 for all i, plus computing ĉᵢ = cᵢ+ΔΦ for each operation type, is the proof.

For the stack, Φ(D) = (number of objects currently on the stack). PUSH: ΔΦ=+1, so ĉ = 1+1 = 2. POP: ĉ = 0. MULTIPOP(k) popping k′ = min(k, size) objects: ΔΦ = −k′, so ĉ = k′ − k′ = 0. Every operation's amortized cost is O(1), matching the accounting method's numbers exactly (that's not a coincidence — Φ = credit balance here).

The potential method for the stack as code, with the telescoping identity checked exactly:

Try your own sequence of stack operations (comma-separated, e.g. PUSH 4, PUSH 7, MULTIPOP 2, POP, MULTIPOP 10):

The telescoping identity, tested directly in verify/c17.dart's testStackAggregateAndPotential (an exact equality, not just a bound):
check(totalAmortized, totalActual + phiPrev - 0,
    'telescoping identity: sum(amortized) == sum(actual) + Phi_n - Phi_0 (trial $trial)');
This is checked on hundreds of randomly generated operation sequences — the identity holds exactly every time, by construction of ĉᵢ.
The potential method's signature move: instead of taping credit to individual objects (accounting method), summarize the entire structure's stored-up capacity in one number Φ. The amortized cost formula ĉᵢ = cᵢ + ΔΦ, plus Φ(D₀)=0 and Φ(Dᵢ)≥0 always, is a complete, self-contained proof that total actual cost is O(n) — no separate correctness argument required.

7. The potential method: INCREMENT (and why DECREMENT breaks it)

For the counter, the fuel gauge Φ is simply "how many dials currently show 1." Resetting a dial to 0 lowers the gauge (spending stored fuel); setting a dial to 1 raises it (storing fuel for later).

Let Φ(D) = bᵢ = number of 1-bits after operation i. If INCREMENT resets tᵢ bits, its actual cost is ≤ tᵢ+1 (the resets, plus at most one set). Whatever happens, bᵢ ≥ bi−1 − tᵢ + 1 (you lose the tᵢ bits you reset but gain at least the one you might set), so ΔΦ ≥ 1 − tᵢ, giving:

ĉᵢ = cᵢ + ΔΦ ≤ (tᵢ+1) + (1−tᵢ) = 2

Every INCREMENT's amortized cost is at most 2, regardless of how many bits it resets — matching the aggregate 2n bound and the accounting numbers, as it must.

A bonus the potential method makes easy: this argument works even if the counter does not start at 0. If it starts with b₀ ones, then total actual cost = Σĉᵢ − Φ(Dn) + Φ(D₀) ≤ 2n − bn + b₀ (every ĉᵢ ≤ 2, and the bound is tight unless an overflowing call, which amortizes to 0, occurs) — still O(n) as long as n = Ω(k) (since b₀ ≤ k always). Try it (format k=6, start=45, n=20 — k = number of bits, start = initial value, n = number of INCREMENTs):

The same page example (k=6, start=45, n=20) as code, with that bound checked:

A reverse gear breaks it: what if the counter can also count DOWN (a DECREMENT operation)? It seems harmless, but consider a k-bit counter that starts at 0111...1 (2k−1−1) and alternates INCREMENT, DECREMENT forever: INCREMENT crosses to 1000...0 (flipping all k bits), then DECREMENT crosses straight back to 0111...1 (flipping all k bits again) — every single operation costs exactly k, forever. That's Θ(nk) total for n operations, not O(n): adding DECREMENT breaks the O(1) amortized bound entirely, because no potential function can make bᵢ (or any other simple measure) keep the telescoping sum small when the structure keeps returning to a high-potential state every single step. Verified exactly in verify/c17.dart's testCounterDecrementBreaksBound.
The potential method doesn't just prove bounds — trying (and failing) to find a valid Φ for a modified operation (like DECREMENT) is itself a way to discover that an amortized bound doesn't hold. If every candidate Φ you try keeps getting driven back up to a high value by an adversarial sequence, that's a strong hint the true amortized cost really is worse than O(1).

8. Dynamic tables: insert (expansion only)

A dynamic table is like a storage shelf that starts small and, whenever it's completely full and you need to add one more box, you buy a brand-new shelf with twice the capacity, move every existing box onto it, and only then add the new box. Moving all the boxes is expensive, but it happens rarely (only when totally full), and each time it happens the shelf gets a lot more room before it needs to happen again.

A table tracks num (items currently stored) and size (slots allocated). The load factor α = num / size measures how full it is (an empty table has α defined as 1). The insert operation doubles the table whenever it's exactly full:

insert(x)

Doubling keeps α(T) ≥ 1/2 always (right after doubling, before the new item goes in, the table is exactly half full; it only gets fuller from there until the next doubling). Aggregate analysis: inserting item i costs i if i−1 was an exact power of two (an expansion, copying i−1 old items plus inserting the new one), else it costs 1. Summing a geometric series of doublings gives total cost < n + 2n = 3n for n inserts — amortized ≤ 3 per insert.

The aggregate total and the potential Φ = 2·num − size as code for n = 1,000 inserts:

Normal case — 10 inserts from an empty table, watch it double at sizes 1, 2, 4, 8:

Edge & worst case — zooming into inserts #6 through #10, right around the moment insert #9 triggers an expansion (the most expensive single insert in this run, since 9−1=8 is a power of two):

Try your own number of inserts (1 to 40) and watch the potential Φ = 2·num − size rise and crash in a sawtooth pattern:

Dart implementation. Note this is a hand-rolled fixed-length-array table, not Dart's own growable List (whose internal doubling is invisible to us) — we need to see and measure the doubling ourselves, which is the entire subject of this section:
class DynamicTable17 {
  List<int?> table = [];
  int size = 0;
  int num = 0;
  int insert(int x) {
    var cost = 0;
    if (size == 0) {
      table = List<int?>.filled(1, null);
      size = 1;
    }
    if (num == size) {
      final newTable = List<int?>.filled(2 * size, null);
      for (var i = 0; i < size; i++) {
        newTable[i] = table[i];
        cost++;
      }
      table = newTable;
      size = 2 * size;
    }
    table[num] = x;
    cost++;
    num++;
    return cost;
  }
}
The new item goes into table[num], the first empty slot (slots are numbered from 0, so with num items stored the next free index is exactly num).
A tempting but wrong "optimization": allocate exactly as many slots as needed each time (no doubling, no slack) to "save memory." This seems reasonable — but the naive-vs-fixed comparison for delete in Section 9 shows exactly this kind of zero-slack thinking is what turns O(1) amortized into O(n) per operation. Slack (extra unused capacity) is not wasted memory; it is exactly what makes the amortized argument work.
Dart’s own List is a dynamic table. Every list.add that finds the backing array full allocates a bigger one and copies, exactly like the insert above. The Dart VM grows its capacity by (old * 2) | 3 (0 → 3 → 7 → 15 …); the lesson D26 · List methods (growth internals) animates that rule, and the code below counts its copies to show the same ≤ 2n total as doubling:

Input size → what’s feasible: n = 106 inserts → total cost < 3·106 (about 2 per insert) and the table never holds more than 2n slots; growing by +1 instead of doubling would cost n²/2 = 5·1011 copies.

insert is O(1) amortized (≤3) via the potential Φ = 2·num − size, which is 0 right after an expansion (num = size/2) and rises back to exactly num right before the next one — precisely enough banked potential to pay for that expansion's copying cost. Verified on 200 random-length sequences in verify/c17.dart's testTableInsertOnlyAggregateAndPotential.

9. Dynamic tables: delete (expansion + contraction)

Now allow removing boxes too. The obvious idea: whenever the shelf gets less than half full, buy a smaller shelf (half the size) and move everything over, to avoid "wasting" all that empty space. Sounds reasonable — but this section shows this specific rule can backfire catastrophically if you're not careful about exactly where the shrink-trigger sits relative to the grow-trigger.

Here is delete in pseudocode (remove the last item, then shrink to half size if that leaves the table below a chosen load-factor threshold):

delete()

Normal case — a mixed sequence of inserts and deletes, using the fixed rule (contract only when the load factor would drop below 1/4, not 1/2). Watch num, size and Φ (defined below) move together, staying O(1) amortized per operation:

The potential Φ (defined below) on a mixed run of inserts and deletes (the 1/4 rule): every amortized cost stays a small constant and Φ never goes negative:

Worst case — this is the important one. Fill a table completely (num = size = S), then alternate INSERT, DELETE forever. Compare the naive rule (grow at full, shrink whenever load ≤ 1/2 — the same threshold value used for both directions, with no buffer between them) against the fixed rule (grow at full, shrink only below 1/4):

Read that last animation closely: under the naive rule, every single INSERT re-doubles the table (because it's exactly full), and every single DELETE right afterward finds the load factor at exactly 1/2 — which the naive rule treats as "must shrink," halving it straight back down to a full table of the original size. The next INSERT then doubles again. This repeats forever, with zero net progress, at a cost of Θ(size) on every single operation — genuinely Θ(n²) for n operations, not merely a rare bad case. The fixed rule avoids this because after any resize, the load factor sits at (about) 1/2 — exactly 1/2 right after an expansion, just under it after a contraction — safely inside the "no resize" zone (between 1/4 and 1), so it takes Θ(size) more operations before the next resize can possibly trigger. That buffer zone is the entire fix.
A strict variant thrashes too. A slightly different naive rule halves when a deletion would make the table strictly less than half full (< 1/2); its adversary is INSERT followed by DELETE, DELETE, INSERT, INSERT, DELETE, DELETE, …. That thrashes as well (about 33 copies per operation in the Dart run below). We animate the ≤ 1/2 variant because its bad sequence is a clean two-step cycle you can watch; the lesson (never put the shrink threshold right next to the grow threshold) is the same.

The two rules as a tiny cost model (ResizeModel only counts copies; the rule is just two functions), run on the full-table adversary from above:

Input size → what’s feasible: 106 alternating INSERT/DELETE on a full table of 5·105 slots → naive rule: ≈ 106·5·105 = 5·1011 copies (never finishes); fixed 1/4 rule: ≈ 106 + one 5·105-item resize.

Try your own sequence of inserts/deletes on the fixed (1/4-threshold) table (comma-separated I/D, e.g. I,I,I,D,I,D,D,I,I,I,D,D,D,D):

The two strategies differ in exactly one line — the contraction threshold and its comparison direction:
class DynamicTable2 {
  // ... same insert() as DynamicTable17 above ...
  int delete(int idx) {
    var cost = 0;
    table[idx] = table[num - 1];
    table[num - 1] = null;
    num--;
    cost++;
    if (num == 0) {
      table = [];
      size = 0;
    } else if (num / size < 0.25) {
      final newSize = size ~/ 2;
      final newTable = List<int?>.filled(newSize, null);
      for (var i = 0; i < num; i++) {
        newTable[i] = table[i];
        cost++;
      }
      table = newTable;
      size = newSize;
    }
    return cost;
  }
}
versus the naive version's trigger num * 2 <= size (i.e. load ≤ 1/2 — the same boundary value as the "table is full" grow-trigger, with no gap between them).
The potential function: Φ(T) = 2·num − size when α(T) ≥ 1/2, else size/2 − num. This is 0 exactly at α=1/2, and rises to exactly num right before either an expansion (α=1) or a contraction (α=1/4) — always enough banked potential to pay for that resize's copying cost. The key design lesson: pick expansion and contraction thresholds with a gap between them (1 and 1/4 here, not 1 and 1/2), so no adversarial sequence can bounce between "must grow" and "must shrink" using only O(1) net operations.

Quiz

Interview questions

Cheat sheet

TechniqueIdeaGives amortized cost as…Best for
Aggregate analysisBound total cost T(n) of n ops directly (often via a "charge each unit of work to a specific object" argument)T(n)/n, same for every op typeQuick bounds when one clean global argument exists (MULTIPOP's "pops ≤ pushes")
Accounting methodAssign fixed prices ĉ per op type; overcharge early ops, bank the difference as credit on objectsThe chosen ĉ per op type (credit balance must stay ≥ 0)When it's natural to say "this object is pre-paying for its own future cost"
Potential methodOne function Φ(D) of the whole structure; ĉᵢ = cᵢ + Φ(Dᵢ) − Φ(Di−1), telescoping to prove the boundDerived per-op from ΔΦ (can differ per call, not just per type)Most structures — usually the cleanest, most flexible proof; doubles as the correctness argument
multipop / push / popAmortized O(1) per op for any sequence of n stack ops (Φ = stack size)
increment (binary counter)Amortized O(1) per op starting from 0 (Φ = # of 1-bits); adding DECREMENT breaks this to Θ(k) per op
insert (expansion only)Amortized ≤3 per insert; keeps load factor ≥ 1/2 (Φ = 2·num − size)
insert + delete (fixed 1/4 threshold)Amortized O(1) per op; keeps load factor ≥ 1/4 (Φ as defined in Section 9)
insert + delete (naive 1/2 threshold)FAILS: Θ(size) cost on every operation of an adversarial full/insert/delete sequence — Θ(n²) total, not O(n)

All three techniques prove valid bounds on the same real sequence of operations — they can (and often do) produce different-looking intermediate numbers (e.g. accounting's per-type ĉ vs potential's per-call ĉ), but the final asymptotic bound they certify is identical.