Foundations/Amortized Analysis

Lesson 1.64,786 words

Amortized Analysis

Some operations are occasionally expensive but cheap on average across any sequence. Amortized analysis bounds the average cost per operation over a worst-case sequence — not an expectation — so a rare costly step is paid for by the many cheap ones around it.

╌╌╌╌

The asymptotic tools from the previous lessons bound a single operation in the worst case. But for many data structures that bound is misleading. Appending to a dynamic array is usually (write into the next free slot), yet once in a while the array is full and the append must copy every element to a larger block, costing . Bounding each append by its worst case, , and multiplying by appends gives , which hugely overstates the truth: appends really take total. The expensive copies are rare, and the cheap appends between them more than pay for them.

Amortized analysis is the technique for making that intuition rigorous. It charges each operation an amortized cost so that the total over any sequence is correct, while individual amortized costs are smooth and easy to reason about.

What amortized means — and does not

Fix a data structure and consider a sequence of operations performed on it.

The point of the definition is that the amortized costs need only upper-bound the actual total; we are free to choose smooth that overcharge cheap operations and undercharge expensive ones, as long as the running sum never falls behind. If every , then operations cost at most in total, no matter how the costs are distributed inside the sequence.

The distinction matters in both directions. An average-case bound can be excellent on random inputs and useless against the one input your program actually sees; an amortized bound cannot. Conversely, an amortized bound says nothing about any single operation, only about prefixes of the sequence: the -th append may well cost , and the guarantee only promises that the operations before it were cheap enough to compensate. We return to when that trade is unacceptable at the end.

The three classical methods (aggregate, accounting, and potential) all certify the same kind of bound; they differ only in bookkeeping.1 Each is developed below, first on one running example so the methods can be compared side by side, then on a second structure so the mechanics show twice.

The running example: dynamic-array doubling

A table holds items in a block of slots. writes into the next free slot; but when the block is full (), it first doubles the block, allocating slots and copying all existing items over, then inserts.

Algorithm 1:Table-Insert(T,x)\textsc{Table-Insert}(T, x) — append xx, doubling when full
  1. 1
    if T.size=0T.\mathit{size} = 0 then
  2. 2
    allocate T.tableT.\mathit{table} with 11 slot; T.size1T.\mathit{size} \gets 1
  3. 3
    if T.num=T.sizeT.\mathit{num} = T.\mathit{size} then
    block full: grow
  4. 4
    allocate new\mathit{new} with 2T.size2 \cdot T.\mathit{size} slots
  5. 5
    copy all T.numT.\mathit{num} items into new\mathit{new}
    the expensive step
  6. 6
    T.tablenewT.\mathit{table} \gets \mathit{new}; T.size2T.sizeT.\mathit{size} \gets 2 \cdot T.\mathit{size}
  7. 7
    insert xx into T.table[T.num]T.\mathit{table}[T.\mathit{num}]; T.numT.num+1T.\mathit{num} \gets T.\mathit{num} + 1
  8. 8
    return

Count the cost of an insert as for the write plus, when it doubles, the number of items copied. Inserts that do not trigger a doubling cost . The -th insert triggers a doubling exactly when is a power of , copying items, so its cost is . Plotting cost against operation index shows the characteristic picture: a flat baseline of , punctuated by spikes at that double in height.

Cost per against operation index . Most inserts cost (the pale baseline); the -th insert doubles exactly when is a power of two, copying items for a total cost of . The spikes double in height but also double in spacing, so their area spreads out to a constant per insert: the dashed amortized line sits flat at .

The naive analysis multiplies the worst single insert, , by inserts and concludes . The truth is total, amortized per insert, and each of the three methods proves it in its own vocabulary. Watch how the same fact, that cheap inserts outnumber and prepay the copies, gets encoded three different ways.

Method 1: aggregate analysis

The aggregate method is the most direct: bound the total cost of any sequence of operations as a whole, then divide by . Every operation is assigned the same amortized cost, , where is the worst-case total.

The dynamic array: sum the sequence

For inserts into an initially empty table, separate the two kinds of work. Every insert performs exactly one write, contributing in total. The copies happen only at doublings: the insert at index copies items, and doublings occur for every with . The total number of items copied is therefore a geometric sum:

Adding the writes,

so the amortized cost per insert is . Concretely, for : the writes cost , the doublings copy items, and the total is . The spikes in the figure above are tall, but their combined area is smaller than the baseline they interrupt.

The whole argument is one sum. That is the aggregate method's appeal: when the total can be computed directly, nothing more is needed.

A stack with multipop

The second aggregate example needs a global counting argument rather than a closed-form sum. Augment the usual stack ( and , each ) with one more operation, , which pops the top items, where is the current size.

Algorithm 2:Multipop(S,k)\textsc{Multipop}(S, k) — pop up to kk items off stack SS
  1. 1
    while not Empty(S)\textsc{Empty}(S) and k>0k > 0 do
  2. 2
    call Pop(S)\textsc{Pop}(S)
    discard one item
  3. 3
    kk1k \gets k - 1
  4. 4
    return

A single can be expensive: on a stack of items, runs the loop times, costing . With as large as , the naive per-operation bound is , suggesting for the whole sequence.

That bound is far too pessimistic. One fact tightens it: each item is popped at most once for each time it is pushed. Over a sequence of operations there are at most pushes, so the total number of pop actions, whether by or inside , is at most . Every operation does work besides popping, contributing another . Hence the total cost of any sequence of operations is .

Why the total is : each item is pushed once and popped at most once, so the dollar a push deposits pays for that item's eventual pop — whether by or inside a . Total pops never exceed total pushes, so the whole sequence does at most pop actions.

Aggregate analysis is appealing when a clean global count (here, pops pushes) bounds the total directly. Its limitation is that it assigns one cost to all operation types; the next two methods let us charge them differently.

multipop_stack.pypython
from __future__ import annotations

from typing import Generic, Optional, TypeVar

Item = TypeVar("Item")

class StackNode(Generic[Item]):
  """
    One stack cell: its value and a link down to the cell beneath it.\n
    The bottom cell links to None.\n
  """

  def __init__(self, value: Item, below: Optional[StackNode[Item]]) -> None:
    self.value: Item = value
    self.below: Optional[StackNode[Item]] = below

  def __repr__(self) -> str:
    return f"StackNode({self.value!r})"

class MultipopStack(Generic[Item]):
  """
    A LIFO stack of linked nodes supporting push, pop, and multipop.\n
    Tracks `cost` — the running count of elementary pop actions — so the\n
    amortized argument (total pops <= total pushes) can be checked\n
    directly against an executed sequence.\n
  """

  def __init__(self) -> None:
    self._top: Optional[StackNode[Item]] = None
    self._size: int = 0
    self.cost: int = 0

  def __len__(self) -> int:
    return self._size

  def is_empty(self) -> bool:
    """
      Whether the stack holds no items.\n
    """
    return self._size == 0

  def push(self, value: Item) -> None:
    """
      Place `value` on top of the stack. Costs one unit of work.\n
    """
    # link a fresh node above the current top.
    self._top = StackNode(value, self._top)
    self._size += 1
    self.cost += 1

  def pop(self) -> Item:
    """
      Remove and return the top item. Raises IndexError when empty.\n
    """
    top = self._top
    if top is None:
      raise IndexError("pop from an empty stack")
    self._top = top.below
    self._size -= 1
    self.cost += 1
    return top.value

  def peek(self) -> Item:
    """
      Return the top item without removing it. Raises when empty.\n
    """
    if self._top is None:
      raise IndexError("peek into an empty stack")
    return self._top.value

  def multipop(self, count: int) -> list[Item]:
    """
      Pop and return the top min(count, size) items, top-most first.\n
      A non-positive `count` pops nothing.\n
    """
    popped: list[Item] = []
    while self._top is not None and count > 0:
      popped.append(self.pop())
      count -= 1
    return popped

Method 2: the accounting method

The accounting method assigns each operation type its own amortized cost, called its charge, which may differ from its actual cost. When an operation's charge exceeds its actual cost, the surplus is stored as credit on specific elements of the data structure; when an operation costs more than its charge, it spends stored credit to cover the difference. Erickson develops the same idea as taxation: overtax the cheap operations and let the treasury pay for the expensive ones.2

The one rule that makes this a valid proof:

If credit stays non-negative, the amortized charges upper-bound the actual costs over every prefix, matching the definition, so the per-operation charges are a valid amortized bound.

The dynamic array: charge three dollars per insert

The aggregate bound of per insert suggests the scheme: charge every exactly units. Spend them as follows.

  1. unit pays for writing the new item into its slot.
  2. unit is banked on the new item itself, reserved for the next time a doubling copies it.
  3. unit is banked on the new item on behalf of one older item — one that was already copied by the last doubling and has no credit left.

Why the third unit works out: right after a doubling to capacity , the table holds exactly items, all with zero credit (the doubling spent it). Before the next doubling can fire, another inserts must arrive. Each new item banks units, one for its own future copy and one for exactly one credit-less older item; the new items cover the older items one-for-one.

When the table fills at , the newcomers hold units — exactly the cost of copying all items into the new block. The doubling spends every credit, the copied items land in the new block with no credit, and the invariant is re-established with the table again half full. No operation ever overdraws, so the charge of is a valid amortized cost.

The accounting invariant mid-sequence: capacity , six items. Items - were copied by the last doubling and hold no credit; items and arrived after it and hold credits apiece (dots). When items and arrive, the four newcomers will hold credits in total: exactly the bill for copying all items into the next block.

The scheme also explains the constant. Two units would not suffice: a new item could pay for its own future copy but not for the older item copied in the same doubling. Charging keeps the credit non-negative.

The binary counter

The second accounting example. A -bit binary counter starts at and supports , which adds . The cost of an increment is the number of bits it flips.

Algorithm 3:Increment(A)\textsc{Increment}(A) — add one to the binary counter A[0..k1]A[0..k-1]
  1. 1
    i0i \gets 0
  2. 2
    while i<ki < k and A[i]=1A[i] = 1 do
  3. 3
    A[i]0A[i] \gets 0
    flip a trailing 1 down to 0 (carry)
  4. 4
    ii+1i \gets i + 1
  5. 5
    if i<ki < k then
  6. 6
    A[i]1A[i] \gets 1
    set the first 0 bit
  7. 7
    return

A single increment can flip many bits: incrementing to flips four. In the worst case (, then a carry out) an increment costs , so increments appear to cost .

The truth is , and the accounting method shows it cleanly. Charge each increment units, and store unit of credit on every bit that is set to . When a bit flips , pay unit for the flip and bank unit on that bit. When a bit flips inside the carry loop, pay for the flip with the credit already sitting on that bit — for free, from the increment's perspective.

Each sets exactly one bit to (the bit at the end), so it banks exactly one new credit and spends units total: to flip that bit up, to bank. Every flip in the carry chain is already paid for. Credit equals the number of -bits in the counter, which is never negative, so the invariant holds.

Accounting for the binary counter. Each is charged units: it sets one bit from to , banking credit on that bit (solid blue), and every flip in a carry (outlined blue) is paid from the credit already stored on that bit. Rows show the counter after each of the first four increments: .

The aggregate method reaches the same constant from a different direction, and the cross-check is worth seeing. Bit flips on every increment; bit on every second; in general bit flips only when the increment count crosses a multiple of , so over increments it flips times. The total number of flips is

amortized cost less than per increment — the same the accounting scheme charges. Half of all the work happens in bit , a quarter in bit , and the tail vanishes geometrically.

Aggregate view of increments: bit flips times, so the flip counts halve across the columns and the total is a geometric series, — not the naive .

The accounting method's strength is its locality: credit lives on concrete elements (here, the -bits), and you verify the bound by checking that whoever pays an expensive operation's cost has the credit on hand.

binary_counter.pypython
class BinaryCounter:
  """
    A fixed-width binary counter over `width` bits, low bit first.\n
    `bits[0]` is the least significant bit. Increment flips a run of\n
    trailing 1s down to 0 (the carry) then sets the first 0 bit;\n
    overflow past the top bit wraps to zero. `cost` accumulates the total\n
    number of bit flips performed, the actual cost being analysed.\n
  """

  def __init__(self, width: int) -> None:
    if width < 0:
      raise ValueError("width must be non-negative")
    self.width: int = width
    self.bits: list[int] = [0 for _ in range(width)]
    self.cost: int = 0

  def increment(self) -> int:
    """
      Add one to the counter and return the number of bits flipped\n
      by this single operation (its actual cost).\n
    """
    flips: int = 0
    index: int = 0

    # flip the run of trailing 1s down to 0 — propagate the carry leftward.
    while index < self.width and self.bits[index] == 1:
      self.bits[index] = 0
      flips += 1
      index += 1

    # set the first 0 bit; skipped on a full-width overflow (wraps to 0).
    if index < self.width:
      self.bits[index] = 1
      flips += 1

    self.cost += flips
    return flips

  def value(self) -> int:
    """
      The non-negative integer the bits currently encode.\n
    """
    # sum the place values of the set bits.
    return sum(1 << index for index, bit in enumerate(self.bits) if bit)

  def popcount(self) -> int:
    """
      The number of 1-bits — equal to the credit banked by accounting.\n
    """
    return sum(self.bits)

  def __str__(self) -> str:

    # most significant bit first, matching how integers are written.
    return "".join(str(bit) for bit in reversed(self.bits))

Method 3: the potential method

The potential method is the most flexible and the one used most in practice. Instead of tracking credit on individual elements, it assigns the entire data structure a single number, the potential , that measures stored-up work. An operation's amortized cost is its actual cost plus the change in potential it causes.

Why this works: the potential changes telescope. Summing over the sequence,

Since , the trailing term is non-negative, so — precisely the amortized-cost definition. An expensive operation (large ) is affordable only if it drops the potential enough to offset itself; a cheap operation that builds potential pre-pays for the expensive one to come.

The dynamic array, one more time

Aggregate summed the sequence and accounting placed coins on items; the potential method compresses both into one function, and the same idea will reappear for deletion and for resizing hash tables. Choose the potential to measure how close the table is to overflowing:

Right after a doubling the table is half full ( before the pending insert lands), so is small; just before the next doubling it is completely full (), so — exactly enough banked potential to pay for copying all items. The potential is never negative because the table is always at least half full once it has grown. Plotted over a run of inserts, sawtooths: up by per cheap insert, falling back down whenever a doubling spends it.

The potential over the first inserts, drawn exactly. Each cheap insert raises by ; when the table fills (, the peaks at , , ), the next insert doubles the block and falls back to . The height of each fall is the potential the copy consumes, so the doubling's amortized cost stays at like everyone else's.

The doubling expense is invisible in the amortized cost: a doubling insert costs the same flat as a trivial one, because the potential it consumes ( drops from back to ) matches the potential the cheap inserts before it built up. That is the whole mechanism, and it is why the flat dashed line in the cost figure sits at no matter how tall the spikes.

The binary counter, one more time

The counter's accounting scheme kept a coin on every -bit; the corresponding potential is simply the coin count:

Suppose the -th increment resets trailing s to and then sets one bit to . Its actual cost is , and the number of -bits changes by . The amortized cost is

for every increment, no matter how long the carry chain: a long chain has a large and an equally large potential drop, and the two cancel except for the constant. (If the counter overflows — all bits are and the increment clears them all — then , the potential drops by , and the amortized cost is .) Since and , the telescoped total is : the same bound as before, now with no coins to track.

Why geometric growth wins. Doubling spaces the reallocations exponentially, so the copy sizes sum to less than (amortized ). Adding a fixed increment reallocates every inserts, copying , which sums to (amortized ).
dynamic_array.pypython
from typing import Generic, Optional, TypeVar

Item = TypeVar("Item")

class DynamicArray(Generic[Item]):
  """
    A growable, array-backed list that doubles its block when full.\n
    Backed by a flat list of fixed `capacity` whose first `count` slots\n
    hold the live items — the canonical array representation, not nested.\n
    `cost` totals the elementary work (writes plus copies) so the\n
    O(n)-for-n-appends bound can be checked against an executed run.\n
  """

  def __init__(self, growth_factor: int = 2) -> None:
    if growth_factor < 2:
      raise ValueError("growth_factor must be at least 2")
    self.growth_factor: int = growth_factor
    self._slots: list[Optional[Item]] = []
    self._count: int = 0
    self.cost: int = 0

  def __len__(self) -> int:
    return self._count

  @property
  def capacity(self) -> int:
    """
      The number of slots in the current backing block.\n
    """
    return len(self._slots)

  def potential(self) -> int:
    """
      The potential Phi = 2*count - capacity used in the amortized proof.\n
      Non-negative once the array has grown (it stays at least half full).\n
    """
    return 2 * self._count - self.capacity

  def _resize(self, new_capacity: int) -> None:
    """
      Move the live items into a fresh block of `new_capacity` slots,\n
      charging one unit per item copied — the expensive step.\n
    """
    # copy each live item into the new block, charging one unit per move.
    new_slots: list[Optional[Item]] = [None for _ in range(new_capacity)]
    for index in range(self._count):
      new_slots[index] = self._slots[index]
      self.cost += 1

    self._slots = new_slots

  def append(self, value: Item) -> None:
    """
      Add `value` to the end, growing the block first if it is full.\n
    """
    # grow the block (geometrically) when it is full, before the write.
    if self._count == self.capacity:
      grown: int = self.capacity * self.growth_factor
      new_capacity: int = 1 if self.capacity == 0 else grown
      self._resize(new_capacity)

    # write into the next free slot in O(1).
    self._slots[self._count] = value
    self._count += 1
    self.cost += 1

  def get(self, index: int) -> Item:
    """
      The item at `index`, supporting Python-style negative indices.\n
    """
    # normalise a negative index, then bounds-check against live items.
    position: int = index + self._count if index < 0 else index
    if not 0 <= position < self._count:
      raise IndexError("index out of range")

    value = self._slots[position]
    assert value is not None
    return value

  def __getitem__(self, index: int) -> Item:
    return self.get(index)

  def to_list(self) -> list[Item]:
    """
      A plain list of the live items in insertion order.\n
    """
    # walk the live prefix, asserting each slot was filled.
    result: list[Item] = []
    for index in range(self._count):
      value = self._slots[index]
      assert value is not None
      result.append(value)

    return result

class LinearGrowthArray(Generic[Item]):
  """
    A dynamic array that grows by a fixed `increment` of slots rather than\n
    multiplying — the counter-example showing why geometric growth matters.\n
    Reallocating every `increment` appends copies c, 2c, 3c, ... items,\n
    which sum to Theta(n^2 / increment): amortized Theta(n) per append, not\n
    O(1). Same interface as `DynamicArray`, with `cost` for comparison.\n
  """

  def __init__(self, increment: int = 1) -> None:
    if increment < 1:
      raise ValueError("increment must be at least 1")
    self.increment: int = increment
    self._slots: list[Optional[Item]] = []
    self._count: int = 0
    self.cost: int = 0

  def __len__(self) -> int:
    return self._count

  @property
  def capacity(self) -> int:
    """
      The number of slots in the current backing block.\n
    """
    return len(self._slots)

  def append(self, value: Item) -> None:
    """
      Add `value`, growing by a fixed increment when the block is full.\n
    """
    # when full, grow by a fixed increment and copy every item across.
    if self._count == self.capacity:
      grown: int = self.capacity + self.increment
      new_slots: list[Optional[Item]] = [None for _ in range(grown)]
      for index in range(self._count):
        new_slots[index] = self._slots[index]
        self.cost += 1
      self._slots = new_slots

    # write into the next free slot.
    self._slots[self._count] = value
    self._count += 1
    self.cost += 1

  def to_list(self) -> list[Item]:
    """
      A plain list of the live items in insertion order.\n
    """
    # walk the live prefix, asserting each slot was filled.
    result: list[Item] = []
    for index in range(self._count):
      value = self._slots[index]
      assert value is not None
      result.append(value)

    return result

Choosing a method

All three methods prove the same bound; the choice is one of convenience.

Aggregate and accounting can be seen as special cases of the potential method, with the potential playing the role of remaining budget and total stored credit respectively, so when in doubt, reach for a potential function. The array and counter examples above show the translation: the counter's (count of -bits) is its total accounting credit, and the array's equals the credits held by items inserted since the last doubling.

When an amortized bound is not enough

An amortized insert still permits a single insert that costs . For throughput (total work over the whole sequence) that is irrelevant. For latency it can be fatal: an audio callback, a game frame, a real-time control loop, or a packet-processing path that stalls for one copy misses its deadline, and the average is irrelevant to that one pause. Amortized analysis answers how much work in total, not how long is the longest pause.

When the pause matters, the standard remedy is to de-amortize: keep both the old and the new block during growth and move a constant number of items on every subsequent insert, so the copy finishes before the new block itself fills. Every operation then costs in the worst case, at the price of extra space and constant-factor overhead. Real-time and latency-sensitive systems pay that price; everything else takes the simpler amortized structure.

Where amortized analysis recurs

This machinery recurs throughout the course. It is the only accurate way to state the running time of several data structures you will meet later:

  • Union–Find. With union by rank and path compression, a sequence of operations runs in amortized time, where is the inverse Ackermann function — effectively constant. The proof is a sophisticated potential argument.
  • Hash tables. A hash table that doubles (and halves) its bucket array to keep the load factor bounded uses exactly the argument above, giving amortized insert and delete.
  • Dynamic arrays (vector, ArrayList, Python list) are the doubling table itself; their amortized append is what makes them the default sequence container.3

The recurring moral: when worst-case-per-operation overcounts because expensive steps are rare and self-limiting, amortize.

Persistence and competitive analysis

Two threads extend the machinery here. The first is persistent and functional data structures. The accounting and potential methods assume the structure is used linearly — each version replaces the last — so banked credit is spent at most once. When an old version can be reused (as in a purely functional setting, where nothing is mutated in place), a naive amortized bound breaks, because an adversary can repeatedly force the one expensive operation the credit was saved for. Okasaki's work resolves this with lazy evaluation and scheduling: memoized thunks make the expensive step happen only once even under reuse, restoring the amortized bound and yielding functional queues and deques with the same amortized costs as their imperative cousins.4 His names for the accounting and potential methods — the banker's and physicist's methods — are now standard.

The second thread is competitive analysis, which applies the same average-over-a-sequence idea to online algorithms that must respond to each request before seeing the next. The self-adjusting splay tree of Sleator and Tarjan achieves amortized cost per operation through a potential argument almost identical to the ones above, and their move-to-front and paging results launched the study of how well an online algorithm can do against an adversary that knows the future.5 Amortized analysis, in short, is the entry point to a much larger theory of sequences of operations rather than single ones.

Takeaways

  • Amortized cost is the worst-case average over a sequence: assign with for every sequence. It is not expected or average-case — there is no probability anywhere, and the bound survives an adversarial input.
  • Aggregate: bound the whole sequence's cost, divide by . For the array, writes plus copies give a total under ; multipop is amortized because total pops total pushes; the counter flips bits.
  • Accounting: overcharge cheap operations, bank the surplus as credit on elements, spend it on expensive ones; keep credit . Charge per append (write, own future copy, one older item's copy) or per increment (flip a bit up, bank a coin on it).
  • Potential: encode banked work as ; then , and the changes telescope. Doubling arrays: , amortized . The counter: number of -bits, amortized .
  • Geometric growth is essential: doubling makes the copy cost telescope to a constant; growing by a fixed increment gives amortized.
  • An amortized bound is a throughput guarantee, not a latency guarantee: individual operations may still stall for . Real-time systems de-amortize (incremental copying) to get worst-case at a constant-factor price.
  • The technique underpins union–find, resizable hash tables, and every dynamic array.

Footnotes

  1. CLRS, Introduction to Algorithms, Ch. 16 — Amortized Analysis: the aggregate, accounting, and potential methods, with the multipop stack, binary counter, and dynamic table as the chapter's running examples.
  2. Erickson, Algorithms — the amortized-analysis chapter frames the accounting method as taxation and derives the potential method from it; the binary counter and multipop stack appear there in the same roles.
  3. Skiena, The Algorithm Design Manual, §3.4 — Dynamic Arrays: the doubling construction and its amortized append.
  4. Okasaki, C. (1998). Purely Functional Data Structures. Cambridge University Press — lazy evaluation and scheduling to make amortized bounds hold under persistence; the banker's and physicist's methods.
  5. Sleator, D. D. & Tarjan, R. E. (1985). Self-adjusting binary search trees. Journal of the ACM 32(3) — splay trees and the potential argument for amortized operations; Sleator & Tarjan (1985), Amortized efficiency of list update and paging rules, CACM 28(2), on competitive analysis.
Practice

╌╌ END ╌╌