Series: Stacks · Queues and deques · Linked lists

COL 1101 Programming with Data Structures — Lecture 13, plus the queue and deque parts of Lectures 14 and 15, rewritten as one guide

Queues and deques

A queue is the fair rule: whoever arrived first is served first. It sounds trivial, and the ADT is. The interesting part is making it fast, because the obvious implementations either waste time shifting elements or quietly leak memory. This page builds the circular array that fixes both, then plays with queues and stacks built out of each other, and finishes with the double-ended queue.

A queue holding 5, 3, 8. New arrivals join at the rear; dequeue always takes the front, which is the element that has waited longest.

How to use this page

Read the parts in order; each Check yourself box is worth trying before you open the answers. Dashed boxes marked Beyond the lecture are optional. Part 6 is the whole topic on one page. This page assumes you know what a stack is and what “O(1) amortized” means; both are explained on the Stacks page.

Queue: a collection where insertions happen at one end (the rear or back) and removals at the other (the front), so elements leave in the order they arrived: first-in, first-out (FIFO).

Deque (say “deck”): a double-ended queue, where you may add and remove at both ends.

mod: the remainder after division. 7 mod 4 = 3; 8 mod 4 = 0; in Python, %. Python's % always gives a result between 0 and N − 1 for positive N, even for negative numbers: -1 % 4 == 3. Part 4 relies on that.

Part 1

The queue idea

1.1The problem: a clinic's waiting room

Patients type their names into a computer as they arrive. When the doctor is free, the computer must return the name of the patient who arrived earliest among those still waiting. A stack would be exactly wrong here: it would call the most recent arrival first. We need the opposite discipline.

1.2The Queue ADT

OperationWhat it doesOn an empty queue
enqueue(e)Add e at the rearfine
dequeue()Remove and return the front elementerror
first()Return the front element without removing iterror
len(Q)Number of elements storedreturns 0
is_empty()True if no elementsreturns True

A trace, written front … rear:

OperationReturnsQueue afterwards (front … rear)
Q.enqueue(5)–[5]
Q.enqueue(3)–[5, 3]
len(Q)2[5, 3]
Q.dequeue()5[3]
Q.is_empty()False[3]
Q.dequeue()3[ ]
Q.is_empty()True[ ]
Q.dequeue()error[ ]
Q.enqueue(7)–[7]
Q.enqueue(9)–[7, 9]
Q.first()7[7, 9]
Q.enqueue(4)–[7, 9, 4]
len(Q)3[7, 9, 4]
Q.dequeue()7[9, 4]

Compare with the same pushes on a stack: there, the first pop after pushing 5 and 3 returned 3. Here dequeue returns 5.

1.3Where queues appear

  • A networked printer. Jobs from many computers wait in a queue and print in the order they were sent.
  • A web server. Requests that arrive faster than they can be handled wait in a queue, so nobody is starved by later arrivals.
  • Round-robin scheduling. The operating system gives each running program a short slice of CPU time in turn: dequeue a program, run it briefly, enqueue it again at the back (Part 5).
  • Later in the course. Breadth-first search explores a graph level by level using a queue.

Check yourself: the ADT

  1. Starting empty: enqueue(1), enqueue(2), dequeue(), enqueue(3), enqueue(4), dequeue(), dequeue(). What does each dequeue return, and what is left?
  2. Run the same sequence on a stack (push for enqueue, pop for dequeue). What changes?
Show answers
  1. Dequeues return 1, 2, 3. Left: [4].
  2. Pops return 2, 4, 3. Left: [1]. Same operations, opposite discipline.

Part 2

Array-based queue: the circular array

2.1Attempt 1: append and pop(0)

Enqueue with L.append(e) (O(1) amortized) and dequeue with L.pop(0). It is correct, but pop(0) removes the first slot of an array, and arrays have no gaps: every remaining element must shift one place left. With n elements that is n − 1 moves, so dequeue is Θ(n) in the worst case (Θ means “exactly this order”, both an upper and a lower bound).

Python
from timeit import timeit

for n in [10_000, 20_000, 40_000]:
    t = timeit("while L: L.pop(0)", setup=f"L = list(range({n}))", number=1)
    print(f"dequeue all {n:>7,} with list.pop(0): {t:.3f} s")
dequeue all  10,000 with list.pop(0): 0.163 s
dequeue all  20,000 with list.pop(0): 0.514 s
dequeue all  40,000 with list.pop(0): 2.121 s

Doubling n roughly quadruples the time: emptying a queue of n elements this way costs about n²/2 moves.

2.2Attempt 2: a front index

Do not shift. Keep an index f of the current front; dequeue reads L[f], sets that slot to None, and does f = f + 1. Every operation is now O(1). But the lecture asks: what happens if we keep repeating enqueue and dequeue for a long time?

Python
class LeakyQueue:
    """Implementation #2 from the lecture: never shift, just move f forward."""
    def __init__(self):
        self._data, self._f = [], 0
    def enqueue(self, e):
        self._data.append(e)
    def dequeue(self):
        answer = self._data[self._f]
        self._data[self._f] = None       # forget the element...
        self._f += 1                     # ...but the slot stays in the list
        return answer
    def __len__(self):
        return len(self._data) - self._f

Q = LeakyQueue()
for step in range(100_000):              # a busy server: one in, one out
    Q.enqueue(step)
    Q.dequeue()
print("elements in queue:", len(Q))
print("slots in the list:", len(Q._data))
elements in queue: 0
slots in the list: 100000

The queue is empty, yet the list has grown to 100,000 slots. The front of the list fills with dead None slots that are never reused, so memory grows with the total number of operations ever performed, not with the number of elements currently stored. On a server that runs for months, that is a memory leak.

2.3The fix: wrap around

The dead slots at the front are free space. Reuse them: treat an array of size N as a ring, where the slot after N − 1 is slot 0. Moving forward one place becomes

i ← (i + 1) mod N

which is i + 1 normally and 0 when i = N − 1. Two indices describe the queue:

  • f: the index of the front element (the next to be dequeued);
  • r: the index immediately past the rear element (where the next enqueue will write).
Left: the normal configuration, with the queue in one block from f up to r − 1. Right: the wrapped-around configuration, where the queue runs from f to the end of the array and continues from slot 0 up to r − 1. Shaded slots hold elements.

2.4Why slot r is always kept empty

Start with an empty queue: f = r. Now enqueue until all N slots are used. r has moved N steps and wrapped right round, so again f = r. With only f and r to look at, “empty” and “full” would be indistinguishable.

The lecture's rule removes the ambiguity: never let the array become completely full. Slot r is always kept empty, so an array of N slots holds at most N − 1 elements, and

f = r means empty, always. len = N − 1 means full, and the next enqueue must resize first.

(The textbook's own code avoids the wasted slot by storing the size as a third variable instead of r. Both designs are correct; the lecture uses f and r, so this page does too.)

2.5The length formula

len = (N − f + r) mod N

Why it works: in the normal configuration (f ≤ r) the elements occupy f, …, r − 1, so there are r − f of them. Adding N and taking mod N leaves r − f unchanged, because it is between 0 and N − 1. In the wrapped configuration (r < f), there are N − f elements from f to the end and r more from 0 to r − 1: total N − f + r, which is already below N. The single formula covers both cases.

Example: N = 8, f = 6, r = 2. Elements at 6, 7, 0, 1, so 4 elements; the formula gives (8 − 6 + 2) mod 8 = 4.

2.6The operations

Pseudocode (lecture)
Algorithm len():
    return (N - f + r) mod N

Algorithm isEmpty():
    return (f = r)

Algorithm enqueue(o):
    if len() = N - 1:              # full: only slot r is free
        Q = resize(Q, 2 * N)
    Q[r] = o
    r = (r + 1) mod N

Algorithm dequeue():
    o = Q[f]
    f = (f + 1) mod N
    if 0 < len() < N / 4:          # mostly empty
        Q = resize(Q, N / 2)
    return o

The lecture's pseudocode leaves out the empty check in dequeue; real code must raise an error when f = r.

2.7Resizing, and why the front moves to 0

You cannot simply copy the old array into the first half of a bigger one. In the wrapped configuration the queue would then have a hole in the middle: the part that wrapped round to slots 0, 1, … would sit before the front instead of after it. So resize walks the queue in queue order, starting at f and wrapping, and writes the elements into slots 0, 1, …, n − 1 of the new array. Then f = 0 and r = n.

Pseudocode (lecture)
Algorithm resize(Q, newLen):
    newQ = new array of length newLen
    n = len()
    walk = f
    for i = 0 to n - 1:
        newQ[i] = Q[walk]
        walk = (walk + 1) mod N
    Q = newQ;  N = newLen
    f = 0;  r = n
The lecture's example. The full queue E F G H I J K wraps round the end of an 8-slot array: E to H in slots 4–7 (f = 4), I to K in slots 0–2, and slot r = 3 empty. After resizing to 16 slots it is stored in queue order from index 0, with f = 0 and r = 7.

2.8Why shrink at a quarter, not a half

Shrinking keeps memory proportional to the current number of elements. The threshold matters. Suppose the rule were “halve when less than half full”, and the queue sits right at a boundary: an enqueue fills the array and doubles it to exactly half full; a dequeue drops below half and halves it back to full; the next enqueue doubles again. Every single operation would copy all n elements: O(n) each, forever. This is called thrashing.

With “halve below a quarter”, after any resize the array is about half full, so at least about N/4 cheap operations must happen before the next resize in either direction. Those cheap operations pay for the copying, and every operation stays O(1) amortized.

2.9The code and its cost

Python
class Empty(Exception):
    pass

class CircularQueue:
    """FIFO queue on a circular array, exactly as in the lecture.
    f = index of the front element
    r = index just PAST the rear element (the next free slot)
    Slot r is always kept empty, so N slots hold at most N - 1 elements."""

    def __init__(self, N=4):
        self._Q = [None] * N
        self._f = 0
        self._r = 0

    def __len__(self):
        N = len(self._Q)
        return (N - self._f + self._r) % N

    def is_empty(self):
        return self._f == self._r

    def first(self):
        if self.is_empty():
            raise Empty("queue is empty")
        return self._Q[self._f]

    def enqueue(self, o):
        if len(self) == len(self._Q) - 1:          # only slot r is left: full
            self._resize(2 * len(self._Q))
        self._Q[self._r] = o
        self._r = (self._r + 1) % len(self._Q)     # step forward, wrapping

    def dequeue(self):
        if self.is_empty():
            raise Empty("queue is empty")
        o = self._Q[self._f]
        self._Q[self._f] = None                    # help garbage collection
        self._f = (self._f + 1) % len(self._Q)
        if 0 < len(self) < len(self._Q) // 4:      # mostly empty: shrink
            self._resize(len(self._Q) // 2)
        return o

    def _resize(self, new_len):
        old, n, walk = self._Q, len(self), self._f
        self._Q = [None] * new_len
        for i in range(n):                         # copy in QUEUE order...
            self._Q[i] = old[walk]
            walk = (walk + 1) % len(old)
        self._f, self._r = 0, n                    # ...so the front is at 0

A trace on an array of 4 slots, including a wrap-around and a resize:

Python
Q = CircularQueue(4)
Q.enqueue(5); Q.enqueue(3)
print(len(Q), Q.dequeue(), Q.is_empty(), Q.dequeue(), Q.is_empty())
Q.enqueue(7); Q.enqueue(9)
print("array:", Q._Q, " f =", Q._f, " r =", Q._r)
Q.enqueue(4)                     # r wraps from 0 to 1; queue is now full
print("array:", Q._Q, " f =", Q._f, " r =", Q._r, " len =", len(Q))
Q.enqueue(6)                     # full (3 of 4), so resize to 8 first
print("array:", Q._Q, " f =", Q._f, " r =", Q._r, " len =", len(Q))
2 5 False 3 True
array: [None, None, 7, 9]  f = 2  r = 0
array: [4, None, 7, 9]  f = 2  r = 1  len = 3
array: [7, 9, 4, 6, None, None, None, None]  f = 0  r = 4  len = 4

Look at the third line: 4 was written into slot 0, before 7 and 9 in the array, but it is behind them in the queue. The fourth line shows the resize putting everything back in queue order.

OperationCost
enqueueO(1) amortized (O(n) when it resizes)
dequeueO(1) amortized (O(n) when it shrinks)
first, len, is_emptyO(1)
spaceO(n)

Circular queue, live

Enqueue and dequeue and watch f and r chase each other round the ring. The ring and the strip are the same array. Fill it to see the resize realign the queue; empty it to see it shrink.

Check yourself: the circular array

  1. N = 8, f = 5, r = 5. How many elements? What if f = 5 and r = 4?
  2. N = 8, f = 6, r = 3. Which slots hold elements, in queue order? What is len?
  3. In the trace above, why did enqueue(6) resize even though slot 1 was empty?
  4. Why can't resize just copy Q[0..N-1] into newQ[0..N-1]?
Show answers
  1. f = r means empty: 0 elements. f = 5, r = 4: (8 − 5 + 4) mod 8 = 7 = N − 1, so the queue is full (slots 5, 6, 7, 0, 1, 2, 3); the next enqueue resizes.
  2. Slots 6, 7, 0, 1, 2. len = (8 − 6 + 3) mod 8 = 5.
  3. Slot 1 was slot r, which is never filled. The queue held 3 = N − 1 elements, which counts as full.
  4. If the queue is wrapped, the elements in slots 0 … r−1 belong after the ones in f … N−1. A straight copy into a longer array would leave them before the front with empty slots in between, so walking forward from f would hit the gap instead of them.

Part 3

Building one ADT out of the other

The lecture spends several slides on puzzles: build a queue using only stacks, and a stack using only queues. Nobody does this in production; Python already has both. They are here because they force you to reason precisely about cost, and because one of them is the cleanest example of amortized analysis you will see.

3.1Queue from two stacks, strategy 1: keep the oldest on top

Keep all elements in stack A arranged so that the oldest is on top. Then dequeue is just A.pop() and first is A.top(), both O(1). The work moves to enqueue: the new element must go to the bottom of A. To get it there, pour all of A into B, push the new element onto the now-empty A, and pour B back.

Pseudocode (lecture)
Algorithm enqueue(e):          # O(n)
    while not A.is_empty():
        B.push(A.pop())         # A reversed into B
    A.push(e)                   # e goes to the bottom of A
    while not B.is_empty():
        A.push(B.pop())         # everything back on top of e

dequeue():  return A.pop()      # O(1)
first():    return A.top()      # O(1)
len():      return A.len()      # O(1)

Every enqueue moves every element twice: O(n) per enqueue, every time. There is no averaging that saves it.

3.2Strategy 2: pour only when needed

Let A be the inbox and B the outbox. Enqueue always pushes onto A. Dequeue pops from B. Only when B is empty does dequeue pour all of A into B.

Why does that give the right order? A holds elements newest on top. Popping A and pushing onto B reverses them, so the oldest ends up on top of B, exactly where dequeue wants it. And while B still has elements, they are all older than anything in A (they arrived before the last pour), so serving B first is correct.

Pseudocode (lecture)
enqueue(e):  A.push(e)                     # O(1)
len():       return A.len() + B.len()      # O(1)

Algorithm dequeue():
    if A.is_empty() and B.is_empty():
        return null                        # (or raise an error)
    if not B.is_empty():
        return B.pop()
    while not A.is_empty():
        B.push(A.pop())                    # pour: O(size of A)
    return B.pop()

# first() is identical, with B.top() instead of B.pop()
Python
class TwoStackQueue:
    """Strategy 2: A receives, B serves. Pour A into B only when B is empty."""

    def __init__(self):
        self.A, self.B = ArrayStack(), ArrayStack()
        self.work = 0                              # count push/pop operations

    def __len__(self):
        return len(self.A) + len(self.B)

    def enqueue(self, e):
        self.A.push(e); self.work += 1             # O(1)

    def _pour(self):
        if self.B.is_empty():                      # only when B has run dry
            while not self.A.is_empty():
                self.B.push(self.A.pop()); self.work += 2

    def first(self):
        self._pour()
        return self.B.top()

    def dequeue(self):
        self._pour()
        self.work += 1
        return self.B.pop()

Q = TwoStackQueue()
for x in "ABC":
    Q.enqueue(x)
print(Q.dequeue(), end=" ")                         # pours C, B, A into B
Q.enqueue("D")
print(Q.dequeue(), Q.dequeue(), Q.dequeue())        # B, C come from B; D needs a pour

Q = TwoStackQueue()
n = 100_000
for i in range(n):
    Q.enqueue(i)
    if i % 3 == 2:
        Q.dequeue()
while len(Q):
    Q.dequeue()
print("operations per element:", Q.work / n)
A B C D
operations per element: 4.0

3.3The amortized argument, carefully

One dequeue can cost O(n): when B is empty and A holds n elements, it does n pops and n pushes. So the worst case per operation is O(n). The lecture's question is the amortized cost.

Count work per element, over its whole life in the queue:

Event in one element's lifeStack operations
enqueued: pushed onto A1
poured, at most once: popped from A, pushed onto B2
dequeued: popped from B1
total, ever4

The crucial fact is “at most once”: an element in B never goes back to A, so it can never be poured again. Over any sequence of m operations, at most m elements are ever enqueued, so the total work is at most 4m, plus O(1) of bookkeeping per operation. That is O(m) for m operations: O(1) amortized per dequeue (and per first).

The code above measures exactly 4.0 operations per element. This is the same “charge the work to the element” argument that showed spans are O(n) and appends are O(1) amortized.

Two-stack queue

Enqueue pushes onto the inbox A. Dequeue pops from the outbox B, pouring A into B only when B is empty. Watch the counter: the expensive pours are rare, and the work per element never exceeds 4.

Both stacks empty.

3.4Queue from one stack plus recursion

Recursion gives you a second stack for free: the call stack. Each recursive call can pop an element and hold it in a local variable until the call returns.

Strategy 1 (expensive enqueue). Keep the oldest on top, so dequeue = A.pop() and first = A.top(), O(1). To enqueue e at the bottom: if A is empty, push e; otherwise pop the top into temp, recursively enqueue e into the rest, then push temp back. O(n).

Strategy 2 (expensive dequeue). Enqueue = A.push(e), O(1); the oldest is at the bottom. To dequeue, pop the top into temp; if A is now empty, temp was the bottom, so return it; otherwise recursively dequeue the rest, push temp back, and return what the recursion found. O(n). first() is the same but pushes the bottom element back too.

Python
class OneStackQueue:
    """Strategy 2 with one stack: enqueue is a push; dequeue digs out the bottom."""

    def __init__(self):
        self.A = ArrayStack()

    def enqueue(self, e):
        self.A.push(e)                   # O(1)

    def dequeue(self):                   # O(n): recursion depth = len(A)
        temp = self.A.pop()
        if self.A.is_empty():            # temp was the bottom = the oldest
            return temp
        e = self.dequeue()               # the call stack holds what we popped
        self.A.push(temp)                # put each one back on the way out
        return e

Q = OneStackQueue()
for x in "ABCD":
    Q.enqueue(x)
print(Q.dequeue(), Q.dequeue(), "left (bottom to top):", Q.A._data)
A B left (bottom to top): ['C', 'D']

Here the O(n) is not amortized away: every dequeue digs to the bottom, because nothing is ever left in a convenient order.

3.5Stack from queues

The mirror-image puzzle. A queue only gives access to its oldest element, but a stack needs the newest. Each strategy pays O(n) somewhere to bring the newest to the front.

StrategyIdeapushpop, top
1: two queuesOn push, enqueue the new element into the empty q2, move all of q1 behind it, swap names. The newest is always at the front of q1.O(n)O(1)
2: two queuesPush = enqueue into q1. On pop, move all but the last element into q2; the last one is the top. Swap names.O(1)O(n)
3: one queueAfter each push, rotate the queue len − 1 times (dequeue and re-enqueue) so the newest reaches the front.O(n)O(1)
Python
from collections import deque            # used here only as a plain FIFO queue

class StackPushHeavy:
    """Strategy 1 (two queues): push is O(n); pop and top are O(1)."""
    def __init__(self):
        self.q1, self.q2 = deque(), deque()
    def push(self, e):
        self.q2.append(e)                       # newest goes in first...
        while self.q1:
            self.q2.append(self.q1.popleft())   # ...then everything older behind it
        self.q1, self.q2 = self.q2, self.q1     # swap names
    def pop(self):
        return self.q1.popleft()

class StackPopHeavy:
    """Strategy 2 (two queues): push is O(1); pop and top are O(n)."""
    def __init__(self):
        self.q1, self.q2 = deque(), deque()
    def push(self, e):
        self.q1.append(e)
    def pop(self):
        while len(self.q1) > 1:
            self.q2.append(self.q1.popleft())   # move all but the newest
        e = self.q1.popleft()                   # the newest = top of stack
        self.q1, self.q2 = self.q2, self.q1
        return e

class StackOneQueue:
    """Strategy 3 (one queue): rotate after each push so newest is at the front."""
    def __init__(self):
        self.q = deque()
    def push(self, x):
        self.q.append(x)
        for _ in range(len(self.q) - 1):
            self.q.append(self.q.popleft())
    def pop(self):
        return self.q.popleft()

for S in (StackPushHeavy(), StackPopHeavy(), StackOneQueue()):
    for x in [1, 2, 3]:
        S.push(x)
    print(type(S).__name__, S.pop(), S.pop(), S.pop())
StackPushHeavy 3 2 1
StackPopHeavy 3 2 1
StackOneQueue 3 2 1

Beyond the lecture: why there is no amortized trick here

The two-stack queue works because pouring a stack reverses it, and one reversal turns LIFO into FIFO permanently. Moving elements between queues never reverses anything: it preserves order. So no pour can put the stack in a lasting good order, and some operation must pay O(n) every time the newest element changes.

Check yourself: puzzles

  1. In the two-stack queue (strategy 2), enqueue A, B, C; dequeue; enqueue D, E; dequeue; dequeue; dequeue. What is returned, and when do pours happen?
  2. Give a sequence of 2n operations on the two-stack queue in which one dequeue costs about 2n stack operations. Does that contradict O(1) amortized?
  3. Which strategy would you pick for a stack built from queues if your program pushes far more often than it pops?
Show answers
  1. The first dequeue finds B empty and pours C, B, A (so A is on top of B): returns A. D, E go onto the inbox. Next dequeues return B and C from B with no pour. The last dequeue finds B empty, pours E, D: returns D.
  2. Enqueue n times, then dequeue once: that dequeue pours n elements (2n operations). No contradiction: the next n − 1 dequeues are O(1) each, and the total stays about 4n.
  3. Strategy 2: push is O(1), and the O(n) cost is paid only on the rarer pops.

Part 4

The deque

4.1The clinic, modified

Two new rules: a VIP may be inserted at the front of the line, and the person at the end of the line may give up and leave. A queue cannot do either. We need adds and removals at both ends.

4.2The Deque ADT

OperationWhat it does
add_first(e)Add e at the front
add_last(e)Add e at the back
delete_first()Remove and return the front element (error if empty)
delete_last()Remove and return the back element (error if empty)
first(), last()Return the front / back element without removing it
len(D), is_empty()as usual
OperationReturnsDeque (front … back)
D.add_last(5)–[5]
D.add_first(3)–[3, 5]
D.add_first(7)–[7, 3, 5]
D.first()7[7, 3, 5]
D.delete_last()5[7, 3]
len(D)2[7, 3]
D.delete_last()3[7]
D.delete_last()7[ ]
D.add_first(6)–[6]
D.last()6[6]
D.add_first(8)–[8, 6]
D.is_empty()False[8, 6]
D.last()6[8, 6]

A deque generalises both earlier ADTs. Use only add_last and delete_first and it is a queue; use only add_last and delete_last and it is a stack.

4.3Deque on the circular array

Two of the four operations are the queue's: add_last is enqueue and delete_first is dequeue. The other two move the indices backwards:

Pseudocode (lecture)
Algorithm add_first(o):
    if len() = N - 1:
        Q = resize(Q, 2 * N)
    f = (f - 1) mod N          # step back; from 0 this wraps to N - 1
    Q[f] = o

Algorithm delete_last():
    r = (r - 1) mod N          # the last element sits just before r
    o = Q[r]
    if 0 < len() < N / 4:
        Q = resize(Q, N / 2)
    return o

This is where Python's mod rule matters: (0 - 1) % 8 is 7, so stepping back from slot 0 lands on the last slot, as a ring should. (In C or Java, -1 % 8 is −1, and you would write (f - 1 + N) % N instead.)

Python
D = CircularDeque(4)
D.add_last(5)                     # [5]
D.add_first(3)                    # [3, 5]   f steps back from 0 to 3
D.add_first(7)                    # [7, 3, 5]
print("array:", D._Q, " f =", D._f, " r =", D._r)
print(D.first(), D.last(), len(D))
print(D.delete_last(), D.delete_first(), D.first())
array: [5, None, 7, 3]  f = 2  r = 1
7 5 3
5 7 3

After adding 5 at the back and then 3 and 7 at the front, f has stepped back from 0 to 3 to 2: the front of the deque is in slot 2 and it wraps round to 5 in slot 0. All four operations are O(1) amortized, the same as the queue.

Circular deque

The same ring, with all four deque operations. Notice f moving backwards on add_first and r moving backwards on delete_last.

4.4Deque on a doubly linked list

Lecture 14 gives the other implementation: a doubly linked list, where each node links to both neighbours, with dummy header and trailer nodes at the ends. Adding or removing at either end then changes a fixed handful of links, so all four operations are O(1) in the worst case, not just amortized. The Linked Lists page builds it in detail.

Circular array dequeDoubly linked deque
add/delete at either endO(1) amortizedO(1) worst case
access the i-th elementO(1): Q[(f + i) mod N]O(n): walk from an end
memoryreferences + spare slotsone node (3 references) per element

Beyond the lecture: collections.deque

Python's standard library ships a deque. Its four core operations are appendleft, append, popleft and pop, all O(1). (Internally it is a doubly linked list of fixed-size blocks, combining both designs.) Whenever you need a queue in real Python code, use it; never use list.pop(0).

Python
from collections import deque
from timeit import timeit

d = deque()
d.append("B")                     # add_last
d.appendleft("A")                 # add_first
d.append("C")
print(d)
print(d.popleft(), d.pop(), d)    # delete_first, delete_last

n = 40_000
t = timeit("while d: d.popleft()", setup=f"from collections import deque; d = deque(range({n}))", number=1)
print(f"dequeue all {n:,} with deque.popleft(): {t:.4f} s")
deque(['A', 'B', 'C'])
A C deque(['B'])
dequeue all 40,000 with deque.popleft(): 0.0010 s

Compare with the list.pop(0) timings in Part 2: the same 40,000 dequeues take about a millisecond instead of seconds.

Check yourself: deques

  1. N = 8, empty deque with f = r = 0. Do add_first(A), add_first(B), add_last(C). Give f, r and the slot of each element.
  2. How do you use a deque as a stack? As a queue?
  3. Why does delete_last decrement r before reading the slot, while dequeue reads Q[f] before incrementing f?
Show answers
  1. add_first(A): f = 7, A in slot 7. add_first(B): f = 6, B in slot 6. add_last(C): C in slot 0, r = 1. Deque front to back: B, A, C.
  2. Stack: add_last + delete_last (or add_first + delete_first). Queue: add_last + delete_first.
  3. Because of what the indices mean: f points at the front element, but r points one past the back element. The back element lives at r − 1.

Part 5

Linked-list queue and round-robin

5.1Queue on a singly linked list

A singly linked list (Lecture 14) keeps a head and a tail reference. Its costs are lopsided: adding at the head or tail is O(1), removing at the head is O(1), but removing at the tail is O(n), because finding the node before the tail means walking from the head.

A queue adds at one end and removes at the other. Put the front at the head and the rear at the tail: enqueue adds at the tail (O(1)), dequeue removes at the head (O(1)). The opposite choice would make dequeue a tail removal, O(n).

A linked queue holding 5, 3, 8. Dequeue moves head to the next node; enqueue links a new node after tail and moves tail.
Python
class LinkedQueue:
    """FIFO queue on a singly linked list: front = head, rear = tail."""

    class _Node:
        __slots__ = "_element", "_next"
        def __init__(self, element, nxt):
            self._element, self._next = element, nxt

    def __init__(self):
        self._head = self._tail = None
        self._size = 0

    def __len__(self):
        return self._size

    def is_empty(self):
        return self._size == 0

    def first(self):
        if self.is_empty():
            raise IndexError("queue is empty")
        return self._head._element

    def enqueue(self, e):                     # add at the TAIL: O(1)
        newest = self._Node(e, None)
        if self.is_empty():
            self._head = newest
        else:
            self._tail._next = newest         # link the old tail first...
        self._tail = newest                   # ...then move tail
        self._size += 1

    def dequeue(self):                        # remove at the HEAD: O(1)
        if self.is_empty():
            raise IndexError("queue is empty")
        answer = self._head._element
        self._head = self._head._next
        self._size -= 1
        if self.is_empty():
            self._tail = None                 # removed the only node
        return answer

Q = LinkedQueue()
for x in [5, 3, 8]:
    Q.enqueue(x)
print(Q.dequeue(), Q.first(), len(Q))
5 3 2

Two edge cases. Enqueueing into an empty queue must set head too, since the new node is both front and rear. Dequeueing the only element must reset tail to None, or it keeps pointing at a removed node.

OperationCircular arrayLinked list
enqueueO(1) amortizedO(1)
dequeueO(1) amortizedO(1)
first, len, is_emptyO(1)O(1)
spaceO(n)O(n)

5.2Round-robin scheduling

A round-robin scheduler cycles through a collection forever, “servicing” each element in turn, such as giving each running program a slice of CPU time. With a queue, each turn is three steps: dequeue the next element, service it, enqueue it at the back.

Lecture 14 notes a leaner structure: a circularly linked list, whose tail links back to the head, kept by a single reference to the current node. Each turn services the front and calls rotate(), which moves one reference: no node is removed or created. Both are O(1) per turn; the circular list just does less work per turn.

Check yourself: linked queues

  1. Why does the linked queue keep a tail reference? What would enqueue cost without one?
  2. If you built a queue with the front at the tail, which operation would become O(n)?
Show answers
  1. To add at the rear without searching for it. Without a tail reference, enqueue would walk the whole list: O(n).
  2. Dequeue, because it would remove at the tail of a singly linked list.

Part 6

Queues on one page

Queue = FIFO: enqueue at the rear, dequeue at the front. Deque = add and remove at both ends.
Implementationenqueue / adddequeue / deleteNotes
list: append + pop(0)O(1) amortizedΘ(n)never do this
list + front index fO(1) amortizedO(1)memory leaks: dead slots never reused
circular array (f, r)O(1) amortizedO(1) amortizedslot r kept empty; double when len = N−1; halve when len < N/4
singly linked (front = head)O(1)O(1)needs a tail reference
two stacks, strategy 1O(n)O(1)oldest kept on top of A
two stacks, strategy 2O(1)O(1) amortized, O(n) worsteach element: 4 stack operations, ever
circular array dequeO(1) amortized, both endsO(1) amortized, both endsf−1 and r−1 mod N wrap backwards
doubly linked dequeO(1), both endsO(1), both endsheader and trailer sentinels

Formulas to know cold

  • Step forward: i ← (i + 1) mod N. Step back: i ← (i − 1) mod N.
  • len = (N − f + r) mod N. Empty: f = r. Full: len = N − 1.
  • After resize: f = 0, r = n.

Ideas to carry forward

  1. The obvious implementation can be correct and still wrong: Θ(n) dequeues or unbounded memory.
  2. Reusing space by wrapping is the general trick behind ring buffers, used everywhere from keyboards to network cards.
  3. Shrink at a quarter, grow when full: the gap between the thresholds is what keeps resizing amortized O(1).
  4. “Each element is moved at most once” is the shape of most amortized proofs.

Final mixed check

  1. An array queue has N = 16, f = 14, r = 3. What is len? Is the next dequeue going to shrink the array?
  2. A circular queue holds 3 elements in an array of size 4, wrapped. Enqueue one more. Describe the new array, f and r.
  3. Explain in two sentences why the two-stack queue is O(1) amortized but the one-stack recursive queue is not.
Show answers
  1. (16 − 14 + 3) mod 16 = 5. After a dequeue len = 4, and 4 < 16/4 = 4 is false, so no shrink. (A second dequeue would bring len to 3 and halve the array to 8.)
  2. len = 3 = N − 1, so it resizes to 8 first: the 3 elements are copied in queue order to slots 0–2, the new one goes in slot 3, f = 0, r = 4.
  3. In the two-stack queue, pouring reverses the inbox once, after which those elements are served in O(1) each, so every element is moved a bounded number of times. The recursive version never leaves the elements in a better order, so every dequeue digs through all of them again.