ArraysLesson 3

Dynamic Arrays

How arrays that grow automatically actually work — capacity doubling, amortized O(1) append, and the copy cost under the hood.

What Is It?

Fixed-size arrays have one big problem: you have to decide how many elements you need before you start. If you guess too small, you're stuck. Real programs almost never know the exact count ahead of time.

A dynamic array solves this. It wraps a regular fixed array with some extra logic that automatically grows the storage when it runs out of room. When the array fills up, it creates a bigger array behind the scenes, copies everything over, and keeps going. From the outside, it looks like an array with no size limit.

You already use dynamic arrays all the time — they're just called different things depending on the language: list in Python, ArrayList in Java, vector in C++, slice in Go.

In this lesson, you'll build one from scratch so you understand exactly what's happening under the hood.

Analogy

The Expanding Notebook

Imagine taking notes in a notebook with exactly 4 pages. You fill all 4 pages. Now what?

Option A — Grow by one: Buy a 5-page notebook and copy everything. Fill it, buy a 6-page notebook and copy again. Every time you need one more page, you copy everything. This is painful and slow.

Option B — Double the size: When your 4-page notebook fills up, buy an 8-page notebook and copy everything. Now you have 4 blank pages of breathing room. When those fill up, buy a 16-page notebook. Each time, you double. You copy far less often.

The key insight: copying is expensive, so do it rarely. When you do have to copy, make the new space big enough that it'll be a long time before you need to copy again. This is the doubling strategy — and it's the standard approach.

How It Works

Size vs. Capacity

A dynamic array keeps track of two separate numbers:

  • size — how many elements are actually stored (the number you care about)
  • capacity — how many elements the underlying array can hold (the reserved space)
Capacity = 8, Size = 53 slots allocated but unused
10
20
30
40
50
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]

Slots 0–4: used (size) · Slots 5–7: reserved (capacity − size)

Slots 5, 6, and 7 are allocated but empty. They're waiting for future appends.

The Resize Operation

When size == capacity and you try to append, the dynamic array has to grow:

pseudocode
1
2
3
4
5
6
7
8
9
10
11
12
function resize(dynArray, newCapacity):
// Step 1: Create a new, bigger array
newArray = allocate(newCapacity)
// Step 2: Copy all existing elements into it
for i = 0 to dynArray.size - 1:
newArray[i] = dynArray.data[i]
// Step 3: Swap in the new array and update capacity
free(dynArray.data)
dynArray.data = newArray
dynArray.capacity = newCapacity

Step-by-step trace — appending 60 when the array is full:

Resize & Appendappending 60 when array is full
1 / 5
FULL — size=4, capacity=4, can't append
10
20
30
40
[0]
[1]
[2]
[3]

Why Double? The Amortized O(1) Trick

Here's the thing: sometimes an append triggers a resize, which copies every element. That's slow! But most of the time, appending is instant. The question is: what's the average cost?

Watch what happens as we append 17 elements, starting with capacity 1:

Append 1
0copies
Append 2
1copy
Append 3
2copies
Append 4
0copies
Append 5
4copies
Append 6–8
0copies
Append 9
8copies
Append 10–16
0copies
Append 17
16copies

Total copies across all 17 appends: 1 + 2 + 4 + 8 + 16 = 31. Plus 17 appends themselves. Grand total: 48 operations for 17 elements — that's less than 3 operations each on average.

The math works out to roughly 2n total operations for n appends. That means each append costs O(1) on average, even though the occasional resize costs O(n). This average-per-operation cost is called amortized O(1).

Why does doubling specifically achieve this? Because every time you copy n elements, you've just unlocked n new slots. By the time you need to copy again, you'll have done n more appends first. The expensive copies happen less and less often as the array grows.

Shrinking

If you delete many elements, the array wastes memory. A common approach: when size < capacity / 4, shrink the capacity down to capacity / 2.

Why capacity/4 instead of capacity/2? To avoid thrashing — a pattern where you resize up and down repeatedly:

1
2
3
4
5
6
7
8
9
10
// Bad: shrink at capacity/2
Array is full with 8 slots, size = 4 → shrink to 4
Append one element → resize back to 8
Delete one element → shrink to 4
Append one element → resize back to 8
...resizes forever!
// Good: shrink at capacity/4
This only triggers when you're using less than 25% of space.
You can add/remove several elements without thrashing.

Complete DynamicArray Implementation

pseudocode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class DynamicArray:
data = allocate(1) // start small
size = 0
capacity = 1
function append(value):
if size == capacity:
resize(capacity * 2) // double the capacity
data[size] = value
size = size + 1
function get(index):
if index < 0 or index >= size:
error "Index out of bounds"
return data[index]
function set(index, value):
if index < 0 or index >= size:
error "Index out of bounds"
data[index] = value
function deleteAt(index):
if index < 0 or index >= size:
error "Index out of bounds"
for i = index to size - 2:
data[i] = data[i + 1]
size = size - 1
if size > 0 and size == capacity / 4:
resize(capacity / 2) // shrink to save memory
function insertAt(index, value):
if index < 0 or index > size:
error "Index out of bounds"
if size == capacity:
resize(capacity * 2)
for i = size - 1 down to index:
data[i + 1] = data[i]
data[index] = value
size = size + 1
function resize(newCapacity):
newData = allocate(newCapacity)
for i = 0 to size - 1:
newData[i] = data[i]
free(data)
data = newData
capacity = newCapacity
function length():
return size
function isEmpty():
return size == 0

Examples

Example 1: Append sequence — watch capacity double

Append Sequencewatch capacity double
1 / 6
new DynamicArray() — size=0, cap=1
_
[0]

Example 2: Delete with shrink

Delete with Shrinksize drops to capacity/4
1 / 4
Before — size=5, capacity=16
10
20
30
40
50
_
_
_
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]

Common Mistakes

  1. Growing by 1 each time instead of doubling. If you grow by 1 on every resize, every single append triggers a full copy. Appending n elements ends up costing n² total operations. Doubling costs only 2n — exponentially better.
  1. Mixing up size and capacity. size is how many elements you've stored. capacity is how many the underlying array can hold. Always use size when iterating or bounds-checking. Use capacity only for resize decisions.
  1. Shrinking at the wrong threshold. Shrinking when size == capacity/2 causes thrashing. Use capacity/4 as the trigger — it gives you enough buffer to avoid endlessly resizing.
  1. Forgetting that resize is not always instant. Most appends are O(1), but the occasional resize copies every element. This is fine for most use cases, but if your program can never afford a sudden slow operation, keep it in mind.

Best Practices

  • If you already know roughly how many elements you'll have, set the initial capacity to that number. This avoids all the early resizes.
  • Use the built-in list/array type in your language — it already implements all of this correctly and efficiently.
  • Remember that size and capacity are different. When debugging, print both to understand what's actually happening.
  • For most coding problems, just use your language's built-in dynamic array. The goal is to understand how it works, not to rebuild it every time.