πŸ—οΈ Design

A learning sheet β€” "design a data structure" is one move: spot the operation that's slow with your first structure, then bolt on a second one that makes it fast.

🧠 1 Β· How to think β€” combine two structures

A "design X" prompt lists operations and a target cost (usually O(1) or O(log n) each). No single textbook structure does everything β€” a hash map is O(1) but unordered; an array has indices but slow middle-delete; a list keeps order but slow lookup. The whole skill is: name the op that's slow, then pair a second structure that's fast at exactly that op, and keep them in sync.

The combine table β€” memorize the pairings
GoalFirst structure (its weakness)+ Add=
O(1) get/put + LRU evictionhash map (no recency order)doubly linked listLRU cache
O(1) insert/remove/getRandomhash map (no random index)dynamic array (swap-with-last)RandomizedSet
O(1) min on a stackstack (no min query)a parallel "min" stackMin Stack
latest value at-or-before a timehash map (no time search)sorted list + binary searchTime-based KV

Watch it get invented β€” LRU Cache

  1. Pick one structure. A hash map gives O(1) get/put β€” but evicting the least-recently-used key means scanning all of it, O(n).
  2. Name the slow op. "Track recency + evict the oldest in O(1)." A list ordered by recency does that β€” but then finding a key is O(n).
  3. Combine. Hash map key β†’ node (for O(1) find) plus a doubly linked list ordered most-recent-first (for O(1) move-to-front and tail eviction). The map's value is the list node, so every splice is O(1).
  4. Payoff. Both get and put are O(1) β€” neither structure could do it alone.
hash map A β†’ ● B β†’ ● C β†’ ● doubly linked list (recency) head B C A tail most recent LRU β†’ evict get(A): O(1) find via map, then O(1) splice A to the front
Map = O(1) lookup; DLL = O(1) reorder/evict. The map stores the node itself, so the two stay in lockstep.

🎯 2 · Attack plan

AskIf yes β†’Pattern
O(1) cache with eviction order (LRU / LFU)?hash map + doubly linked listMap + DLL (Β§4.1)
O(1) insert / remove / getRandom?hash map + array (swap-with-last)Map + array (Β§4.2)
An extra O(1) query on a stack/queue (min, max)?a parallel auxiliary structureAuxiliary (Β§4.3)
Look up a value by time (latest ≀ t)?map of sorted entries + binary searchTime-ordered (Β§4.4)
Expose next()/hasNext() over a structure lazily?a controlled traversal with a stackIterators (Β§4.5)
Anti-patterns Β· Don't combine when one structure already meets the bound β€” clarify the required cost per operation first, then add only what's missing.
Β· Keep the two structures in sync on every op β€” the classic bug is updating one and forgetting the other (a stale map entry after a delete).
Β· Concurrency / thread-safety is usually out of scope unless explicitly asked.

πŸ”Ž 3 Β· Recognition map

Words in the promptReach for
"LRU / LFU cache", "O(1) get and put with eviction"Map + DLL Β§4.1
"insert, delete, getRandom all O(1)", "randomized set"Map + array Β§4.2
"min stack", "max stack", "queue using stacks", "browser history"Auxiliary Β§4.3
"time-based key-value", "snapshot", "get the value at time t", "hit counter"Time-ordered Β§4.4
"BST iterator", "flatten nested list", "peeking / zigzag iterator"Iterators Β§4.5

🧩 4 · The five core patterns

Every one is "structure A + structure B kept in sync." Each: a one-liner, a πŸ”‘ invariant, a template, a 🎀 interview lens.

4.1 Β· Map + Doubly Linked List (LRU)

β€œMap finds the node in O(1); the list reorders and evicts in O(1).”

πŸ”‘ Invariant β€” why it's safe The list is always ordered by recency (head = most recent, tail = least), and the map points to the exact node for each key. Every get/put re-splices the touched node to the head, so the tail is always the true LRU β€” evictable in O(1), with its key removed from the map in the same step.
class DLLNode { key = 0; val = 0; prev: DLLNode | null = null; next: DLLNode | null = null; }

class LRUCache {
  private map = new Map<number, DLLNode>();
  private head = new DLLNode(); private tail = new DLLNode();   // sentinels
  constructor(private cap: number) { this.head.next = this.tail; this.tail.prev = this.head; }
  private remove(n: DLLNode) { n.prev!.next = n.next; n.next!.prev = n.prev; }
  private addFront(n: DLLNode) { n.next = this.head.next; n.prev = this.head; this.head.next!.prev = n; this.head.next = n; }

  get(key: number): number {
    const n = this.map.get(key);
    if (!n) return -1;
    this.remove(n); this.addFront(n);          // touch β†’ most recent
    return n.val;
  }
  put(key: number, val: number): void {
    const ex = this.map.get(key);
    if (ex) { ex.val = val; this.remove(ex); this.addFront(ex); return; }
    if (this.map.size === this.cap) {          // evict LRU = node before tail
      const lru = this.tail.prev!;
      this.remove(lru); this.map.delete(lru.key);   // keep BOTH in sync
    }
    const n = new DLLNode(); n.key = key; n.val = val;
    this.addFront(n); this.map.set(key, n);
  }
}
🎀 Interview lens Probed: why map + DLL (each op O(1)), the sentinel head/tail, and updating recency on get and put. Β· Escalation: LRU β†’ LFU (a map of frequency β†’ DLL buckets) β†’ LRU with TTL/expiry.

4.2 Β· Map + Array (getRandom in O(1))

β€œArray gives random access; delete by swapping the victim with the last.”

Setup: an array supports O(1) random pick, but O(1) delete only at the end β€” so to delete any element, swap it with the last, then pop. A map of value β†’ index finds it instantly.

a b c d remove b last move d into b's slot, fix d's index, pop
Swap-with-last keeps the array dense (no holes), so getRandom stays a single index pick.
πŸ”‘ Invariant β€” why it's safe The array holds the current members with no gaps, and the map gives each value's exact index. Deleting by overwriting the victim with the last element (and fixing that element's recorded index) preserves both facts in O(1) β€” so a uniform random index is always a valid uniform sample.
class RandomizedSet {
  private arr: number[] = [];
  private idx = new Map<number, number>();          // value β†’ its index in arr
  insert(v: number): boolean {
    if (this.idx.has(v)) return false;
    this.idx.set(v, this.arr.length); this.arr.push(v); return true;
  }
  remove(v: number): boolean {
    if (!this.idx.has(v)) return false;
    const i = this.idx.get(v)!, last = this.arr[this.arr.length - 1];
    this.arr[i] = last; this.idx.set(last, i);       // move last into the hole + fix its index
    this.arr.pop(); this.idx.delete(v); return true;
  }
  getRandom(): number { return this.arr[(Math.random() * this.arr.length) | 0]; }
}
🎀 Interview lens Probed: swap-with-last delete and remembering to update the moved element's index. Β· Escalation: getRandom β†’ duplicates allowed (map value β†’ a set of indices) β†’ reservoir sampling for a stream of unknown length.

4.3 Β· Auxiliary structure (Min Stack, Queue from Stacks)

β€œCarry a second, parallel structure that answers the extra query for free.”

Setup: a stack can't report its minimum in O(1) β€” so keep a parallel stack of running minimums. The same idea (an auxiliary structure) implements a queue from two stacks.

πŸ”‘ Invariant β€” why it's safe mins[i] always equals the minimum of stack[0..i], so it rises and falls in lockstep with the main stack β€” its top is the current minimum. For the two-stack queue: out reversed is the front of the queue, so it's filled from in only when empty, giving amortized O(1).
// Min Stack β€” O(1) push/pop/top/getMin
class MinStack {
  private st: number[] = [];
  private mins: number[] = [];                        // mins[i] = min of st[0..i]
  push(x: number) { this.st.push(x); this.mins.push(this.mins.length ? Math.min(x, this.mins[this.mins.length - 1]) : x); }
  pop() { this.st.pop(); this.mins.pop(); }
  top() { return this.st[this.st.length - 1]; }
  getMin() { return this.mins[this.mins.length - 1]; }
}

// Queue from two stacks β€” amortized O(1)
class MyQueue {
  private inS: number[] = []; private outS: number[] = [];
  push(x: number) { this.inS.push(x); }
  private shift() { if (!this.outS.length) while (this.inS.length) this.outS.push(this.inS.pop()!); }
  pop(): number { this.shift(); return this.outS.pop()!; }
  peek(): number { this.shift(); return this.outS[this.outS.length - 1]; }
}
🎀 Interview lens Probed: the parallel-min invariant (handles duplicate minimums cleanly), and the amortized argument for the two-stack queue. Β· Escalation: min stack β†’ max stack β†’ queue-from-stacks β†’ "Browser History" (back/forward = two stacks).

4.4 Β· Time-ordered (Map + sorted + binary search)

β€œPer key, keep entries in time order; binary-search the latest ≀ t.”

Setup: values are written with increasing timestamps, so each key's history is naturally sorted β€” a binary search finds the most recent value at or before a query time in O(log n).

πŸ”‘ Invariant β€” why it's safe Because timestamps only increase, appending keeps each key's list sorted by time. A floor binary search (largest timestamp ≀ t) is therefore valid and returns the value in effect at time t.
// TimeMap β€” set(key,val,t) with increasing t; get(key,t) = value at the latest time ≀ t
class TimeMap {
  private map = new Map<string, [number, string][]>();    // key β†’ sorted [time, value]
  set(key: string, val: string, time: number) {
    if (!this.map.has(key)) this.map.set(key, []);
    this.map.get(key)!.push([time, val]);                  // appended in time order
  }
  get(key: string, time: number): string {
    const a = this.map.get(key);
    if (!a) return '';
    let lo = 0, hi = a.length - 1, res = '';
    while (lo <= hi) {                                      // floor: latest time ≀ query
      const mid = (lo + hi) >> 1;
      if (a[mid][0] <= time) { res = a[mid][1]; lo = mid + 1; }
      else hi = mid - 1;
    }
    return res;
  }
}
🎀 Interview lens Probed: the increasing-timestamp β†’ sorted observation and the floor (not exact) binary search. Β· Escalation: time map β†’ "Snapshot Array" β†’ "Design Hit Counter" (a queue / circular buffer of timestamps in a window).

4.5 Β· Iterators β€” controlled traversal

β€œPause a traversal between calls by holding its frontier on a stack.”

Setup: an iterator must yield one element per next() and resume later β€” so you store the in-progress traversal explicitly (a stack), instead of recursing all at once.

7 3 1 1 3 7 stack holds the left spine; next() pops 1, then pushes 1.right's spine
The stack is the paused inorder walk; O(h) space, amortized O(1) per next().
πŸ”‘ Invariant β€” why it's safe The stack always holds the un-visited ancestors whose left subtrees are done β€” i.e. the next node to emit is on top. After popping a node, pushing its right child's left spine re-establishes that the new top is the next-smallest. Each node is pushed and popped once β†’ amortized O(1).
// BST Iterator β€” inorder, one step at a time
class BSTIterator {
  private st: TreeNode[] = [];
  constructor(root: TreeNode | null) { this.pushLeft(root); }
  private pushLeft(n: TreeNode | null) { while (n) { this.st.push(n); n = n.left; } }
  hasNext(): boolean { return this.st.length > 0; }
  next(): number {
    const n = this.st.pop()!;       // smallest remaining
    this.pushLeft(n.right);         // its successor's spine
    return n.val;
  }
}
🎀 Interview lens Probed: why the stack = a paused recursion (O(h) space), and the amortized O(1). Β· Escalation: BST iterator β†’ "Flatten Nested List Iterator" β†’ "Peeking Iterator" (buffer one) β†’ "Zigzag Iterator" (round-robin queues).

⏱️ 5 · Complexity

DesignPer opSpacePairing
LRU cacheO(1)O(cap)map + doubly linked list
RandomizedSetO(1)O(n)map + array (swap-with-last)
Min Stack / Queue-from-stacksO(1) (amortized)O(n)parallel / two stacks
Time-based KVO(log n) getO(n)map + sorted list + binary search
BST / nested iteratorO(1) amortized nextO(h)explicit stack (paused recursion)
The design heuristic Write down each required op and its target cost; for any op your main structure can't hit, the answer is almost always "add a hash map (for O(1) lookup), an array (for random access), a linked list (for O(1) splicing), a heap (for the extreme), or a sorted list + binary search (for ranges)."

⚠️ 6 Β· Pitfalls β€” and the test that catches each

BugThe input that exposes it
LRU: not refreshing recency on get (only on put).get a key, then overflow β†’ it gets wrongly evicted as "old".
LRU: evicting from the list but not deleting from the map.Capacity exceeded repeatedly β†’ stale map entries, wrong size / memory leak.
RandomizedSet: not updating the moved element's index on delete.Remove a middle value, then remove the last-moved value β†’ "not found" or corruption.
RandomizedSet: removing the last element (swap-with-self).A single-element set, or removing the tail β†’ index map left pointing at a popped slot.
Time map: exact-match search instead of floor (latest ≀ t).Query a time between two writes β†’ must return the earlier value, not "".
BST iterator: not pushing the right child's left spine after a pop.Any tree with right subtrees β†’ inorder sequence comes out wrong.
πŸ”— This pattern, elsewhere The DLL splicing is pure linked-list pointer surgery; the BST iterator is a paused inorder from trees made explicit with a stack. Min Stack is a cousin of the monotonic stack (auxiliary vs. monotonic); two-heap designs (median) live in heap.html. Time-based lookup is a boundary binary search; LFU / Design Twitter lean on a heap.
βž• More design shapes β€” Logger Rate Limiter (359) Β· Snapshot Array (1146) Β· All O`one (432)
359: map message β†’ next allowed time. 1146: per-cell version list + binary search (the same idea as 981). 432: a doubly linked list of count buckets β€” LFU's cousin that also exposes the max.
// 359 Logger Rate Limiter
class Logger {
  private next = new Map<string, number>();
  shouldPrintMessage(ts: number, msg: string): boolean {
    if ((this.next.get(msg) ?? 0) > ts) return false;   // still inside the 10 s window
    this.next.set(msg, ts + 10);                         // only update when we actually print
    return true;
  }
}

// 1146 Snapshot Array β€” store only the cells that changed, per snapshot
class SnapshotArray {
  private hist: [number, number][][];                    // hist[i] = [[snapId, value], ...] ascending
  private snapId = 0;
  constructor(n: number) { this.hist = Array.from({ length: n }, () => [[0, 0]] as [number, number][]); }
  set(i: number, v: number): void {
    const h = this.hist[i], last = h[h.length - 1];
    if (last[0] === this.snapId) last[1] = v; else h.push([this.snapId, v]);   // overwrite within the same snap
  }
  snap(): number { return this.snapId++; }
  get(i: number, s: number): number {                    // last version with snapId <= s
    const h = this.hist[i]; let lo = 0, hi = h.length - 1;
    while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (h[mid][0] <= s) lo = mid; else hi = mid - 1; }
    return h[lo][1];
  }
}

// 432 All O`one Data Structure β€” DLL of count buckets, O(1) everything
class Bucket { keys = new Set<string>(); prev: Bucket | null = null; next: Bucket | null = null; constructor(public count: number) {} }
class AllOne {
  private head = new Bucket(0); private tail = new Bucket(Infinity); private where = new Map<string, Bucket>();
  constructor() { this.head.next = this.tail; this.tail.prev = this.head; }
  private insertAfter(b: Bucket, count: number): Bucket {
    if (b.next!.count === count) return b.next!;
    const nb = new Bucket(count); nb.prev = b; nb.next = b.next; b.next!.prev = nb; b.next = nb; return nb;
  }
  private insertBefore(b: Bucket, count: number): Bucket {
    if (b.prev!.count === count) return b.prev!;
    const nb = new Bucket(count); nb.next = b; nb.prev = b.prev; b.prev!.next = nb; b.prev = nb; return nb;
  }
  private dropIfEmpty(b: Bucket) { if (b !== this.head && b !== this.tail && b.keys.size === 0) { b.prev!.next = b.next; b.next!.prev = b.prev; } }
  inc(key: string): void {
    const cur = this.where.get(key) ?? this.head;
    const nb = this.insertAfter(cur, cur.count + 1);
    nb.keys.add(key); this.where.set(key, nb);
    if (cur !== this.head) { cur.keys.delete(key); this.dropIfEmpty(cur); }
  }
  dec(key: string): void {
    const cur = this.where.get(key)!;
    if (cur.count === 1) this.where.delete(key);         // count 0 β†’ forget the key entirely
    else { const nb = this.insertBefore(cur, cur.count - 1); nb.keys.add(key); this.where.set(key, nb); }
    cur.keys.delete(key); this.dropIfEmpty(cur);
  }
  getMaxKey(): string { const b = this.tail.prev!; return b === this.head ? '' : b.keys.values().next().value!; }
  getMinKey(): string { const b = this.head.next!; return b === this.tail ? '' : b.keys.values().next().value!; }
}
⚠️ Traps 359: a message may print again at exactly ts + 10; the follow-up is bounding memory (queue of the last 10 s). 1146: overwrite the last version if the snap id is unchanged; every cell starts with [0, 0]. 432: delete empty buckets immediately; sentinels remove the empty-list cases.

πŸ“Š Coverage β€” pattern β†’ LeetCode (medium & hard)

Own each pattern and these are in range. Bold = Hard tier.

PatternRepresentative LeetCodeTier
Map + DLL (LRU)146 LRU Cache Β· 460 LFU Cache Β· 432 All O`one (count buckets)Med β†’ Hard
Map + array (random)380 Insert Delete GetRandom O(1) Β· 381 (dups allowed) Β· 528 Random Pick WeightMed β†’ Hard
Auxiliary structure155 Min Stack Β· 232 Queue via Stacks Β· 716 Max Stack Β· 359 Logger Rate LimiterEasy β†’ Med
Time-ordered981 Time Based Key-Value Store Β· 1146 Snapshot ArrayMed
Iterators173 BST Iterator Β· 284 Peeking Iterator Β· 341 Flatten Nested List IteratorMed β†’ Hard

βœ… 7 Β· Self-check

Cover the right column (blurred β€” hover/tap to reveal). Say your answer out loud first.

PromptAnswer
What is the one move behind every "design X" problem?Find the operation your first structure is slow at, then add a second structure that's fast at exactly that β€” and keep them in sync.
Which two structures make an LRU cache, and why each?A hash map (O(1) find the node) + a doubly linked list (O(1) move-to-front & evict the tail).
How does RandomizedSet delete in O(1)?Swap the victim with the last array element, fix that element's index in the map, then pop.
How does a Min Stack answer getMin in O(1)?A parallel stack where entry i stores the min of the prefix; its top is the current min.
Why can a time-based store binary-search?Timestamps only increase, so each key's history is sorted; a floor search finds the latest time ≀ t.
What does a BST iterator's stack represent?A paused inorder traversal β€” the un-emitted ancestors; O(h) space, amortized O(1) per next().
What's the most common design bug?Updating one structure and forgetting the other (e.g., evicting from the list but leaving the map entry).
Snapshot Array β€” why not copy the array on every snap()?It costs O(n) per snapshot. Store a per-cell list of [snapId, value] only when the cell is set; get binary-searches the last version with snapId ≀ s.
All O`one β€” what makes inc / dec / getMax / getMin all O(1)?A doubly linked list of buckets sorted by count (each holding a set of keys) plus a map key β†’ bucket; moving a key just hops to the neighbouring bucket, and min/max read the two ends.

Write these cold (no peeking)

For each: list the required ops + target cost, then name the two structures and what each covers β€” before the code.