PP26  ·  Lab 2  ·  part A · reading guide

Four Holes in a Queue

python/pqueue.py is 94 lines, and four of its functions are missing. This note walks through what each hole is for, what the surrounding code already guarantees, and how to recognise a correct result — without supplying the four definitions. The whole lab hangs off this one file: the maze solver, the OCaml port, and the benchmark all call it.

01 — the file

What is given, and what is left to write

Four function bodies are missing, ordered so that each may use the one before it. Taken out of order, two unknowns are being debugged at once.

linenamestatuswhat it is
19EMPTYgivenThe empty queue, (None, None).
22is_emptygivenChecks front only — and that is legal precisely because of the invariant.
27peekgivenReads front[0]. One pointer. Never searches.
35_revTODO 1Reverse a cons-list.
46_makeTODO 2The smart constructor that restores the invariant.
57enqueueTODO 3Add at the back. O(1).
65dequeueTODO 4Take from the front, return both the value and the new queue.
77to_listgivenThe abstraction function. The tests measure the implementation against it.
90from_itergivenConvenience: enqueue everything in an iterable.

One rule above all others. Every operation returns a new queue and leaves its argument exactly as it found it. In Python it is very nearly enforced: tuples are immutable, so an existing node cannot be written into at all. What remains possible to get wrong is rebuilding structure that should have been shared. That is what C_Persistence looks for.

02 — the representation

Two lists pretending to be one

A cons-list is the lecture's: None is the empty list, and (head, tail) is a node whose tail is another cons-list. Nothing else. A queue is a pair of them.

Cons-lists are only cheap at one end — a head may be added or removed in constant time, and reaching the last element costs a full walk. A FIFO queue needs both ends. The trick is to keep two lists pointing in opposite directions, so that both ends of the queue are somebody's head.

front holds the oldest elements in the order they will leave. back holds the newest elements reversed — the most recently enqueued one is its head. So the sequence the queue actually represents is:

the abstraction functionto_list, line 77
contents(front, back)  =  front  ++  reverse(back)

invariant:   front is None  ⟹  back is None
The invariant is what makes peek a one-liner. If the front is allowed to run empty while elements sit in the back, then peek would have to know about that case and go digging. Forbid the state instead, and every reader gets simpler.
front oldest first — read this end 1 2 3 None back newest first — write this end 5 4 None CONTENTS 1 2 3 4 5 ↑ dequeue / peek here enqueue lands here ↑ the two crossings are the reversal
Nobody stores the bottom row. It exists only in to_list, and in the reader's understanding. The queue is the two lists; the sequence is what they mean. Notice the crossing on the right: the back list is stored newest-first, so the last two contents cells come out of it backwards.

03 — todo 1

_rev(lst) — reverse a cons-list

The only piece of real list work in the file. Everything else is a rearrangement of pointers.

python/pqueue.pylines 35–44
def _rev(lst):
    """TODO 1: reverse a cons-list, returning a new cons-list.

    _rev((1, (2, (3, None))))  ==  (3, (2, (1, None)))

    An imperative while-loop over local variables is fine — the lists
    themselves must not be touched (they can't be: tuples are immutable).
    """

Take the docstring's permission seriously. Recursion is not required here, and on a large maze it would yield a RecursionError rather than a path. The shape required is the standard accumulator walk: carry a second cons-list that starts empty, and keep moving the head of the input onto the head of the accumulator until the input is exhausted. Each step is one (head, tail) allocation.

_rev((1, (2, (3, None)))) — trace
stepremaining inputaccumulator
01 → 2 → 3 → NoneNone
12 → 3 → None1 → None
23 → None2 → 1 → None
3None3 → 2 → 1 → None

The accumulator is the return value once the input runs out. Note what doesn't happen in that table: no node of the input is ever altered. The old list is still intact at step 3 — a new spine was built alongside it.

Check yourself before moving on

_rev(None) must give None, not crash and not (None, None). Reversing twice must give back an equal list. If in doubt, try those two cases in a REPL before touching _make.

04 — todo 2

_make(front, back) — the smart constructor

This is the heart of the data structure. It is also four lines. Every other function builds queues by calling it, and never by writing a tuple literal — that is the whole reason the invariant holds everywhere.

python/pqueue.pylines 46–55
def _make(front, back):
    """TODO 2: smart constructor.

    Return a queue (front, back) — but restore the invariant first:
    if `front` is empty, the reversed `back` must BECOME the front.
    Every other function builds queues only through _make, so the
    invariant will hold everywhere. This is where the O(n) work hides.
    """

Two cases, and the branch is on front alone:

  • Front is non-empty. Nothing to fix. Return the pair as it stands — and return the very front object that was passed in, not a copy of it. A test checks this with assertIs.
  • Front is empty. The invariant demands the back be empty too, so move it: the reversal of back becomes the new front, and the new back is empty. If back was also empty this quietly produces EMPTY, which is exactly right — no special case needed.
BEFORE — INVARIANT BROKEN front None back c b a None peek has nothing to look at _rev(back) O(n), paid once AFTER — INVARIANT RESTORED front a b c None back None a is the oldest — one pointer away
The flip happens at most once per element. An element enters the back, waits, gets moved to the front by one reversal, and leaves. It is never reversed a second time — which is the entire content of the amortised bound in section 08.

The tempting wrong version. Reversing unconditionally — always returning (_rev(back) ++ front, None) or similar — passes the correctness tests and destroys the performance story. Every enqueue would then be O(n), the front would be rebuilt on every operation, and test_structural_sharing fails. Branch on the front.

05 — todo 3

enqueue(q, x) — add at the back

One cons and one call. A version longer than two lines is probably doing work that belongs to _make.

python/pqueue.pylines 57–63
def enqueue(q, x):
    """TODO 3: return a new queue with x added at the back. O(1).

    Do not rebuild anything: cons x onto back, keep front AS IS (shared!).
    """

Unpack the queue, put x at the head of the back list, and hand both halves to _make. Do not build the pair yourself: enqueueing onto EMPTY would leave an empty front with a one-element back, which is precisely the state the invariant forbids. _make is what catches that.

shares

The new queue points into the old one. Both versions stay usable.

q1 = from_iter([1, 2, 3])
q2 = enqueue(q1, 4)

# q2's front IS q1's front —
# the same object, not a copy
front2 is front1   → True

Only one new node exists after the call: the one holding 4. Three elements were reused untouched.

copies

Correct answers, wrong structure. The tests catch it.

def enqueue(q, x):
    # walks the front, or reverses,
    # or rebuilds the pair by hand
    ...

front2 is front1   → False

to_list would still agree. test_structural_sharing would not: persistence that copies is merely an expensive imitation of persistence.

And the O(1) claim survives the _make call, which is worth a second of thought. _make only reverses when the front is empty — and when the front is empty, the invariant says the back was empty too, so the list being reversed holds exactly the one element just consed. Constant work, always.

06 — todo 4

dequeue(q) — take from the front

The one operation that returns two things, because a persistent structure cannot surrender the value by mutating itself.

python/pqueue.pylines 65–71
def dequeue(q):
    """TODO 4: return (value, new_queue); raise IndexError if empty.

    Take the head of front; rebuild the rest through _make.
    """

Three moves. Refuse the empty queue with an IndexErrorpeek above shows the exact idiom, message and all. Otherwise the head of the front is the value, and the queue returned is built from the front's tail and the untouched back, passed through _make. That call is where the front can run dry and trigger the flip.

The return order matters and is not symmetric: callers all over the lab write x, q = dequeue(q), value first. Get it backwards and every test in B_FifoContract fails in a confusing way.

Why the old queue still works afterwards

Nothing in those three moves writes anywhere. The node holding the old head is still there, still pointing at the same tail; we have merely stopped referring to it. That is why test_fan_out can dequeue the same version twice and get the same answer both times, then push the two results in different directions. One present, several futures.

07 — the tests

What each test class asks

The classes are named A_, B_, C_ so that unittest runs them in that order. Read a failure as a question about the design, not as a broken assertion.

classasksif it fails
A_Basicsdoes it work at all?Empty cases and the single-element round trip. Usually a missing IndexError or a swapped return order.
B_FifoContractis it a queue?FIFO order under interleaving, plus 2000 random operations checked against collections.deque. A mismatch here means the abstraction function and the implementation disagree — trace the step number it prints.
B_…invariantis the invariant real?500 random operations, asserting after each that an empty front implies an empty back. Fails when something built a queue without going through _make.
C_Persistenceis it persistent?Old versions survive; the same version can be dequeued twice; the front is shared, not copied. A correct ephemeral queue fails these — that is the point of the class.

Read test_structural_sharing once, deliberately. It is the only test in the file that inspects the representation rather than the contents, and it is the only one that distinguishes real structural sharing from copying. Everything else in the lab — the time-travel view of the solver, the benchmark in part D — depends on the property it checks.

08 — the cost

Amortised O(1), and the assumption inside it

Every operation is constant time except one, which is linear. The claim is that the linear one is rare enough to pay for itself.

The banker's argument. Charge every enqueue two coins: one for the cons it actually performs, one deposited on the element itself. An element sits in the back holding its coin. When the front finally runs dry and _make reverses, each element in the back spends its own coin to pay for its own move. No element is ever reversed twice, so n operations cost O(n) in total — constant amortised.

The argument is airtight, and it quietly assumes something the rest of this lab exists to break: that each version of the queue is used once. Coins are spent when an element moves; if a version whose reversal is still pending is retained and dequeued repeatedly, every one of those calls performs the same O(n) reversal and there is only one deposit to pay for all of them.

Part D does exactly that in python/bench.py: keep one bad version and re-dequeue it, and watch a supposedly constant-time operation trace out a quadratic curve. Persistence and amortisation do not automatically get along — the fix is a different queue (real-time or banker's), not a different proof.

09 — run it

The working loop

$ cd python
$ python3 -m unittest test_pqueue -v # after every TODO
# once everything is green — the queue drives a real search:
$ python3 solver.py ../mazes/medium.txt --bfs
$ python3 solver.py ../mazes/medium.txt --bfs --html=bfs.html # time travel
$ python3 solver.py ../mazes/medium.txt --bfs --inspect=40 # ask an old version

When progress stalls

  • Everything fails with NotImplementedError. Expected — that is the starting state. Work top to bottom; A_Basics cannot go green until all four are in, because from_iter calls enqueue.
  • test_structural_sharing alone fails. Something rebuilt the front. Look for a _rev call on a path where the front was not empty.
  • The invariant test fails. Some queue was constructed as a bare tuple instead of through _make. Search the file for return (.
  • RecursionError on the large maze. A recursive _rev. The docstring's suggestion of a loop was not decorative.
  • Values are right but shifted by one. dequeue rebuilt from front instead of from front[1].

Next: architecture.html — where this queue sits in the maze solver, and what changes when it is exchanged for a stack.