AI EngineeringZero to ProductionHome·About·Contact
Appendix · Data Structures & Algorithms · Part 2

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.

⏱️ ~2 hours🎯 Basic → Expert🥞 LIFO / FIFOrunnable

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.

The mental splitADT = the promise (operations + their meaning). Data structure = the machinery that keeps the promise (and fixes the cost of each operation). "Stack" is an ADT; "array-backed stack" is a data structure.

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.

LIFO — push & pop at the same end (top) 'a' 'b' 'c' ← top push('c') pop() → 'c' bottom Stack = Last In, First Out. The most recently pushed item ('c') is the first one pop() returns. Both operations touch only the top → O(1).
🗺️ How to read this diagram

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' ← top marks 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 word bottom at 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the Pythonic stack, then from scratch
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)
▶ How this works

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).

  1. 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: -1 means "last item", and it looks without removing. stack.pop() is a pop: it removes and returns the last item ('b' here).
  2. The Stack class stores its data in a private list self._items. The leading underscore is a convention meaning "don't poke at this from outside — use my methods".
  3. push appends, pop first calls is_empty() and raises an error on an empty stack instead of returning garbage, and peek returns the top without removing it — exactly the contract the diagram drew.
  4. __len__ lets you write len(my_stack), and is_empty returns not self._items — an empty list is "falsy", so not [] is True.

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.

Classic use — balanced-brackets checker
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
▶ How this works

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.

  1. pairs is a lookup table from each closer to the opener it must match (")" needs "(", and so on).
  2. Walking through the string one character at a time: if ch is an opener (in "([{"), we push it onto the stack — "remember this is open".
  3. If ch is a closer, the top of the stack must be its matching opener. not stack (nothing to match) or stack.pop() != pairs[ch] (wrong partner) means it's unbalanced, so we return False immediately.
  4. At the end, return not stack means "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.

🔗 Used in the courseThe agent's reasoning is a stack of frames — a tool call pushes a sub-task, its result pops back to the caller. Recursion (D3) is a stack: Python's call stack. When you parse JSON tool arguments or nested runbook sections, a stack tracks the open structures.

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.

FIFO — enqueue at the back, dequeue from the front t1 t2 t3 t4 dequeue → t1 front enqueue t5 back Queue = First In, First Out. t1 arrived first, so it leaves first. Use collections.deque so both ends are O(1) (a list's pop(0) is O(n)).
🗺️ How to read this diagram

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 t4 are items waiting in line, left to right in arrival order.
  • The left end is the front (marked green). The grey arrow dequeue → t1 leaves from here — the item that has waited longest goes first.
  • The right end is the back. The blue arrow enqueue t5 joins 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)).

Try it — the right queue
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)
▶ How this works

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.

  1. deque() is a double-ended queue. q.append("task1") adds at the back and q.popleft() removes from the front — both fast (O(1)). This is the queue you should actually use.
  2. The Queue class keeps two lists: _in (where new items land) and _out (where items leave). enqueue just appends to _in.
  3. dequeue is the trick: if _out is empty, it pours everything from _in into _out by 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.
  4. After the refill it pops from _out, so the oldest item comes out first. It still raises an IndexError if 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.

Why two stacks give O(1) amortizedEach element is moved from _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.

double-ended queue · O(1) at BOTH ends m2 m3 m4 appendleft popleft append pop A deque is open at both ends. Unlike a list (where 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.
🗺️ How to read this diagram

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 m4 are the current contents.
  • On the left: the blue arrow appendleft pushes a new item in at the front, and the grey arrow popleft removes from the front.
  • On the right: append adds at the back and pop removes 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").

Try it — a bounded history (auto-drops the oldest)
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'
▶ How this works

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.

  1. deque(maxlen=3) can hold at most 3 items. Once it's full, every new append silently drops one from the far end to make room.
  2. 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.
  3. list(recent) just converts the deque to a normal list so we can print it.
  4. 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.

🔗 Used in the coursedeque(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).

push in any order · pop always returns the smallest priority pushed: (2,reindex) (1,page) (3,cleanup) ↓ heap keeps the min reachable at the root (1,page) heappop() → (1, 'page') first, in O(log n) A priority queue orders by priority, not arrival. The heap underneath keeps the smallest element at the root, so heappop always returns the highest-priority item in O(log n) — the mechanism behind nlargest top-k and Dijkstra (D5).
🗺️ How to read this diagram

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 shows heappop() 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.

Try it — top-k with a heap (the RAG pattern)
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')]
▶ How this works

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.

  1. pq = [] starts as an ordinary empty list; heapq functions maintain the heap order for you. heappush(pq, (2, "reindex")) adds a (priority, item) pair.
  2. 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.
  3. scores is a list of (score, doc) pairs. heapq.nlargest(2, scores) returns the two largest pairs directly, again comparing by the first element.
  4. 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.

🔗 Used in the courseRAG's "return the k most similar chunks" is exactly 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.

each node = value + pointer to next · scattered in memory head → A B C X insert: A.next→X, X.next→B (O(1), no shifting) A linked list is nodes joined by pointers. Indexing means walking from the head (O(n)), but inserting at a known spot is just re-pointing two links (O(1)) — no elements move.
🗺️ How to read this diagram

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 X below is being inserted between A and B. The dashed arrow shows the new wiring: A.next→X then X.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.

Try it — nodes, traversal, and the classic reverse
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
▶ How this works

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.

  1. Node holds a value and next (the arrow to the following node, or None at the end). That's the whole box-with-a-pointer idea in code.
  2. LinkedList keeps only self.head. push_front makes a new node whose next is the old head, then makes it the new head — an O(1) insert at the start.
  3. __iter__ walks the chain: start at head, yield each value, then hop to cur.next until cur is None. This lets you write for v in my_list. find reuses it — it must scan (O(n)) because there's no random access.
  4. reverse is the classic: keep three markers. nxt remembers the rest before you break the link, cur.next = prev flips this node's arrow backwards, then prev and cur both step forward. When cur runs off the end, prev is 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.

OpArray (list)Linked list
index [i]O(1)O(n)
insert/remove at frontO(n)O(1)
insert/remove at known nodeO(n)O(1)
searchO(n)O(n)
memory locality (cache)excellentpoor (scattered nodes)
When NOT to use a linked listIn practice, arrays/deques beat linked lists almost always, because CPU caches love contiguous memory. Linked lists shine when you need O(1) splicing at arbitrary held positions (e.g. the LRU cache below). Learn them because trees and graphs are linked structures — not because you'll often build a bare linked list.

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 + next pointers · remove a held node in O(1) A B (remove) C A.next→C, C.prev→A (splice B out, no scanning) Two-way links enable O(1) removal. Because each node knows its 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.
🗺️ How to read this diagram

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, C are 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→C and C.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.

Try it — O(1) removal of a held node
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
▶ How this works

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.

  1. DNode stores a key, a value, and two pointers prev and next (both start as None).
  2. The constructor creates a head and tail sentinel, 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".
  3. add_back inserts a node just before tail: grab the current last real node (self.tail.prev), then rewire four pointers so the new node sits between it and tail.
  4. remove is the diagram's blue arc in code: node.prev.next = node.next and node.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.

slow moves 1 step, fast moves 2 — in a loop they must collide 1 2 3 4 5 6 slow & fast meet here → cycle! Tortoise & hare. Inside a loop the fast pointer gains 1 position on the slow one each step, so the gap shrinks to 0 and they meet — detecting the cycle in O(1) extra space.
🗺️ How to read this diagram

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 1 and 2 are a straight tail; nodes 3 4 5 6 form a loop that circles back (follow the arrows — they come back around to 3).
  • 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).

Try it
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
▶ How this works

The whole tortoise-and-hare idea in six lines: walk two pointers at different speeds and watch for them to collide.

  1. slow = fast = head starts both pointers at the front of the list.
  2. The loop condition while fast and fast.next stops safely: if fast or the node after it is None, we've hit the end and can call fast.next.next without crashing.
  3. Each pass, slow advances one node and fast advances two. If there's a loop, fast keeps circling and gains on slow.
  4. if slow is fast checks whether they're now the same node (is compares identity, not value). If so, they met inside a loop → return True. If the loop instead runs off the end, we return 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.

Why they're guaranteed to meetInside a cycle, each step the fast pointer closes the gap to the slow one by exactly 1. A gap that shrinks by 1 every step must hit 0 — so they collide within one lap. It's the same fast/slow idea from D1's two-pointer section, applied to pointers instead of indices.

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.

hash map (find in O(1)) + DLL (recency order) map key → node "a" → • "b" → • "c" → • recency list (front=old → back=new) a (LRU) b c (MRU) evict from front insert/touch at back Two structures, one O(1) cache. The map finds a node instantly; the doubly linked list reorders it to "most-recent" (or evicts the front "least-recent") in O(1). Neither alone is enough — the map has no order, the list has no fast lookup.
🗺️ How to read this diagram

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.

Try it — build it, then see the stdlib shortcut
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)
▶ How this works

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.

  1. In __init__, self.map is the dict (key → node) for instant lookup and self.dll is the doubly linked list holding recency order (front = old, back = new).
  2. get: if the key is missing, return None. Otherwise find the node via the map, then remove it and add_back it — pulling it out and re-attaching at the back marks it most-recently-used, all in O(1).
  3. put: if the key already exists, unlink the old node first. Make a new node, record it in the map, and add_back (most-recent). If we're now over self.cap, evict: self.dll.head.next is the front node (least recently used), remove it and delete its key from the map.
  4. 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?

🔗 Used in the courseCaching embeddings or tool results by input is an LRU cache — @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

Practice
  1. Use a stack to evaluate a postfix (reverse-Polish) expression like "3 4 + 2 *".
  2. Implement a MinStack where push, pop, and get_min are all O(1) (hint: a second stack of running minima).
  3. Merge two sorted linked lists into one sorted list, reusing the nodes (O(n), O(1) space).
  4. Extend Floyd's algorithm to return the node where the cycle begins, not just whether one exists.
  5. Add a maxlen-style capacity to your from-scratch Queue so 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.

Valid parentheses (classic) — stack

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
▶ How this works

The interview version of the balanced-brackets checker from §2 — classic problem. Same stack idea, written as tightly as possible.

  1. pairs maps each closer to its opener. stack holds openers we haven't matched yet.
  2. For each character: if it's an opener (c in "([{"), push it. Otherwise it's a closer, so the top must be its partner.
  3. 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 returns False.
  4. return not stack at 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.

Min stack (classic) — O(1) get_min

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]
▶ How this works

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.

  1. self.st is the normal value stack; self.mins is a parallel stack whose top is always the smallest value currently in st.
  2. On push(x), we push x to st, and push to mins either x (if it's the first) or min(x, self.mins[-1]) — the smaller of the new value and the previous minimum.
  3. pop pops both stacks together, so the two stay in lock-step and the minimum for the remaining values is still correct.
  4. get_min just returns self.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) and deque.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.

Exercise 1 · Stack vs queueBeginner

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/pop for the stack and deque with popleft for the queue
  • Explain that list.pop(0) is O(n) while deque.popleft is O(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.

Exercise 2 · Valid parenthesesIntermediate

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".

Exercise 3 · Detect a cycle (Floyd)Advanced

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.

Exercise 4 · Min-stack in O(1)Expert

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_min is O(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.

Exercise 5 · Priority queue with heapqProfessional

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.

Exercise 6 · LRU cache in O(1)Industry scenario

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:

  • get and put are both O(1)
  • Accessing or updating a key marks it most-recently-used
  • At capacity, put evicts 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

✓ Knowledge check

How does Floyd's tortoise-and-hare algorithm detect a cycle in a linked list, and why is it O(1) space?

Show answer
Two pointers advance at different speeds (one step vs two steps per iteration); if a cycle exists the fast pointer eventually laps and meets the slow one. It uses only the two pointers, so space is O(1) with no extra hash set.
✓ Knowledge check

What data structures combine to give an LRU cache O(1) get and put, and what does each contribute?

Show answer
A hash map gives O(1) lookup from key to node, and a doubly linked list gives O(1) removal and move-to-front so the least-recently-used item sits at the tail for O(1) eviction.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in