Question 1 · easy
Inserting a key into a packed sorted array may require shifting items. What is the tightest listed worst-case upper bound?
Answer: option C. Finding the spot is O(log n), but shifting the later items over is O(n).
append vs insert(0), pop() vs pop(0), + vs comprehension — measured, not guessed.
Which everyday list operations are cheap, and which quietly cost a fortune?
A Python list stores references in an array. Access by index is O(1). Removing its last item avoids shifting the others; repeatedly removing the first creates quadratic work.
Most appends place one reference; a resize copies many. Spread all resizing work over the whole sequence: append costs O(1) amortised. An individual append can still take O(n). Python uses its own overallocation rule.
| Operation | list | collections.deque |
|---|---|---|
| Read a middle index | O(1) | O(n) |
| Append at an end | O(1) amortised | O(1) |
| Remove from the front | O(n) | O(1) |
| Search for a value | O(n) | O(n) |
from collections import deque
queue = deque(["A", "B", "C"])
queue.append("D")
first = queue.popleft()
print(first, list(queue))A deque fits a queue: add at the back, remove at the front. It does not make arbitrary searches constant-time.
parts = ["algorithm", "analysis", "with", "Python"]
text = " ".join(parts)
print(text)join builds the final text in work proportional to total output length. Some Python string-concatenation patterns are optimised, so timings may vary; use the copying model only when each step really copies the prefix. Repeated list result = result + [x] does copy the growing list.
10 test questions · 9 written questions · 19 total
Each question is followed by its answer and explanation. Hard questions also include a starting hint and smaller reasoning steps.
Choose one option. Cost questions state their model and whether the bound is tight, expected or worst-case. Here lg means log₂, and heap positions start at 1.
Question 1 · easy
Inserting a key into a packed sorted array may require shifting items. What is the tightest listed worst-case upper bound?
Answer: option C. Finding the spot is O(log n), but shifting the later items over is O(n).
Question 2 · easy
A dynamic array starts with capacity 1 and doubles only when full. How many doublings are needed to hold 5,000 items?
Answer: option B. 2¹² = 4,096 is too small and 2¹³ = 8,192 is enough.
Question 3 · medium
Growing a full dynamic array from capacity 1 to capacity 1,024 by doubling copies exactly how many existing items in total?
Answer: option B. 1 + 2 + 4 + … + 512 = 1,023, less than n.
Question 4 · medium
If the array instead grows by 100 slots each time it fills, inserting n items costs in total:
Answer: option C. About n/100 expansions, each copying up to n items.
Question 5 · medium · course question
In a packed array-backed list of n items, inserting at the front shifts how many existing items?
Answer: option A. Every existing item moves one slot to make room. This gives linear work for a front insertion.
Question 6 · medium · course question
For an ordinary array-backed list, what is the tight worst-case cost of reading an existing index?
Answer: option D. The slot address is computed directly from the index under the standard RAM model. No scan of preceding items is needed.
Question 7 · medium · course question
Why is a single append sometimes slower than most other appends to a dynamic array?
Answer: option B. When capacity is exhausted, resizing can copy existing items. Most appends use an already available slot, giving constant amortised cost with geometric growth.
Question 8 · medium · course question
A slice copies k references from an ordinary Python list into a new list. What is the tight time bound in k?
Answer: option C. The operation must place k references in the new list. Copying references is different from recursively copying the referred-to objects.
Question 9 · medium · course question
Build a new list by repeatedly replacing result with result + [x] for n items. If each concatenation copies the growing list, what is the total copied-item growth?
Answer: option A. The copies have lengths 1,2,…,n. Their sum n(n+1)/2 grows quadratically; repeated append avoids copying the entire prefix on every step.
Question 10 · medium · course question
A dynamic array has capacity 8 and currently holds 8 items. It doubles capacity before storing one more. What are its new length and capacity?
Answer: option D. Only one new item is added, so length becomes 9. Doubling the allocated space changes capacity from 8 to 16.
Read each question together with its explanation, trace or proof. Numbering continues from the test questions.
Question 11 · easy
List the seven dictionary operations.
search, insert, delete, minimum, maximum, predecessor, successor.
Question 12 · easy
In a sorted array, what do search and insert cost, and why is insert slow?
search O(log n) by binary search; insert O(n), because after finding the right spot every later item must shift one place to make room.
Question 13 · easy
Starting with a dynamic array of size 1 and doubling whenever it fills, how many doublings are needed before it can hold 1,000 items?
10, since 2¹⁰ = 1,024 ≥ 1,000; in general ⌈lg n⌉.
Question 14 · medium
A dynamic array holds n = 2ᵏ items after growing from size 1 by doubling. How many item-copies were made in total over all the doublings? Show the total is less than n.
the doublings copied 1, 2, 4, …, 2ᵏ⁻¹ items, totalling 2ᵏ − 1 = n − 1 < n. So the copying costs O(n) in total, or O(1) per item on average (“amortised”).
Question 15 · medium
Suppose the array grows by adding 10 slots each time it fills, instead of doubling. What does inserting n items cost in total?
Θ(n²). About n/10 expansions copy 10, 20, 30, … items, whose sum grows quadratically. A multiplicative factor greater than 1, such as 1.5 or 2, gives linear total copying instead; doubling is convenient but not essential.
Question 16 · medium
You hold a pointer to a node x in a singly linked list, x is not the last node, and you have no pointer to the head. Delete x’s value from the list in O(1).
copy the next node’s item into x, then splice out the next node: x.next = x.next.next. The list now contains the same values minus x’s original one, and no predecessor search was needed.
Question 17 · medium
Build a queue out of two stacks so that every operation is O(1) on average.
enqueue pushes onto stack In. Dequeue pops from stack Out; if Out is empty, first pop everything from In and push it onto Out (which reverses the order). Each item is moved at most twice in its lifetime, so n operations cost O(n) in total.
Question 18 · hard
Design a stack that supports push, pop and get-minimum, all in O(1).
In simpler words: Keep the smallest stack value available without searching.
Starting hint: Remember the minimum after every push.
keep a second stack of running minima. On push(x), push x onto the main stack and push min(x, current top of the min stack) onto the min stack. On pop, pop both. The top of the min stack is always the minimum of the main stack.
Question 19 · hard
Design a cache that holds at most k items and evicts the least recently used one, with get and put both in O(1).
In simpler words: Evict the item that has gone longest without a recent use.
Starting hint: Use a hash map to find an item and a linked list to record recency.
Use a doubly linked list ordered by recency and a hash map from keys to nodes. Get moves a found node to the front. Put inserts or updates at the front; if capacity is exceeded, unlink the tail and remove its map entry. Pointer changes are worst-case O(1); the combined operations are expected O(1) with suitable hashing, not a universal worst-case guarantee.
Predict on paper, run the code, then explain any difference. Use the animations below to inspect individual steps.
Draw each pop(0) while emptying four items. Count shifts.
3 + 2 + 1 + 0 = 6 shifts.
How many shifts occur when emptying 1,000 items from the front?
1,000 × 999 / 2 = 499,500.
Replace a list queue with deque. Which operation changes, and which stays linear?
Use popleft() for O(1) front removal. Searching for an arbitrary value still costs O(n).
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).
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.
Here is a real-shaped piece of slow code. Find both problems and rewrite it.
def unique_reversed(data):
result = []
for item in data:
if item not in result: # problem 1
result = [item] + result # problem 2
return result
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:
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 resultFrom O(n²) to O(n). At 100 000 items that is minutes down to milliseconds — measure both to see it.
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 investigate the observed scaling, including any interpreter optimisation.
If every concatenation rebuilds the growing text, expect a near-four doubling ratio; if the interpreter optimises this loop, it may instead be near two. Joining fixed-length pieces once has linear total-character work. Report the measured outcome rather than forcing the illustrative §10.5 numbers.
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"?
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.
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.
(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.
Why is data.pop(0) O(n) while data.pop() is O(1)?
result = result + [i] inside a loop of n items is:
You keep taking jobs from the front of a very long queue. The right change is:
"append is O(1) amortised" means:
To turn a quadratic text += word loop into a linear one, you should:
+=. Collecting pieces and joining once does the whole build in a single linear pass.On a deque, reading a middle position dq[i] 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:
O(1).
O(n).
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.
| Operation | Example | Cost | Why |
|---|---|---|---|
| index read / write | data[i] | O(1) | Jump straight to the slot |
| length | len(data) | O(1) | Python keeps a running count |
| append | data.append(x) | O(1)* | Write into the next free slot |
| pop from end | data.pop() | O(1) amortised | No tail shifts; occasional resizing may occur |
| insert at front | data.insert(0, x) | O(n) | Everything shifts right |
| pop from front | data.pop(0) | O(n) | Everything shifts left |
| membership | x in data | O(n) | Look at items until found |
| delete by value | data.remove(x) | O(n) | Find it, then shift |
| slice | data[a:b] | O(k) | Copies k items into a new list |
| concatenate | a + b | O(n+m) | Builds a whole new list |
| sort | data.sort() | O(n log n) | See week 13 |
| min / max / sum | max(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 reserves extra capacity
so total resizing work across many appends remains linear. The exact growth policy depends on the implementation. The next
section watches that happen.
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 with spare capacity, potentially copy the existing references 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. Use an idealised doubling model with capacities 1, 2, 4, 8, 16 …; this is not Python's literal allocation policy. For n items where n is a power of two, copying costs 1 + 2 + … + n/2 = n − 1. Together with n new writes, that is 2n − 1 units: a constant amortised cost per append. Other n also give linear total work. 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:
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)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.
"Amortised" is the honest word for "individual calls can spike, but the long-run average is constant". It bounds the total work of a sequence rather than relying on random inputs. An individual resizing append can still have O(n) worst-case cost; that and O(1) amortised cost are compatible. This does not promise identical wall-clock time for every call. When someone asks the cost of append, "O(1) amortised" is the full and correct answer.
Test the model yourself. These builders use the same values but produce opposite orders: append preserves their arrival order, while front insertion reverses it. If order matters, include a final O(n) reversal when comparing equivalent outputs.
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")
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.
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.
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")
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.
Repeated string concatenation can be quadratic when every step copies the growing result. Some Python interpreter contexts optimise this pattern, so a particular += benchmark need not show quadratic growth. 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.
import time
def by_plus(n):
text = ""
for i in range(n):
text += "word " # repeated concatenation; runtime optimisations may affect scaling
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")
These illustrative timings show the repeated-copy model; they are not a guaranteed output of this interpreter-dependent experiment. Record your actual ratios, even if += scales nearly linearly. Building pieces and joining once gives a predictable linear construction in the total number of characters.
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.
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")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.
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.
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. Each repeat gets fresh input. Append grows its list, while pop removes 50 000 of the available 100 000 items; these are averages over changing sizes, not fixed-size single-operation measurements. The front-removal row drains its 2 000-item list exactly once. The output below is illustrative, and sorting this already sorted input exercises the adaptive best case.
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(), 50000),
("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")
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 sort of this already ordered list 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 work models explain the expected trends, but precise relative tiers can vary.
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.
| Mistake | Why it is wrong |
|---|---|
result = result + [x] in a loop | Copies the whole list each round → O(n²). Use append. |
| Repeated string concatenation | Can copy the growing text repeatedly; interpreter optimisations affect measurements. Collect pieces and join once for predictable linear construction. |
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 loop | A 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²). |
From the beginner notes · Lecture 4
A dynamic array is an array that can grow; it keeps some empty slots for new items. In the simplified doubling model, growth copies 1, 2, 4, … items into successively larger arrays. To reach n = 2ᵏ stored items from capacity 1, the copies total n − 1, even though the final resize alone is expensive.
This gives constant copying cost per append when the total is spread over the whole sequence (amortised cost). It does not make every individual append constant-time. Growing by only ten slots instead repeatedly copies nearly all the items already stored, producing quadratic total copying.
The exact way Python reserves extra slots depends on its implementation; the doubling model explains the idea rather than specifying its precise behaviour. Keep “amortised over a sequence” separate from “expected over random choices” and “worst case for one operation”.
Engineering use. A data logger may tolerate occasional slow appends. A controller with a strict deadline may not tolerate the pause when storage grows.
append is cheap and insert(0, x) is not;00:00–01:00: launch, predict–run–explain cycle, questions · 01:00–01:10: break · 01:10–02:00: worked variation, peer instruction, questions · 02:00–02:10: break · 02:10–03:00: core mechatronics practice, exam bridge, and exit ticket.
Ask at any point. Weekly self-checks stay private; optional extensions are not collected.
Need a slower explanation? Open the English + Türkçe reference guide.
AA_Week10.ipynb and complete Tasks 1–5.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".append and insert(0, …) across four sizes.| Term | Meaning in plain words |
|---|---|
| contiguous storage | Items kept in one unbroken row of slots — the reason indexing is instant. |
| shifting | Moving 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 / capacity | The bigger block a list rents when it fills up; the rare O(n) copy behind append. |
| accidentally quadratic | An O(n) operation used once per item, giving O(n²) without any visible nested loop. |
| deque | A 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. |
You learned that a full list search and a front insertion cost O(n). Week 11 introduces average O(1) lookup with dictionaries and sets, under the usual hashing assumptions.