The Fundamental Question
Every programmer has typed something like arr.append(x) or arr.push(x) tens of thousands of times. It feels free. The list just gets longer, no questions asked. But arrays in physical memory are contiguous: a single block of bytes laid out one after another. There's no “just put it after the last element” — the slot after the last element belongs to some other variable, struct, or piece of code. So how does append work?
The answer is one of the most beautiful trade-offs in computer science: there are two fundamentally different kinds of array, and dynamic arrays are built on top of static ones. Understanding which is which — and the cost of converting between them — is the difference between code that scales and code that mysteriously stalls in production.
Why this matters: “Append is O(1)” is a half-truth. A single append can be O(n). What's actually true is the amortized cost is O(1) — and the amortized analysis itself is one of the most elegant arguments in algorithms.
Static Arrays: Fixed and Predictable
A static array has a size fixed at the moment of allocation. You ask the runtime for contiguous slots, you get them, and that's it forever. If you want a 101st element, tough — the slot after the 100th already belongs to someone else.
| Language | Static array syntax | Where it lives |
|---|---|---|
| C | int A[100]; | Stack (auto) or .bss / heap (malloc) |
| C++ | std::array<int, 100> A; | Stack — size baked into type |
| Java | int[] A = new int[100]; | Heap, size fixed at construction |
| Rust | let A: [i32; 100] = [0; 100]; | Stack, size baked into type |
| NumPy | A = np.zeros(100, dtype=np.int32) | Heap, size fixed at construction |
| Go | var A [100]int | Stack/heap, size part of the type |
Notice how strict the typing is in C++, Rust, and Go: the size is part of the type. [100]int and [200]int are different types. This is intentional — it lets the compiler know the exact stack frame layout and emit instruction-level optimal code with zero allocation overhead.
The address formula
For an array with base address and element size bytes, accessing takes one arithmetic step:
That's why static-array indexing is : one multiply (often a shift, since is a power of two), one add, one memory load. The CPU does this in a single cycle.
Static arrays in real-time systems
Dynamic Arrays: Capacity vs Size
A dynamic array (also called a growable array, vector, or list) hides a static array under the hood and adds bookkeeping to make it grow. The key insight is that there are now two sizes:
- Logical size — the number of elements the user actually stored.
- Capacity — the number of slots actually allocated. Always .
Slots between and are real memory but are logically empty — they hold uninitialized garbage from the user's perspective. len(arr) returns , never .
Append works in two cases:
- Cheap path (): write the new value at slot , then . One store. .
- Expensive path (): allocate a larger backing array of size , copy all existing elements, free the old buffer, then take the cheap path. .
Definition
append reallocates and copies when .The Growth Strategy: Why Doubling?
When the buffer is full, how much should we grow it by? This single decision separates from over a sequence of appends. There are three serious options.
Option A: Add a constant (e.g., +10 every time)
Every appends triggers a resize that copies elements. After appends, the total copy cost is:
That makes append amortized — disaster. Never grow by an additive constant.
Option B: Multiply by a constant (e.g., ×2 every time)
Resizes happen at sizes . The total copy cost is a geometric series:
So appends total work, which means amortized per append. This is the magic.
Option C: Multiply by 1.5
Same asymptotic story as ×2 (still total), but with a twist that matters in practice. With factor 2, the new buffer is always strictly larger than the sum of all previously freed buffers — so the allocator can never reuse old slots. With factor or smaller, recently-freed memory is large enough to satisfy the next allocation, reducing fragmentation.
| Implementation | Growth factor | Why |
|---|---|---|
| GCC libstdc++ std::vector | 2.0 | Simple, slightly faster |
| Clang libc++ std::vector | 2.0 | Simple, slightly faster |
| Microsoft STL std::vector | 1.5 | Memory reuse on Windows allocator |
| Facebook Folly fbvector | 1.5 | Memory reuse |
| Java ArrayList | 1.5 | (oldCap + (oldCap >> 1)) |
| Python list | ~1.125 + slack | Conservative — list_resize over-allocates by n >> 3 |
| Go slice | 2.0 then 1.25 | 2x while small, then 1.25x past 1024 |
| Rust Vec | 2.0 | Simple |
The takeaway
Amortized Analysis: The Real Cost of Append
Let's prove the claim directly using the aggregate method. Suppose we start with an empty dynamic array (capacity 1) and perform appends with doubling.
A resize is triggered exactly when reaches a power of two: at appends number . The resize at size copies elements.
Total cost = (n cheap writes) + (sum of all copy costs):
So the amortized cost per append is . Three writes per append, no matter how large grows.
What amortized doesn't mean
When the worst case bites
vec.reserve(N) in C++, list = [None] * N in Python), use a static array, or use a deque / linked structure that has true O(1) worst-case append.Interactive: Dynamic Array Builder
Click append repeatedly and watch the buffer fill. When it's full, the next append triggers a resize: the strip widens, every existing element is copied (the cost spike), and amber-tinted free slots appear at the right. Toggle the growth factor between 1.5× and 2× to see how often each strategy resizes.
Two things to notice as you click:
- The amortized cost per op stays close to a small constant (around 2–3) regardless of how many appends you do. That's the geometric series at work.
- With factor 1.5×, resizes happen more often (about every 1.5x in size) but each one copies fewer elements. Total work is the same up to constants. The visible difference is how much “slack” (amber free space) you carry: factor 2 has up to 50% slack just after a resize; factor 1.5 has up to 33%.
Quick Check
Starting from capacity 1 and using doubling, how many resize events occur during the first 1000 appends?
Operation Costs Side by Side
Here is the table you should commit to memory. Worst-case is the cost of a single operation in the unluckiest case; amortized is the long-run average over a sequence.
| Operation | Static array | Dynamic array (worst) | Dynamic array (amortized) |
|---|---|---|---|
| access A[i] | O(1) | O(1) | O(1) |
| update A[i] = x | O(1) | O(1) | O(1) |
| append (push_back) | not supported | O(n) | O(1) |
| pop_back | not supported | O(1) | O(1) |
| prepend (push_front) | not supported | O(n) | O(n) |
| pop_front | not supported | O(n) | O(n) |
| insert at index k | not supported | O(n - k) | O(n - k) |
| delete at index k | not supported | O(n - k) | O(n - k) |
| search (unsorted) | O(n) | O(n) | O(n) |
| binary search (sorted) | O(log n) | O(log n) | O(log n) |
| len() | O(1) | O(1) | O(1) |
Three rows deserve a comment.
Why is prepend O(n)?
To insert at index 0, every existing element must shift one slot to the right to make room. That's moves regardless of capacity. No clever growth strategy fixes this. If you need fast prepend, use a deque (double-ended queue, Chapter 7) or a circular buffer.
Why is insert at index k actually O(n - k)?
Inserting at index shifts the suffix from to rightward — that's moves. Inserting near the end is cheap; near the front is expensive. Same logic for delete.
Why is access still O(1) on a dynamic array?
Because the backing storage is still a contiguous static array. is one pointer dereference to the buffer, plus . The dynamic-vs-static distinction only matters at the moment of growth.
Implementation: A Dynamic Array From Scratch (Python)
Python's list is already a dynamic array, so to see the machinery we have to build one against raw memory. We'll use ctypes to allocate a fixed-capacity backing buffer.
Trace what happens with four appends starting from empty:
| Step | Before | Resize? | Action | After |
|---|---|---|---|---|
| append('a') | n=0, cap=1 | no (0 < 1) | A[0]='a', n→1 | n=1, cap=1 |
| append('b') | n=1, cap=1 | YES (1 == 1) | alloc cap=2, copy 1, A[1]='b' | n=2, cap=2 |
| append('c') | n=2, cap=2 | YES (2 == 2) | alloc cap=4, copy 2, A[2]='c' | n=3, cap=4 |
| append('d') | n=3, cap=4 | no (3 < 4) | A[3]='d', n→4 | n=4, cap=4 |
Total cost: 1 + (1 copy + 1 write) + (2 copies + 1 write) + 1 = writes for appends. Amortized 1.5 — well under our 3n upper bound.
Implementation: The Same Idea in C++
In C++ we have to manage memory by hand with new[] and delete[] — exactly the work that std::vector hides. (A production version would use std::allocator, placement new, move semantics, exception safety, and an iterator interface; we're showing the bones.)
Move semantics in real std::vector
std::vector::push_back uses std::move on the copy loop when 's move constructor is noexcept. The asymptotic cost is the same — still per resize — but the constant factor is much smaller because moving a string or unique_ptr is essentially copying a pointer, not copying its contents.Real-World Implementations
Every serious language ships a dynamic array as its default sequence type. Each one tunes growth differently for its target use case.
| Implementation | Element type | Notes |
|---|---|---|
| Python list | PyObject* | Array of pointers — heterogeneous. Growth roughly n + (n >> 3) plus small slop. |
| NumPy ndarray | Fixed dtype | Truly homogeneous, contiguous, fixed-size. np.resize copies. For appending, use lists then convert. |
| Java ArrayList | Object[] | 1.5x growth. Boxed primitives — int autoboxes to Integer. For raw int[], use static arrays. |
| JavaScript Array | Tagged values | V8 internally distinguishes packed-SMI, packed-doubles, holey, and dictionary modes. |
| Go slice | Fixed type | A slice is a (ptr, len, cap) triple over a backing array. append() may or may not allocate. |
| Rust Vec<T> | Fixed type T | 2x growth. Move semantics by default — push doesn't copy. |
| C++ std::vector<T> | Fixed type T | Implementation-defined growth (1.5x or 2x). Reserve up front for known sizes. |
Beyond “just a list”: the dynamic-array pattern in disguise
- Hash table internals — when load factor exceeds a threshold (typically 0.75), the bucket array is reallocated and every entry is rehashed into the larger array. Same allocate-copy-free pattern.
- String builders — Java's
StringBuilder, Python'sio.StringIO, and Rust'sStringare all dynamic arrays of bytes/chars under the hood. That's why repeatedly concatenating with+=in a loop can be O(n²) in some languages but O(n) when using a builder. - Database write-ahead-log buffers — bounded dynamic arrays. They grow up to a hard cap, then flush to disk and reset rather than grow further, giving worst-case bounds on memory.
- GPU memory pools and game-engine object pools — pre-allocated static arrays acting as a free list. Game engines pay the static-array cost up front to avoid GC pauses during a frame.
- Image and video buffers — almost always static. A 4K frame is a 3840×2160×4 byte chunk, allocated once at startup and reused every frame.
When NOT to Use Dynamic Arrays
Dynamic arrays are an excellent default, but they have failure modes. Reach for something else when:
| Scenario | Problem with dynamic array | Better choice |
|---|---|---|
| Hard real-time systems (audio, flight control) | Worst-case append is O(n) — unbounded latency spike | Static array, pre-reserved |
| Embedded systems with no malloc | Resize requires the heap allocator | Static array, fixed pool |
| Frequent insert/delete at the front | Each prepend is O(n) | Deque / circular buffer |
| Frequent insert/delete in the middle | Shifting cost is O(n) | Linked list or balanced tree |
| Very large, mostly-empty data | Resizing copies everything, even sparse zeros | Sparse data structure (hash map, CSR matrix) |
| Multiple references to old size | Reallocation invalidates pointers/iterators | Index-based access, or use std::deque |
| Concurrent reads + writes | Reallocation moves the buffer mid-use | Lock-free queue, persistent structure |
Pre-reserve when you know the size
vec.reserve(N) (C++) or list = [None] * N (Python, then write by index) up front. Every subsequent append is now strictly — no resize ever happens. This single line is one of the most common, easy, and impactful performance fixes in real code.Pitfalls and Gotchas
1. Iterator and pointer invalidation
In C++, holding a pointer or iterator into a std::vector across a push_back is undefined behavior — the resize may have moved the entire buffer to a different address. The same trap exists in Rust (the borrow checker prevents it at compile time) and in Go slices (an append may or may not return a slice pointing to the original backing array).
1std::vector<int> v = {1, 2, 3};
2int* p = &v[0]; // points into v's buffer
3v.push_back(4); // may reallocate — p is now dangling
4*p = 99; // undefined behavior!2. Memory holes after pop
Most implementations don't shrink on pop. After pushing 1 million elements then popping all of them, capacity is still ~1 million. To release memory you have to call shrink_to_fit() (C++), construct a new list (Python), or assign a freshly-sized slice (Go).
3. Off-by-one in capacity vs size
Beginners writing their own dynamic array often get the resize trigger wrong. The condition is , not or . Resize before writing the new element, not after.
4. Assuming append is “always fast”
It's amortized fast, not worst-case fast. If you're writing a 60-fps game loop and a single resize copies 100 MB, you'll drop a frame. Profile, and pre-reserve.
5. Confusing dynamic arrays with linked lists
A “list” in Python, JavaScript, Java, or Ruby is a dynamic array, not a linked list. The name is a historical accident inherited from LISP. If you want O(1) middle insertion, you need a different data structure entirely (Chapter 6).
Quick Check
Why is prepending to a dynamic array O(n) even with a doubling growth strategy?
Summary
| Concept | Static array | Dynamic array |
|---|---|---|
| Size | Fixed at allocation | Grows on demand |
| Capacity vs size | Always equal | capacity ≥ size, with slack |
| Append | Not supported | Amortized O(1), worst-case O(n) |
| Index access | O(1) | O(1) |
| Prepend / middle insert | Not supported | O(n) |
| Memory overhead | Zero | Up to 50% slack (factor 2) or 33% (factor 1.5) |
| Worst-case latency | Bounded, predictable | Unbounded — resize spikes |
| Best for | Real-time, embedded, fixed-size data | Unknown size, general-purpose |
The two big ideas to take away:
- Capacity is not size. A dynamic array carries hidden slack so that most appends can skip allocation. The pair is the entire trick.
- Geometric growth makes amortized append O(1). Total cost over appends is bounded by because the geometric series sums to less than . The choice of factor (1.5 or 2) is a memory-vs-latency knob, not a complexity one.
In the next section we'll examine Memory Allocation Strategies in depth — exactly how and where the runtime gets these contiguous blocks, and why malloc isn't actually free.
Exercises
Conceptual
- A dynamic array starts empty with capacity 1 and uses doubling. After 17 appends, what are the values of , , and the total number of writes performed (including resize copies)?
- Prove that any growth factor gives amortized append. Where does the proof break for ?
- Why does Python's list use a growth pattern of roughly 1.125x with constant slop, instead of 2x? Hint: think about typical Python program memory pressure vs C++ program memory pressure.
Implementation
- Extend the
DynamicArrayclass above withinsert(k, obj),pop(), and__setitem__. What is the worst-case complexity of each? - Add automatic shrinking: when , halve the capacity. Why and not ? (Hint: the wrong threshold causes amortized behavior on a pathological alternating pattern.)
- Implement
pop_front. Then implement a circular-buffer version that runs in amortized.
Analysis
- Suppose the growth factor is 1.5. Prove that appends cost at most total writes. Then redo the argument for factor in general — express the bound as a function of .
- A user calls
appendexactly times, starting from capacity 1 with doubling. How many times does the backing buffer get reallocated? Express your answer in terms of .
Challenge
- Design a data structure that supports both
appendandprependin amortized , plus random access. (Hint: two stacks, or a circular buffer.) How does the constant factor compare to a plain dynamic array? - Most implementations use a single backing array. Investigate the tiered-vector structure: an array of arrays of size . What complexities does it offer for append, prepend, insert, and access? Why is it not the default?