Amortized analysis
Measure the average cost of each operation over a long run, so a rare expensive step is shared by many cheap ones.
O(1) amortized per pushSpace O(n)2 practice problemsStep-through pictureThe idea
Some operations are usually cheap but sometimes expensive. A JavaScript array's push is the classic case: most pushes just write into a free slot, but when the array is full it must get a bigger block of memory and copy every item across.
Amortized analysis looks at the total cost of many operations together, then divides by how many there were. If the expensive steps are rare enough, the average per operation is small, even though one single operation can be slow.
The trick that makes push cheap on average is doubling: when the array is full, make it twice as big. The code here is a tiny dynamic array that counts every copy, so you can see that n pushes cause fewer than 2n copies in total.
An everyday example
Moving to a bigger apartment
You collect records. Your shelf holds 1 record. When it is full, you buy a shelf twice as big and move every record onto it. Moving is tiring, but each move gives you as much empty space as you have records. After moving 8 records to a 16-slot shelf, you get 8 more records with no moving at all. Over time, each record has been moved fewer than 2 times on average.
| In the example | In the code |
|---|---|
| How many records the shelf holds | this.capacity |
| How many records you own | this.length |
| Buying a shelf twice as big | this.capacity *= 2 |
| Moving each record to the new shelf | bigger[i] = this.slots[i] and this.copies++ |
| Putting a new record in a free slot | this.slots[this.length] = value |
From the slow way to amortized analysis
- Start with the simplest growing array: keep exactly as many slots as items. Every
pushmakes a new array one slot bigger and copies everything across. Thek-th push copiesk - 1items, so 1,000 pushes copy0 + 1 + ... + 999 = 499,500items. That isO(n^2)work fornpushes. - The waste is copying on every push. So leave spare room: grow by 10 slots whenever the array is full. Now a copy happens only every 10 pushes. It helps, but not enough: 1,000 pushes still copy
10 + 20 + ... + 990 = 49,500items. Each copy is still as big as the array, and copies still come at a fixed rhythm. - Make the spare room grow with the array: when it is full, double
this.capacity. A copy ofkitems now leaveskempty slots, so the nextkpushes copy nothing. Big copies happen, but each one buys as many free pushes as it cost. With doubling, 1,000 pushes copy only 1,023 items. - Judging this one push at a time is misleading: a single push can copy everything, yet the total over many pushes is small. So count the total work of a long sequence and divide by the number of operations. That way of counting is amortized analysis.
How it works
- Start with room for one item:
this.capacity = 1andthis.length = 0. - To
push(value), first check whether the array is full:this.length === this.capacity. - If it is full, double
this.capacityand make a new arraybiggerof that size. - Copy each existing item into
bigger, adding 1 tothis.copiesfor each one, then usebiggerasthis.slots. - Write
valueinto the next free slot and add 1 tothis.length. - Over
npushes, copies happen only at sizes 1, 2, 4, 8, and so on, which add up to less than2n.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
class DynamicArray {constructor() {this.capacity = 1;this.length = 0;this.slots = new Array(1);this.copies = 0;}push(value) {if (this.length === this.capacity) {this.capacity *= 2;const bigger = new Array(this.capacity);for (let i = 0; i < this.length; i++) {bigger[i] = this.slots[i];this.copies++;}this.slots = bigger;}this.slots[this.length] = value;this.length++;}}
How fast it is
| Time | O(1) amortized per push |
|---|---|
| Extra memory | O(n) |
n pushes cause fewer than 2n copies in total, so each push costs a constant amount on average. The array never holds more than twice as many slots as items.
- Best case
O(1)A push into an array with a free slot, like the 4th, 6th, and 7th pushes in the trace: one write, no copy.
- Average case
O(1)Any long run of pushes: the total is under
3nsteps fornpushes, so the amortized cost per push is constant. This is a guarantee for every sequence, not an average over lucky inputs. - Worst case
O(n)The single push that finds the array full, such as the 1,025th push, which copies 1,024 items before writing.
Common mistakes
- Calling an operation
O(1)when only its amortized cost isO(1). A single slow step can still cause a dropped animation frame. - Growing by a fixed number of slots. That makes the total copying
O(n^2)overnpushes. - Shrinking by half as soon as the array is half empty. Alternating push and pop at that size then resizes on every call; shrink only when the array is a quarter full.
- Adding up the worst case of every operation (
npushes timesO(n)each) and concluding the whole run isO(n^2). Count the total work instead.
When to use it
Look for these clues in a problem:
- An operation is usually
O(1)but now and then rebuilds or copies everything. - Someone asks why
pushon an array orseton aMapcounts asO(1)even though it sometimes resizes. - A loop inside a loop where the inner loop's total work across the whole run is limited, such as each item being pushed and popped once.
- Designing a structure with occasional cleanups, such as a queue built from two stacks or a buffer that compacts itself.
Check yourself
Answer each one in your head first, then open it.
How many copies do 16 pushes cause, and how many do 17 cause?
16 pushes copy 1 + 2 + 4 + 8 = 15 items. The 17th push finds 16 items in 16 slots, so it copies 16 more, for 31.
Why does doubling give an O(1) average while adding 10 slots doesn't?
After doubling, the array has as many free slots as it just copied, so the copy is spread over that many cheap pushes. Adding 10 slots spreads a copy of n items over only 10 pushes.
Can a single push ever be O(n)?
Yes. A push into a full array copies every item. Amortized O(1) is about the total of many pushes, not each one.
At most how many slots can be empty after n pushes, for n of at least 2?
Fewer than n. The capacity is the smallest power of two that is at least n, which is less than 2n, so fewer than n slots are unused.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 2 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
Copies happen only when the array is full, and with doubling it is full only when this.length is a power of two: 1, 2, 4, 8, and so on. So n pushes copy 1 + 2 + 4 + ... + 2^k items, where 2^k is the largest power of two below n. That sum is 2^(k+1) - 1, which is less than 2n.
Add the n writes, one per push, and n pushes cost fewer than 3n steps in total. Divide by n: under 3 steps per push, whatever the values and however long the run. That is what "O(1) amortized" means: a promise about the total of every sequence, not a guess about typical inputs.
Another way to see it is a savings account. Charge each push 3 coins: 1 pays for its own write, and 2 are saved. When the array doubles from m to 2m slots, the m / 2 items pushed since the last doubling have saved m coins, exactly enough to copy all m items. The account never goes negative, so 3 coins per push always cover the real cost.
Going deeper
3 more ideas
Why the copies stay under 2n
Copies happen only when the array is full at sizes 1, 2, 4, 8, ..., up to the largest power of two below n; call it P. Those sizes add up to 2P - 1, because each power of two is one more than all the smaller ones added together (1 + 2 + 4 = 7, one less than 8). P is less than n, so that's fewer than 2n copies: 1,000 pushes cause 1,023. So n pushes cost n writes plus fewer than 2n copies: under 3n steps, which is O(1) each on average.
Why growing by a fixed amount fails
If the array grew by 10 slots each time instead of doubling, it would copy all its items every 10 pushes. After n pushes that is about 10 + 20 + 30 + ... + n, which is about n^2 / 20 copies: O(n) per push on average. Growing by a fraction of the current size (doubling, or 1.5 times) is what keeps the average constant.
Amortized is not the same as average-case luck
An amortized bound is a promise about every long sequence, not a guess about typical inputs. Any n pushes cost under 3n steps, whatever the values. One single push can still take O(n) time, which can matter for smooth animation, so some systems grow in the background a bit at a time instead.
Work it by hand
Every step in a table, with why
Push 7 values into the doubling array and count the copies each push causes.
values pushed in order: 12, 5, 30, 8, 21, 17, 40
| Step | value | length before | capacity after | Copies this push | copies | What happens | Why |
|---|---|---|---|---|---|---|---|
| 1 | 12 | 0 | 1 | 0 | 0 | A free slot: write at index 0 | The array starts with room for one item, so the first push is cheap. |
| 2 | 5 | 1 | 2 | 1 | 1 | Full: double to 2, copy 1 | this.length === this.capacity, so it doubles and copies 12 into the bigger array before writing 5. |
| 3 | 30 | 2 | 4 | 2 | 3 | Full: double to 4, copy 2 | Two items are copied, and the new array has 2 spare slots. |
| 4 | 8 | 3 | 4 | 0 | 3 | A free slot: no copy | This push is paid for by the last doubling, which left a free slot. |
| 5 | 21 | 4 | 8 | 4 | 7 | Full: double to 8, copy 4 | The biggest copy so far: all 4 items move. It leaves 4 free slots. |
| 6 | 17 | 5 | 8 | 0 | 7 | A free slot: no copy | Cheap again. |
| 7 | 40 | 6 | 8 | 0 | 7 | A free slot: no copy | Still cheap. The next push, the 8th, also needs no copy. |
Seven pushes caused 1 + 2 + 4 = 7 copies, exactly 1 per push on average, and one slot is still free. The trace ends when the last value is written.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Amortized analysis | O(1) | O(n) | Doubling array: you care about the total time of many pushes, which is under 3n steps, and an occasional slow push is acceptable. The O(1) is per push, amortized. |
| Grow by a fixed number of slots | O(n) | O(n) | Almost never. Each push costs O(n) on average, because copies of the whole array come every few pushes. It only makes sense if the final size is small and known. |
| Grow by one slot every push | O(n) | O(n) | Never for a growing list: every push copies everything, so n pushes cost O(n^2) in total. |
Preallocate with new Array(size) | O(1) | O(n) | You know the final size in advance. Every push is truly O(1), with no copies at all. |
Extra memory: O(n): this.capacity is always less than 2n for n items, so at most about half the slots are empty. During a copy, the old and new arrays exist at the same time, briefly using about 3 times the item count.
How the steps grow
- Amortized analysis, Preallocate with
new Array(size)O(1) - Grow by a fixed number of slots, Grow by one slot every push
O(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Amortized analysis | 1 | 1 | 1 |
| Grow by a fixed number of slots | 10 | 1,000 | 1 million |
| Grow by one slot every push | 10 | 1,000 | 1 million |
Preallocate with new Array(size) | 1 | 1 | 1 |
Inputs that need care
5 tricky inputs
- The first push into a new array
this.capacityis 1 andthis.lengthis 0, so the slot is free: no copy. The first copy happens on the second push.n = 1,000pushes- Copies happen at sizes 1, 2, 4, ..., 512, which add up to 1,023: slightly more than
n, but always less than2n. The rule is "fewer than2ncopies", not "fewer thann". - Pushing when
this.lengthis exactly a power of two, like 1,024 - That one push copies all 1,024 items. In an animation loop this can cause one slow frame, even though the average stays constant.
- Adding a
popthat shrinks the array to half as soon as it is half empty - At the boundary, push, pop, push, pop would grow and shrink every time, copying about
nitems per operation. Shrink only when the array is a quarter full, so a resize is always followed by many cheap operations. - Zero pushes
- Nothing is copied and
this.copiesstays 0. The array still holds one empty slot from the constructor.
Variations and follow-ups
Twists interviewers add
A different growth factor
Growing by 1.5 times instead of 2 wastes fewer empty slots but copies more often. In a test with a million pushes, doubling copied about 1.05 items per push and growing by 1.5 times copied about 2.1. Both are O(1) amortized; any factor above 1 works.
Growing a little at a time
To avoid one very slow push, keep the old array while filling a new one, and copy a few old items on every push. Each push then does a bounded amount of work, so even the worst single push is O(1), at the cost of more complex code.
The potential method
A third way to prove amortized bounds: define a "potential", such as 2 * this.length - this.capacity, that rises with cheap operations and is spent by expensive ones. The amortized cost is the real cost plus the change in potential, and it comes out constant for every push.
Other structures with the same pattern
A queue built from two stacks moves each item from one stack to the other at most once, so each operation is O(1) amortized. Hash tables that double when they get too full, like the ones behind Map and Set, use the same argument as this array.
In an interview
Spot it, explain it, answer follow-ups
- Spot it when an operation is usually cheap but sometimes rebuilds everything, or when a loop inside a loop has an inner loop that, over the whole run, can only do so much work in total, such as each item being pushed and popped once.
- Explain it with the total, not the worst single step: "one push can copy
nitems, but copies only happen at powers of two, sonpushes copy fewer than2nitems in total:O(1)amortized per push." - Be ready to say why doubling works and adding 10 slots doesn't: with a fixed step, a full copy comes every 10 pushes, so the total is
O(n^2); with doubling, each copy is paid for by as many cheap pushes as it copied. - The usual follow-up is "is amortized the same as average case?". The answer is no: average case depends on the input being typical, while an amortized bound holds for every sequence of operations, even one chosen by an adversary.
Where frontend code uses it
The same idea in real UI code
- JavaScript engines grow arrays and the hash tables behind
MapandSetby a multiple of their size, which is whypushandmap.setcount asO(1)on average. - Building a long string with
+=in a loop or withparts.push(...)thenparts.join("")relies on the same kind of amortized growth inside the engine. - A queue built from two arrays used as stacks (common for undo buffers and task queues) moves each item between them at most once, so each operation is
O(1)amortized.