Build a Dynamic Array
Apologetic question: "What is built on a firm foundation?"
Project 2 — Build a Dynamic Array
“Everyone then who hears these words of mine and does them will be like a wise man who built his house on the rock.” — Matthew 7:24
Chapter: 2 — Arrays and the Memory You Can Feel
Due: End of Week 2
Submit: A link to a public GitHub repo containing dynamic_array.py, your test file, your benchmark scripts, and README.txt. Real toolchain, real git this term — see Appendix A for the Python + git setup.
Allowed tools: Python 3.10+, the standard library (time, sys, tracemalloc, unittest/pytest), a real editor, the textbook.
AI: Phase 1 (wk 1–8): AI is OFF. No assistants, no autocomplete-beyond-a-word, no “explain this.” You cannot reason about the cost of a structure you have never built yourself — and the entire point of this project is to build it so that in Phase 2 you can direct an agent who builds structures for you and know when it chose badly. Phase 2 (wk 9–16) will turn agentic AI on and require an agent-log.txt. Not yet.
The Setup
A small ministry is building a tool that ingests a stream of incoming items — prayer requests as they arrive, attendees as they check in, verses as a study group adds them — and the count is never known in advance. Sometimes it’s twelve. Sometimes it’s twelve thousand. The lead developer, who learned to code the year Python hid all the memory, keeps saying “just use a list, append is free.”
She’s mostly right. But “mostly free” has a shape, and the architect’s job is to know the shape — to know why append is usually instant and occasionally stalls, and to be able to say what it would cost to grow differently. The only way to know that cold is to build the thing list does for you. So you will.
You are going to implement a growable array on top of a fixed-capacity buffer — the exact structure CPython runs under list — and you are going to prove its costs with measurements, not assertions. By the end you will never again be surprised by why a real-time append loop occasionally hiccups, and you will be able to defend a growth policy the way the person who wrote your standard library had to.
This is the rock under every house you build the rest of the term. Found it well.
Learning Targets
By completing this project, you will demonstrate that you can:
- Implement a dynamic array on a raw fixed-capacity buffer, managing
capacityandlengthas separate invariants. - Implement doubling growth and explain — and measure — why
appendis amortized O(1). - Distinguish amortized from worst-case cost with real timings of individual operations.
- Implement and measure the O(n) cost of middle insert/delete and contrast it with O(1) append.
- Compare growth policies empirically and make a justified default recommendation, the way a standard-library author must.
- Write a measurement memo that relates measured curves to the cost table from §2.6 — the Phase 1 deliverable.
Normal Tier
Goal: Implement a DynamicArray class backed by a fixed-capacity buffer, with doubling growth, and prove the doubling and the amortized O(1) cost with tests.
Required features
- Raw storage, not a Python list-as-list. Your backing store is a Python list pre-sized with
Noneand used purely as a fixed-capacity buffer — like a C array. You may writeself._buffer = [None] * capacityand index it (self._buffer[i]). You may not callself._buffer.append(...),.insert(...),.pop(...),+=, slicing-to-grow, or anything else that lets Python’s own dynamic array do your growth for you. The growth is your job — that’s the project. - Core operations:
__init__(self)— start with a small capacity (1 or 2) and length 0.__len__(self)— return the current length (number of elements in use), O(1).get(self, i)/__getitem__— return elementi, O(1); raiseIndexErrorfor out-of-rangei.set(self, i, value)/__setitem__— overwrite elementi, O(1); raiseIndexErrorfor out-of-rangei.append(self, value)— add to the end; double the capacity when the buffer is full, copying existing elements one at a time in an explicit loop.__str__(self)— a readable representation of the elements in use (not theNonepadding): e.g.DynamicArray([10, 20, 30]).
- Invariant discipline.
0 <= length <= capacityholds after every operation. Grow before writing whenlength == capacity. (See the Common Bugs section of the chapter — this is the off-by-one that corrupts memory in C.) - Tests that prove the behavior (use
unittestorpytest):- Append
nitems;lenisn, andget(i) == ifor alli. - Capacity doubles: expose
capacity(a property or_capacityyou read in the test) and assert that after appending 1, 2, 3, 5, 9 items the capacity is 1→2→4→8→16 (or your chosen start, doubling). The grader checks you tested the doubling explicitly. - Amortized O(1): count total element-copies across
nappends (instrument_resize) and assert the total is< 2 * nfor severalnincludingn = 1_000_000. This is the amortized proof in code. get/setraiseIndexErrorout of range;setthengetround-trips a value.
- Append
Example output
A tiny driver (python3 dynamic_array.py) should be able to do this:
>>> da = DynamicArray()
>>> for x in [10, 20, 30, 40, 50]: da.append(x)
>>> print(da)
DynamicArray([10, 20, 30, 40, 50])
>>> len(da)
5
>>> da.capacity # internal, exposed for the lesson
8
>>> da.get(2)
30
>>> da.set(2, 99); da.get(2)
99
>>> da.get(5)
IndexError: index 5 out of range for length 5
And your amortized proof, run directly, should print something like:
n= 1 copies= 0 ratio=0.000
n= 16 copies= 15 ratio=0.938
n= 1000000 copies= 999999 ratio=1.000
PASS: total copies < 2n for all tested n (amortized O(1) confirmed)
Normal-tier rubric (out of 100)
| Criterion | Points |
|---|---|
DynamicArray runs and the example driver works | 6 |
Raw fixed buffer used correctly; no list.append/insert/pop/slicing for storage | 14 |
get/set/__len__/__str__ correct, with IndexError on out-of-range | 12 |
append with doubling-on-full, explicit element-by-element copy in _resize | 16 |
Invariant 0 <= length <= capacity maintained; grow-before-write | 8 |
| Test: appending n items, length and values correct | 8 |
| Test: capacity doubles (explicitly asserted at the right lengths) | 10 |
Test: amortized O(1) — total copies < 2n including n = 1,000,000 | 12 |
| Code clarity: short methods, named invariants, no dead code | 6 |
| README + reflection + AI honesty line | 8 |
Medium Tier (+up to 25% extra credit)
M1. insert(i, x) and delete(i), built right
Add:
insert(self, i, value)— insert at indexi, shifting elementsi..length-1one slot to the right (growing first if full). Validiis0..length. Shift right-to-left so you don’t overwrite elements you haven’t moved yet.delete(self, i)— remove elementi, shifting elementsi+1..length-1one slot left to close the gap, then decrement length. (Optionally null out the now-unused tail slot.) Validiis0..length-1.
Write tests for both, including front insert/delete (the most-shifting case), end insert/delete, and out-of-range errors.
M2. Measure that insert/delete are O(n) and append is amortized O(1)
Write a benchmark (benchmark_ops.py) that times append, insert(0, x) (front), and delete(0) (front) at increasing sizes (e.g. 1k, 2k, 4k, 8k, 16k operations). Produce a table of size vs time-per-operation. Show that per-operation time for front insert/delete grows with n (O(n) each → O(n²) for the loop) while append’s per-operation time stays roughly flat (amortized O(1)). Include the table in your README and write two sentences relating it directly to the cost table in §2.6.
Hard Tier (+up to 25% additional extra credit)
H1. Pluggable growth policy
Add a growth-policy parameter to __init__: DynamicArray(growth="double") where the choices are at least:
"double"— new capacity =old * 2."fixed_k"— new capacity =old + kfor a fixedkyou pick (e.g. 1024). Grow by a constant amount, not a factor."one_point_five"— new capacity =old + old // 2(1.5x; the policy Java’sArrayListactually uses).
All three must keep append correct. The point is to measure them, not just to support them.
H2. The million-append showdown
Write benchmark_growth.py. For each policy, append 1,000,000 elements and measure two things:
- Time: total wall-clock to do the million appends (median of a few runs).
- Space/work: total number of element-copies performed across all resizes (instrument
_resize), and peak capacity reached (a proxy for wasted memory). Optionally usetracemallocto confirm peak memory.
Produce a results table like:
| Policy | Total copies | Resizes | Peak capacity | Time (s) |
|---|---|---|---|---|
| double | … | … | … | … |
| one_point_five | … | … | … | … |
| fixed_k (k=1024) | … | … | … | … |
fixed_k should be visibly catastrophic (O(n²) copies — on the order of billions — and far slower). Don’t be surprised if you have to cut its n down to keep the run finite; if you do, document the smaller n and the projected cost at a million. That impracticality is the finding.
H3. The recommendation memo (the judgment piece)
Write MEMO.docx (half a page to a page). Using your own measured numbers, answer the question a standard-library author actually faces:
- Which growth policy would you make the default, and why? Weigh time (fewer copies) against wasted memory (higher peak capacity).
- Why is
fixed_kdisqualified outright? Name its asymptotic class and cite your numbers. - Java’s
ArrayListgrows by 1.5x; many C++vectorimplementations grow by 2x (some by ~1.5x). Given your data, why might a real library pick 1.5x over 2x even though 2x does fewer copies? (Hint: think about what happens to freed memory blocks and whether the next allocation can reuse them — the memory-reuse argument is the real reason 1.5x is popular, and it’s a judgment an agent can’t make for you without the constraint.) - State the constraint under which you’d switch your recommendation (e.g., “if peak memory is the binding constraint, I’d choose …; if append latency variance is binding, I’d choose …”).
This memo is the heart of the Hard tier. The numbers are necessary; the judgment about which number matters under which constraint is the architect’s work, and it’s exactly the work no tool does for you.
Submission
Submit one URL: a public GitHub repo.
What the repo must contain
dynamic_array.py— theDynamicArrayclass, with the example driver underif __name__ == "__main__":.test_dynamic_array.py— your tests (the Normal-tier proofs at minimum; insert/delete tests for Medium). Confirm they pass before submitting (python3 -m pytestorpython3 -m unittest).benchmark_ops.py(Medium) andbenchmark_growth.py(Hard) — your measurement scripts, runnable.MEMO.docx(Hard) — the growth-policy recommendation.README.txt— your reflection:
# Project 2 — Build a Dynamic Array
**Tier targeted:** Normal / Medium / Hard
**Features done:** (list)
**How append stays amortized O(1):** (two sentences, in your own words)
**Amortized proof:** (where in the tests; the largest n you proved it at)
**Measured costs:** (paste your Medium/Hard tables if applicable)
**Growth-policy recommendation:** (one line; full reasoning in MEMO.docx)
**What I learned:** (one paragraph)
**What I'd change:** (one sentence)
**AI usage:** NONE — Phase 1. Signed: <your name>
- Reflection comment block at the top of
dynamic_array.py— same fields as the README, condensed. - The code left runnable —
python3 dynamic_array.pyruns the driver and the amortized proof without errors.
Hints (Read Before You Begin)
- Capacity and length are different numbers. Tattoo this on your brain. Capacity is how big the buffer is; length is how much you’ve used. Almost every bug in this project is a confusion between the two. Grow when
length == capacity, write at indexlength, then incrementlength. - Grow before you write. If you write
self._buffer[self._length] = valuewhile the buffer is full, you go out of bounds. Check-and-grow first, always. (In Python you get anIndexError; in C this is the classic buffer overflow.) _resizecopies one element at a time, on purpose. You couldnew[:length] = old[:length], but don’t — the explicitfor i in range(self._length)loop is the O(n) work you’re studying, and seeing it is the point. Slicing hides the cost you’re here to measure.- For
insert, shift right-to-left. Move the last element first, then work backward towardi. If you shift left-to-right you’ll overwrite elements before you’ve moved them. Traceinsert(0, x)on paper for a 3-element array before you code it. - Instrument, don’t assume. To prove amortized O(1), add a counter to
_resize(self._copies += self._length) and read it in the test. Numbers you generated beat claims you repeated. - Time with
time.perf_counter(), run it more than once, report the median. The first run is cold. For the million-append benchmark, disable any debug printing inside the loop — printing dominates the timing and ruins the measurement.
What Mastery Looks Like (Beyond the Rubric)
A great Project 2 has a DynamicArray whose append you can read in ten seconds and immediately see the invariant: full? double. then write. then bump length. The _resize is an explicit copy loop with no cleverness. The tests don’t just check that it works — they prove the cost claims: a test named test_capacity_doubles and a test named test_append_amortized_constant that fails loudly if someone “optimizes” the growth into something quadratic.
A great Hard tier produces a memo that a real standard-library maintainer would nod at. It doesn’t just say “double is fastest.” It weighs copies against wasted memory, disqualifies fixed_k with its O(n²) class and the measured billions of copies, and engages honestly with why 1.5x is so common despite doing more copies than 2x — the memory-reuse argument. That last point is judgment under a constraint, and it is the whole reason Phase 1 makes you build and measure before Phase 2 lets you delegate.
Coach’s Note — Students are tempted to make
_resize“smart” — slice, comprehend, batch. Resist. The unclever explicit copy is what makes the cost visible, and visibility is the entire deliverable. You are not building the fastest dynamic array on earth; CPython already did that in C. You are building the one whose every cost you can see and defend. That second thing is what makes you an architect. The first is just a faster house on a foundation you don’t understand.
When You’re Done
- Run
python3 dynamic_array.py. Confirm the driver and the amortized proof both print clean. - Run your tests. All green. Then deliberately break
_resizeto grow by+1instead of*2, re-run the amortized test, and watch it fail (copies blow past2n). Restore. This is the red-green discipline from Coding 2 applied to a cost claim. - (Hard) Run
benchmark_growth.py, paste the table intoMEMO.docx, and write the recommendation. - Read your own
appendand_resizeslowly. Could a stranger see the doubling and the invariant from the code alone? If not, simplify. - Commit, push, submit the repo URL.
- Read Chapter 3. Linked lists next — the structure that claims to beat the array’s O(n) middle. You’ll benchmark whether it actually does, and the cache lesson from §2.2 will have something to say about the answer.
A theological footnote. The wise builder in Matthew 7 was not wiser because his house was bigger. He was wiser because he understood what his house stood on — and built accordingly when the easier, sandier ground was right there. You spent this week refusing the easy ground. You did not type
list.appendand move on; you built the thing it does, measured what it costs, and now you understand the rock the rest of the term stands on. The storms — the scale, the latency spike, the memory ceiling — come for every program. The house founded on understood costs is the one still standing when they do. Build on the rock.
See you next week.