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.
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.
| Goal | First structure (its weakness) | + Add | = |
|---|---|---|---|
O(1) get/put + LRU eviction | hash map (no recency order) | doubly linked list | LRU cache |
O(1) insert/remove/getRandom | hash map (no random index) | dynamic array (swap-with-last) | RandomizedSet |
O(1) min on a stack | stack (no min query) | a parallel "min" stack | Min Stack |
| latest value at-or-before a time | hash map (no time search) | sorted list + binary search | Time-based KV |
O(1) get/put β but evicting the least-recently-used key means scanning all of it, O(n).O(1)." A list ordered by recency does that β but then finding a key is O(n).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).get and put are O(1) β neither structure could do it alone.| Ask | If yes β | Pattern |
|---|---|---|
O(1) cache with eviction order (LRU / LFU)? | hash map + doubly linked list | Map + 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 structure | Auxiliary (Β§4.3) |
| Look up a value by time (latest β€ t)? | map of sorted entries + binary search | Time-ordered (Β§4.4) |
Expose next()/hasNext() over a structure lazily? | a controlled traversal with a stack | Iterators (Β§4.5) |
| Words in the prompt | Reach 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 |
Every one is "structure A + structure B kept in sync." Each: a one-liner, a π invariant, a template, a π€ interview lens.
βMap finds the node in O(1); the list reorders and evicts in O(1).β
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);
}
}
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.β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.
getRandom stays a single index pick.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]; }
}
β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.
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]; }
}
β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).
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;
}
}
β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.
O(h) space, amortized O(1) per next().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;
}
}
O(h) space), and the amortized O(1). Β· Escalation: BST iterator β "Flatten Nested List Iterator" β "Peeking Iterator" (buffer one) β "Zigzag Iterator" (round-robin queues).| Design | Per op | Space | Pairing |
|---|---|---|---|
| LRU cache | O(1) | O(cap) | map + doubly linked list |
| RandomizedSet | O(1) | O(n) | map + array (swap-with-last) |
| Min Stack / Queue-from-stacks | O(1) (amortized) | O(n) | parallel / two stacks |
| Time-based KV | O(log n) get | O(n) | map + sorted list + binary search |
| BST / nested iterator | O(1) amortized next | O(h) | explicit stack (paused recursion) |
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)."| Bug | The 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. |
// 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!; }
}
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.Own each pattern and these are in range. Bold = Hard tier.
| Pattern | Representative LeetCode | Tier |
|---|---|---|
| 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 Weight | Med β Hard |
| Auxiliary structure | 155 Min Stack Β· 232 Queue via Stacks Β· 716 Max Stack Β· 359 Logger Rate Limiter | Easy β Med |
| Time-ordered | 981 Time Based Key-Value Store Β· 1146 Snapshot Array | Med |
| Iterators | 173 BST Iterator Β· 284 Peeking Iterator Β· 341 Flatten Nested List Iterator | Med β Hard |
Cover the right column (blurred β hover/tap to reveal). Say your answer out loud first.
| Prompt | Answer |
|---|---|
| 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. |
LRUCache (map + DLL, sentinels)RandomizedSet (swap-with-last)MinStack + MyQueue (two stacks)TimeMap (floor binary search)BSTIteratorFor each: list the required ops + target cost, then name the two structures and what each covers β before the code.