Stacks, Queues & Linked Lists
The linear data structures — the ones where items sit in a line and the interesting question is which end you touch. Stacks (last-in-first-out), queues (first-in-first-out), double-ended deques, priority queues, and the pointer-based linked lists that underpin them. Each is built from scratch so you understand the mechanics, then mapped to the fast standard-library version you'd actually reach for.
Learning objectives
- Understand an abstract data type (ADT) vs. its concrete implementation.
- Build and use a stack, queue, deque and priority queue — and know each operation's cost.
- Implement singly and doubly linked lists node-by-node.
- Detect a cycle with Floyd's fast/slow pointers.
- Combine a hash map + doubly linked list into an O(1) LRU cache (expert).
1 · Abstract data types vs. implementations basic
A stack is defined by its behaviour — push, pop, peek, all last-in-first-out — not by how it's stored. That behaviour is the abstract data type (ADT). You can implement it with an array, a linked list, whatever, as long as it obeys the contract. This separation matters: your agent code talks to the interface, so you can swap the implementation for a faster one without changing callers.
2 · Stack — Last In, First Out basic
A stack adds and removes only at one end (the "top"). Think of a stack of plates: you take the last one you put down. Push and pop are both O(1). In Python a plain list is a perfect stack — append is push, pop() is pop.
'c') is the first one pop() returns. Both operations touch only the top → O(1).
A stack is a pile where you only ever touch the top. This picture shows three boxes stacked up, plus the two arrows that add and remove from that top.
- The three stacked boxes are the items, oldest at the bottom (
'a') and newest at the top ('c'). The label'c' ← topmarks where all the action happens. - The blue arrow labelled
push('c')comes in from the right and lands on top — adding an item always puts it on top. - The grey arrow labelled
pop() → 'c'lifts the top item back off — removing always takes the top one, the most recently added. - So the item that came in last (
'c') is the first to leave. That is what LIFO means: Last In, First Out. The wordbottomat the base reminds you the old items are trapped underneath until everything above them pops.
In short: Both arrows point at the same end, so both push and pop cost O(1) — no reshuffling. Think of a stack of plates: you add and take from the top only.
python# Pythonic: a list IS a stack
stack = []
stack.append("a") # push
stack.append("b")
top = stack[-1] # peek -> 'b' (no removal)
x = stack.pop() # pop -> 'b'
# From scratch, so the contract is explicit
class Stack:
def __init__(self):
self._items = []
def push(self, x): # O(1) amortized
self._items.append(x)
def pop(self): # O(1)
if self.is_empty():
raise IndexError("pop from empty stack")
return self._items.pop()
def peek(self): # O(1)
return self._items[-1]
def is_empty(self):
return not self._items
def __len__(self):
return len(self._items)
This lab shows two ways to get a stack: the quick way (a plain Python list already behaves like a stack) and the honest way (a small class that spells out every operation so the rules are obvious).
stack.append("a")is a push — it adds to the end of the list, which we treat as the top.stack[-1]is a peek:-1means "last item", and it looks without removing.stack.pop()is a pop: it removes and returns the last item ('b'here).- The
Stackclass stores its data in a private listself._items. The leading underscore is a convention meaning "don't poke at this from outside — use my methods". pushappends,popfirst callsis_empty()and raises an error on an empty stack instead of returning garbage, andpeekreturns the top without removing it — exactly the contract the diagram drew.__len__lets you writelen(my_stack), andis_emptyreturnsnot self._items— an empty list is "falsy", sonot []isTrue.
What the output means: Nothing prints here (it's building blocks). After the top lines, top holds 'b' (peeked, still on the stack) and x holds 'b' (popped, now removed).
Try this: Call pop() on an empty Stack() and read the IndexError — that deliberate error is the class keeping its promise instead of failing silently.
pythondef is_balanced(s):
pairs = {")": "(", "]": "[", "}": "{"}
stack = []
for ch in s:
if ch in "([{":
stack.append(ch) # opener -> push
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False # closer must match the last opener
return not stack # nothing left unclosed
print(is_balanced("{[()]}")) # True
print(is_balanced("{[(])}")) # False
A real, famous use of a stack: checking that brackets in a string are balanced — every (, [ and { is closed by the right partner in the right order. The stack remembers the openers you still owe a closer for.
pairsis a lookup table from each closer to the opener it must match (")"needs"(", and so on).- Walking through the string one character at a time: if
chis an opener (in "([{"), we push it onto the stack — "remember this is open". - If
chis a closer, the top of the stack must be its matching opener.not stack(nothing to match) orstack.pop() != pairs[ch](wrong partner) means it's unbalanced, so wereturn Falseimmediately. - At the end,
return not stackmeans "balanced only if the stack is empty" — anything left over is an opener that never got closed.
What the output means: is_balanced("{[()]}") prints True (perfectly nested); is_balanced("{[(])}") prints False — the ] arrives while ( is still on top, so the partners cross.
Try this: Trace "([)]" by hand, pushing and popping as you go. You will hit a mismatch — which is exactly why editors use a stack to highlight your unbalanced brackets.
3 · Queue — First In, First Out basic → intermediate
A queue adds at one end (enqueue, the "back") and removes at the other (dequeue, the "front") — like a line at a checkout. The trap: a plain list makes a bad queue, because pop(0) is O(n) (§4 of D1 — everything shifts). The right tool is collections.deque, which is O(1) at both ends.
t1 arrived first, so it leaves first. Use collections.deque so both ends are O(1) (a list's pop(0) is O(n)).
A queue is a line, like people waiting at a checkout. You join at one end and leave from the other — the two ends have different jobs, and this picture labels both.
- The four boxes
t1 t2 t3 t4are items waiting in line, left to right in arrival order. - The left end is the
front(marked green). The grey arrowdequeue → t1leaves from here — the item that has waited longest goes first. - The right end is the
back. The blue arrowenqueue t5joins here — new arrivals always go to the back of the line. - So the first item in (
t1) is the first item out. That is FIFO: First In, First Out — the opposite of a stack.
In short: Add at the back, remove from the front. The caption warns: use collections.deque, because a plain list's pop(0) has to shift every remaining item and is slow (O(n)).
pythonfrom collections import deque
q = deque()
q.append("task1") # enqueue at the back — O(1)
q.append("task2")
first = q.popleft() # dequeue from the front — O(1) -> 'task1'
# A from-scratch queue that stays O(1) using two stacks (a classic):
class Queue:
def __init__(self):
self._in = [] # push here
self._out = [] # pop here
def enqueue(self, x):
self._in.append(x) # O(1)
def dequeue(self):
if not self._out: # refill only when empty
while self._in:
self._out.append(self._in.pop()) # reverse into _out
if not self._out:
raise IndexError("dequeue from empty queue")
return self._out.pop() # amortized O(1)
Two queues in one lab: first the easy, correct tool (collections.deque), then a clever from-scratch queue built out of two stacks — a classic interview puzzle.
deque()is a double-ended queue.q.append("task1")adds at the back andq.popleft()removes from the front — both fast (O(1)). This is the queue you should actually use.- The
Queueclass keeps two lists:_in(where new items land) and_out(where items leave).enqueuejust appends to_in. dequeueis the trick: if_outis empty, it pours everything from_ininto_outby popping (self._in.pop()) and appending. Popping off one stack and pushing onto another reverses the order — which flips last-in-first-out back into first-in-first-out.- After the refill it pops from
_out, so the oldest item comes out first. It still raises anIndexErrorif there is genuinely nothing to dequeue.
What the output means: first ends up as 'task1' — the first thing enqueued is the first thing dequeued, proving FIFO order from two LIFO stacks.
Try this: The note below explains why this averages O(1): each item is moved from _in to _out exactly once. Enqueue three items, dequeue one, enqueue two more, and trace when the refill fires.
_in to _out exactly once over its lifetime. The occasional refill is O(k), but spread across k dequeues it averages O(1) — the same amortized argument as list growth in D1. It's a lovely demonstration that a queue can be built from two stacks.4 · Deque — fast at both ends intermediate
A deque ("deck", double-ended queue) supports O(1) add/remove at both the front and back. CPython's collections.deque is a doubly-linked list of fixed-size blocks — so it's cache-friendlier than a naive linked list while keeping O(1) ends. It's the correct structure for queues, sliding windows, and bounded histories.
pop(0) is O(n)), all four operations — append/appendleft/pop/popleft — are O(1). With maxlen it becomes a ring buffer that auto-drops the far end.
A deque (say "deck") is a queue that is open at both ends — you can add or remove at the front and the back. This picture shows all four moves as arrows on either side.
- The three middle boxes
m2 m3 m4are the current contents. - On the left: the blue arrow
appendleftpushes a new item in at the front, and the grey arrowpopleftremoves from the front. - On the right:
appendadds at the back andpopremoves from the back — the ordinary list operations you already know. - The point of the diagram: all four arrows are equally cheap (O(1)). A plain list is only fast at the back; a deque is fast at both ends.
In short: Blue arrows add, grey arrows remove; left side = front, right side = back. With maxlen set, adding to one end automatically drops an item off the other (a "ring buffer").
pythonfrom collections import deque
# maxlen makes it a ring buffer: appending past the limit drops the far end
recent = deque(maxlen=3)
for msg in ["m1", "m2", "m3", "m4"]:
recent.append(msg)
print(list(recent)) # ['m2', 'm3', 'm4'] — 'm1' fell off automatically
recent.appendleft("m0") # add at the front — O(1); drops 'm4'
This lab shows the single most useful deque trick: give it a maxlen and it becomes a bounded history that automatically forgets the oldest item once it's full.
deque(maxlen=3)can hold at most 3 items. Once it's full, every newappendsilently drops one from the far end to make room.- The loop appends
"m1","m2","m3", then"m4". When"m4"is added the deque is already full, so"m1"(the oldest) falls off the front. list(recent)just converts the deque to a normal list so we can print it.appendleft("m0")adds at the front instead; since the deque is full, that pushes off the far end ("m4").
What the output means: print(list(recent)) shows ['m2', 'm3', 'm4'] — 'm1' was evicted the moment a 4th item arrived, with no bookkeeping from you.
Try this: This is exactly how you keep "the last N chat messages" in an agent. Change maxlen to 2 and predict the printout before running it.
deque(maxlen=N) is the cleanest way to keep an agent's last-N-messages context window (Ch 4) — bounded memory, O(1) updates, oldest turn evicted automatically. It's also the frontier queue for BFS in D5.5 · Priority queue & heapq advanced
A priority queue always removes the highest-priority item (by convention, the smallest). It's backed by a binary heap (built in full in D4). Python ships one as the heapq module operating on a plain list: heappush/heappop are O(log n), and peeking the min is heap[0] at O(1).
heappop always returns the highest-priority item in O(log n) — the mechanism behind nlargest top-k and Dijkstra (D5).
A priority queue ignores arrival order — it always hands back the most important item. By Python's convention, "most important" means the smallest number. The picture shows items going in jumbled and the smallest coming out first.
- The top row (
pushed:) shows three items added in a random order, each a pair(priority, name)— e.g.(2,reindex),(1,page),(3,cleanup). - The middle arrow says the heap keeps the min reachable at the root — internally it quietly reorganises so the smallest priority is always ready at the front.
- The green box
(1,page)is that smallest, and the bottom arrow showsheappop()returning it first — even though it wasn't added first. - "Smallest wins" is just a convention: give urgent work a low number so it pops before everything else.
In short: Read it top (things pushed) to bottom (what pops). Each pop is O(log n) — fast even with millions of items, because the heap never fully sorts them.
pythonimport heapq
# A min-heap. Push (priority, item); smallest priority pops first.
pq = []
heapq.heappush(pq, (2, "reindex"))
heapq.heappush(pq, (1, "page oncall")) # higher priority = lower number
heapq.heappush(pq, (3, "cleanup"))
print(heapq.heappop(pq)) # (1, 'page oncall')
# Top-k largest scores WITHOUT sorting everything: O(n log k)
scores = [(0.91, "docA"), (0.55, "docB"), (0.73, "docC"), (0.88, "docD")]
top2 = heapq.nlargest(2, scores) # [(0.91,'docA'), (0.88,'docD')]
The heapq module turns any plain list into a min-heap — a structure whose smallest item is always instantly available. This lab uses it two ways: as a priority queue, and to grab the top-k biggest scores without sorting everything.
pq = []starts as an ordinary empty list;heapqfunctions maintain the heap order for you.heappush(pq, (2, "reindex"))adds a(priority, item)pair.- Because tuples compare by their first element, the priority number decides ordering.
heappop(pq)removes and returns the smallest — here(1, 'page oncall')— no matter what order things were pushed. scoresis a list of(score, doc)pairs.heapq.nlargest(2, scores)returns the two largest pairs directly, again comparing by the first element.- This is the retrieval trick: to get the k best matches out of millions you never sort the whole list — a size-k heap does it in O(n log k).
What the output means: heappop prints (1, 'page oncall') (lowest number = highest priority), and top2 becomes [(0.91,'docA'), (0.88,'docD')] — the two highest scores.
Try this: Add (0, "outage") to pq and pop again — it jumps the line. That is how a scheduler makes urgent work run first.
Why a heap and not just sorted()[:k]? Sorting all n items is O(n log n). If you only need the top k, a size-k heap does it in O(n log k) — a big win when n is millions and k is 10, and it never holds more than k items in memory.
heapq.nlargest(k, scored_chunks) — the retrieval step in Ch 3. A max-priority task scheduler for the DevOps agent (Ch 8) is a priority queue.6 · Singly linked list — from scratch intermediate → advanced
A linked list stores each element in a node that also holds a pointer to the next node. Unlike an array, the nodes aren't contiguous — so there's no O(1) indexing (you must walk from the head), but inserting/removing at a known node is O(1) because you just relink pointers, no shifting. This pointer-relinking is the whole idea; master it here and trees/graphs (D4/D5) become easy.
A linked list is a chain: each item lives in its own little box (a node) that also holds an arrow pointing to the next box. This picture shows the chain and how you splice a new box in.
head →on the left is the entry point — the only handle you get; there's no jumping straight to the middle.- Each node box has two halves: a value (
A,B,C) and a pointer (the•). The arrows follow those pointers left-to-right; the last node points at∅(nothing), meaning "end of list". - The green box
Xbelow is being inserted between A and B. The dashed arrow shows the new wiring:A.next→XthenX.next→B. - Inserting only changes two pointers — nothing else moves. That's why insertion at a known spot is O(1), while finding a spot means walking the arrows one by one (O(n)).
In short: Follow the arrows from head; the node with ∅ is the end. Insertion = re-pointing two arrows, no shifting — the opposite trade-off from an array.
pythonclass Node:
def __init__(self, value, nxt=None):
self.value = value
self.next = nxt # pointer to the next node (or None)
class LinkedList:
def __init__(self):
self.head = None
def push_front(self, value): # insert at head — O(1)
self.head = Node(value, self.head)
def __iter__(self): # walk head -> tail — O(n)
cur = self.head
while cur:
yield cur.value
cur = cur.next
def find(self, value): # O(n) — no random access
for v in self:
if v == value: return True
return False
# Reverse a linked list in place — the canonical pointer-juggling problem
def reverse(head):
prev = None
cur = head
while cur:
nxt = cur.next # 1. remember the rest
cur.next = prev # 2. flip this node's pointer backwards
prev = cur # 3. advance prev
cur = nxt # 4. advance cur
return prev # new head — O(n) time, O(1) space
Here we build a singly linked list from nothing, then solve its most famous puzzle: reversing it in place. Everything is just re-pointing the .next arrows the diagram drew.
Nodeholds avalueandnext(the arrow to the following node, orNoneat the end). That's the whole box-with-a-pointer idea in code.LinkedListkeeps onlyself.head.push_frontmakes a new node whosenextis the old head, then makes it the new head — an O(1) insert at the start.__iter__walks the chain: start athead,yieldeach value, then hop tocur.nextuntilcurisNone. This lets you writefor v in my_list.findreuses it — it must scan (O(n)) because there's no random access.reverseis the classic: keep three markers.nxtremembers the rest before you break the link,cur.next = prevflips this node's arrow backwards, thenprevandcurboth step forward. Whencurruns off the end,previs the new head.
What the output means: reverse returns the new head of a list running in the opposite direction — done in one pass (O(n) time) using only three variables (O(1) extra space).
Try this: Draw three nodes A→B→C and move prev/cur/nxt along on paper. Watch each arrow flip; this pointer-juggling is asked in interviews constantly.
| Op | Array (list) | Linked list |
|---|---|---|
| index [i] | O(1) | O(n) |
| insert/remove at front | O(n) | O(1) |
| insert/remove at known node | O(n) | O(1) |
| search | O(n) | O(n) |
| memory locality (cache) | excellent | poor (scattered nodes) |
7 · Doubly linked list advanced
A doubly linked list gives each node a prev pointer as well as next, so you can walk backwards and — crucially — remove a node in O(1) when you already hold a reference to it (no need to find its predecessor). A sentinel head/tail node removes edge cases.
prev and next, unlinking B is just re-pointing A and C around it — no walk to find the predecessor. This is the engine of the LRU cache below.
A doubly linked list adds a backward arrow to every node, so each box knows both its next and its previous neighbour. This picture shows why that makes removing a node trivial.
- Boxes
A,B,Care the nodes. Between each pair there are now two grey arrows — one pointing forward (next) and one back (prev). B (remove)is drawn red: it's the node we want to delete, and we already hold a direct reference to it.- The blue arc over the top is the fix:
A.next→CandC.prev→A. We simply re-point A and C around B, and B is gone from the chain. - Because each node already knows its neighbours, we never scan to find B's predecessor — that's why removal is O(1). A singly linked list would have to walk from the head to find who points at B.
In short: Two arrows between every pair = two-way links. To delete a held node, bend its two neighbours' arrows past it. This exact move powers the LRU cache in §9.
pythonclass DNode:
def __init__(self, key=None, value=None):
self.key, self.value = key, value
self.prev = self.next = None
class DoublyLinkedList:
def __init__(self):
self.head = DNode() # sentinels — never hold real data
self.tail = DNode()
self.head.next = self.tail
self.tail.prev = self.head
def add_back(self, node): # insert before tail — O(1)
last = self.tail.prev
last.next = node; node.prev = last
node.next = self.tail; self.tail.prev = node
def remove(self, node): # unlink a held node — O(1)
node.prev.next = node.next
node.next.prev = node.prev
This builds the doubly linked list, using a neat trick — two sentinel nodes — to make the code short and bug-free. Sentinels are permanent dummy nodes at the ends that hold no real data.
DNodestores akey, avalue, and two pointersprevandnext(both start asNone).- The constructor creates a
headandtailsentinel, then links them to each other. Real items always live between these two, so the list is never truly empty and you never have to special-case "first item" or "last item". add_backinserts a node just beforetail: grab the current last real node (self.tail.prev), then rewire four pointers so the new node sits between it andtail.removeis the diagram's blue arc in code:node.prev.next = node.nextandnode.next.prev = node.prev— the two neighbours now point at each other, skipping the node. No scanning, so O(1).
What the output means: Nothing prints — this is a reusable building block. Both add_back and remove touch a fixed handful of pointers, so both are O(1) regardless of list size.
Try this: Notice there are no if node is None checks. That simplicity is because of the sentinels — the ends always exist, so the neighbour pointers are never missing.
8 · Cycle detection — Floyd's tortoise & hare advanced
Does a linked list loop back on itself? Naively you'd store every visited node in a set (O(n) space). Floyd's algorithm does it in O(1) space with two pointers moving at different speeds: if there's a cycle, the fast one laps the slow one and they meet.
This diagram explains how to detect a loop in a linked list using two pointers that move at different speeds — the "tortoise and hare". If the chain loops, the fast one catches the slow one.
- Nodes
1and2are a straight tail; nodes3 4 5 6form a loop that circles back (follow the arrows — they come back around to3). - The slow pointer moves 1 node per step; the fast pointer moves 2. On a straight list fast would just reach the end and you'd know there's no cycle.
- But inside the loop the fast pointer keeps lapping. Each step it closes the gap to slow by exactly 1, so eventually they land on the same node — the red node marked slow & fast meet here → cycle!.
- That collision is the whole signal: if they ever meet, there's a cycle; if fast reaches the end (
∅), there isn't.
In short: Slow = 1 step, fast = 2 steps. A shrinking gap that drops by 1 each step must hit 0 — meeting proves a loop, using no extra memory (O(1) space).
pythondef has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next # 1 step
fast = fast.next.next # 2 steps
if slow is fast: # they met -> cycle
return True
return False # fast reached the end -> no cycle
The whole tortoise-and-hare idea in six lines: walk two pointers at different speeds and watch for them to collide.
slow = fast = headstarts both pointers at the front of the list.- The loop condition
while fast and fast.nextstops safely: iffastor the node after it isNone, we've hit the end and can callfast.next.nextwithout crashing. - Each pass,
slowadvances one node andfastadvances two. If there's a loop, fast keeps circling and gains on slow. if slow is fastchecks whether they're now the same node (iscompares identity, not value). If so, they met inside a loop →return True. If the loop instead runs off the end, wereturn False.
What the output means: True when the list loops back on itself, False when it terminates normally — using just two pointers, no set of visited nodes.
Try this: Build 1→2→3→1 (a loop) versus 1→2→3→None and run both. The first returns True, the second False.
9 · Expert — an O(1) LRU cache expert advanced
This is the classic interview capstone that ties D2 together: a Least-Recently-Used cache with O(1) get and put. The trick is combining two structures — a hash map (key → node, for O(1) lookup) and a doubly linked list (ordering by recency, for O(1) move-to-front and evict-from-back). Neither alone can do it; together they can.
An LRU cache keeps the N most-recently-used items and throws away the least-recently-used when it's full. The clever part is combining two structures so both lookup and reordering are instant. This picture shows them side by side.
- On the left is the hash map (a dict): it maps each key (
"a","b","c") straight to its node. That's what makes finding an item O(1) — but a dict has no sense of order. - On the right is the recency list (a doubly linked list) ordered oldest→newest:
a (LRU)at the front is least-recently-used,c (MRU)at the back is most-recently-used. - The blue arrow from the map into the list shows how they connect: look a key up in the map to jump directly to its node in the list, then move that node to the back in O(1).
- The red label evict from front is what happens when the cache overflows — you drop the front node (the least recently used); the green insert/touch at back is where fresh or just-used items go.
In short: Map = instant lookup, list = recency order. Every access finds the node via the map, then re-links it to the back. Neither structure alone works — the map has no order, the list has no fast search.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def DNode(*a, **k): # demo stub
return _Any()
def DoublyLinkedList(*a, **k): # demo stub
return _Any()pythonclass LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {} # key -> DNode (O(1) find)
self.dll = DoublyLinkedList() # recency order (front=old, back=new)
def get(self, key):
if key not in self.map:
return None
node = self.map[key]
self.dll.remove(node) # pull it out ...
self.dll.add_back(node) # ... and mark most-recent — O(1)
return node.value
def put(self, key, value):
if key in self.map:
self.dll.remove(self.map[key])
node = DNode(key, value)
self.map[key] = node
self.dll.add_back(node) # most-recent
if len(self.map) > self.cap: # over capacity -> evict oldest
oldest = self.dll.head.next # front = least recently used
self.dll.remove(oldest)
del self.map[oldest.key]
# In real code you rarely hand-roll this — the stdlib gives you memoization:
from functools import lru_cache
@lru_cache(maxsize=256)
def embed(text): # repeated calls with the same text are free
... # (see P4/P5 — this IS an LRU cache under the hood)
Here the two structures from the diagram become one working cache with O(1) get and put — then the punchline: Python's standard library already gives you this for free.
- In
__init__,self.mapis the dict (key → node) for instant lookup andself.dllis the doubly linked list holding recency order (front = old, back = new). get: if the key is missing, returnNone. Otherwise find the node via the map, thenremoveit andadd_backit — pulling it out and re-attaching at the back marks it most-recently-used, all in O(1).put: if the key already exists, unlink the old node first. Make a new node, record it in the map, andadd_back(most-recent). If we're now overself.cap, evict:self.dll.head.nextis the front node (least recently used), remove it and delete its key from the map.- The final block shows the shortcut:
@lru_cache(maxsize=256)above a function makes Python cache its results automatically — the same dict-plus-recency machinery, hidden behind a decorator.
What the output means: Not runnable as-is (it uses the DoublyLinkedList from §7 — see the collapsed setup block). Conceptually: repeated get/put stay fast forever, and old entries silently disappear once you pass capacity.
Try this: Now you know what @lru_cache does under the hood. Trace a capacity-2 cache: put a, put b, get a, put c — which key gets evicted, and why is it b?
@lru_cache in P5 and the prompt/result caching in Ch 6. Now you know what's happening inside it: a dict for lookup + a recency list for eviction, both O(1).Exercises advanced
- Use a stack to evaluate a postfix (reverse-Polish) expression like
"3 4 + 2 *". - Implement a
MinStackwherepush,pop, andget_minare all O(1) (hint: a second stack of running minima). - Merge two sorted linked lists into one sorted list, reusing the nodes (O(n), O(1) space).
- Extend Floyd's algorithm to return the node where the cycle begins, not just whether one exists.
- Add a
maxlen-style capacity to your from-scratchQueueso it drops the oldest on overflow.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see D8 · Big Tech DSA patterns.
Push openers; on a closer, the top must be its match. Empty at the end = balanced.
pythondef is_valid(s):
pairs = {")":"(", "]":"[", "}":"{"}
stack = []
for c in s:
if c in "([{":
stack.append(c)
elif not stack or stack.pop() != pairs[c]:
return False
return not stack
The interview version of the balanced-brackets checker from §2 — classic problem. Same stack idea, written as tightly as possible.
pairsmaps each closer to its opener.stackholds openers we haven't matched yet.- For each character: if it's an opener (
c in "([{"), push it. Otherwise it's a closer, so the top must be its partner. not stack or stack.pop() != pairs[c]catches both failures at once — an empty stack (a closer with nothing open) or a mismatched partner — and returnsFalse.return not stackat the end means "valid only if nothing is left open".
What the output means: True for correctly nested brackets, False otherwise — the canonical one-stack answer interviewers expect.
Try this: This is the §2 checker minus comments. Say out loud why the stack is the right structure here: the most recent opener must be the first one closed — that's LIFO.
Keep a parallel stack of running minima so the current min is always on top.
pythonclass MinStack:
def __init__(self):
self.st = []; self.mins = []
def push(self, x):
self.st.append(x)
self.mins.append(x if not self.mins else min(x, self.mins[-1]))
def pop(self): self.mins.pop(); return self.st.pop()
def get_min(self): return self.mins[-1]
classic problem: a stack that can also report its current minimum in O(1) — no scanning. The trick is a second stack that tracks the running minimum alongside the values.
self.stis the normal value stack;self.minsis a parallel stack whose top is always the smallest value currently inst.- On
push(x), we pushxtost, and push tominseitherx(if it's the first) ormin(x, self.mins[-1])— the smaller of the new value and the previous minimum. poppops both stacks together, so the two stay in lock-step and the minimum for the remaining values is still correct.get_minjust returnsself.mins[-1]— the top of the mins stack — instantly.
What the output means: get_min() always returns the smallest value still on the stack in O(1), because that answer was pre-computed on every push.
Try this: Push 5, 3, 7, 2, then pop twice, calling get_min after each step. Watch the mins stack shadow the values and always keep the current minimum on top.
Checkpoint advanced
- Explain LIFO vs FIFO and pick the right structure (list / deque / heapq) for a task.
- Say why
list.pop(0)is O(n) anddeque.popleft()is O(1). - Relink pointers to insert, remove, and reverse a linked list.
- Detect a cycle in O(1) space, and explain the LRU cache's dict + DLL design.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Stacks and queues are the two workhorse linear structures, and choosing the wrong container for a queue is a classic performance trap in Python.
Your task: Use a Python list as a LIFO stack and collections.deque as a FIFO queue, showing that their removal order differs and why deque — not a list — is the right queue.
Requirements:
- The stack removes most-recently-added first (LIFO)
- The queue removes oldest first (FIFO)
- Use
append/popfor the stack anddequewithpopleftfor the queue - Explain that
list.pop(0)isO(n)whiledeque.popleftisO(1)
💡 Hint: Popping from the front of a list shifts every remaining element; a deque is doubly-linked so both ends are constant time.
Show solution
from collections import deque
stack = []
for x in "ABC": stack.append(x)
print([stack.pop() for _ in range(3)]) # ['C','B','A'] LIFO
queue = deque()
for x in "ABC": queue.append(x)
print([queue.popleft() for _ in range(3)]) # ['A','B','C'] FIFO
A list's pop(0) is O(n) (shifts everything); deque.popleft() is O(1). Use deque for queues.
Context: Balanced-bracket checking (classic) is the archetypal stack problem and a common warm-up in phone screens.
Your task: Given a string of brackets ()[]{}, decide whether it is balanced and correctly nested using a stack, in O(n).
Requirements:
- Push opening brackets; on a closing bracket, pop and check it matches
- Reject if the top doesn't match, or if a closer arrives on an empty stack
- The string is balanced only if the stack ends empty
O(n)time- Handle mismatched types (e.g.
(]) as invalid
💡 Hint: Map each closing bracket to its opener and compare against the popped top; a leftover stack at the end means unmatched openers.
Show solution
def is_valid(s): # O(n) time, O(n) space
match = {")": "(", "]": "[", "}": "{"}
stack = []
for ch in s:
if ch in "([{":
stack.append(ch)
else:
if not stack or stack.pop() != match[ch]:
return False
return not stack # leftover openers = unbalanced
print(is_valid("([]{})")) # True
print(is_valid("(]")) # False
print(is_valid("((")) # False
A stack naturally models "most recent unclosed opener must match next closer".
Context: Floyd's tortoise-and-hare is the constant-space way to detect a cycle in a linked structure — a pointer-technique staple.
Your task: Build a linked list and detect whether it contains a cycle using two pointers moving at different speeds, in O(n) time and O(1) space.
Requirements:
- Advance a slow pointer one step and a fast pointer two steps per iteration
- A cycle exists iff the two pointers ever meet
- No cycle iff the fast pointer reaches the end
O(n)time,O(1)space (no set of visited nodes)
💡 Hint: In a cycle the fast pointer laps the slow one and they collide; on a straight list the fast pointer simply runs off the end.
Show solution
class Node:
def __init__(self, val):
self.val = val; self.next = None
def has_cycle(head): # O(n) time, O(1) space
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast: # pointers meet inside the loop
return True
return False
a, b, c = Node(1), Node(2), Node(3)
a.next, b.next, c.next = b, c, a # c points back to a -> cycle
print(has_cycle(a)) # True
c.next = None
print(has_cycle(a)) # False
The fast pointer laps the slow one iff there's a loop; O(1) space because we store no visited set.
Context: A min-stack (classic) that reports its minimum in constant time is a favourite because the naive "scan for the min" answer is too slow.
Your task: Design a stack supporting push, pop, top, and get_min, all in O(1).
Requirements:
get_minisO(1), not a scan- Track the running minimum alongside each pushed element
- Popping correctly restores the previous minimum
- All four operations are
O(1)
💡 Hint: Store (value, min_so_far) pairs (or a parallel min-stack) so the current minimum is always sitting on top.
Show solution
class MinStack: # every op O(1)
def __init__(self):
self.stack = [] # (value, min-so-far)
def push(self, x):
cur_min = x if not self.stack else min(x, self.stack[-1][1])
self.stack.append((x, cur_min))
def pop(self):
return self.stack.pop()[0]
def top(self):
return self.stack[-1][0]
def get_min(self):
return self.stack[-1][1]
s = MinStack()
for x in (5, 2, 7, 1): s.push(x)
print(s.get_min()) # 1
s.pop()
print(s.get_min()) # 2
Storing the min with each entry keeps get_min O(1) instead of scanning the stack.
Context: A priority queue via heapq underlies schedulers, Dijkstra, and event loops — but you must handle equal priorities without it crashing on unorderable payloads.
Your task: Schedule tasks by priority using heapq so each pop returns the smallest priority in O(log n), and show the counter trick that keeps equal priorities in FIFO order.
Requirements:
- Push and pop maintain the heap invariant in
O(log n) - Pop always returns the lowest-priority-number task
- Break ties with a monotonic insertion counter so equal priorities stay FIFO
- The counter also prevents comparing the task payloads directly
💡 Hint: Push (priority, insertion_index, task) tuples; the index both stabilises ties and stops Python from ever comparing two task objects.
Show solution
import heapq, itertools
class PriorityQueue:
def __init__(self):
self.h = []; self.counter = itertools.count()
def push(self, item, priority):
heapq.heappush(self.h, (priority, next(self.counter), item))
def pop(self): # O(log n)
return heapq.heappop(self.h)[2]
pq = PriorityQueue()
pq.push("email", 3); pq.push("page", 1); pq.push("log", 3)
print(pq.pop(), pq.pop(), pq.pop()) # page email log
The counter breaks ties deterministically so two equal-priority items never compare their (unorderable) payloads and keep arrival order.
Context: An LRU cache with O(1) get and put (classic) is a real caching primitive and a very common systems-flavoured interview question.
Your task: Implement an LRU cache with O(1) get and put that evicts the least-recently-used key when it exceeds capacity.
Requirements:
getandputare bothO(1)- Accessing or updating a key marks it most-recently-used
- At capacity,
putevicts the least-recently-used key - Use an ordered map (or a hash map plus a doubly-linked list)
💡 Hint: Python's OrderedDict with move_to_end and popitem(last=False) gives you both ordering and O(1) access.
Show solution
from collections import OrderedDict
class LRUCache: # get/put both O(1)
def __init__(self, capacity):
self.cap = capacity
self.d = OrderedDict()
def get(self, key):
if key not in self.d:
return -1
self.d.move_to_end(key) # mark most-recently-used
return self.d[key]
def put(self, key, value):
if key in self.d:
self.d.move_to_end(key)
self.d[key] = value
if len(self.d) > self.cap:
self.d.popitem(last=False) # evict least-recently-used
c = LRUCache(2)
c.put(1, 1); c.put(2, 2)
print(c.get(1)) # 1 (now 2 is LRU)
c.put(3, 3) # evicts key 2
print(c.get(2)) # -1
OrderedDict gives O(1) reorder + O(1) evict; under the hood it's a hash map over a doubly linked list, which is exactly how production caches are built.
Knowledge check check yourself
How does Floyd's tortoise-and-hare algorithm detect a cycle in a linked list, and why is it O(1) space?
Show answer
What data structures combine to give an LRU cache O(1) get and put, and what does each contribute?