Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

34 chapters
⌕
Beginner10›
B1Complexity analysisB2Arrays & stringsB3HashingB4Two pointersB5Sliding windowB6Binary searchB7Sorting algorithmsB8Stacks & queuesB9Linked listsB10Basic recursion
Intermediate12›
I1TreesI2Tree problems in depthI3Heaps & priority queuesI4Graphs: representationI5Graph problemsI6BacktrackingI7DP: 1DI8DP: 2DI9Greedy algorithmsI10IntervalsI11Bit manipulationI12Matrix problems
Advanced12›
A1Advanced DPA2Union-FindA3Advanced graph algorithmsA4Minimum Spanning TreeA5TriesA6Segment & Fenwick treesA7String algorithmsA8Monotonic stack & queueA9Design problemsA10Advanced backtrackingA11Topological patternsA12Interview strategy
/ search[ ] chaptert top

DSA in JS levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A9

Design problems

The interface is the spec — pick the data structure combo that meets the complexity contract before you write a line of code.

These questions are graded on the choice, not the code

"Design an LRU cache with O(1) get and put." "Design a stack with O(1) getMin." The implementation is usually forty lines of unremarkable pointer-juggling. What's being tested is the twenty seconds before that: can you read a set of required operations and their required complexities, notice that no single data structure delivers all of them, and compose two structures where each covers the other's blind spot?

The universal opening move: write the operation table first. Every operation, its required complexity, and the structure that provides it. Do this out loud, on the board, before touching code.

RequirementTargetStructure that delivers itWhat it cannot do
Find a value by keyO(1)Hash mapAny ordering
Reorder / evict by recencyO(1)Doubly linked listFind a node by key
Min or max of a stackO(1)Parallel auxiliary stackArbitrary deletion
Min or max, arbitrary insert/deleteO(log n)HeapO(1) lookup by key
Kth smallest / median, streamingO(log n)Two heaps, balancedRange queries
Prefix / range sums with updatesO(log n)Fenwick or segment treeKey lookup
Expire old events by timeO(1) amortizedDeque or ring buffer of bucketsRandom access by key
Group by count, get the smallest countO(1)Frequency buckets (LFU)Ordering within… unless the bucket is itself ordered
The composition rule When one structure can find things but not order them, and another can order things but not find them, store pointers from the first into the second. The hash map's value is not the data — it's a handle into the ordered structure. Almost every "design X in O(1)" answer is an instance of that one sentence.

LRU cache: the canonical composition

The contract: get(key) and put(key, value), both O(1), with the least-recently-used entry evicted when capacity is exceeded. A hash map gives O(1) lookup but has no notion of "oldest." An array or list gives ordering, but finding a key in it is O(n). A doubly linked list gives O(1) removal if you already hold the node — which is exactly what the map can hand you.

hash map "A" → "B" → "C" → head A B C tail most recent evict this one map: O(1) "which node is key K?" list: O(1) unlink, O(1) move-to-front, O(1) evict-from-back
Neither structure alone is enough; the dashed arrows — map entries pointing at list nodes — are the entire design.
// LRU cache — get, put, and eviction all O(1). O(capacity) space.
class LRUCache {
  constructor(capacity) {
    this.cap = capacity;
    this.map = new Map(); // key → node reference (NOT key → value)

    // sentinel head/tail remove every null check from the unlink code
    this.head = { key: null, val: null, prev: null, next: null };
    this.tail = { key: null, val: null, prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  _unlink(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _pushFront(node) { // front (next to head) = most recently used
    node.next = this.head.next;
    node.prev = this.head;
    this.head.next.prev = node;
    this.head.next = node;
  }

  get(key) {
    const node = this.map.get(key);
    if (!node) return -1;
    this._unlink(node);
    this._pushFront(node); // a read counts as a use — this is the line people forget
    return node.val;
  }

  put(key, value) {
    const existing = this.map.get(key);
    if (existing) {
      existing.val = value;
      this._unlink(existing);
      this._pushFront(existing);
      return; // an update must NOT evict anything
    }

    if (this.map.size === this.cap) {
      const lru = this.tail.prev;
      this._unlink(lru);
      this.map.delete(lru.key); // why nodes store their key: you evict from the list, delete from the map
    }

    const node = { key, val: value, prev: null, next: null };
    this._pushFront(node);
    this.map.set(key, node);
  }
}
⚠ Three bugs that turn an O(1) LRU into a wrong one (1) Not refreshing on get — a read is a use, and skipping the move-to-front makes it an insertion-order cache, not an LRU. (2) Not storing key inside the node — on eviction you hold the node and need its key to delete the map entry; without it you'd have to scan the map, and the whole design collapses to O(n). (3) Evicting on an update to an existing key — capacity didn't change, so nothing should be evicted; this shows up as a failing test only when the cache is exactly full.

Worth mentioning after you've written the real thing: JavaScript's Map preserves insertion order, so map.delete(k); map.set(k, v); moves a key to the back, and map.keys().next().value is the oldest key — a ten-line LRU. Say it as a language-specific shortcut you know about, then note that interviewers ask this question precisely to see the linked-list mechanics, so you'd hand-roll it here.

Say it like this → "Both operations have to be O(1), so I need constant-time lookup and constant-time reordering. No single structure gives me both. A hash map maps keys to nodes, and a doubly linked list holds recency order — the map tells me which node in O(1), and because the list is doubly linked I can unlink that node in O(1) without traversing. Sentinel head and tail nodes let me skip all the null-checking edge cases."

LFU cache: the same trick, one level deeper

LFU evicts the least frequently used entry, breaking ties by least-recently used. Now three things must be O(1): find a key, find the minimum frequency, and find the oldest key at that frequency. The answer is to bucket by frequency and keep each bucket internally ordered — a map from count to an ordered collection of keys — plus a single minFreq integer.

Why a bare minFreq counter is enough is the elegant part: frequencies only ever increase by exactly 1. So minFreq can only rise by 1 (when the last key in the minimum bucket is promoted) or reset to 1 (when a brand-new key is inserted). You never have to search for the new minimum.

// LFU — get/put O(1). A JS Set preserves insertion order, so it doubles as
// the per-bucket LRU list; in another language this is a DLL per bucket.
class LFUCache {
  constructor(capacity) {
    this.cap = capacity;
    this.vals = new Map();    // key → value
    this.freq = new Map();    // key → use count
    this.buckets = new Map(); // count → Set of keys, oldest first
    this.minFreq = 0;
  }

  _promote(key) {
    const f = this.freq.get(key);
    const bucket = this.buckets.get(f);
    bucket.delete(key);
    if (bucket.size === 0) {
      this.buckets.delete(f);
      if (this.minFreq === f) this.minFreq++; // safe: counts only ever step up by 1
    }
    this.freq.set(key, f + 1);
    if (!this.buckets.has(f + 1)) this.buckets.set(f + 1, new Set());
    this.buckets.get(f + 1).add(key);
  }

  get(key) {
    if (!this.vals.has(key)) return -1;
    this._promote(key);
    return this.vals.get(key);
  }

  put(key, value) {
    if (this.cap === 0) return;
    if (this.vals.has(key)) { this.vals.set(key, value); this._promote(key); return; }

    if (this.vals.size === this.cap) {
      const victims = this.buckets.get(this.minFreq);
      const victim = victims.values().next().value; // oldest key in the least-used bucket → LRU tiebreak
      victims.delete(victim);
      if (victims.size === 0) this.buckets.delete(this.minFreq);
      this.vals.delete(victim);
      this.freq.delete(victim);
    }

    this.vals.set(key, value);
    this.freq.set(key, 1);
    if (!this.buckets.has(1)) this.buckets.set(1, new Set());
    this.buckets.get(1).add(key);
    this.minFreq = 1; // a fresh key always resets the minimum
  }
}

Note the shape is identical to LRU — a lookup structure pointing into an ordered structure — just nested one level: map → bucket → ordered keys. If you can explain LRU cleanly, LFU is a five-sentence extension, and saying "it's LRU with a frequency dimension, and minFreq works because counts only increment" is usually enough to satisfy the follow-up.

Min stack: O(1) getMin with an auxiliary stack

The trap is reaching for a heap. A heap gives O(log n) min with arbitrary removal — but a stack doesn't have arbitrary removal. Pops happen in exactly the reverse order of pushes, which means you can precompute the answer: at push time, record the minimum of everything at or below this point. Popping automatically restores the previous minimum because you pop that record too.

// push / pop / top / getMin all O(1). O(n) extra space.
class MinStack {
  constructor() {
    this.main = [];
    this.mins = []; // mins[i] = min of main[0..i] — a running prefix minimum
  }

  push(x) {
    this.main.push(x);
    const currentMin = this.mins.length ? this.mins[this.mins.length - 1] : x;
    this.mins.push(Math.min(x, currentMin));
  }

  pop() {
    this.mins.pop(); // discarding this entry restores the previous min for free
    return this.main.pop();
  }

  top()    { return this.main[this.main.length - 1]; }
  getMin() { return this.mins[this.mins.length - 1]; }
}

The space optimization interviewers like to fish for: only push to mins when the new value is a new minimum, and only pop from it when the popped value equals the current minimum.

  push(x) {
    this.main.push(x);
    if (!this.mins.length || x <= this.mins[this.mins.length - 1]) this.mins.push(x);
  }

  pop() {
    const x = this.main.pop();
    if (x === this.mins[this.mins.length - 1]) this.mins.pop();
    return x;
  }
⚠ The duplicate-minimum bug in the optimized version It must be x <= min, not x < min. Push [2, 2] with a strict comparison and only one 2 lands in mins; the first pop() removes it, and getMin() now reports a stale minimum even though a 2 is still on the stack. This is the single most common failure on this problem, and the test case that catches it is two lines long — offer it yourself.

Max Stack is the same design for peekMax(). But if the question also demands popMax() — remove the maximum from anywhere in the stack — the auxiliary-stack trick breaks, because you're no longer popping in reverse push order. That version needs a doubly linked list plus an ordered map from value to the list of nodes holding it, giving O(log n) popMax. Naming that boundary unprompted ("this trick works only because removals are LIFO") is the senior-level version of this answer.

Hit counter: designing for a stream

"Count hits in the last 5 minutes, with timestamps arriving in non-decreasing order." The naive store-everything approach is O(1) per hit but unbounded memory. The complexity contract to negotiate here isn't just time — it's space, and the interviewer is waiting for you to notice that the window is fixed-size.

// Version 1: a queue of timestamps. O(1) amortized hit, O(1) amortized
// getHits, but O(hits) space — unbounded under load.
class HitCounter {
  constructor(windowSec = 300) {
    this.window = windowSec;
    this.times = [];
    this.head = 0; // head pointer instead of shift() — shift() is O(n)
  }

  hit(ts) { this.times.push(ts); }

  getHits(ts) {
    while (this.head < this.times.length && this.times[this.head] <= ts - this.window) {
      this.head++; // each timestamp is skipped at most once across all calls
    }
    return this.times.length - this.head;
  }
}
// Version 2: a ring buffer of per-second buckets. O(1) hit, O(window)
// getHits, and O(window) space no matter the traffic — the one to ship.
class BucketedHitCounter {
  constructor(windowSec = 300) {
    this.n = windowSec;
    this.stamps = new Array(windowSec).fill(-1); // which second this slot currently represents
    this.counts = new Array(windowSec).fill(0);
  }

  hit(ts) {
    const i = ts % this.n;
    if (this.stamps[i] !== ts) {         // slot belongs to an older second — reuse it
      this.stamps[i] = ts;
      this.counts[i] = 1;
    } else {
      this.counts[i]++;
    }
  }

  getHits(ts) {
    let total = 0;
    for (let i = 0; i < this.n; i++) {
      if (ts - this.stamps[i] < this.n) total += this.counts[i]; // skip stale slots without clearing them
    }
    return total;
  }
}

The lazy-expiry idea — never clear old data, just check whether a slot's timestamp is still in range when you read it — is the reusable insight here. It's the same technique behind lazy deletion in heaps and tombstones in log-structured storage.

Rate limiter: a sliding window counter

The natural follow-up, and the one that bridges into system design. A fixed-window counter is trivial but lets a client fire 2× the limit across a window boundary. Storing every request timestamp is exact but O(limit) memory per user. The sliding-window-counter approximation keeps two integers per user and weights the previous window by how much of it still overlaps — the algorithm real API gateways ship.

// O(1) time and O(1) space PER USER. Approximate, but bounded error.
class SlidingWindowRateLimiter {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.state = new Map(); // userId → { start, count, prevCount }
  }

  allow(userId, now = Date.now()) {
    const start = Math.floor(now / this.windowMs) * this.windowMs;
    let s = this.state.get(userId);

    if (!s || s.start < start - this.windowMs) {
      s = { start, count: 0, prevCount: 0 }; // idle for 2+ windows — everything expired
    } else if (s.start < start) {
      s = { start, count: 0, prevCount: s.count }; // rolled into a new window: demote count
    }
    this.state.set(userId, s);

    // fraction of the previous window still inside the trailing window
    const overlap = 1 - (now - start) / this.windowMs;
    const estimate = s.prevCount * overlap + s.count;

    if (estimate >= this.limit) return false;
    s.count++;
    return true;
  }
}
⚠ Bounded memory is part of the contract, and it's the part people skip Two things will be probed. (1) This Map grows forever as new user IDs appear — a real implementation needs TTL eviction, which is… an LRU, from earlier in this chapter. Say that; it closes the loop. (2) The counter is an approximation: it assumes requests were spread evenly across the previous window, so a burst clustered at one edge can be over- or under-counted by a few percent. State that trade-off before being asked, and name the exact alternative (a deque of timestamps, O(limit) memory) so the interviewer knows you chose rather than settled.
Say it like this → "Let me pin the contract first: which operations must be O(1), and is memory bounded? For the rate limiter, per-request work has to be O(1) and per-user memory has to be O(1) — that immediately rules out storing every timestamp, so I'll use a weighted two-window counter. It's approximate at window boundaries; if you need exactness I'd switch to a deque of timestamps and pay O(limit) memory per user."

How to run the first two minutes of any design question

StepWhat you say
1. Restate the interface"So: get, put, and eviction — three operations."
2. Pin the complexity per operation"All three O(1)? Including eviction? Good."
3. Name what breaks"A map has no order; a list can't find a key. Neither alone works."
4. Compose, then justify"Map from key to node, list for order. Map finds, list reorders."
5. Call out the edge cases first"Capacity 0, update-an-existing-key, get on a miss."
6. Then write itSentinels first, helpers second, public methods last.

Steps 1-5 take ninety seconds and are where the hiring signal lives. A candidate who writes a flawless LRU without ever explaining why the list must be doubly linked has demonstrated recall; a candidate who derives it from "unlink must be O(1) and I only hold the node, not its predecessor" has demonstrated design.

Recognizing it in an unseen problem

  • The prompt starts with the word "Design" and hands you a class signature with named methods and stated complexities — the complexities are the problem statement
  • Two operations pull in opposite directions: fast lookup versus maintained order, or fast insert versus fast min/max. That tension is the signal to compose two structures rather than search for one perfect one
  • Something must be evicted, expired, or capped — look for hash map + doubly linked list (recency), frequency buckets (LFU), or a ring buffer / deque (time windows)
  • "O(1) min/max" on a structure with LIFO removal → auxiliary stack. "O(1) min/max" with arbitrary removal → you need a heap or ordered map, and the honest answer is O(log n), not O(1)
  • "Streaming," "in the last N seconds," "timestamps arrive in order" → the window is fixed, so memory should be O(window), not O(events); bucket and expire lazily
  • Distinguish from an algorithms question: there's no clever traversal or recurrence here. If you're searching for an algorithm, you've misread it — you're searching for a combination
  • Pitfalls: forgetting that a read counts as a use, not storing the key inside the node so eviction can clean up the map, using < where <= is needed on duplicate minimums, and letting the per-key map grow without bound
Practice this layer

Opens in the editor — write it, run it, and check it against real tests.

LRU Cache5 tests · advancedDesign HashMap5 tests · advancedTime Based Key-Value Store5 tests · advancedInsert Delete GetRandom O(1)5 tests · advancedEncode and Decode Strings5 tests · advanced
←previousMonotonic stack & queue↑ CovernextAdvanced backtracking→