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
OperationArrayLinked list
Access i-th elementO(1)O(n) β€” walk from head
Insert/delete at frontO(n)O(1)
Insert/delete after a node you holdO(n)O(1) β€” rewire two pointers
SearchO(n)O(n)
Cache friendlinessexcellentpoor (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.

Pointer-reversal stepper β€” flip 1β†’2β†’3β†’4 in place

Each Step runs one loop iteration: save next, flip curr.next to prev, slide all three forward. Flipped arrows turn green.

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

Floyd stepper β€” 🐒 vs πŸ‡ on 1β†’2β†’3β†’4β†’5β†’(back to 3)

Slow moves 1, fast moves 2. Watch the gap shrink until they collide.

└────── 5 loops back to 3 β”€β”€β”€β”€β”€β”€β”˜

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

Merge two sorted lists

Dummy node + advance the smaller head. The heart of merge sort and k-way merges (files 06/08).

Remove Nth from end

Two pointers n apart; when the lead hits the end, the trailer is just before the target. Dummy node handles "remove the head."

Palindrome list

Middle (fast/slow) β†’ reverse second half β†’ compare β†’ (optionally restore).

Reorder list

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 readsarray (list)
Ordered processing, arrival orderqueue (deque)
Nested / most-recent-firststack (list)
"Next greater/smaller element"monotonic stack
Sliding window max/minmonotonic deque
O(1) add/remove at BOTH endsdeque
O(1) delete of a held node + O(1) key lookup (LRU)hashmap + doubly linked list
Frequent middle insertion with held referenceslinked 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

  1. Write iterative linked-list reversal from memory (4 lines inside the loop, in the right order). What's the loop invariant?
  2. Why does a dummy node simplify deletion problems? Name two problems where you'd use one.
  3. Floyd's cycle detection: why O(1) space, and why do the pointers necessarily meet?
  4. Valid Parentheses: name the three distinct failure modes your code must catch.
  5. Explain the Min Stack invariant in one sentence.
  6. Daily Temperatures with a monotonic stack: what exactly is on the stack, and what does popping mean?
  7. Prove (informally) the two-stack queue is amortized O(1).
  8. list.pop(0) vs deque.popleft() β€” costs and why.
  9. Find the middle of a linked list in one pass. For even length, which middle does your code return?
  10. 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
  1. save next / flip / advance prev / advance cur; invariant: prev heads the reversed prefix, cur heads the untouched suffix.
  2. Head is no longer special; Remove-Nth-from-end, Merge-two-lists, Delete-duplicates.
  3. Two pointers only; gap decrements by 1 per step inside the cycle.
  4. Mismatched pair, closer with empty stack, unclosed openers at end.
  5. mins[i] = minimum of the bottom i+1 elements, so the top of mins is always the current min.
  6. Indices with decreasing temps β€” the unresolved days; popping = that day just found its next warmer day.
  7. Each element: 1 push-in, ≀1 move, ≀1 pop-out β‡’ ≀3 ops per element over any sequence.
  8. O(n) shift vs O(1) unlink at a held end.
  9. slow/fast; second middle.
  10. 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.