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
| Operation | What it does | On an empty queue |
|---|---|---|
enqueue(e) | Add e at the rear | fine |
dequeue() | Remove and return the front element | error |
first() | Return the front element without removing it | error |
len(Q) | Number of elements stored | returns 0 |
is_empty() | True if no elements | returns True |
A trace, written front … rear:
| Operation | Returns | Queue 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
- Starting empty: enqueue(1), enqueue(2), dequeue(), enqueue(3), enqueue(4), dequeue(), dequeue(). What does each dequeue return, and what is left?
- Run the same sequence on a stack (push for enqueue, pop for dequeue). What changes?
Show answers
- Dequeues return 1, 2, 3. Left: [4].
- 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).
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?
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
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).
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
(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
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
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 oThe 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.
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 = n2.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
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 0A trace on an array of 4 slots, including a wrap-around and a resize:
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.
| Operation | Cost |
|---|---|
| enqueue | O(1) amortized (O(n) when it resizes) |
| dequeue | O(1) amortized (O(n) when it shrinks) |
| first, len, is_empty | O(1) |
| space | O(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
- N = 8, f = 5, r = 5. How many elements? What if f = 5 and r = 4?
- N = 8, f = 6, r = 3. Which slots hold elements, in queue order? What is
len? - In the trace above, why did enqueue(6) resize even though slot 1 was empty?
- Why can't
resizejust copyQ[0..N-1]intonewQ[0..N-1]?
Show answers
- 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.
- Slots 6, 7, 0, 1, 2. len = (8 − 6 + 3) mod 8 = 5.
- Slot 1 was slot r, which is never filled. The queue held 3 = N − 1 elements, which counts as full.
- 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.
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.
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()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 life | Stack operations |
|---|---|
| enqueued: pushed onto A | 1 |
| poured, at most once: popped from A, pushed onto B | 2 |
| dequeued: popped from B | 1 |
| total, ever | 4 |
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.
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.
| Strategy | Idea | push | pop, top |
|---|---|---|---|
| 1: two queues | On 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 queues | Push = 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 queue | After each push, rotate the queue len − 1 times (dequeue and re-enqueue) so the newest reaches the front. | O(n) | O(1) |
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
- 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?
- 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?
- Which strategy would you pick for a stack built from queues if your program pushes far more often than it pops?
Show answers
- 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.
- 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.
- 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
| Operation | What 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 |
| Operation | Returns | Deque (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:
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 oThis 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.)
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 deque | Doubly linked deque | |
|---|---|---|
| add/delete at either end | O(1) amortized | O(1) worst case |
| access the i-th element | O(1): Q[(f + i) mod N] | O(n): walk from an end |
| memory | references + spare slots | one 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).
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
- 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.
- How do you use a deque as a stack? As a queue?
- Why does
delete_lastdecrement r before reading the slot, whiledequeuereads Q[f] before incrementing f?
Show answers
- 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.
- Stack: add_last + delete_last (or add_first + delete_first). Queue: add_last + delete_first.
- 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).
head to the next node; enqueue links a new node after tail and moves tail.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.
| Operation | Circular array | Linked list |
|---|---|---|
| enqueue | O(1) amortized | O(1) |
| dequeue | O(1) amortized | O(1) |
| first, len, is_empty | O(1) | O(1) |
| space | O(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
- Why does the linked queue keep a tail reference? What would enqueue cost without one?
- If you built a queue with the front at the tail, which operation would become O(n)?
Show answers
- To add at the rear without searching for it. Without a tail reference, enqueue would walk the whole list: O(n).
- Dequeue, because it would remove at the tail of a singly linked list.
Part 6
Queues on one page
| Implementation | enqueue / add | dequeue / delete | Notes |
|---|---|---|---|
| list: append + pop(0) | O(1) amortized | Θ(n) | never do this |
| list + front index f | O(1) amortized | O(1) | memory leaks: dead slots never reused |
| circular array (f, r) | O(1) amortized | O(1) amortized | slot 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 1 | O(n) | O(1) | oldest kept on top of A |
| two stacks, strategy 2 | O(1) | O(1) amortized, O(n) worst | each element: 4 stack operations, ever |
| circular array deque | O(1) amortized, both ends | O(1) amortized, both ends | f−1 and r−1 mod N wrap backwards |
| doubly linked deque | O(1), both ends | O(1), both ends | header 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
- The obvious implementation can be correct and still wrong: Θ(n) dequeues or unbounded memory.
- Reusing space by wrapping is the general trick behind ring buffers, used everywhere from keyboards to network cards.
- Shrink at a quarter, grow when full: the gap between the thresholds is what keeps resizing amortized O(1).
- “Each element is moved at most once” is the shape of most amortized proofs.
Final mixed check
- An array queue has N = 16, f = 14, r = 3. What is len? Is the next dequeue going to shrink the array?
- A circular queue holds 3 elements in an array of size 4, wrapped. Enqueue one more. Describe the new array, f and r.
- Explain in two sentences why the two-stack queue is O(1) amortized but the one-stack recursive queue is not.
Show answers
- (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.)
- 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.
- 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.