Definition
A dynamic array stores a sequence in one contiguous block of slots, keeps a count of the slots in use, and, when the block is full, moves the entries into a larger block, usually twice the size.
After Cormen et al., Introduction to Algorithms, §17.4
01Keep the order, leave the end open
Results arrive one by one. How many is unknown.
Bibs 12, 7 and 31 cross the line in that order. The results panel must show them in that order and let you jump straight to the second result. You do not know how many more runners are coming.
A dynamic array keeps a sequence in one block of numbered slots, and makes the block larger when it runs out. Reading position 2 is one step, whatever the length. Growing is the part with a cost, and this lesson makes it visible.
With 12, 7 and 31 in four slots, the length is 3 and the capacity is 4. Slots 0, 1 and 2 hold results; slot 3 is room for the next. The rule that never breaks: exactly the slots from 0 to length − 1 hold values, in recorded order, and the length never exceeds the capacity.
02Read the model
A buffer, a length, and a copy when it fills.
TypeScript arrays, Go slices and Rust Vecs already do this. To see the growth, this lesson
spells out a small model: a buffer of slots, a length, and a count of moves,
every entry copied, shifted or written. In real code, use your language’s own.
Start with four empty slots. Appending 12, 7, 31 and 4 writes each into the next free slot,
one move each, and the length climbs 1, 2, 3, 4. Now the length equals the capacity. Here is append, from the program below:
append(value: number): void {
if (this.length === this.capacity) {
// Full: copy every entry into a buffer twice the size, then use it.
const oldSlots = this.slots;
const newSlots: (number | undefined)[] = new Array(oldSlots.length * 2).fill(undefined);
for (let i = 0; i < this.length; i++) {
newSlots[i] = oldSlots[i];
this.moves++;
}
this.slots = newSlots;
}
this.slots[this.length] = value;
this.length++;
this.moves++;
} append writes into the next free slot. When there is none, it copies every
result into a buffer twice the size, switches to it, and then writes. remove keeps
the order by moving every later result one place earlier, then clears the last slot.
The whole program the lab runsThe model, then five finishers and one correction
// A teaching model of a growing array. In real code, use your language's own:
// a TypeScript array, a Go slice, a Rust Vec.
class GrowingArray {
slots: (number | undefined)[] = new Array(4).fill(undefined);
length = 0;
/** Entries copied, shifted or written: the work this lesson counts. */
moves = 0;
get capacity(): number {
return this.slots.length;
}
append(value: number): void {
if (this.length === this.capacity) {
// Full: copy every entry into a buffer twice the size, then use it.
const oldSlots = this.slots;
const newSlots: (number | undefined)[] = new Array(oldSlots.length * 2).fill(undefined);
for (let i = 0; i < this.length; i++) {
newSlots[i] = oldSlots[i];
this.moves++;
}
this.slots = newSlots;
}
this.slots[this.length] = value;
this.length++;
this.moves++;
}
get(index: number): number | undefined {
return index >= 0 && index < this.length ? this.slots[index] : undefined;
}
remove(index: number): number | undefined {
if (index < 0 || index >= this.length) return undefined;
const removed = this.slots[index];
// Keep the order: move every later entry one place earlier.
for (let i = index + 1; i < this.length; i++) {
this.slots[i - 1] = this.slots[i];
this.moves++;
}
this.length--;
this.slots[this.length] = undefined;
return removed;
}
values(): number[] {
return this.slots.slice(0, this.length) as number[];
}
}
const results = new GrowingArray();
for (const bib of [12, 7, 31, 4]) results.append(bib);
const beforeFifth = results.moves;
results.append(19);
const fifthAppend = results.moves - beforeFifth;
console.log(`five: ${results.values().join(", ")} | capacity ${results.capacity} | fifth append ${fifthAppend} moves`);
const beforeRemove = results.moves;
const removed = results.remove(1);
const removal = results.moves - beforeRemove;
console.log(`removed ${removed}: ${results.values().join(", ")} | capacity ${results.capacity} | ${removal} moves`);
console.log(`index 1 now: ${results.get(1)}`); Reading the TypeScriptClasses, getters and fill
new Array(4).fill(undefined) makes four real slots; without fill they would be holes, which length counts and map and forEach skip. get capacity() is a getter: it reads like a field
and is computed from the buffer. The model’s slot count is teaching state; a JavaScript
engine decides its own capacity and does not show it.
What a production results log addsAny value type, absence, and admission rules
The same growing array, generic over its values, with reads that tell a missing index apart from a stored empty value, and an optional trace of every step. The results log on top owns what the array should not: bibs are whole numbers from 1 to 999, and a bib already recorded is refused. That rule lives in a set beside the array; it is not something arrays do.
export type Lookup<T> = { found: true; value: T } | { found: false };
export type Step = {
kind: 'allocate' | 'copy' | 'switch' | 'append' | 'read' | 'shift' | 'clear' | 'missing';
from: number;
to: number;
};
// Explicit teaching storage. This capacity is NOT JavaScript engine capacity.
export class GrowingArray<T> {
#slots: (T | undefined)[] = new Array(4).fill(undefined);
#length = 0;
#steps: Step[] = [];
#capture: boolean;
constructor(capture = false) {
this.#capture = capture;
}
get length(): number {
return this.#length;
}
get capacity(): number {
return this.#slots.length;
}
#record(kind: Step['kind'], from: number, to: number): void {
if (this.#capture) this.#steps.push({ kind, from, to });
}
append(value: T): void {
this.#steps = [];
if (this.#length === this.capacity) {
const previousCapacity = this.capacity;
const next: (T | undefined)[] = new Array(previousCapacity * 2).fill(undefined);
this.#record('allocate', previousCapacity, next.length);
for (let i = 0; i < this.#length; i++) {
next[i] = this.#slots[i];
this.#record('copy', i, i);
}
this.#slots = next;
this.#record('switch', previousCapacity, this.capacity);
}
this.#slots[this.#length] = value;
this.#record('append', -1, this.#length);
this.#length++;
}
get(index: number): Lookup<T> {
this.#steps = [];
if (!Number.isInteger(index) || index < 0 || index >= this.#length) {
this.#record('missing', index, -1);
return { found: false };
}
this.#record('read', index, index);
return { found: true, value: this.#slots[index] as T };
}
remove(index: number): Lookup<T> {
this.#steps = [];
if (!Number.isInteger(index) || index < 0 || index >= this.#length) {
this.#record('missing', index, -1);
return { found: false };
}
const value = this.#slots[index] as T;
for (let from = index + 1; from < this.#length; from++) {
this.#slots[from - 1] = this.#slots[from];
this.#record('shift', from, from - 1);
}
this.#length--;
this.#slots[this.#length] = undefined;
this.#record('clear', this.#length, -1);
return { found: true, value };
}
// Independent outer arrays; arbitrary T payloads are not deep-cloned.
values(): T[] {
return this.#slots.slice(0, this.#length) as T[];
}
trace(): Step[] {
return this.#steps.map((step) => ({ ...step }));
}
}
export type Admission = 'recorded' | 'invalid-bib' | 'duplicate-bib';
export class RaceLog {
#rows: GrowingArray<number>;
#bibs = new Set<number>();
#steps: Step[] = [];
constructor(captureTrace = false) {
this.#rows = new GrowingArray(captureTrace);
}
get length(): number {
return this.#rows.length;
}
get capacity(): number {
return this.#rows.capacity;
}
record(bib: number): Admission {
this.#steps = [];
if (!Number.isInteger(bib) || bib < 1 || bib > 999) return 'invalid-bib';
if (this.#bibs.has(bib)) return 'duplicate-bib';
this.#rows.append(bib);
this.#bibs.add(bib);
this.#steps = this.#rows.trace();
return 'recorded';
}
read(index: number): number | null {
const result = this.#rows.get(index);
this.#steps = this.#rows.trace();
return result.found ? result.value : null;
}
remove(index: number): number | null {
const result = this.#rows.remove(index);
this.#steps = this.#rows.trace();
if (!result.found) return null;
this.#bibs.delete(result.value);
return result.value;
}
values(): number[] {
return this.#rows.values();
}
trace(): Step[] {
return this.#steps.map((step) => ({ ...step }));
}
} 03Predict the fifth result
The fourth result fits. What does the fifth cost?
04Meet it in your language
Your language’s array grows the same way, and it shows.
A JavaScript array grows like this lesson’s model, but hides the buffer: you never see its
capacity, and push stays cheap the way the model’s append does. The trap is the
code that copies the whole array to add one entry, which immutable updates do on purpose.
// The bug: a spread per append builds a new array and copies every result so
// far into it, so building n results copies about n²/2 entries.
let results: number[] = [];
let copied = 0;
for (let bib = 1; bib <= 10_000; bib++) {
copied += results.length; // the spread below copies this many
results = [...results, bib];
}
console.log(results.length, copied); // 10000 49995000
// The fix: push into a local array, which grows by a factor like the model,
// then copy once if the caller needs a new array (one setState, one spread).
const built: number[] = [];
for (let bib = 1; bib <= 10_000; bib++) built.push(bib);
const next = [...built]; // one copy of 10,000 entries
console.log(next.length); // 10000 Build with push into a local array and copy once at the end (one setState, one spread): one spread per update is O(n), one per item is
O(n²). Draining with shift() or splice(0, 1) in a loop has the same
shape, because each call moves every remaining entry one place; keep an index instead. And
build a sized array with Array.from({ length: n }, …), not new Array(n), which is all holes: writing past the end leaves holes too, and length counts them.
05Count the cost
One expensive append can belong to a cheap sequence.
Here n is the number of results. These are worst-case bounds for one operation on this model, except append’s amortized bound, which describes a sequence of appends.
| Operation | Time | Extra space | Why |
|---|---|---|---|
| Read index i | O(1) | O(1) | One slot at a known offset from the start of the buffer. |
| Append | O(1) amortized; O(n) worst | O(1); O(n) during growth | A full buffer copies n entries into one twice the size; both buffers exist while it copies. |
| Remove index i, keeping order | O(n − i) | O(1) | Every entry after i moves one place earlier. Removing the last entry moves nothing. |
| Insert at index i | O(n − i), plus O(n) if it grows | O(1); O(n) during growth | Every entry from i moves one place later; the lab builds it. |
| Find a value | O(n) | O(1) | Positions are indexed; values are not. A set or map indexes values. |
| Memory held | — | O(peak n) | This model never shrinks; halving at a quarter full gives memory back (below). |
An append that finds the buffer full copies n results and writes one: O(n) for that append. But doubling makes those appends rare. Copies happen at lengths 4, 8, 16 and so on, and each copy is as big as all the ones before it put together, plus four: 4 + 8 + 16 + … stays under 2n. With the n writes, any n appends cost fewer than 3n moves in all. That bound holds for every sequence of appends, not on average over lucky ones: each append is O(1) amortized, not O(1) every time.
| Appends | Copies | Writes |
|---|---|---|
| 1–4 | 0 | 4 |
| 5 | 4 | 1 |
| 6–8 | 0 | 3 |
| 9 | 8 | 1 |
| Total after 9 | 12 | 9 |
During growth both buffers exist, so that one append needs O(n) extra memory, and this model never shrinks: it holds memory for its largest size. Removing keeps order by shifting, so its cost depends on how many results come after the one removed, not on the result.
Giving memory back is the mirror of growth, with one trap. Shrink to fit as soon as half the slots are empty, and a list that hovers at that size thrashes: the next append finds the buffer full and copies everything up again, the next removal copies everything down. The safe rule halves the buffer only when it is a quarter full. After a halving it is half full, with room on both sides: with n results left, n more appends or n/2 more removals must come before the next copy, enough to pay for it, so both stay O(1) amortized. A JavaScript engine decides for itself whether an array gives memory back.
06Choose another structure
Use it when order and positions earn their keep.
A dynamic array is usually the simplest answer when you append, scan and read by position. A fixed-size array is simpler still when the size is known and cannot change. Choose something else when the work is not at the end:
- Stack: work at one end only. A dynamic array is a fine stack.
- Order does not matter: remove index i by moving the last entry into its slot (a swap-remove), O(1) with no shift.
- Queue or deque, or a ring buffer: items leave from the front. An array shifts everything on every removal from the front; these keep a moving start instead.
- Linked list: callers hold a particular item and insert or move around it, so nothing should shift. You give up reading by position.
- Hash map or hash set: you look items up by value, not position. The results log uses a set beside its array for exactly that.
Two things the cost table hides. Scanning an array is fast beyond its O(n): the slots sit next to each other, so the processor’s cache loads them in runs, while a linked structure follows a pointer to a new place in memory for every item. And amortized O(1) still includes one O(n) append now and then: if a single slow step matters (a frame budget, a latency target), reserve the size up front, or use a structure that grows in pieces.
07Make the call
Is this array doing the right work?
Before you open the answer below, say it in your own words: what do length and capacity each mean, why is append O(1) amortized but sometimes O(n), and when is an array the wrong structure?
One way to say itTwo numbers, one occasional copy, and where it fails
A dynamic array keeps its values in one block of slots: the length counts the slots in use, from the start, in order; the capacity counts the slots it has. Reading a position is one step. Appending writes into the next free slot, and when there is none it copies everything into a block twice the size. That copy is O(n), but doubling makes it rare enough that appends cost O(1) amortized: under 3n moves for n appends. Removing or inserting keeps order by shifting everything after the position, so work at the front is expensive. When items leave from the front, use a queue or ring buffer; when callers hold an item and move things around it, a linked list; when you look items up by value, a hash map or set.
Practice in the lab
Subtitlers work on a caption track: the lesson’s growing array, holding a film’s caption cues in order. Its buffer, from the checker’s plumbing, counts every cue written into a slot (a copy, a shift or a new cue), so the checker can tell a correct operation that is too slow from one that scales, however the code is written. Each level starts from the previous level’s finished code, so you can begin at any of them.
- DYNAMIC-ARRAY-101 (easy) A missed caption has nowhere to go
- DYNAMIC-ARRAY-102 (medium) Pasting a scene’s captions freezes the editor Pro
- DYNAMIC-ARRAY-103 (hard) Review: exporting a feature film takes minutes Pro