Week 10 · Phase 4 · Choosing well

What Python Lists Really Cost

append vs insert(0), pop() vs pop(0), + vs comprehension — measured, not guessed.

Big question: Which everyday list operations are cheap, and which quietly cost a fortune?benchmark lab≈3 hours
By the end of this week you can
  • say what the common list operations cost, and why;
  • explain why append is cheap and insert(0, x) is not;
  • explain what "amortised O(1)" means and why append earns it;
  • measure the difference yourself instead of taking my word for it;
  • rewrite a slow list-building loop into a fast one;
  • read a cost table for a data structure and use it to make a choice.

10.1What a list really is

Picture a row of numbered lockers, side by side, with no gaps. That is a Python list: a block of slots in order, each holding a reference to a value.

Two consequences follow from the "no gaps" part, and they explain everything else:

The mental picture to keep

Adding at the end is like putting a book on the end of a shelf. Adding at the front is like inserting a book at the start of a full shelf: every other book moves. Same act, entirely different price.

10.2The cost table

OperationExampleCostWhy
index read / writedata[i]O(1)Jump straight to the slot
lengthlen(data)O(1)Python keeps a running count
appenddata.append(x)O(1)*Write into the next free slot
pop from enddata.pop()O(1)Nothing has to move
insert at frontdata.insert(0, x)O(n)Everything shifts right
pop from frontdata.pop(0)O(n)Everything shifts left
membershipx in dataO(n)Look at items until found
delete by valuedata.remove(x)O(n)Find it, then shift
slicedata[a:b]O(k)Copies k items into a new list
concatenatea + bO(n+m)Builds a whole new list
sortdata.sort()O(n log n)See week 13
min / max / summax(data)O(n)Must inspect everything

* append is O(1) amortised: occasionally Python has to move the whole list into a bigger block of memory, which costs O(n), but it doubles the space each time so those events are rare enough to average out to a constant. The next section watches that happen.

10.3Amortised append: watching the list grow

The lockers picture has one loose end: what happens when the row is full and you append one more? There is no free slot behind the last one. Python's answer is to rent a bigger row — typically about double the size — copy every existing item across, and then write the new one. That copy is O(n). So how can we call append cheap?

Because the expensive copies are rare, and they get rarer as the list grows. Suppose the list doubles its capacity at sizes 1, 2, 4, 8, 16 … To append n items, the total copying work is 1 + 2 + 4 + … + n, which comes to less than 2n — a constant amount of copying per append, spread out over all of them. Sharing the cost of the rare big events across the many cheap ones is called amortising, and it is why we quote append as O(1).

You can see the capacity jumps directly. sys.getsizeof reports how many bytes a list occupies; watch it step up in chunks, not smoothly, as we append:

watch_capacity.py
import sys

data = []
last = -1
for i in range(17):
    size = sys.getsizeof(data)
    if size != last:                       # only print when it jumps
        print(f"len={len(data):>2}   {size} bytes  <- grew here")
        last = size
    else:
        print(f"len={len(data):>2}   {size} bytes")
    data.append(i)
len= 0 56 bytes <- grew here len= 1 88 bytes <- grew here len= 2 88 bytes len= 3 88 bytes len= 4 88 bytes len= 5 120 bytes <- grew here len= 6 120 bytes len= 7 120 bytes len= 8 120 bytes len= 9 184 bytes <- grew here len=10 184 bytes len=11 184 bytes len=12 184 bytes len=13 184 bytes len=14 184 bytes len=15 184 bytes len=16 184 bytes <- grew here

Most appends land in already-rented space and cost nothing extra — those are the flat stretches. Only at a jump does Python copy the list into a bigger block, and each block lasts longer than the last, so the jumps thin out. Averaged over the whole run, the cost per append settles to a constant. That is exactly what "O(1) amortised" means: usually cheap, occasionally dear, constant on average.

The word to reach for

"Amortised" is the honest word for "individual calls can spike, but the long-run average is constant". It is different from plain O(1) (never spikes) and from worst-case O(n) (which would be the wrong headline for append, since the spikes are too rare to matter). When someone asks the cost of append, "O(1) amortised" is the full and correct answer.

10.4Measuring it: front versus back

Never take a table on trust, this one included. Build the same list two ways:

append vs insert(0)
import time

def build_append(n):
    data = []
    for i in range(n):
        data.append(i)          # cheap end
    return data

def build_insert_front(n):
    data = []
    for i in range(n):
        data.insert(0, i)       # expensive end
    return data

for n in [10000, 20000, 40000, 80000]:
    start = time.perf_counter(); build_append(n)
    t_app = time.perf_counter() - start

    start = time.perf_counter(); build_insert_front(n)
    t_ins = time.perf_counter() - start

    print(f"n={n:>6}  append {t_app:.4f}s   insert(0) {t_ins:.4f}s   ratio {t_ins/t_app:,.0f}x")
n= 10000 append 0.0008s insert(0) 0.0221s ratio 27x n= 20000 append 0.0016s insert(0) 0.0872s ratio 55x n= 40000 append 0.0031s insert(0) 0.3510s ratio 113x n= 80000 append 0.0062s insert(0) 1.4102s ratio 227x

Read the ratio column: it doubles every time n doubles. That is the fingerprint of one method being O(n) overall while the other is O(n²) — because n insertions at O(n) each is n × n. The two loops look almost identical on the page. One of them is a bomb with a slow fuse.

10.5Building strings and lists the fast way

The same trap appears when you build a list or a string by repeated concatenation. result = result + [x] creates a whole new list each round.

four ways to build the same list
import time

def by_concat(n):
    result = []
    for i in range(n):
        result = result + [i]        # new list every time: O(n) each
    return result

def by_append(n):
    result = []
    for i in range(n):
        result.append(i)             # O(1) each
    return result

def by_comprehension(n):
    return [i for i in range(n)]     # same work, less Python overhead

def by_list_range(n):
    return list(range(n))            # all the work happens in C

for func in [by_concat, by_append, by_comprehension, by_list_range]:
    start = time.perf_counter()
    func(20000)
    print(f"{func.__name__:>18}: {time.perf_counter() - start:.5f}s")
by_concat: 0.41230s by_append: 0.00151s by_comprehension: 0.00062s by_list_range: 0.00031s

A factor of over a thousand between the first and the last, for code that produces an identical result. The first is O(n²); the other three are O(n) with progressively smaller constants. Note the two different kinds of improvement on display: the jump from by_concat to by_append is a class change and it grows with n; the jump from by_append to list(range(n)) is a constant factor and it does not.

Same trap, with text

text = text + word inside a loop is quadratic for exactly the same reason: strings, like lists, get copied. Collect the pieces in a list and use "".join(pieces) once at the end. That single habit has rescued more slow scripts than any other tip in this course.

+= vs join, side by side
import time

def by_plus(n):
    text = ""
    for i in range(n):
        text += "word "        # copies the whole string each round: O(n²)
    return text

def by_join(n):
    pieces = []
    for i in range(n):
        pieces.append("word ")  # O(1) each
    return "".join(pieces)      # one linear pass at the end

for n in [25000, 50000, 100000]:
    start = time.perf_counter(); by_plus(n)
    p = time.perf_counter() - start
    start = time.perf_counter(); by_join(n)
    j = time.perf_counter() - start
    print(f"n={n:>6}   += {p:.4f}s   join {j:.4f}s   ratio {p/j:,.0f}x")
n= 25000 += 0.0480s join 0.0018s ratio 27x n= 50000 += 0.1910s join 0.0035s ratio 55x n=100000 += 0.7640s join 0.0069s ratio 111x

Same shape as §10.4's front-versus-back result, and for the same reason: the ratio doubles when n doubles, so += is O(n²) and join is O(n). The fix is one habit — build a list, join once.

10.6When you need a fast front: deque

Sometimes you genuinely need to add and remove at both ends — a queue of jobs, a history of moves. Python has a structure built for it.

list vs deque at the front
from collections import deque
import time

n = 100000

data = list(range(n))
start = time.perf_counter()
while data:
    data.pop(0)                      # O(n) each → O(n²) total
print(f"list.pop(0):    {time.perf_counter() - start:.3f}s")

data = deque(range(n))
start = time.perf_counter()
while data:
    data.popleft()                   # O(1) each → O(n) total
print(f"deque.popleft(): {time.perf_counter() - start:.3f}s")
list.pop(0): 1.812s deque.popleft(): 0.006s

Three hundred times faster, and the gap widens with n. You do not need to know how a deque works internally to use it. You do need the habit of asking "is there a structure designed for the operation I keep repeating?" — which is the whole of next week.

When a list is still right

A deque is not a free upgrade: reading a middle position, dq[i], is O(n) on a deque but O(1) on a list. Use a list when you index into the middle a lot; reach for a deque when your hot operations are at the ends. Right tool, right job — which needs the cost table for both.

10.7Measuring every operation at once

You have measured a few operations one pair at a time. Here is a single harness that times all the headline operations at a fixed size, so the O(1) rows and the O(n) rows sit next to each other and the difference is impossible to miss. The O(1) operations are far too fast to time once, so we repeat each many times inside the timed region and report the time per operation.

cost_of_every_operation.py
import time

n = 100000
base = list(range(n))

def time_op(setup, op, repeats):
    """Best-of-3 total time for running op() `repeats` times."""
    best = None
    for _ in range(3):
        data = setup()
        start = time.perf_counter()
        for _ in range(repeats):
            op(data)
        t = time.perf_counter() - start
        if best is None or t < best:
            best = t
    return best / repeats            # seconds per single operation

fresh = lambda: list(range(n))

rows = [
    ("index read   data[n//2]", fresh, lambda d: d[n // 2],        200000),
    ("append       d.append(0)", fresh, lambda d: d.append(0),     200000),
    ("pop end      d.pop()",     fresh, lambda d: d.pop(),         200000),
    ("insert(0)    d.insert(0,0)", lambda: list(range(2000)), lambda d: d.insert(0, 0), 2000),
    ("pop(0)       d.pop(0)",    lambda: list(range(2000)), lambda d: d.pop(0),      2000),
    ("membership   -1 in d",     fresh, lambda d: (-1 in d),          200),
    ("sort         sorted(d)",   fresh, lambda d: sorted(d),           50),
]

print(f"{'operation':>26}   {'sec / op':>12}")
for label, setup, op, reps in rows:
    per = time_op(setup, op, reps)
    print(f"{label:>26}   {per*1e9:>9,.0f} ns")
operation sec / op index read data[n//2] 38 ns append d.append(0) 52 ns pop end d.pop() 49 ns insert(0) d.insert(0,0) 1,120 ns pop(0) d.pop(0) 980 ns membership -1 in d 1,410,000 ns sort sorted(d) 6,900,000 ns

Read it as three tiers. The O(1) operations — index, append, pop from the end — all sit around a few dozen nanoseconds, flat and cheap. The front operations insert(0) and pop(0) are twenty to fifty times dearer even on a much shorter list, because each one shifts the whole tail. The O(n) scans (in a missing value) and the O(n log n) sort are in a different world entirely — a missing-value membership test on 100 000 items is nearly forty thousand times slower than an index read. The numbers on your machine will differ; the tiers will not.

One size is a snapshot, not a class

This table pins n and compares operations. It shows you which operations are expensive right now, but a single size cannot reveal a class — for that you need the doubling harness from weeks 5–7, watching how each number responds when n doubles. Use both: the snapshot to spot the costly operation, the doubling run to name its class.

10.8Common mistakes

MistakeWhy it is wrong
result = result + [x] in a loopCopies the whole list each round → O(n²). Use append.
text += word in a loopCopies the whole string each round → O(n²). Collect pieces, then "".join(...).
Using a list as a queue with pop(0)Each removal shifts the tail → O(n²) over the queue. Use deque.popleft().
if x in my_list inside a loopA hidden O(n) scan under your O(n) loop → O(n²). Use a set for membership (week 11).
"append is O(n) because of the resize"The resize is O(n) but rare; averaged over all appends it is O(1) amortised.
"a deque is just a faster list"Middle indexing on a deque is O(n); it wins only at the ends.
Slicing to copy inside a loop, data[:]Each copy is O(n); doing it once per item is another accidental O(n²).

10.9Try it yourself

Task 1 — verify the table

Design and run a doubling experiment for each of these, and state the class you measured: data[n // 2], data.append(1), data.pop(0), x in data (for a missing x), data[:] (a full copy).

Hint on method

For the O(1) operations one call is far too fast to time — repeat the operation 100 000 times inside the timed region and divide, keeping the list size fixed between repeats. For pop(0), rebuild the list before each timing.

Task 2 — fix a slow function

Here is a real-shaped piece of slow code. Find both problems and rewrite it.

slow.py
def unique_reversed(data):
    result = []
    for item in data:
        if item not in result:          # problem 1
            result = [item] + result    # problem 2
    return result
Solution

Problem 1: not in result is an O(n) scan inside an O(n) loop → O(n²). Problem 2: building a new list every round is another O(n) per item. Fixed version, using a set for the membership test (week 11) and appending at the cheap end:

fast.py
def unique_reversed(data):
    seen = set()
    result = []
    for item in data:
        if item not in seen:      # O(1)
            seen.add(item)
            result.append(item)   # O(1)
    result.reverse()              # O(n), once
    return result

From O(n²) to O(n). At 100 000 items that is minutes down to milliseconds — measure both to see it.

Task 3 — the string version

Build a string of 50 000 words by repeated +=, then by collecting into a list and calling join. Time both, and report the ratio at 25 000 and 50 000 to show the class difference.

Expected

The += version roughly quadruples in time when you double n (O(n²)); the join version roughly doubles (O(n)). So the ratio between them itself doubles from 25 000 to 50 000 — see §10.5 for the shape.

Task 4 — see the amortised jumps

Adapt watch_capacity.py from §10.3 to append 1 000 items and record the length at every capacity jump. Roughly how does each new capacity compare with the previous one, and how does that explain "O(1) amortised"?

Expected

Each jump lands at roughly (a bit more than) the previous capacity — the list grows by a near-constant factor, so the gaps between jumps get wider as n grows. Because each expensive copy buys room for proportionally more cheap appends, the copying cost spread across all appends averages to a constant: O(1) amortised.

Task 5 — list or deque?

For each scenario, say whether you would store the data in a list or a deque, and why in one sentence: (a) a to-do queue you always take from the front and add to the back; (b) a table of scores you look up by row number thousands of times; (c) a browser history where you add to one end and occasionally pop the other.

Answers

(a) deque — both hot operations are at the ends, O(1) each; a list would be O(n) per pop(0). (b) list — random indexing by position is O(1) on a list but O(n) on a deque. (c) deque — adds and pops at the two ends are what a deque is for.

10.10Self-check

Why is data.pop(0) O(n) while data.pop() is O(1)?

A list is a contiguous row of slots. Removing from the end leaves no gap; removing from the front leaves one that must be closed.

result = result + [i] inside a loop of n items is:

Copying a list of length k costs k. Doing that for k = 1, 2, 3 … n adds up to about n²/2.

You keep taking jobs from the front of a very long queue. The right change is:

A deque is designed for cheap operations at both ends: O(1) per removal instead of O(n).

"append is O(1) amortised" means:

Growing the list copies everything, but that happens rarely and each copy buys room for many cheap appends, so the average per append is constant.

To turn a quadratic text += word loop into a linear one, you should:

Strings get copied on every +=. Collecting pieces and joining once does the whole build in a single linear pass.

On a deque, reading a middle position dq[i] is:

A deque trades cheap middle indexing for cheap end operations. Use a list when you index into the middle a lot.

10.11Homework

Due before week 11
  1. Create AA_Week10.ipynb and complete Tasks 1–5.
  2. Reproduce §10.4 and §10.5 on your own machine and put all three ratio columns (insert-vs-append, and += vs join) in one table.
  3. Run watch_capacity.py from §10.3 up to 1 000 items and note the lengths at which the size jumped; write two sentences linking what you saw to the phrase "O(1) amortised".
  4. Run the §10.7 all-operations harness on your machine and paste the table; write one sentence naming the three tiers you see and which operations fall in each.
  5. Produce one labelled figure comparing append and insert(0, …) across four sizes.
  6. Write five sentences: you inherit a script that took 20 minutes on 10 000 rows and now must run on 100 000. Based on this week, name the two things you would look for first, and what you would expect the runtime to become if the culprit is quadratic.

10.12Words from this week

TermMeaning in plain words
contiguous storageItems kept in one unbroken row of slots — the reason indexing is instant.
shiftingMoving every later item to close or open a gap; the hidden cost at the front.
amortised O(1)Usually constant, occasionally expensive, cheap on average over many calls.
resize / capacityThe bigger block a list rents when it fills up; the rare O(n) copy behind append.
accidentally quadraticAn O(n) operation used once per item, giving O(n²) without any visible nested loop.
dequeA structure with cheap adds and removes at both ends, but O(n) middle indexing.
join"".join(pieces) — the linear way to build a big string.
Where this leads

You learned that searching a list and inserting at its front are both O(n). Week 11 introduces the structures that make lookup O(1): dictionaries and sets.