Ch 7 / 30 Queues, Deques and Circular Buffers 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 7

Part 2 · Linear Structures · Chapter 7

Queues, Deques and Circular Buffers

The queue at a coffee shop. Fair, boring, and the backbone of everything from print spoolers to breadth-first search — as long as you never implement it with a plain list.

Reading
CLRS Ch. 10.1
Focus
FIFO, done properly
Cost
enqueue/dequeue O(1)
Powers
BFS · schedulers · buffers

By the end of this chapter you can

  1. State the FIFO contract and name three systems that depend on it
  2. Explain precisely why list.pop(0) makes a queue O(n²) and demonstrate it
  3. Implement a circular buffer with head, tail and modulo arithmetic
  4. Use collections.deque correctly, including as a stack, a queue and a sliding window
  5. Implement a monotonic deque and use it for sliding-window maximum in O(n)

1First in, first out

A stack serves the newest waiting item. A queue serves the oldest. One word of difference, and completely different behaviour.

The coffee shop

You join the back of the line. People are served from the front. Nobody is served twice, nobody jumps in, and the person who has waited longest goes next.

It is the most boring possible rule, and that is exactly why we use it: it is fair, and fairness means no item waits forever. A stack under constant load can starve the item at the bottom indefinitely. A queue cannot.

A queue supports two operations, on opposite ends:

  • enqueue(x) — add x at the back
  • dequeue() — remove and return the item at the front
Where you have used oneWhat is queuedWhy FIFO
A printer spoolerPrint jobsFirst submitted, first printed
A web serverIncoming requestsNobody's request should starve
A task runner (Celery, Sidekiq)Background jobsFairness, and predictable latency
Keyboard inputKeystrokesCharacters must arrive in typing order
Breadth-first searchGraph vertices to exploreIt is what makes BFS find shortest paths
A CPU schedulerRunnable processesRound-robin is a queue
The one-line difference from a stack

Both structures hold “things not yet dealt with.” The only question is which one you take next.

Stack: the newest. Queue: the oldest. In Chapter 20 you will see the exact same graph-search code produce depth-first or breadth-first behaviour depending purely on which of the two holds the frontier.

2The pop(0) trap

The obvious Python implementation is quietly quadratic. This is the single most common performance bug in beginner-to-intermediate code.

A list looks like it can be a queue: append to add at the back, pop(0) to take from the front. It works. It is also O(n) per removal, because Chapter 3 told us why — an array's contract is that element i lives at a fixed offset, so removing the front means shifting every remaining element one slot left.

Draining a queue of n items this way costs n + (n−1) + (n−2) + … element moves — which is n²/2. On a thousand items that is half a million moves you did not ask for. On a hundred thousand it is five billion, and your program appears to hang.

the_trap.py
from collections import deque
import time

N = 40_000

# --- the trap ---
q = list(range(N))
t0 = time.perf_counter()
while q:
    q.pop(0)           # O(n) each time  →  O(n²) overall
t1 = time.perf_counter()

# --- the fix ---
d = deque(range(N))
t2 = time.perf_counter()
while d:
    d.popleft()        # O(1) each time  →  O(n) overall
t3 = time.perf_counter()

print(f"list.pop(0):    {(t1-t0)*1000:8.1f} ms")
print(f"deque.popleft():{(t3-t2)*1000:8.1f} ms")

# Double N. The list time roughly QUADRUPLES; the deque time doubles.
How to spot it in your own code

Any of these inside a loop should make you stop:

  • xs.pop(0)
  • xs.insert(0, x)
  • xs = xs[1:]
  • del xs[0]

All four shift the entire list. All four are O(n). All four have an O(1) deque equivalent.

3The circular buffer

The classic fix, and the one you would implement in C. A fixed array plus two indices and one modulo.

The problem with a list-backed queue is that the front keeps moving right, abandoning space behind it. The fix is to let the queue wrap around and reuse that space: a fixed array with a head index (where the next item leaves) and a tail index (where the next item arrives), both advancing modulo the capacity.

ring_buffer.py
class RingBuffer:
    def __init__(self, capacity):
        self._buf = [None] * capacity
        self._cap = capacity
        self._head = 0        # next item to leave
        self._tail = 0        # next free slot
        self._size = 0

    def enqueue(self, x):
        if self._size == self._cap:
            raise OverflowError("buffer full")
        self._buf[self._tail] = x
        self._tail = (self._tail + 1) % self._cap    # ← the whole trick
        self._size += 1

    def dequeue(self):
        if self._size == 0:
            raise IndexError("dequeue from an empty buffer")
        x = self._buf[self._head]
        self._buf[self._head] = None                 # release the reference
        self._head = (self._head + 1) % self._cap
        self._size -= 1
        return x

    def __len__(self):
        return self._size

# Both operations: a couple of assignments and one modulo. No shifting,
# no allocation, ever. O(1) with a very small constant.
Why the size counter exists

When head == tail, is the buffer empty or full? The indices alone cannot tell you — both states look identical.

Two standard fixes: keep an explicit size counter (done above), or waste one slot so that full means (tail + 1) % cap == head. Either is fine; forgetting to do one of them is a classic and very confusing bug.

Ring buffers are everywhere in systems where memory is fixed and allocation is forbidden: audio buffers, network packet queues, kernel logs, embedded firmware. When the buffer fills, a real system picks a policy — block, drop the newest, or overwrite the oldest. Your terminal's scrollback is the last of those.

4deque: the one you will actually use

Python ships a double-ended queue that is O(1) at both ends. It is the right answer to nearly every queue-shaped problem in Python.

collections.deque (“deck”) supports O(1) append and pop at both ends. Internally it is a doubly linked list of fixed-size array blocks — which gets it the cache locality of arrays and the O(1) end operations of a linked list.

deque.py
from collections import deque

d = deque([1, 2, 3])

d.append(4)         # add at the right   O(1)
d.appendleft(0)     # add at the left    O(1)
d.pop()             # remove from right  O(1)
d.popleft()         # remove from left   O(1)

d[0]                # O(1)  — the ends are cheap
d[len(d) // 2]      # O(n)  — the MIDDLE is not. This is the trade.

# A bounded deque: pushing past maxlen silently drops from the other end.
recent = deque(maxlen=3)
for x in [1, 2, 3, 4, 5]:
    recent.append(x)
print(recent)       # deque([3, 4, 5], maxlen=3)
                    # a rolling window of the last 3, for free
You wantUseOperations
A stacklistappend / pop
A queuedequeappend / popleft
A double-ended queuedequeall four
The last k items seendeque(maxlen=k)append, and it self-trims
Indexing into the middlelista deque's middle is O(n)
The one thing a deque gives up

Indexing the middle is O(n), because you have to walk the blocks. If you need both random access and cheap front operations, you need a different structure — or, more often, a different plan.

In practice this almost never bites, because code that wants a queue does not want to index into the middle of it.

5The monotonic deque

Chapter 6's monotonic stack, with one extra ability: throwing away from the other end too. It solves sliding-window maximum in O(n).

The problem: given an array and a window size k, report the maximum in every window as it slides. The obvious solution recomputes the max per window: O(n·k). A heap gets it to O(n log k). A monotonic deque gets it to O(n).

The insight is one sentence: if a value arrives that is larger than something already in the window, that smaller thing can never be the maximum again — the new value is bigger and will outlive it. So throw it away immediately.

sliding_max.py
from collections import deque

def sliding_max(xs, k):
    dq = deque()          # indices, values strictly DECREASING front to back
    out = []
    for i, x in enumerate(xs):
        # 1. drop indices that have fallen out of the window (from the FRONT)
        if dq and dq[0] <= i - k:
            dq.popleft()

        # 2. drop values smaller than x — they can never win again (from the BACK)
        while dq and xs[dq[-1]] <= x:
            dq.pop()

        dq.append(i)

        # 3. the front is always the maximum of the current window
        if i >= k - 1:
            out.append(xs[dq[0]])
    return out

sliding_max([1, 3, -1, -3, 5, 3, 6, 7], 3)   # [3, 3, 5, 5, 6, 7]

Both ends are used, and for different reasons — which is exactly why this needs a deque rather than a stack:

  • The front is popped because an index has expired out of the window.
  • The back is popped because a value has become irrelevant.

And the cost argument is the same one as always: each index enters the deque once and leaves once, so the total work is at most 2n. The nested while does not make it quadratic.

enqueue
O(1)
dequeue
O(1)
peek front
O(1)
index middle
O(n)
sliding max
O(n)
space
O(k)
The pattern, stated generally

Discard anything that can never be the answer again.

That single principle underlies the monotonic stack (Chapter 6), the monotonic deque (here), and the pruning in Chapter 27's backtracking. When a problem feels like it needs to remember everything, ask which of those things is already provably useless.

What to carry forward

  • A queue serves the oldest waiting item; a stack serves the newest. Fairness is the point — a queue cannot starve an item.
  • list.pop(0) is O(n), so a list-backed queue is O(n²) overall. So are insert(0,x), del xs[0] and xs[1:].
  • A circular buffer is a fixed array plus head, tail and modulo. Nothing shifts, nothing allocates — and you need a size counter to tell empty from full.
  • collections.deque is O(1) at both ends and is the right answer in Python. It gives up O(1) indexing of the middle.
  • A monotonic deque solves sliding-window maximum in O(n) by discarding values that can never win again — popping the front for expiry and the back for irrelevance.
  • Swap the queue in a graph search for a stack and BFS becomes DFS. Same code, different container.

>_Playground

Measure the pop(0) trap yourself, then watch a ring buffer wrap around.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 8 — Hash Tables

A coat check that computes your ticket number from your coat. Almost magic, entirely mundane once you see the arithmetic.

Continue →