On this page

Amortized Analysis

A thousand appends

An array has a fixed number of slots, its capacity. A growable list hides that limit. When an append finds every slot taken, the list allocates a bigger array, copies every element across, and only then writes the new one. Throughout this module the cost is one unit per element written, removed or copied. Allocating the new block is charged nothing, because it is one call however big the block is.

There are two obvious ways to choose "bigger": add a few slots, or double. Start from capacity 1 and append a thousand elements:

  • growing by 4 slots at a time costs 125,750 units;
  • doubling costs 2,023.

Both rules have appends that copy the entire array, so a single append can be just as expensive under either. Yet one total is sixty times the other, and the gap keeps widening as the list grows. This module is about the kind of guarantee that tells them apart.

def append_costs(n, grow, cap=1):
    """Cost of each of n appends: 1 write, plus a copy of
    every element when the array is full."""
    costs, size = [], 0
    for _ in range(n):
        cost = 1
        if size == cap:
            cost += size          # copy everything into the new block
            cap = grow(cap)
        size += 1
        costs.append(cost)
    return costs

double = lambda cap: 2 * cap
plus4 = lambda cap: cap + 4

assert sum(append_costs(1000, plus4)) == 125_750
assert sum(append_costs(1000, double)) == 2_023

On a small scale the two rules look alike. Here are the first seventeen appends under each:

×2 +4 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 1 2 3 1 5 1 1 1 9 1 1 1 1 1 1 1 17 1 2 1 1 1 6 1 1 1 10 1 1 1 14 1 1 1
The cost of appends 1 to 17 from capacity 1: doubling (top row, total 48) and growth by 4 slots (bottom row, total 45). Highlighted appends copy: doubling copies 1 + 2 + 4 + 8 + 16 = 31 elements, growth by 4 copies 1 + 5 + 9 + 13 = 28. At n = 17 the constant rule is even slightly cheaper.
d17 = append_costs(17, double)
a17 = append_costs(17, plus4)
assert d17 == [1, 2, 3, 1, 5, 1, 1, 1, 9, 1, 1, 1, 1, 1, 1, 1, 17]
assert a17 == [1, 2, 1, 1, 1, 6, 1, 1, 1, 10, 1, 1, 1, 14, 1, 1, 1]
assert sum(d17) == 48 and sum(a17) == 45

Analysing one append at a time

The natural first analysis looks at one append in isolation. The worst append copies everything, which is up to n−1 elements, so n appends cost O(n2). That is true under both rules.

For growth by a constant it is also the right answer. With c new slots per resize, the resizes come every c appends and copy 1,1+c,1+2c,… elements. That is an arithmetic series, and it sums to about n2/(2c). For c=4 and n=1000 this estimate gives 125,000 copies, and the exact count is 124,750. A bigger constant only divides the quadratic by c.

For doubling, the per-append bound is far too pessimistic. The analysis charges every append the price of the rarest one. After a doubling to capacity 2c, the next expensive append is c appends away, and the copy it makes is only as big as the number of appends since the one before. Expensive appends are paid for by the cheap appends between them. Making "paid for by" precise is what the rest of this module does.

The model: amortized cost

Definition

A data structure has amortized cost c per operation if every sequence of k operations, starting from the empty structure, costs at most k·c in total (plus a constant that does not depend on k).

Three kinds of bound appear in this course, and they are easy to mix up:

guarantee promises example
worst case every single operation is cheap a plain array write, O(1)
expected the average over the algorithm's own coins, for every input quickselect (Selection in Linear Time), universal hashing (Universal and Perfect Hashing)
amortized the total over every sequence, divided by its length doubling append, <3

An amortized bound involves no probability and no average over inputs. The sequence may be chosen by an adversary who knows the code. What it gives up is a guarantee for any one operation. The 1,025th append to a doubling array copies 1,024 elements, and the amortized bound does not say otherwise. It only says that someone has already paid for that copy.

Method 1: add it all up

The aggregate method bounds the total directly.

Theorem. n appends to a doubling array that starts at capacity 1 cost less than 3n.

Proof. There are n writes. A copy happens when the array is full, at sizes 1,2,4,…,2j, where 2j≤n−1. These copies total 1+2+…+2j=2j+1−1<2n. Adding the writes gives less than 3n. ◻

On the seventeen appends above, the copies are 1+2+4+8+16=31 and the total is 17+31=48≤51. The series here is geometric. Each copy is as large as all the earlier copies put together, so the last copy dominates the sum. The arithmetic series of the constant rule has no such term.

assert all(sum(append_costs(n, double)) < 3 * n for n in range(1, 2000))

The aggregate method needs a formula for the whole sequence. Once pops can come in any order between the appends, that formula is hard to write down. The next two methods reason about one operation at a time, and they still bound the total.

Method 2: prepay every append

The banker's method charges every operation a fixed price, which may be more than it costs. The surplus is saved as credits stored on the structure, and an expensive operation spends them. If no credit balance ever goes negative, the total actual cost is at most the total charged.

For doubling, charge 3 per append. One unit pays for the write, and two are stored on the new element. A resize spends one credit per element it copies, taking them from the newest elements first. Here is the array from capacity 1 after sixteen appends, just before append 17 doubles it:

cell credit 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 e15 1 0 0 0 0 0 0 0 2 2 2 2 2 2 2 2
Size 16, capacity 16. The eight elements appended since the last resize (highlighted) hold 2 credits each: 16 credits, exactly the bill for copying 16 elements. The 1 on cell 0 is left over from the very first resize.

Invariant (banker's)

Every element appended since the last resize holds 2 credits.

Why it holds. A resize from capacity c≥2 happens when size reaches c. The previous resize happened when size was c/2, so c/2 elements have been appended since then, and they hold c credits. That is exactly one credit per element to copy. After the copy they hold none, and the new array is half empty, ready for the next c appends to fill it with 2c fresh credits. The very first resize, from capacity 1, has one element with 2 credits against a bill of 1, which is the leftover credit in the figure. The balance never goes negative, so n appends cost at most 3n.

bank = 3 * 16 - sum(d17[:16])   # charged minus spent, after 16 appends
assert bank == 17
assert d17[16] - 1 == 16         # the 17th append copies 16 elements

Method 3: a potential

Credits scattered over cells are awkward to track. The potential method replaces them with a single number Φ computed from the state of the structure. Define

amortizedi=actuali+Φi−Φi−1.

Adding up over k operations, the middle terms cancel (the sum telescopes):

∑i=1kactuali=∑i=1kamortizedi+Φ0−Φk.

So if Φ never falls below its starting value Φ0, the total actual cost is at most the total amortized cost. The usual way to arrange that is Φ≥0 with a small Φ0. This is the step not to skip. A Φ that can end below where it started proves nothing, however neat the per-operation algebra looks.

Doubling. Take Φ=2·size−cap. An append that does not resize has actual cost 1 and raises Φ by 2, so its amortized cost is 3. An append to a full array of capacity cap has actual cost 1+cap, while Φ changes by 2−cap: size goes up by one, and the capacity doubles. The amortized cost is 3 again. Doubling keeps the array at least half full after the first append, so Φ stays at or above its start.

A binary counter. A counter of bits starts at 0 and is incremented k times. An increment flips the trailing 1s to 0 and the next 0 to 1, and each flip costs 1. The worst increment flips every bit. Take Φ = the number of 1 bits. An increment that clears t trailing ones costs t+1 and changes Φ by 1−t, so it costs 2 amortized, whatever t is. From 0, Φ0=0 and Φk is the number of ones in k, so the total is exactly 2k−(ones in k)<2k.

flips Φ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1 2 1 3 1 2 1 4 1 2 1 3 1 2 1 5 1 1 2 1 2 2 3 1 2 2 3 2 3 3 4 1
Increments 1 to 16 of a counter that starts at 0: bits flipped (top, total 31) and Φ, the number of 1 bits afterwards (bottom). Highlighted: the increments that flip 3 or more bits, and Φ just before each. Φ climbs before each expensive increment and falls as it pays: it is 4 before increment 16, which flips 5 bits and leaves Φ = 1. Flips + ΔΦ = 2 in every column.
def flips(k):
    """Bits flipped by each of k increments from 0.
    x ^ (x + 1) has a 1 exactly where the increment flips a bit."""
    return [bin(x ^ (x + 1)).count("1") for x in range(k)]

f16 = flips(16)
ones = [bin(x).count("1") for x in range(17)]   # Φ after x increments
assert f16 == [1, 2, 1, 3, 1, 2, 1, 4, 1, 2, 1, 3, 1, 2, 1, 5]
assert sum(f16) == 31
assert [i + 1 for i, f in enumerate(f16) if f >= 3] == [4, 8, 12, 16]   # highlighted
assert all(f + ones[i + 1] - ones[i] == 2 for i, f in enumerate(f16))
assert all(sum(flips(k)) == 2 * k - bin(k).count("1")
           for k in range(1, 2000))
Predict: the sixteenth increment alone flips 5 bits. Does that contradict an amortized cost of 2?

No. Φ was 4 just before it (the counter read 01111), and afterwards it is 1 (10000). The increment costs 5 and Φ drops by 3, so it is charged 5−3=2. The 3 units of potential were saved by the earlier increments that set those bits.

Popping, and a rule that thrashes

A real list also shrinks, or a list that once held a million elements keeps a million slots for ever. Add pop, which removes the last element for a cost of 1. The mirror image of doubling seems natural: halve the capacity when the array becomes half full.

It fails on a sequence that sits on the boundary. Fill an array to size 8 in capacity 8, then alternate append and pop:

  • the append finds the array full, so it doubles to 16 and copies 8: cost 9;
  • the pop leaves 8 elements in 16 slots, which is half full, so it halves to 8 and copies 8: cost 9;
  • the next append finds the array full again, and so on.

Every operation costs 9. At size 2j every operation costs about 2j, so this sequence costs Θ(n) per operation for as long as it runs. The array thrashes. No accounting can rescue the rule, because the total really is quadratic.

def step(size, cap, op, shrink_at=4):
    """One append ('a') or pop ('p'), tracking only size and capacity.
    A stand-in for the real array: it returns the cost and stores
    nothing. Grow when full; shrink when a pop leaves
    shrink_at * size <= cap; never below capacity 2."""
    if op == "a":
        cost = 1
        if size == cap:
            cost += size
            cap *= 2
        return size + 1, cap, cost
    size, cost = size - 1, 1
    if cap > 2 and shrink_at * size <= cap:
        cost += size
        cap //= 2
    return size, cap, cost

def costs(ops, shrink_at=4):
    size, cap, out = 0, 2, []
    for op in ops:
        size, cap, cost = step(size, cap, op, shrink_at)
        out.append(cost)
    return out

wiggle = "a" * 8 + "apapap"
# halve at one half: thrash.  Halve at one quarter: calm.
assert costs(wiggle, shrink_at=2)[8:] == [9, 9, 9, 9, 9, 9]
assert costs(wiggle, shrink_at=4)[8:] == [9, 1, 1, 1, 1, 1]

The fix is to leave a gap. Double when an append finds the array full. Halve when a pop leaves it a quarter full (size ≤ cap/4), never going below capacity 2. After a resize in either direction the array is exactly half full. From there it takes at least cap/2 appends to fill it, or about cap/4 pops to empty it to a quarter, before the next resize.

One potential for both directions

The doubling potential 2·size−cap goes negative as soon as the array is less than half full, so it cannot pay for shrinking. The potential that works measures the distance from half full, scaled so that it reaches the copy bill at both thresholds:

Φ={2size−capif 2size≥cap,cap/2−sizeotherwise.

Invariant

Φ≥0 in every state. Every resize leaves the array exactly half full, where Φ=0 (a growing append then writes its element, so Φ=2). By the time the next resize comes, Φ has grown to pay its copies: Φ=cap before a growing append, and Φ=cap/4−1 before a shrinking pop, which copies cap/4 elements.

Here it is on nine appends followed by seven pops, from an empty array of capacity 2:

op size cap cost Φ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 a a a a a a a a a p p p p p p p 1 2 3 4 5 6 7 8 9 8 7 6 5 4 3 2 2 2 4 4 8 8 8 8 16 16 16 16 16 8 8 4 1 1 3 1 5 1 1 1 9 1 1 1 1 5 1 3 0 2 2 4 2 4 6 8 2 0 1 2 3 0 1 0
Nine appends (a) then seven pops (p) from an empty array of capacity 2, one column per operation, with the state after it. The resizes (highlighted costs) cost 3, 5, 9 on the way up and 5, 3 on the way down, and each one leaves Φ = 0 or 2. The column before each resize (highlighted Φ) shows the potential that pays for it. Total cost 36, within 3 × 16 + 1 = 49.

Checking the invariant means checking every kind of operation. Write s for the size before the operation:

operation actual change in Φ amortized
append, s< cap, array at least half full 1 +2 3
append to a full array (grows) 1+cap 2−cap 3
append to an array less than half full 1 −1 0
pop that leaves it at least half full 1 −2 −1
pop that leaves it less than half full, no shrink 1 +1 2
pop that shrinks (leaves size cap/4) 1+cap/4 −(cap/4−1) 2

The growing append is the doubling argument again. For the shrinking pop, the size before the pop is cap/4+1, so Φ=cap/2−cap/4−1=cap/4−1. After it, cap/4 elements sit in cap/2 slots, exactly half full, and Φ=0. Every amortized cost is at most 3, and Φ≥0 everywhere with Φ0=1 for the empty array of capacity 2.

Theorem. With grow-when-full and halve-at-one-quarter, every sequence of k appends and pops from the empty array costs at most 3k+1.

def phi(size, cap):
    return 2 * size - cap if 2 * size >= cap else cap // 2 - size

def trace(ops):
    """Rows (op, size, cap, cost, Φ after, amortized cost)."""
    size, cap, rows = 0, 2, []
    for op in ops:
        before = phi(size, cap)
        size, cap, cost = step(size, cap, op)
        after = phi(size, cap)
        rows.append((op, size, cap, cost, after, cost + after - before))
    return rows

rows = trace("a" * 9 + "p" * 7)
cost = [r[3] for r in rows]
pot = [r[4] for r in rows]
assert cost == [1, 1, 3, 1, 5, 1, 1, 1, 9, 1, 1, 1, 1, 5, 1, 3]
assert pot == [0, 2, 2, 4, 2, 4, 6, 8, 2, 0, 1, 2, 3, 0, 1, 0]
assert all(r[4] >= 0 and r[5] <= 3 for r in rows)
assert sum(r[3] for r in rows) == 36 <= 3 * 16 + 1
assert 2 * 3 - 8 == -2 and phi(3, 8) == 1   # one pop past the shrink
Predict: why not keep the simpler potential 2 · size − cap for the shrinking array?

It goes negative. One pop after the shrink to capacity 8, the array holds 3 elements and 2·3−8=−2. The telescoping sum then no longer bounds the actual cost. The two-piece Φ is 1 in that state.

The implementation

The real array owns a block of cap slots and a count size. One helper, resize(new_cap), allocates a new block and copies the size elements across. It is the only line that costs more than 1. append resizes to twice the capacity if the array is full, then writes. pop removes the last element, then checks whether the array is now a quarter full and, if the capacity is above 2, resizes to half. Two details matter. The shrink test runs after the removal, and the minimum capacity stops a tiny array from shrinking to nothing. The lab has you build it and check the potential after every operation.

Complexity

  • Model: one unit per element written, removed or copied; allocating a block is O(1).
  • Doubling, appends only: n appends cost less than 3n. Growth by a constant c: about n2/(2c), so Θ(n2).
  • Grow when full, halve at one quarter: every sequence of k operations, starting from the empty array, costs at most 3k+1, which is amortized O(1) per operation. A single operation can still cost Θ(n). Space: once the array holds anything, it has fewer than 4·size slots.
  • Halve at one half: Θ(n) per operation on a boundary sequence.
  • Optimality: every append writes its element, so Ω(1) per operation is unavoidable. The constant 3 is not the point. The point is that n has disappeared from the cost per operation.

When one slow operation is unacceptable, as in an audio callback, the cost can be de-amortized, at least for appends. When the array becomes half full, allocate the double-size block early, and let every later append also move two old elements across. By the time the old block is full, the new one already holds everything. Each append then costs O(1) in the worst case, at the price of keeping two blocks alive. This is sketched, not proved, here.

Measure the claim

Cost per append, over n appends, for three growth rules. Rule "CPython" is described in the next section:

n doubling CPython's rule growth by 4
1,000 2.023 8.556 125.75
4,000 2.024 8.829 500.75
16,000 2.024 9.400 2,000.75

Doubling sits just above 2, under its bound of 3. Growth by 4 grows linearly with n, as the arithmetic series says it must. The middle column stays below 10 without growing, because its capacities also form a geometric series, only with ratio about 9/8 instead of 2. All the earlier copies put together are then about 8 times the latest one, rather than equal to it, so the copies total about 9 times the last copy, which is less than 9n. These are measurements on appends only, in a model where every growth copies. The theorem covers every sequence.

def cpython_grow(cap):
    """New allocation when an append finds a CPython list full."""
    new_size = cap + 1
    return (new_size + (new_size >> 3) + 6) & ~3

def per_append(n, rule, start):
    return round(sum(append_costs(n, rule, start)) / n, 3)

rules = ((double, 1), (cpython_grow, 0), (plus4, 1))
table = {n: [per_append(n, *r) for r in rules]
         for n in (1000, 4000, 16000)}
assert table == {1000: [2.023, 8.556, 125.75],
                 4000: [2.024, 8.829, 500.75],
                 16000: [2.024, 9.4, 2000.75]}
assert all(per_append(n, cpython_grow, 0) < 10 for n in range(1, 20001))

What CPython does

CPython's list is a dynamic array, and its growth rule is the function list_resize in Objects/listobject.c. When an append needs room for new_size elements, it allocates new_size + new_size // 8 + 6 slots, rounded down to a multiple of 4. From an empty list the allocations are 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, and so on. That is growth by about one eighth, a constant factor above 1, so append is amortized O(1) with a larger constant than doubling. (CPython hands the resize to the memory allocator's realloc, which can sometimes extend a block without copying, so our copy count is an upper bound on its work.)

Shrinking leaves a gap too. list.pop calls the same function, and it reallocates only when the size falls below half of the allocation. It then allocates about 9/8 of the new size, not half of the old allocation, so the next append finds spare room and nothing thrashes.

import struct, sys

caps = [0]
while len(caps) < 10:
    caps.append(cpython_grow(caps[-1]))
assert caps == [0, 4, 8, 16, 24, 32, 40, 52, 64, 76]

if sys.implementation.name == "cpython" and sys.version_info >= (3, 12):
    ptr = struct.calcsize("P")
    slots = lambda lst: (sys.getsizeof(lst) - sys.getsizeof([])) // ptr
    lst, seen = [], [0]
    for i in range(80):
        lst.append(i)
        if slots(lst) != seen[-1]:
            seen.append(slots(lst))
    assert seen[:10] == caps          # the interpreter agrees
    lst = list(range(1000))
    while slots(lst) == 1000:
        lst.pop()
    # shrinks below half full, to about 9/8 of the size
    assert len(lst) == 499 and slots(lst) == 564
    for _ in range(100):              # wiggle there: no reallocation
        lst.append(0)
        lst.pop()
        assert slots(lst) == 564

A problem that looks different

A write-ahead log appends records to a file. Records die when they are superseded, but the file keeps their bytes. Whenever dead records outnumber live ones, a compaction pass rewrites every live record into a fresh file. One write can therefore trigger a rewrite of millions of records. What does a write cost over any sequence of writes and supersessions, and what would you charge each write so that compaction is always paid for? Nothing here mentions an array. The lab's last problem is a different one.

Practise

In the lab you prepay appends with credits and watch every cell's balance frame by frame, predict and then trace a binary counter that does not start at zero, build the growing and shrinking array with the potential checked after every operation, construct the sequence that makes the half-full rule thrash and measure it, and finish with a problem that doesn't say what it is.

Recap

  • You can now: define amortized cost as a bound on every sequence and tell it apart from expected and worst-case cost; prove the doubling bound by the aggregate, banker's and potential methods; design a potential that pays for resizing in both directions; and explain why halving at one half thrashes.
  • Invariant: Φ≥0, every resize leaves the array half full where Φ=0, and Φ has grown to pay the copies by the time the next resize comes.
  • Complexity achieved: at most 3k+1 units for any k appends and pops from the empty array (amortized O(1)), against Θ(n) amortized per append for growth by a constant, and Θ(n) per operation on a boundary sequence for halving at one half.
  • Failure mode: shrinking at the same threshold as growing, so that one boundary sequence resizes on every operation; or a potential that can fall below its start.
  • In real software: CPython's list_resize grows a list by about one eighth plus a small constant, and shrinks only below half full, to about 9/8 of the size.
  • Retrieval: the chained dictionary from Universal and Perfect Hashing doubles its table and redraws the hash function whenever n>m, rehashing every key. What does one insert cost in the worst case, and what does an insert cost over any sequence?

Check yourself

  1. Why does the grow-and-shrink array have amortized cost at most 3, and why does its potential need two pieces?
  2. Grow by 50% (cap←⌈1.5·cap⌉) instead of doubling. Is append still amortized O(1)? Give a potential, or a counterexample.
  3. Compare amortized O(1) appends with the de-amortized array that is O(1) in the worst case. When would you pay for the second, and what does it cost?

Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…

In this lab

  1. Prepay every append, and watch the credits

    An array starts with capacity 3 and doubles whenever an append finds it full. Every append is charged 3. One unit pays for writing the new element, and the other 2 are stored as credit on the new element's cell. A resize costs one unit per element it copies, and it pays with credits, taking them from the newest cells first. That is the banker's method from the lesson, on a different start.

    BankedArray already doubles, copies and writes, and it keeps charged and spent. Your part is the credit. First, when a resize happens, pay its bill of self.size credits from the cells, newest first. Second, after the write, store the 2 unspent units on the new cell. No cell may ever go below zero, and the credits on the cells must always add up to charged − spent.

    The program makes 12 appends and draws a frame after each: the cells on top, each cell's credit below, and the bank. Before you press Run, answer the question. Then scrub to append 7 and look at which cells paid for its 6 copies.

  2. Trace a counter that does not start at zero

    A binary counter keeps its bits in a list, most significant bit first, so [0, 1, 0, 1, 1, 1] is 23. Each flip of a bit costs 1. This counter does not start at zero, and that changes what the potential method promises.

    Answer the two questions first, on paper. Then write increment(bits): add 1 in place by flipping the trailing 1s to 0 and the next 0 to 1, and return the number of bits flipped. If every bit is 1, the counter wraps around to all zeros.

    The program draws the start and one frame per increment. The flipped bits are highlighted, and the potential Φ is the number of 1 bits. In every frame, check that the flips plus the change in Φ come to exactly 2.

  3. Build the growing and shrinking array

    Now build the array itself. Memory comes only in fixed blocks: Slots(n) is a block of n slots that cannot grow, and every write to a slot costs 1 (Slots.writes counts them). So writing an element costs 1, removing one (setting its slot to None) costs 1, and copying one into a new block costs 1, which is the lesson's cost model, counted in your own code.

    DynamicArray keeps its elements in self.data, a Slots block of exactly self.cap slots, with self.size of them in use. The starter is correct, but it grows by one slot at a time and never shrinks. Make it double when an append finds it full, and halve when a pop leaves it a quarter full (4 * size <= cap), never going below capacity 2.

    The program runs 5 appends, 2 pops, 5 appends and 8 pops, and draws a frame after each: the block, the size, the cost, and the lesson's potential Φ. Answer the question before you run. The checks read your frames and also count your writes on random sequences, and after every operation they require Φ ≥ 0 and cost + ΔΦ ≤ 3.

  4. Make the half rule thrash, and measure both rules

    The lesson showed the half rule thrashing on one small wiggle. Now build the sequence that makes it thrash at any size, and measure both rules with your own counts.

    Simulated(shrink_at) is the growing array reduced to its size and capacity, starting at capacity 2. Its append() and pop() return what each operation costs. With shrink_at=4 it halves when a pop leaves it a quarter full, as in the lesson. With shrink_at=2 it halves as soon as a pop leaves it half full.

    Write three functions. First, boundary_ops(k): exactly k operations that append until the array is full at the smallest power of two that is at least k/2, then alternate append, pop, append, pop. Second, random_ops(k, rng): k operations where a non-empty array is popped with probability 0.45 and otherwise appended to, drawing only from rng. Third, cost_per_op(shrink_at, ops): run ops on a fresh Simulated(shrink_at) and return the total cost divided by len(ops).

    profile then runs four sequences on the quarter rule (k appends; k/2 appends then k/2 pops; a random sequence; your boundary sequence) and keeps the worst, and runs your boundary sequence on the half rule, at k = 500, 2,000 and 8,000. Before you run: what will the half rule cost per operation at k = 8,000?

  5. Mastery challenge: the render farm's broker

    A render farm hands jobs out to its machines in the order the jobs were submitted. The broker's storage layer is old and offers only piles. push(job) puts a job on top, pop() takes the top job off, and is_empty() is free. Each push or pop costs one unit, and nothing else costs anything. The broker has two piles.

    The operations team signed an agreement on totals. Over any stretch of k requests (submits, and requests for the next job, which never come when nothing is waiting), the piles may do at most 4k operations. A single request may be slow, but the total may not. Traffic reaches a million requests a day. The checks send 200,000 requests in one go, plus request patterns chosen to hurt, and stop your broker as soon as it goes over.

    The starter is correct, but for every next job it pours all the waiting jobs onto the spare pile to reach the oldest, then pours them back. Answer the four questions, then rewrite JobBroker. Keep the jobs only in the two piles, and use a pile only through push, pop and is_empty: after every request the checks count what your pushes and pops left in the piles, and it must equal the number of jobs waiting. The program serves 12 requests and draws both piles at the end.