Amortized complexity
In short: Amortized complexity is the average cost of an operation over a long sequence: appending to a dynamic array sometimes copies everything, but because the array grows by a factor (such as ×2), it is O(1) on average.
Problem
The import that copies everything
The shop imports the orders from an old system one by one. Someone wrote the import so that with every order the array gets one place bigger:
static long importOrders(int n) { int[] orders = new int[0]; long copies = 0; for (int i = 0; i < n; i++) { copies += orders.length; orders = Arrays.copyOf(orders, orders.length + 1); orders[orders.length - 1] = 100000 + i; } return copies;}It looks harmless: one line per order. But every new array is a copy of the old one plus one place. The program counts the copies:
for (int n : new int[] {1000, 10000}) { System.out.println(n + " orders: " + importOrders(n) + " copies");}1000 orders: 499500 copies10000 orders: 49995000 copiesWith 10 times more orders there are 100 times more copies. With 100 000 orders there are 4 999 950 000, and the import, which takes a blink in the test, now runs for minutes. Yet ArrayList.add on the same 100 000 orders finishes at once, although it too sometimes copies the whole array. How?
The idea
The list does not grow by one place
Inside, a list is an array with more places than elements. When it fills up, it makes a new, bigger array and copies everything into it:
ArrayList<String> cart = new ArrayList<>(3);cart.add("Bread");cart.add("Milk");cart.add("Coffee");cart.add("Butter");cart.remove("Milk");System.out.println(cart);System.out.println("Items: " + cart.size());Step by step, in words
- cart points to an ArrayList object. Inside there is an array with 3 places and a size counter.
- add puts each element in the first free place and increases size.
- The array is full. ArrayList makes a new one with 4 places and copies the three elements into it.
- The object now points to the new array, and nobody needs the old one. "Butter" goes into the free place.
- remove removes "Milk", and everything after it moves one place to the left. No hole is left.
- From the outside you see only a list of three items: ArrayList does the copying and the shifting.
The secret is how much bigger the new array is. If it has one more place, every addition after the full array copies everything, like the import above. If it is twice as big, the next copy comes only after as many additions again.
See it
Your own array that doubles
Here is the same mechanism written by hand, with a counter of the copies. The array starts with one place:
class OrderList { private int[] items = new int[1]; private int size = 0; long copies = 0;
void add(int order) { if (size == items.length) { int[] bigger = new int[items.length * 2]; for (int i = 0; i < size; i++) { bigger[i] = items[i]; copies++; } items = bigger; } items[size] = order; size++; }}for (int n : new int[] {9, 1000}) { OrderList orders = new OrderList(); for (int i = 0; i < n; i++) { orders.add(100000 + i); } System.out.println(n + " orders: " + orders.copies + " copies" + ", 2n = " + 2 * n);}9 orders: 15 copies, 2n = 181000 orders: 1023 copies, 2n = 2000class OrderList { private int[] items = new int[1]; private int size = 0; long copies = 0;
void add(int order) { if (size == items.length) { int[] bigger = new int[items.length * 2]; for (int i = 0; i < size; i++) { bigger[i] = items[i]; copies++; } items = bigger; } items[size] = order; size++; }}
OrderList orders = new OrderList();for (int i = 0; i < 9; i++) { orders.add(100000 + i);}Step by step, in words
- The array starts with one place, and size is 0. We count only the copies of old elements.
- The first order: there is room, add writes it. Copies: 0.
- The second: the array is full. A new array with 2 places, and the old order is copied into it: 1 copy.
- The third: full again, 4 places and 2 copies. The fourth goes in without copying.
- The fifth copies 4, the ninth 8. The copies grow, but between them come more and more additions without copying.
- 9 additions, 1 + 2 + 4 + 8 = 15 copies, under 2n = 18. Spread over all additions, that is under 2 per addition: O(1) amortized.
An addition that finds the array full copies everything: 1, 2, 4, 8, 16 … But after it come as many additions without copying as it copied. The sum 1 + 2 + 4 + … + 2ᵏ is always under 2 · 2ᵏ, and the last copy 2ᵏ is under n. So the copies are under 2n, however big n is.
The idea
Amortized complexity
One addition can cost O(n): the one that copies everything. But n additions cost under 3n operations in total: n writes and under 2n copies. Divided by n, that is under 3 per addition: O(1).
The cost spread over a whole sequence of operations is called amortized complexity. Adding at the end of a list is O(1) amortized.
This is not "on average for a random input". The promise holds for every sequence of additions, with no luck: the expensive ones are rare, and always that rare.
Try it
Predict the output
Five orders in an array that starts with one place and doubles. How many copies?
class OrderList { private int[] items = new int[1]; private int size = 0; long copies = 0;
void add(int order) { if (size == items.length) { int[] bigger = new int[items.length * 2]; for (int i = 0; i < size; i++) { bigger[i] = items[i]; copies++; } items = bigger; } items[size] = order; size++; }}
for (int n : new int[] {5}) { OrderList orders = new OrderList(); for (int i = 0; i < n; i++) { orders.add(100000 + i); } System.out.println(n + " orders: " + orders.copies + " copies");}See it
One place more: O(n²)
The same class, except that the new array has one more place:
int[] bigger = new int[items.length + 1];for (int n : new int[] {9, 1000}) { OrderList orders = new OrderList(); for (int i = 0; i < n; i++) { orders.add(100000 + i); } System.out.println(n + " orders: " + orders.copies + " copies");}9 orders: 36 copies1000 orders: 499500 copiesWith 1000 orders doubling copies 1023 times, and growing by one place 499 500 times: 1 + 2 + … + 999. Run both growths and look at the curves. We count only the copies:
- Growth ×2: copies
O(n) - Growth +1: copies
O(n²)
| Algorithm | n = 10 | n = 1,000 | n = 100,000 |
|---|---|---|---|
| Growth ×2: copies | 15 | 1,023 | 131,071 |
| Growth +1: copies | 45 | 499,500 | ≈ 4,995,000,000 |
Doubling grows like a straight line: O(n) in total, O(1) per addition. Growing by one place grows like a parabola: O(n²) in total, O(n) per addition.
Try it
Fill in
How many places does the new array have, so that adding is O(1) amortized?
if (size == items.length) {
int[] bigger = new int[];The new array is twice as big as the old one. So the next growth comes only after as many additions again as there are elements now.
Try it
Find the bug
A colleague replaced the doubling "so it does not waste memory". With 1000 orders the test passes quickly, and the nightly import of 100 000 drags on:
for (int n : new int[] {1000, 100000}) { OrderList orders = new OrderList(); for (int i = 0; i < n; i++) { orders.add(100000 + i); } System.out.println(n + " orders: " + orders.copies + " copies");}1000 orders: 4510 copies100000 orders: 49951000 copiesWith 100 times more orders there are 10 000 times more copies. Which line is the bug on?
Pick the line with the bug.
The array grows by 100 places at a time. That postpones the growth, but does not make it rare: every 100 additions comes a copy of everything so far, and the sum is still quadratic, about n²/200. The growth must be a multiplication (×2, ×1.5), not an addition, however big the added number.
The idea
How each language grows
No language doubles exactly, but every one multiplies:
-
Java
ArrayList: the new size is the old one plus half, ×1.5. An empty list gets 10 places at the first addition. -
C#
List<T>: ×2; - Python (CPython): about ×1.125;
- JavaScript (V8): about ×1.5.
Any factor above 1 gives O(1) amortized. The smaller it is, the more copies (under 3n for ×1.5, under 9n for ×1.125) and the fewer empty places after a growth. The choice is a trade between time and memory, as in the lesson "Space complexity".
More precisely, if you are curious
O(1) amortized does not mean every addition is fast. The addition that copies is O(n). In most programs you do not notice it, but in a program that must not stop (a till, a game, audio processing), one addition of the millionth element may be the slow one. If you know how many elements there will be, set the places up front: new ArrayList<>(n). Then the list never copies.
ArrayList also has ensureCapacity(n) for a list that already exists.The bound for ×2 has another form too: every addition "pays" 2 copies in advance and saves them. When the array with k elements fills up, the last k/2 additions have saved k, exactly what the copy costs. The savings never go negative, so the sum is under 2n.
Exercise
Growth as in Java
Solve the task in your editor.
Growth by half. Change the growth of OrderList so that the new array has the old places plus half (at least one place more). Count the copies for 1000 and for 100 000 orders. Are they under 2n? And under 3n?
1000 orders: 2137 copies, 2n = 2000100000 orders: 276521 copies, 2n = 200000More tasks
- Removing from the end. Add
removeLast. When should the array shrink, so that removing is O(1) amortized too? Try "by half, when it is half full" and alternate adding and removing exactly at the boundary. Then try "by half, when it is a quarter full". Why is the second O(1) amortized and the first not? - How many places it wastes. After 1000 additions with doubling, how many places are empty? And with growth ×1.5?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Time complexity: counting steps · Big-O: how the work grows · Best, average and worst case · Space complexity
- Continues in: The dynamic array from the inside · Linked list · Stack and queue · Hash table
- Tasks on this topic: Complexity