01 Β· DSA β file 03
Linked Lists, Stacks & Queues
Linked lists are the interview's favorite pointer-manipulation gym, and stacks/queues are the two access disciplines β LIFO and FIFO β that quietly power half of computing: your call stack, your undo history, your Kafka consumers, your BFS.
01Linked lists β what you buy by giving up contiguity
An array's superpower (O(1) indexing) comes from contiguous memory. Its weakness (O(n) insert/delete) comes from the same place. A linked list makes the opposite trade: scatter the nodes anywhere in memory, and have each node carry a pointer to the next.
Array: [A][B][C][D] one block; insert at front shifts everything
Linked list: head ββΊ [A|β’]ββΊ [B|β’]ββΊ [C|β’]ββΊ [D|None]
each node = (value, pointer); nodes live anywhere in memory
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
| Operation | Array | Linked list |
|---|---|---|
| Access i-th element | O(1) | O(n) β walk from head |
| Insert/delete at front | O(n) | O(1) |
| Insert/delete after a node you hold | O(n) | O(1) β rewire two pointers |
| Search | O(n) | O(n) |
| Cache friendliness | excellent | poor (pointer-chasing) |
Insert after a node you already hold β the linked list's party trick. Two pointer writes, O(1), nothing else moves:
before: ... ββΊ [B|β’] ββββββββΊ [C|...]
insert X: [B|β’]ββΊ [X|β’]ββΊ [C|...] X.next = B.next; B.next = X
The honest caveat (say it in interviews): "O(1) insert once you're at the node." Getting there is O(n). And in practice, arrays often win anyway β pointer-chasing defeats CPU caches, and every node costs extra memory for the pointer. That's why Python's stdlib gives you dynamic arrays and deques, not general linked lists.
So why do linked lists exist? Because sometimes you are already holding the node:
- LRU cache β the canonical justification. Redis's LRU-ish eviction, or any LRU you build: a hashmap gives O(1) access to the node; a doubly linked list gives O(1) "move to front" and "evict from back." Neither structure alone can do it; together they're O(1) everything.
- Queues/deques (each end is a held node), free lists in memory allocators, chaining in hashmaps.
Doubly linked lists
Add a prev pointer: now you can delete a node given only that node (rewire node.prev.next and node.next.prev), and walk both directions. Costs one extra pointer per node. collections.deque and every LRU cache use this.
None ββ[A]ββββΊ[B]ββββΊ[C]ββΊ None
each node: (prev, val, next)
The dummy (sentinel) node β the #1 bug preventer
Half of all linked-list bugs are "the head is a special case." Kill the special case by parking a fake node before the head. Use it any time the head might change: deletion, merging, partitioning.
dummy = ListNode(0, head)
prev, cur = dummy, head
# ... surgery that might remove/replace the real head works uniformly ...
return dummy.next # the possibly-new head
02Core techniques
Reversal β the "must write it cold" one
Walk the list, flipping each next pointer backwards. Three variables, one loop:
def reverse_list(head):
prev, cur = None, head
while cur:
nxt = cur.next # 1. save where we're going
cur.next = prev # 2. flip the arrow
prev = cur # 3. advance prev
cur = nxt # 4. advance cur
return prev # prev is the new head
The invariant to narrate out loud: "everything left of cur is already reversed and prev heads it; everything from cur onward is untouched." O(n) time, O(1) space.
Fast & slow pointers (tortoise and hare)
One pointer moves 1 step, the other 2. Two things fall out:
Middle of the list β when fast reaches the end, slow is at the middle:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# slow = middle (second middle if even length)
Cycle detection (Floyd's algorithm) β if there's a cycle, fast laps slow and they must meet:
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast:
return True
return False
Why must they meet? Once both are inside the cycle, the gap between them shrinks by exactly 1 each step (fast gains 1 per step on a circular track). A shrinking non-negative gap hits 0. No set needed β O(1) space (the set version is fine too β say both, lead with Floyd).
Bonus (asked as a follow-up): to find the cycle's start, after the meeting point, reset one pointer to head and advance both 1 step at a time; they meet at the cycle entrance. (Provable with a little arithmetic on distances β knowing the recipe is usually enough.)
Backend anchor: cycle detection is how you find a loop in a symlink chain, a retry-forwarding loop, or a circular dependency β "two cursors at different speeds" beats "remember everything I've seen" when memory matters.
Classic surgeries built from these parts
Dummy node + advance the smaller head. The heart of merge sort and k-way merges (files 06/08).
Two pointers n apart; when the lead hits the end, the trailer is just before the target. Dummy node handles "remove the head."
Middle (fast/slow) β reverse second half β compare β (optionally restore).
L0βLnβL1βLn-1β¦ = middle + reverse + interleave β a medley of all three techniques, which is why it's a favorite.
03Stacks: LIFO β "the undo button"
A stack is an access discipline, not a memory layout: push and pop at the same end. Last In, First Out.
push(A), push(B), push(C): pop() β C (most recent first)
β C β ββ top
β B β
β A β
βββββ
In Python, a plain list is a perfectly good stack β append/pop at the tail are O(1):
stack = []
stack.append(x) # push
x = stack.pop() # pop
top = stack[-1] # peek
Why LIFO matters: it exactly models nested structure β the most recently opened thing is the first that must close. Function calls (the call stack!), parentheses, HTML tags, editor undo, DFS. Whenever a problem says "most recent unmatched X," your brain should say stack.
Valid Parentheses β the canonical stack problem
def is_valid(s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch in "([{":
stack.append(ch)
else:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack # leftovers = unclosed openers
Three failure modes, all covered: wrong type (pop mismatch), close-with-nothing-open (not stack), unclosed leftovers (final check). Rattle those off and you're done.
Min Stack β O(1) minimum
"Design a stack that also returns the minimum in O(1)." The trick: a second stack recording the min-so-far at each depth; push/pop it in lockstep (or only push when a new min arrives).
class MinStack:
def __init__(self):
self.stack, self.mins = [], []
def push(self, x):
self.stack.append(x)
self.mins.append(min(x, self.mins[-1]) if self.mins else x)
def pop(self):
self.mins.pop()
return self.stack.pop()
def get_min(self):
return self.mins[-1]
Why it works: popping rewinds history, and mins is a snapshot of history β the min at every past depth is remembered, so rewinding restores it for free.
Monotonic stack β the pattern that feels like a superpower
When: "for each element, find the next/previous greater/smaller element" β or anything reducible to that (daily temperatures, stock span, largest rectangle in histogram, trapping rain water).
The idea: maintain a stack whose values are always e.g. decreasing. When a new element arrives that's bigger than the top, the top has just found its "next greater element" β pop it and record the answer. Keep popping while true; then push the new element.
def daily_temperatures(temps):
ans = [0] * len(temps)
stack = [] # indices; temps decreasing
for i, t in enumerate(temps):
while stack and temps[stack[-1]] < t:
j = stack.pop()
ans[j] = i - j # today is j's next warmer day
stack.append(i)
return ans
temps: 73 74 75 71 69 72 76
push 73 β 74 arrives: 74>73, pop 73 (ans=1), push 74
75 arrives: pop 74 (ans=1), push 75
71, 69 arrive: smaller, just pile up stack: 75 71 69
72 arrives: pop 69 (ans=1), pop 71 (ans=2), push 72
76 arrives: pop 72, pop 75, push 76
Complexity β the amortized argument again: the nested while looks like O(nΒ²), but each index is pushed once and popped at most once β O(n) total. (This is the flagship example for file 01 Β§5's magic phrase.)
Why the waiting elements can pile up: an element stays on the stack precisely while its answer is still unknown; the stack holds the "unresolved" elements, and they're necessarily in decreasing order (anything smaller than a newcomer would have been resolved by it).
04Queues & deques: FIFO β "the fair line"
Push at one end, pop at the other. First In, First Out. Queues model fairness and buffering: things are handled in arrival order.
enqueue ββΊ [ D β C β B β A ] ββΊ dequeue
(back) (front, oldest first)
Backend anchor β you already live here: Kafka topics, task queues (Celery/SQS), request buffers, connection backlogs. A Kafka partition is a durable FIFO queue; consumer offsets are just "how far into the queue I've read." When an interviewer says "process in arrival order," you're being asked for a queue; when they say BFS (file 05), the queue is the algorithm.
The Python trap
q = [1, 2, 3]
x = q.pop(0)
# O(n) β shifts EVERY
# remaining element left
Use collections.deque
from collections import deque
q = deque()
q.append(x) # enqueue (right)
x = q.popleft() # dequeue β O(1)
A deque (double-ended queue, internally a doubly-linked list of blocks) does O(1) push/pop at both ends β it can impersonate both a stack and a queue, and it stars in the monotonic-deque solution to Sliding Window Maximum (keep indices of a decreasing sequence; front is always the window max β O(n) total).
Queue from two stacks (classic design question)
Two stacks: inbox and outbox. Enqueue β push to inbox. Dequeue β if outbox is empty, pour the entire inbox into it (reversing order β LIFO twice = FIFO), then pop outbox.
class MyQueue:
def __init__(self):
self.inbox, self.outbox = [], []
def push(self, x):
self.inbox.append(x)
def pop(self):
if not self.outbox:
while self.inbox:
self.outbox.append(self.inbox.pop())
return self.outbox.pop()
Each element moves at most twice in its lifetime (inβoverβout) β amortized O(1) per operation, even though a single pop can be O(n). (Yes β amortized analysis three times in one file. It's that common.)
05Choosing between them β the 10-second decision
| You need⦠| Reach for |
|---|---|
| Index access / random reads | array (list) |
| Ordered processing, arrival order | queue (deque) |
| Nested / most-recent-first | stack (list) |
| "Next greater/smaller element" | monotonic stack |
| Sliding window max/min | monotonic deque |
| O(1) add/remove at BOTH ends | deque |
| O(1) delete of a held node + O(1) key lookup (LRU) | hashmap + doubly linked list |
| Frequent middle insertion with held references | linked list (rare in practice) |
06Common interview questions
Q1. Array vs linked list β when is a linked list actually better?
When you hold a reference to the node and need O(1) insert/delete there, or O(1) ops at both ends without amortization; LRU cache is the flagship. Otherwise arrays win on cache locality and memory overhead.
Q2. Reverse a linked list β recursive version?
rev(head): if head or head.next is None return head; new_head = rev(head.next); head.next.next = head; head.next = None; return new_head. O(n) stack space β say that, and prefer iterative.
Q3. Why must fast/slow pointers meet in a cycle?
In the cycle, the gap shrinks by exactly 1 per step; a decreasing non-negative integer reaches 0.
Q4. Detect the start of the cycle?
After meeting, reset one pointer to head; step both by 1; they meet at the entrance.
Q5. Why is a list bad as a queue in Python?
pop(0) shifts all n elements: O(n) per dequeue. deque.popleft() is O(1).
Q6. Min stack in O(1) β how?
Parallel stack of mins-so-far; pop in lockstep. History snapshotting.
Q7. Why is the monotonic stack loop O(n) despite the nested while?
Each element is pushed once and popped at most once; total stack operations β€ 2n.
Q8. Merge two sorted lists β key trick?
Dummy head + tail pointer; repeatedly attach the smaller node; attach the leftover tail at the end.
Q9. When would you use a deque over a stack or queue?
Need both ends (sliding window max, palindrome checking, work-stealing-style add/remove at either end).
Q10. Remove Nth node from end in one pass?
Two pointers n apart starting from a dummy; when the lead hits the end, the trailer's next is the victim.
07Self-test
- Write iterative linked-list reversal from memory (4 lines inside the loop, in the right order). What's the loop invariant?
- Why does a dummy node simplify deletion problems? Name two problems where you'd use one.
- Floyd's cycle detection: why O(1) space, and why do the pointers necessarily meet?
- Valid Parentheses: name the three distinct failure modes your code must catch.
- Explain the Min Stack invariant in one sentence.
- Daily Temperatures with a monotonic stack: what exactly is on the stack, and what does popping mean?
- Prove (informally) the two-stack queue is amortized O(1).
list.pop(0)vsdeque.popleft()β costs and why.- Find the middle of a linked list in one pass. For even length, which middle does your code return?
- Sketch the LRU cache design: which structure gives O(1) lookup, which gives O(1) recency updates, and how are they wired together?
Spot-checks
- save next / flip / advance prev / advance cur; invariant: prev heads the reversed prefix, cur heads the untouched suffix.
- Head is no longer special; Remove-Nth-from-end, Merge-two-lists, Delete-duplicates.
- Two pointers only; gap decrements by 1 per step inside the cycle.
- Mismatched pair, closer with empty stack, unclosed openers at end.
- mins[i] = minimum of the bottom i+1 elements, so the top of mins is always the current min.
- Indices with decreasing temps β the unresolved days; popping = that day just found its next warmer day.
- Each element: 1 push-in, β€1 move, β€1 pop-out β β€3 ops per element over any sequence.
- O(n) shift vs O(1) unlink at a held end.
- slow/fast; second middle.
- Hashmap keyβnode for lookup; doubly linked list ordered by recency (move-to-front on access, evict tail); each map value points directly at its list node.