3.2.1 · D4Linear Data Structures

Exercises — Array — static, dynamic; cache locality; amortized O(1) append

3,633 words17 min readBack to topic

The three tools we lean on:

  • the address formula (base address plus index times element size),
  • the geometric-series total-copy bound (derived in full in Solution 3.1),
  • the cache-line model elements per fetch.

Before we start, one shared picture of the machinery we are testing:

Figure — Array — static, dynamic; cache locality; amortized O(1) append

How to read this figure. Each cyan box is one array slot, labelled arr[0], arr[1], … along the bottom. Under each box, in amber, is its actual memory address computed by : 2000, 2004, 2008, … The amber double-arrow across the top marks the constant gap bytes between neighbours. The single idea the picture carries: because the gap never changes, the address of any box is one multiply plus one add — you jump straight to it, never walking the boxes in between.


Level 1 — Recognition

Goal: can you name the fact and read it off directly?

Problem 1.1

An int array (each element bytes) begins at base address . Using only the address formula, where does element arr[7] live?

Recall Solution 1.1

WHAT we do: apply with , , . WHY this tool: the address of any element is computed, not searched — that is the whole point of contiguity. Notice we never touched elements 0–6. Answer: 2028.

Problem 1.2

A dynamic array reports size = 6, capacity = 8. Answer each: (a) How many elements are actually stored? (b) How many slots are allocated? (c) Will the next append trigger a resize?

Recall Solution 1.2

WHAT/WHY: size = elements you put in; capacity = slots allocated; a resize fires only when size == capacity.

  • (a) size = 66 elements stored.
  • (b) capacity = 88 slots allocated.
  • (c) After one append size becomes 7, still . So no resize — it's a cheap O(1) write into slot index 6.

Problem 1.3

Two data structures store the same 1,000,000 integers. Both have scan cost in Big-O Notation. One runs ~7× faster in wall-clock time. Which one, and what single property causes it?

Recall Solution 1.3

Answer: the array. The property is contiguity (contiguous memory with fixed element size). WHY: the CPU loads a whole cache line at once; array neighbours ride in together, so most reads are cache hits. A Linked List scatters nodes, so each next is likely a cache miss. Same Big-O, very different constant.


Level 2 — Application

Goal: plug numbers into the derived formulas correctly.

Problem 2.1

A double array uses bytes per element, base . Find the addresses of arr[0], arr[3], and arr[10]. What is the gap between consecutive elements?

Recall Solution 2.1

Gap: bytes — constant and predictable, which is exactly what lets the hardware prefetcher guess ahead. Answers: 4096, 4120, 4176; gap = 8.

Problem 2.2

A cache line is bytes; elements are short at bytes each. (a) How many elements load per fetch? (b) Scanning 800,000 shorts, roughly how many slow RAM fetches?

Recall Solution 2.2

WHY this tool: one memory fetch loads contiguous elements; scanning (recall: = how many elements we touch) costs fetches.

  • (a) elements per fetch.
  • (b) fetches (versus 800,000 if scattered).

Problem 2.3

A doubling dynamic array starts with capacity = 1. You call append exactly 5 times (so here ). Total copy work? Verify it is below the bound.

Recall Solution 2.3

Resizes fire when size == capacity, at sizes (each copies the current elements before doubling):

  • append 1 → cap 1, write. copies 0
  • append 2 → size(1)==cap(1): copy 1, cap→2
  • append 3 → size(2)==cap(2): copy 2, cap→4
  • append 4 → cap 4, write. copies 0
  • append 5 → size(4)==cap(4): copy 4, cap→8 Bound check: , and . ✓ Total copies = 7.

Level 3 — Analysis

Goal: explain the mechanism, not just compute.

Problem 3.1

Prove that with doubling growth the total copy cost over appends is , starting from an empty array of capacity = 1. Show the geometric-series step explicitly.

Recall Solution 3.1

WHAT: sum the copies at every resize. Resizes occur at sizes where . WHY these numbers: a resize copies the current count, then doubles capacity, so the counts copied are exactly the powers of two. Deriving the geometric-series identity from scratch (do not just cite it — earn it). Call the sum : Now multiply every term by the ratio : WHY multiply by 2? Because doubling shifts the whole list up by one term, so almost everything cancels when we subtract. Line up and subtract from : Every middle term () appears in both sums and cancels; only the new top and the old bottom survive. So: Now the cheap writes. In our cost model each unit of work = "one element touched." Every one of the appends performs exactly one write of the new element into its slot (old[size] = x) — that is 1 unit each, so appends contribute write-units. These are separate from copy-units. Total work . Divide by appends → amortized. ∎ (This is the amortized argument.) The figure below plots exactly this — watch the cyan copy-total stay pinned under the amber line.

Figure — Array — static, dynamic; cache locality; amortized O(1) append

How to read this figure. The horizontal axis is , the number of appends so far; the vertical axis is the running total of elements copied. The cyan staircase jumps upward only at the rare resize points () and stays flat in between — visual proof that most appends copy nothing. The dashed amber line is the ceiling . The cyan staircase never crosses it: that gap is the -amortized guarantee made visible.

Problem 3.2

Your teammate grows the array by +10 slots each time it fills (additive, not multiplicative). Over appends, derive the total copy cost and its amortized per-append cost. Why is this bad?

Recall Solution 3.2

WHY additive fails: with a fixed step , resizes happen at sizes — that is every 10 appends, and the count copied keeps growing linearly. There are about resizes, and the -th resize copies elements. The copies form an arithmetic series (not geometric — the terms grow by a constant difference , not a constant ratio): Deriving the arithmetic-sum from scratch (call ). Write the sum forwards and backwards and add termwise: Each of the paired columns sums to , so twice the sum is : WHY the pairing trick? Pairing the largest with the smallest makes every column identical, turning a sum into one multiplication — the classic Gauss argument. Substitute : (the is negligible for large ). That is total → dividing by appends gives amortized per append. Terrible. The lesson: you must grow by a multiplicative factor (×2, ×1.5). The gaps between resizes must grow, not stay constant.

Problem 3.3

Two arrays of 1,000,000 ints (), cache line . Array A is scanned front-to-back. Array B is scanned by jumping every 16th element (stride 16). Compare the number of cache-line fetches. Explain the result.

Recall Solution 3.3

Elements per line: .

  • Array A (stride 1): consecutive elements share lines. Fetches .
  • Array B (stride 16): each accessed element lands in a different line (they're exactly one line apart), so you touch a fresh line every read. Accesses made , and each costs its own fetch → fetches for only elements used. The point: A gets 16 elements per fetch (locality wins); B gets ~1 useful element per fetch (locality destroyed by the stride). Same Big-O, but B wastes every cache line — a classic "cache-unfriendly access pattern."

Level 4 — Synthesis

Goal: combine growth, copying, and memory into one design decision.

Problem 4.1

A library grows its array by ×1.5 instead of ×2 (start capacity 1, rounding up). (a) Is append still amortized ? (b) What is the trade-off versus ×2 in wasted memory?

Recall Solution 4.1

(a) Yes. Any growth factor gives a geometric series with ratio . Total copies: (This closed form is the same "multiply-by- and subtract" derivation as Solution 3.1, now with ratio instead of : , so .) With : constant , so amortized. (For the constant is .) (b) Trade-off: ×1.5 wastes less memory (worst-case ~50% over-allocation vs ~100% for ×2) but copies more often (bigger constant, 3 vs 2). ×1.5 = memory-friendly; ×2 = fewer copies. Both are amortized — you're choosing the constant.

Problem 4.2

Design the append(x) operation in pseudocode and annotate the cost of each branch. Then state the worst-case single-append cost and the amortized cost, and say why they differ.

Recall Solution 4.2
append(x):
    if size == capacity:                 # array is full
        newcap = max(1, 2 * capacity)    # geometric growth
        B_new  = allocate(newcap * s)    # cost model below
        copy old[0..size-1] -> B_new     # O(n)  <-- the expensive part
        free(old);  B = B_new;  capacity = newcap
    old[size] = x                        # O(1) write
    size = size + 1
  • Cheap branch (size < capacity): just the write + increment → O(1).
  • Expensive branch (size == capacity): copy all size elements → O(n).
  • The allocate cost model. We treat allocate(newcap * s) as O(1). WHY is that fair? A modern allocator reserves a block by handing back a pointer and recording the size in its bookkeeping — it does not touch every byte of the block (the memory is left uninitialised until you write it). So the reservation itself is a constant-time bookkeeping step; the genuine per-element work happens in the copy line, which we already charge as . (If instead you demanded zero-initialised memory, the zeroing would be — but that is still absorbed into the same geometric total, so the amortized result is unchanged.)
  • Worst-case single append: (the resize copy).
  • Amortized append: (Problem 3.1: total copies over appends). Why they differ: the resize is rare — its cost, spread across the many cheap appends that follow, averages to a constant. Worst-case looks at one call; amortized looks at the whole sequence.

Problem 4.3

A stack is built on a dynamic array: push = append, pop = size--. You push items then pop them all. (a) Total cost of the pushes? (b) Total cost of the pops? (c) Overall amortized cost per operation?

Recall Solution 4.3
  • (a) pushes with doubling: total (copies plus writes).
  • (b) pop just decrements size (no shrink assumed): each is , total .
  • (c) Total work over operations , so per operation amortized. That's why an array-backed stack is the standard, fast choice.

Level 5 — Mastery

Goal: prove and generalize.

Problem 5.1

Prove: for any constant growth factor , doubling-style append is amortized, and state the exact amortized copy-constant as a function of . Then find the that minimizes copies-per-element as and interpret.

Recall Solution 5.1

Resizes copy with . Total copies use the geometric closed form derived in Solution 3.1 (multiply-by-, subtract): using . So amortized copies per append: This is a finite constant for every amortized. ∎ Behaviour of : it decreases as grows: , , , and as . Interpretation: bigger → fewer copies per element (approaching 1, the theoretical floor) but worse wasted memory (worst-case over-allocation ). So trades time-constant against space-constant. Real libraries pick as the sweet spot.

Problem 5.2

You are told a mystery container has worst-case (not amortized) append and index access. Can it be a plain dynamic array? Argue rigorously, and name what you'd combine to approach worst-case append.

Recall Solution 5.2

No — a plain dynamic array cannot have worst-case append. Proof by the pigeonhole of contiguity: to keep elements contiguous (which is required for index access via ), a full array must move to a larger contiguous block, and some append must pay the copy. If that copy stayed , you could only ever copy a constant number of elements, contradicting that the block holds of them. Hence at least one append is → worst-case is not . How to approach it: use incremental / geometric resizing with copy-spreading — during the "next" resize, copy a fixed few old elements per append (keep both blocks alive, migrate lazily). This bounds each append's work by a constant while preserving contiguous access on the new block. (This is the classic "amortized→worst-case deamortization" trick; contrast with a Hash Table or Linked List, which give different worst-case profiles.)

Problem 5.3

A dynamic array of int () currently has capacity = 16 sitting in cache lines of bytes. (a) How many cache lines does the entire allocated block occupy? (b) After a doubling resize to capacity = 32, how many cache lines does the new block need? (c) During the resize copy, roughly how many line-fetches to read all 16 old elements sequentially?

Recall Solution 5.3
  • (a) Block bytes . Lines line.
  • (b) New block bytes . Lines lines.
  • (c) Reading 16 contiguous ints sequentially: line-fetch to stream all of them. Contiguity makes even the copy step itself cache-efficient — the resize is in element-work but touches memory minimally.

Recall One-glance recap of every formula used

::: address of element i (base + index × element size) Elements loaded per cache fetch ::: (line bytes ÷ element bytes) Total copies, doubling, n appends ::: Amortized copy-constant for factor g ::: (2 for ×2, 3 for ×1.5, →1 as g→∞) Additive (+c) growth total copies ::: arithmetic series Worst-case single append (dynamic array) ::: (the resize copy)