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
Here's a distinction that quietly separates people who write maintainable code from people who don't. When someone says "use a stack," they're describing what it does — not what it's made of. That gap between "what it does" and "how it's built" is one of the most important ideas in this whole track, and it has a name: the abstract data type.
Think of a car's steering wheel. You know its contract — turn left to go left, turn right to go right — without knowing whether behind it sits a rack-and-pinion, a hydraulic system, or drive-by-wire electronics. You could swap the entire mechanism for a better one and every driver's knowledge still works, because they only ever depended on the interface, never the internals.
Data structures work the same way. 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. Why this earns its own section: your agent code talks to the interface ("give me the next task"), so the day you discover your list-based queue is too slow, you can swap in a faster implementation and not one line of caller code changes. The ADT is the promise; the implementation is a swappable detail behind it. Keep that separation in mind for every structure below — each one is a contract first, and a chunk of code second.
push/pop/peek; either backing structure keeps that promise, so you can switch one for the other without touching a line of caller code.
Read it top-to-bottom: the code at the top talks to a contract in the middle, and that contract can be fulfilled by either box at the bottom.
- The top box is your caller code — the agent logic that just wants "a stack". The blue arrow shows it depends downward on the interface only.
- The green middle box is the Stack ADT: the named operations (
push,pop,peek,is_empty) and what they mean — the promise, with no code attached. - The two dashed arrows fan down to two implementations: an array-backed stack (a Python
list) and a linked-list-backed one. Both honour the same contract. - The payoff: because the caller only touches the green contract, you can replace the left implementation with the right one — or a faster future one — and nothing above the green box changes.
In short: One contract on top, interchangeable machinery underneath — that separation is what lets you swap a slow structure for a fast one without rewriting callers.
2 · Stack — Last In, First Out basic
You use a stack every time you hit Ctrl-Z. Undo doesn't reverse some random edit — it reverses your most recent one, then the one before that, walking backwards in the exact reverse order you did things. That "most recent comes back first" behaviour is a stack, and once you spot the pattern you'll see it everywhere: the browser Back button, the call stack that runs your functions, matching brackets in a parser, an agent unwinding its steps after an error.
The defining rule is LIFO — Last In, First Out: a stack adds and removes only at one end (the "top"). The physical picture is a stack of plates — you can only take the last plate you put down, because it's sitting on top of all the others. You never reach into the middle. Because both actions happen at that single accessible end, push (add) and pop (remove) are each O(1) — no shifting, no searching, just touch the top.
The happy surprise in Python: you don't need a special class. A plain list is a perfect stack — append(x) is push and pop() (with no argument, so it takes from the end) is pop. Both are the O(1) end-operations you met in D1. The lab below builds a tiny explicit Stack wrapper anyway — not because you need it, but so the LIFO contract is spelled out in code you can read.
'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
If a stack is Ctrl-Z, a queue is the checkout line at a shop: whoever arrived first gets served first, and newcomers join the back. That's FIFO — First In, First Out — the opposite discipline to a stack, and it's how you model anything that should be handled fairly in arrival order: tasks waiting for a worker, messages waiting to be processed, requests waiting for your API.
The mechanics: a queue adds at one end (enqueue, the "back") and removes from the other (dequeue, the "front"). Two different ends — that's the crucial difference from a stack, and it's exactly where beginners get burned.
The trap — and it's a big one. Your instinct will be to use a plain list: append to add at the back, pop(0) to remove from the front. It works… and it's secretly O(n). Remember from D1 §4 why: removing item 0 forces every remaining element to physically shift down one slot to close the gap. Do that inside a loop and you've built a hidden O(n²) that runs fine in testing and melts under real load. The right tool is collections.deque, which is engineered for O(1) at both ends — that's the very next section. Rule of thumb: the moment you need a queue, reach for deque, never list.pop(0).
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
We just saw a plain list is a bad queue because its front is slow. The fix is a structure that's fast at both ends — the deque (pronounced "deck", short for double-ended queue). It supports O(1) add and remove at the front and the back. Think of it as a subway car with doors at both ends: people flow in and out either side instantly, no shuffling the whole car.
How it pulls that off (and why it's not just a faster list): CPython's collections.deque isn't one contiguous block — it's a chain of small fixed-size blocks linked together (a doubly-linked list of arrays). Adding at either end just tacks onto the block at that end, so nothing ever shifts. It gives up the O(1) random indexing a list has (you can't jump to d[5000] cheaply) — but for a queue you never index the middle anyway, so that's a trade you happily make.
When to reach for it: any real queue, a sliding window over a stream, or a "keep only the last N" bounded history — deque even takes a maxlen so it auto-drops the oldest item when full, which is a one-liner for chat-history windows. Whenever your problem says "add/remove from the ends," this is the tool.
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 queue serves people in arrival order — but a hospital emergency room can't work that way. A patient with a heart attack jumps ahead of someone with a sprained wrist, no matter who arrived first. That's a priority queue: it doesn't serve first-in, it serves most-important-first. By universal convention we treat the smallest number as highest priority (priority 1 beats priority 5), so "remove the most important" becomes "remove the minimum."
The obvious implementations are both bad, and the tension is worth feeling. Keep the list sorted so the min is always at the front? Then inserting costs O(n) to slot into place. Keep it unsorted for cheap inserts? Then finding the min costs O(n) every time. You can't win with a flat list — one operation is always slow.
The resolution is a cleverer shape called a binary heap (you'll build one from scratch in D4; here we just use it). A heap is a tree kept in "loose" order — each parent is smaller than its children, but siblings are unsorted — which is just enough structure to make both push and pop O(log n) while peeking the minimum stays O(1). Python ships this as the heapq module, and here's the quirk that trips everyone: it doesn't give you a Heap class — it gives you functions (heappush, heappop) that operate on an ordinary list you maintain, with the minimum always sitting at heap[0]. It's a top-k engine: exactly what RAG uses to keep the best-scoring chunks.
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
Everything so far has been array-flavoured — items sitting side by side in memory. Now we meet the opposite philosophy, and it's worth slowing down for, because the pointer-relinking idea you learn here is the foundation of trees (D4) and graphs (D5). Get it now and those chapters feel easy; skip it and they feel like magic.
Picture a treasure hunt. You don't get a map of all the locations — you get one clue, and that clue tells you where the next clue is, which points to the next, and so on. A linked list is exactly that: each element lives in a little box called a node that holds two things — a value, and a pointer ("the address of the next node"). The list itself is just a reference to the first node (the "head"); you follow the pointers to reach the rest.
This design makes a deliberate trade, and understanding the trade is the point. Because the nodes are scattered in memory rather than contiguous, you lose the array's superpower: there's no O(1) jump to element i — to reach the 5th node you must walk the 4 clues before it (O(n)). But you gain something arrays are bad at: inserting or removing at a node you already hold is O(1) — you just rewrite a couple of pointers ("skip me, point to the next one"), with none of the mass-shifting that makes list.insert(0, x) slow. Arrays: instant to read, slow to restructure. Linked lists: slow to read, instant to restructure. The lab builds one node-by-node so the pointer surgery is concrete.
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
The singly linked list has one frustrating blind spot. Its treasure-hunt clues only point forward — so if you're standing on a node and want to delete it, you're stuck: to relink the list you need the node before it, and there's no way back. Your only option is to restart from the head and walk until you find the predecessor — O(n), just to remove a node you were already touching.
A doubly linked list fixes this by giving every node a second pointer, prev, alongside next. Now each node knows both its neighbours — it's a chain you can walk in either direction. The payoff is the operation the singly list couldn't do: remove a node in O(1) when you already hold it, because you can reach its predecessor instantly (node.prev) and splice it out. That single capability is why the doubly linked list is the secret ingredient in the LRU cache at the end of this chapter.
One practical craft note that this section teaches: real doubly-linked-list code is riddled with "what if it's the first node? the last? the only one?" edge cases. The professional trick is a sentinel — a permanent dummy head/tail node that never holds data but guarantees every real node always has a neighbour on both sides. Suddenly there are no special cases, because there are no true "ends" to trip over. It's a small idea that makes the code dramatically cleaner, and you'll see it pay off in the lab.
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
Here's a subtle bug that can hang a program forever: a linked list whose last node points back into itself instead of ending. Walk it looking for the end and you loop infinitely. So how do you detect a loop before it traps you? This is a famous puzzle with a genuinely beautiful answer.
The obvious solution works but is wasteful: keep a set of every node you've visited; if you ever see one twice, there's a cycle. Correct — but it costs O(n) extra memory to store all those nodes. Can we do it with no extra memory?
Floyd's algorithm says yes, using an idea from a running track. Put two runners on the list: a slow tortoise that steps one node at a time, and a fast hare that leaps two. If the list is straight, the hare simply reaches the end — no cycle. But if the list loops, both runners get trapped going around the same loop — and because the hare gains exactly one step on the tortoise per move, it inevitably catches up from behind and they land on the same node. Two runners meeting means there's a cycle. It's O(n) time but O(1) space — the classic "trade cleverness for memory" result, and a favourite interview question precisely because the two-speed insight is so hard to invent on the spot and so satisfying once seen.
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 capstone that ties the whole chapter together — and it's one of the most-asked hard interview questions in the industry, because solving it proves you can combine structures rather than just recite them. The problem: build a cache that holds only the N most recently used items, evicting the least-recently-used one when it's full — with both "look something up" and "store something" running in O(1). Think of your phone's recent-apps list: fixed size, most-recent floats to the front, the stalest drops off the end.
Why it's hard: no single structure can do both jobs. You need two capabilities at once — instant lookup by key ("is 'user_42' cached?") and instant reordering by recency ("mark this the newest, drop the oldest"). A hash map nails the first but has no notion of order. A doubly linked list nails the second (move-to-front and drop-the-tail are the O(1) relink operations from §7) but can't find a key without scanning. Each structure is missing exactly what the other has.
The insight is to run them together, pointing at the same nodes. The hash map maps key → the node, giving O(1) "where is it?"; the doubly linked list holds those same nodes in recency order, giving O(1) "make it newest / evict the oldest." A lookup hits the map to find the node, then splices it to the front of the list. Both structures update in constant time, so the whole cache is O(1). This "two structures covering each other's weakness" pattern is exactly how Python's own functools.lru_cache and OrderedDict work under the hood — you're about to build the real thing.
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?