Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Advanced/A9
43 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣA9 · Part 3 · Advanced

Design problems

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

36 min read1 diagram14 sections13 exercises
43 of 48

In this chapter

  • These questions are graded on the choice, not the code
  • LRU cache: the canonical composition
  • LFU cache: the same trick, one level deeper
  • All O(1): LFU's buckets, with the list made explicit
  • Min stack: O(1) getMin with an auxiliary stack

and 9 more

about 36 min
14On this page
  1. These questions are graded on the choice, not the code
  2. LRU cache: the canonical composition
  3. LFU cache: the same trick, one level deeper
  4. All O(1): LFU's buckets, with the list made explicit
  5. Min stack: O(1) getMin with an auxiliary stack
  6. Hit counter: designing for a stream
  7. Time-based key-value store: a sorted history per key
  8. Rate limiter: a sliding window counter
  9. Insert, delete, getRandom in O(1): a dense array with a map of positions
  10. Median finder: two heaps that meet in the middle
  11. Design HashMap: buckets, collisions and resizing
  12. Encode and decode strings: make the encoding say where each piece ends
  13. How to run the first two minutes of any design question
  14. Recognizing it in an unseen problem

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)Heap (lazy deletion for arbitrary removal)O(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
Value as of time t, writes in time orderO(log n)Per-key sorted list and a binary searchCheap writes that arrive out of order
Uniform random member plus O(1) removalO(1)Dense array and a map from value to indexKeeping the members in any order
Largest and smallest count when each count moves by 1O(1)Linked list of count bucketsJumps in count without a search
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) {
    if (this.cap === 0) return; // nothing can be stored, so there is nothing to evict either
    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);
  }
}

Dry run: LRUCache(3) — the same keys as the figure above

Calllist, front → back (most → least recent)map keysevicted
put("C", 3)CC—
put("B", 2)B, CC, B—
put("A", 1)A, B, CC, B, A— (matches the figure: A newest, C next to evict)
get("C") → 3C, A, BC, B, A— (C refreshed to the front; B is now the LRU)
put("D", 4)D, C, AC, A, DB

The get("C") call is the pivot: before it, B was the newer entry and C was next in line for eviction. One read moves C to the front and leaves B stranded at the tail — B is evicted not because it was written first — C was written first and survives — but because nobody has touched it for the longest time. That's the entire meaning of "least recently used."

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

All O(1): LFU's buckets, with the list made explicit

The contract: inc(key), dec(key), getMaxKey() and getMinKey(), all O(1). A map from key to count makes inc and dec trivial and says nothing about the largest or smallest count. A heap finds the extreme in O(1) but moving one key's count inside it costs O(log n), and sorting on every query costs far more. So the requirement is a structure that keeps counts in sorted order and survives a key changing its count.

The fact that makes it possible is the same one that powered minFreq in the LFU cache: a count changes by exactly 1. When a key moves from count c to c + 1, the only place it can belong in the sorted order is the bucket for c + 1 — which, if it exists, is already the next bucket in the list — or a brand-new bucket spliced in directly after the old one. There is never anything to search for. Keep one bucket per distinct count, each holding a Set of the keys with that count, link the buckets into a doubly linked list sorted by count, and give the map a handle to each key's bucket. The maximum is the last bucket and the minimum is the first.

class AllOne {
  constructor() {
    this.head = { count: 0, keys: new Set(), prev: null, next: null }; // sentinel: "count 0"
    this.tail = { count: Infinity, keys: new Set(), prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
    this.bucketOf = new Map(); // key -> the bucket that holds it
  }

  _insertAfter(node, count) {
    const bucket = { count, keys: new Set(), prev: node, next: node.next };
    node.next.prev = bucket;
    node.next = bucket;
    return bucket;
  }

  _unlinkIfEmpty(bucket) {
    if (bucket === this.head || bucket.keys.size > 0) return;
    bucket.prev.next = bucket.next;
    bucket.next.prev = bucket.prev;
  }

  inc(key) {
    const from = this.bucketOf.get(key) || this.head;
    const count = from.count + 1;
    const to = from.next.count === count ? from.next : this._insertAfter(from, count);
    to.keys.add(key);
    this.bucketOf.set(key, to);
    from.keys.delete(key);
    this._unlinkIfEmpty(from);
  }

  dec(key) {
    const from = this.bucketOf.get(key);
    if (!from) return;
    const count = from.count - 1;
    if (count === 0) {
      this.bucketOf.delete(key);
    } else {
      const to = from.prev.count === count ? from.prev : this._insertAfter(from.prev, count);
      to.keys.add(key);
      this.bucketOf.set(key, to);
    }
    from.keys.delete(key);
    this._unlinkIfEmpty(from);
  }

  getMaxKey() {
    return this.tail.prev === this.head ? "" : this.tail.prev.keys.values().next().value;
  }

  getMinKey() {
    return this.head.next === this.tail ? "" : this.head.next.keys.values().next().value;
  }
}

Dry run: the list of buckets, count : keys, after each call

Callbuckets, smallest count firstgetMaxKeygetMinKey
inc("a")1:aaa
inc("b")1:a,baa
inc("a")1:b | 2:aab
inc("c")1:b,c | 2:aab
inc("c")1:b | 2:a,cab
inc("c")1:b | 2:a | 3:ccb
dec("a")1:b,a | 3:c (the empty 2-bucket was unlinked)cb
dec("b")1:a | 3:cca
dec("c")1:a | 2:c (a new 2-bucket spliced in before the 3-bucket, which then emptied)ca

Row 3 is the whole trick in one step: a leaves the 1-bucket, finds no 2-bucket next to it, and a fresh one is spliced in right after. Row 7 shows the cleanup: a drops to 1, joins the existing 1-bucket, and the 2-bucket it left behind is empty, so it is unlinked at once. Ties return whichever key the bucket's Set yields first; the contract allows any of them.

Every call touches at most three buckets (the old one, the new one, and a neighbour to compare counts with) and does a constant number of Set and Map operations, so each operation is O(1) on average, and the space is O(distinct keys). The two sentinels do real work: the tail's count of Infinity means from.next.count === count can never match it, and the head's count of 0 lets a brand-new key start from "count 0" through the same code path as every other increment.

⚠ Four ways this goes wrong (1) Not unlinking a bucket when its last key leaves: it stays in the list empty, and getMaxKey reads undefined from an empty Set. (2) Treating count 0 as a bucket: a key that reaches 0 must leave the map and every bucket, not sit in a "0" bucket. (3) Splicing in a new bucket without first checking whether the neighbour already has the right count; the list then holds two buckets for the same count and the sorted order is quietly wrong. (4) Unlinking the old bucket before inserting the new one, which loses the node you were going to insert after. The code inserts first and cleans up second.
Say it like this → "A count only ever changes by one, so a key's new position in sorted order is always adjacent to its old one. That means I can keep a sorted linked list of count buckets and splice, never search. The map holds each key's bucket, the list gives me both ends in O(1), and I delete a bucket the moment it empties."

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

The trap is reaching for a heap. A heap pays O(log n) per push and pop to support 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 or ties the current one, 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 the wrong minimum (undefined here, or a larger value left over from beneath) 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.

Time-based key-value store: a sorted history per key

set(key, value, timestamp) records a value; get(key, timestamp) returns the value that was in force at that moment, which is the one with the largest stored time that is at most the query. If nothing was written by then, the answer is the empty string. The naive design keeps every write in one global list and scans it: correct, and O(total writes) per get. The first fix is obvious: histories of different keys never interact, so give each key its own list.

The second fix is the one being tested. If timestamps arrive in increasing order, each key's list is already sorted by time, because appending is the only operation, so get can binary search instead of scanning. The detail that costs offers is the shape of that search. It is not "find this exact time"; it is "find the last version not after this time", the same boundary search as an upper bound: look for the first version written after the query, and the answer is the one just before it.

class TimeMap {
  constructor() {
    this.versions = new Map(); // key -> [[timestamp, value], ...], oldest first
  }

  set(key, value, timestamp) {
    if (!this.versions.has(key)) this.versions.set(key, []);
    this.versions.get(key).push([timestamp, value]); // timestamps only grow, so the list stays sorted
  }

  get(key, timestamp) {
    const list = this.versions.get(key);
    if (!list) return "";
    let lo = 0;
    let hi = list.length; // search for the first version written AFTER timestamp
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (list[mid][0] <= timestamp) lo = mid + 1;
      else hi = mid;
    }
    return lo === 0 ? "" : list[lo - 1][1]; // the version just before that one
  }
}

Dry run: three versions of one key, then get("k", 6)

After set("k","a",2), set("k","b",5) and set("k","c",9) the list is [(2,a), (5,b), (9,c)].

Steplohimidtime at midDecision
start03155 <= 6, so version 1 is not after the query: lo = 2
223299 > 6, so version 2 is after the query: hi = 2
end22--lo = 2 is the first version after time 6; the answer is list[1], which is "b"

Other queries on the same list: time 1 gives "" (lo stays 0, so nothing is at or before it), time 5 gives "b" (an exact match counts, because the comparison is <=), and time 100 gives "c".

set is O(1) amortized and get is O(log m), where m is the number of versions of that one key, not of all keys. Memory is O(total writes), and nothing in this design ever deletes: say so, and say what you would add (a retention window, or compaction of old versions) if asked.

When timestamps can arrive out of order

The "increasing timestamps" promise is what keeps set a plain append. If a write can arrive with any timestamp, the list must be kept sorted by inserting at the position the same binary search finds, and a second write at an existing instant must replace the first rather than add a duplicate. In JavaScript the insert is a splice, which shifts the tail, so set becomes O(m); an ordered tree or skip list makes it O(log m), but JavaScript has no built-in ordered map, so that is a sentence to say, not code to write.

class TimeMapAnyOrder {
  constructor() {
    this.versions = new Map();
  }

  _after(list, timestamp) {
    let lo = 0;
    let hi = list.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (list[mid][0] <= timestamp) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }

  set(key, value, timestamp) {
    if (!this.versions.has(key)) this.versions.set(key, []);
    const list = this.versions.get(key);
    const at = this._after(list, timestamp);
    if (at > 0 && list[at - 1][0] === timestamp) list[at - 1][1] = value; // same instant: last write wins
    else list.splice(at, 0, [timestamp, value]); // splice shifts the tail: O(versions) per out-of-order write
  }

  get(key, timestamp) {
    const list = this.versions.get(key);
    if (!list) return "";
    const at = this._after(list, timestamp);
    return at === 0 ? "" : list[at - 1][1];
  }
}
⚠ Where the boundary search goes wrong (1) Searching for an exact match and returning "" when there is none: most queries fall between two writes. (2) Returning list[lo] instead of list[lo - 1]: lo is the first version after the query, so the answer sits one slot earlier. (3) Forgetting the case lo === 0, where every version is newer than the query and list[-1] would read undefined. (4) Assuming increasing timestamps because the example happens to have them. Ask, and if the statement does not promise it, say that you are relying on it.
Say it like this → "Writes for one key come in time order, so each key's history is a sorted array and get is a binary search for the last version that is not after the query: find the first version after it and step back one. set is O(1) and get is O(log of that key's versions). If timestamps could arrive out of order I'd insert at the found position instead of appending, which costs a shift in an array or O(log n) in a balanced tree."

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 (usually slightly, but in the worst case by most of a window's worth of requests). 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."

Insert, delete, getRandom in O(1): a dense array with a map of positions

insert(v), remove(v) and getRandom(), all O(1) on average, where getRandom must return every current member with equal probability. Try the single structures first, because the failure of each one is the design. A Set inserts, removes and tests membership in O(1) but has no way to say "give me the member at a random position"; turning it into an array first costs O(n). An array picks a random position in O(1) but finds a value in O(n), and deleting from its middle shifts everything after it, also O(n).

Compose them, and attack the one thing that is still slow. Keep a dense array of the values, so a random index is a random member, and a Map from each value to its index in the array, so membership and "where is it" are O(1). Insert pushes onto the end and records the index. The remaining problem is removal from the middle. The key observation is that the array promises density, not order: nobody asked for the members in any particular sequence. So instead of shifting the tail left, move the last element into the hole and pop the end. One write, one pop, O(1).

class RandomizedSet {
  constructor() {
    this.values = []; // dense array: getRandom is an index
    this.slot = new Map(); // value -> its index in values
  }

  insert(value) {
    if (this.slot.has(value)) return false;
    this.slot.set(value, this.values.length);
    this.values.push(value);
    return true;
  }

  remove(value) {
    if (!this.slot.has(value)) return false;
    const hole = this.slot.get(value);
    const last = this.values[this.values.length - 1];
    this.values[hole] = last; // the last value fills the hole
    this.slot.set(last, hole); // ...and learns its new index
    this.values.pop();
    this.slot.delete(value); // LAST: when value === last, this undoes the set above
    return true;
  }

  getRandom() {
    return this.values[Math.floor(Math.random() * this.values.length)];
  }
}

Dry run: insert 10, 20, 30, 40, then remove 20, 40, 30

Callvaluesslot map (value → index)What happened
4 inserts[10, 20, 30, 40]10→0, 20→1, 30→2, 40→3push and record
remove(20)[10, 40, 30]10→0, 40→1, 30→2hole at 1; 40, the last, moves into it and its index changes from 3 to 1
remove(40)[10, 30]10→0, 30→1hole at 1; 30 moves into it, index 2 → 1
remove(30)[10]10→030 is the last element: it fills its own hole, then both the pop and the delete remove it

The last row is the case that breaks careless versions. When the value being removed is itself the last element, "move the last one into the hole" copies it onto itself, and the order of the final two lines of the method decides whether the map ends up correct.

⚠ Delete from the map last, not first The method sets slot[last] = hole and then deletes slot[value]. If it deleted first and set second, then for value === last the set would put the entry back. Starting from insert(1), insert(2) and removing 2 in that wrong order leaves values = [1] but slot still holding both 1→0 and 2→1. insert(2) would then answer "already there", and remove(2) would report success for a value that is already gone and leave 1 mapped to index 1 of a one-element array. Two other classic slips: forgetting to update the moved element's index in the map at all, and calling values.indexOf(v) inside remove, which quietly makes it O(n).

With duplicates: a Set of positions per value

Now insert may add a copy of a value already present, remove removes one copy, and a value stored three times must be three times as likely to be returned. The array may now hold the same value at several indices, so the map's value must grow from one index to a Set of indices. Everything else is the same move: pick any index of the value being removed, fill that hole with the last element, and tell the last element's value that one of its positions changed.

class RandomizedCollection {
  constructor() {
    this.values = [];
    this.slots = new Map(); // value -> Set of every index where it sits in values
  }

  insert(value) {
    const isNew = !this.slots.has(value);
    if (isNew) this.slots.set(value, new Set());
    this.slots.get(value).add(this.values.length);
    this.values.push(value);
    return isNew; // true only for the first copy
  }

  remove(value) {
    const mine = this.slots.get(value);
    if (!mine) return false;
    const hole = mine.values().next().value; // any one copy will do
    const lastIndex = this.values.length - 1;
    const last = this.values[lastIndex];
    mine.delete(hole);
    if (hole !== lastIndex) {
      this.values[hole] = last;
      const theirs = this.slots.get(last); // when last === value this is the same Set as mine
      theirs.delete(lastIndex);
      theirs.add(hole);
    }
    this.values.pop();
    if (mine.size === 0) this.slots.delete(value);
    return true;
  }

  getRandom() {
    return this.values[Math.floor(Math.random() * this.values.length)];
  }
}

The comment on theirs marks the subtle case. If the last element is another copy of the value being removed, theirs and mine are the same Set: the code has already removed hole from it, then removes lastIndex and adds hole, which leaves exactly the right positions. Draws are proportional to count simply because each copy occupies one array slot. In one run of 60,000 draws from a collection holding 1, 1, 2, the value 1 came back 39,868 times, against 40,000 expected.

Complexity: insert, remove and getRandom are O(1) on average for both versions, since each does a constant number of Map, Set and array-end operations. Space is O(n). The whole design is one sentence: make removal O(1) by giving up an order nobody asked you to keep.

Median finder: two heaps that meet in the middle

addNum(x) adds a number to a stream and findMedian() returns the middle value of everything so far (the mean of the two middle values when the count is even). The obvious store is a sorted array. Finding where x belongs is a binary search, O(log n), but inserting there shifts the tail, so each addNum costs O(n). Re-sorting on every query is worse. One heap is no help either: it exposes the minimum or the maximum, and the median is neither.

Look at what the median needs. It does not need the data sorted, it needs the boundary between the smaller half and the larger half. Split the numbers into a lower half and an upper half. If the lower half sits in a max-heap, its top is the largest of the small numbers; if the upper half sits in a min-heap, its top is the smallest of the large ones. The two tops are exactly the middle elements. All that has to be maintained is two invariants: (1) every number in the lower half is at most every number in the upper half, and (2) the halves differ in size by at most one, with the extra element in the lower half.

addNum restores both in three steps and no branching on values. Push x into the lower half; then move that half's largest element across to the upper half, which guarantees invariant (1) whatever x was; then, if the upper half is now bigger, move its smallest back.

class Heap {
  constructor(before) {
    this.items = [];
    this.before = before; // before(a, b) is true when a belongs closer to the top than b
  }

  get size() {
    return this.items.length;
  }

  peek() {
    return this.items[0];
  }

  push(x) {
    const a = this.items;
    a.push(x);
    let i = a.length - 1;
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (!this.before(a[i], a[parent])) break;
      [a[i], a[parent]] = [a[parent], a[i]];
      i = parent;
    }
  }

  pop() {
    const a = this.items;
    const top = a[0];
    const last = a.pop();
    if (a.length > 0) {
      a[0] = last;
      let i = 0;
      for (;;) {
        let best = i;
        const l = 2 * i + 1;
        const r = l + 1;
        if (l < a.length && this.before(a[l], a[best])) best = l;
        if (r < a.length && this.before(a[r], a[best])) best = r;
        if (best === i) break;
        [a[i], a[best]] = [a[best], a[i]];
        i = best;
      }
    }
    return top;
  }
}

class MedianFinder {
  constructor() {
    this.low = new Heap((a, b) => a > b); // max-heap: the smaller half
    this.high = new Heap((a, b) => a < b); // min-heap: the larger half
  }

  addNum(x) {
    this.low.push(x);
    this.high.push(this.low.pop()); // the biggest of the small half crosses over, so every low <= every high
    if (this.high.size > this.low.size) this.low.push(this.high.pop()); // low keeps the extra element
  }

  findMedian() {
    if (this.low.size === 0) return undefined;
    return this.low.size > this.high.size ? this.low.peek() : (this.low.peek() + this.high.peek()) / 2;
  }
}

Dry run: add 5, 2, 8, 1, 9

addNumlower half (max-heap)upper half (min-heap)findMedian
5{5}{}5 (lower is larger: its top)
2{2}{5}(2 + 5) / 2 = 3.5
8{2, 5}{8}5
1{1, 2}{5, 8}(2 + 5) / 2 = 3.5
9{1, 2, 5}{8, 9}5

Row 2 shows the crossing: 2 is pushed into the lower half beside 5, 5 is the larger so it crosses to the upper half, and the sizes are already balanced. Row 4: pushing 1 into {2, 5} then moving the maximum 5 across leaves {1, 2} and {5, 8}, again balanced. Each row's median is the top of one heap, or the mean of two tops.

addNum costs O(log n) (a constant number of heap pushes and pops), findMedian is O(1), and space is O(n). The heap class is there because JavaScript has none; in an interview, state its interface (push, pop, peek, size) and offer to write it if the interviewer wants it.

⚠ What breaks the invariant (1) A comparator pointing the wrong way: the lower half needs a max-heap, and a default a < b comparator quietly builds a min-heap there. (2) Pushing straight into whichever heap "looks right" by comparing with the tops, then forgetting one of the rebalancing cases; the push-cross-rebalance sequence above has no cases to forget. (3) Integer division on the even case: (a + b) / 2 must stay a floating-point mean, so the median of 1 and 2 is 1.5. (4) Reading the median of an empty finder; decide what it returns and say so.

Follow-ups the interviewer is waiting to ask

"All values are integers from 0 to 100." Then no heap is needed. Keep 101 counters, one per value, and a running total. The median is the k-th smallest for one or two ranks k, found by walking the counters from 0 upward until the running sum reaches k. That is at most 101 steps no matter how many numbers have arrived, so findMedian is O(1) and addNum is O(1). The extra shape to name is that a small value range replaces comparison-based structures with counting.

class BoundedMedian {
  constructor() {
    this.counts = new Array(101).fill(0); // counts[v] = how many times v (0..100) arrived
    this.total = 0;
  }

  addNum(x) {
    this.counts[x]++;
    this.total++;
  }

  _kth(k) {
    let seen = 0;
    for (let v = 0; v <= 100; v++) {
      seen += this.counts[v];
      if (seen >= k) return v; // the k-th smallest, 1-based
    }
  }

  findMedian() {
    if (this.total === 0) return undefined;
    const lowRank = Math.ceil(this.total / 2);
    const highRank = Math.floor(this.total / 2) + 1;
    return (this._kth(lowRank) + this._kth(highRank)) / 2;
  }
}

"99% of values are in 0 to 100, the rest are arbitrary." Keep the same 101 counters plus a count of values below 0 and a count above 100. At most 1% of the numbers lie on either side, far fewer than half, so the middle rank always falls inside 0 to 100; subtract the below-0 count when walking the ranks. "Slide a window: remove the oldest number." A heap cannot delete an arbitrary item cheaply, so either mark the item as deleted and discard it when it reaches a top (lazy deletion, keeping separate counts of live items per heap), or switch to an ordered structure.

Design HashMap: buckets, collisions and resizing

"Implement a hash map without using the built-in one." The interviewer is not after a clever structure; they want to see that you know what a hash map is. The idea in three steps. A hash function turns any key into a number. That number, taken modulo the length of an array, picks a bucket. Looking a key up is then one array access instead of a search.

The catch is unavoidable: there are far more possible keys than buckets, so two different keys will land in the same bucket. This is a collision, and it is normal, not a bug. The standard answer is separate chaining: each bucket is a short list of [key, value] pairs, and every operation is "find the bucket, then scan that short list". That scan is only short if the buckets stay nearly empty, so the map also tracks its load factor, the number of entries divided by the number of buckets, and doubles the array when it passes a threshold (here 0.75), re-distributing every pair.

function hashOf(key) {
  const text = String(key);
  let h = 0;
  for (let i = 0; i < text.length; i++) h = (Math.imul(h, 31) + text.charCodeAt(i)) | 0;
  return h >>> 0; // reinterpret as unsigned: a negative index would silently miss every bucket
}

class MyHashMap {
  constructor(capacity = 8) {
    this.buckets = Array.from({ length: capacity }, () => []); // each bucket: a short list of [key, value]
    this.size = 0;
  }

  _bucket(key) {
    return this.buckets[hashOf(key) % this.buckets.length];
  }

  put(key, value) {
    const bucket = this._bucket(key);
    for (const pair of bucket) {
      if (pair[0] === key) {
        pair[1] = value; // overwrite: size must not grow
        return;
      }
    }
    bucket.push([key, value]);
    this.size++;
    if (this.size > 0.75 * this.buckets.length) this._grow();
  }

  get(key) {
    for (const pair of this._bucket(key)) if (pair[0] === key) return pair[1];
    return -1;
  }

  remove(key) {
    const bucket = this._bucket(key);
    const i = bucket.findIndex((pair) => pair[0] === key);
    if (i === -1) return;
    bucket.splice(i, 1);
    this.size--;
  }

  _grow() {
    const old = this.buckets;
    this.buckets = Array.from({ length: old.length * 2 }, () => []);
    for (const bucket of old) {
      for (const pair of bucket) this._bucket(pair[0]).push(pair); // re-hash against the NEW length
    }
  }
}

Dry run: capacity 4, putting a, b, c, e, f (the hash of a single character is its character code)

Callindex = hash % capacitybuckets after the callsize / capacity
put("a")97 % 4 = 1[ , a, , ]1 / 4
put("b")98 % 4 = 2[ , a, b, ]2 / 4
put("c")99 % 4 = 3[ , a, b, c]3 / 4, exactly 0.75, not above it
put("e")101 % 4 = 1, a collision with "a"bucket 1 briefly holds a, e; size 4 > 0.75 × 4, so the map grows to 8 and re-hashes every pair: 97 % 8 = 1, 98 % 8 = 2, 99 % 8 = 3, 101 % 8 = 5, giving [ , a, b, c, , e, , ]4 / 8
put("f")102 % 8 = 6[ , a, b, c, , e, f, ]5 / 8

The collision in row 4 disappeared after the resize only because 97 and 101 differ by 4, and 4 is a multiple of the old capacity but not the new one. That is the point of growing: more buckets spread the same keys out.

Costs, stated with their assumptions. With a hash function that spreads keys evenly and a bounded load factor, put, get and remove take expected O(1) time, because each bucket holds a constant number of pairs on average. A single resize is O(n), but doubling makes the total cost of n insertions O(n), so the amortized cost per put is O(1). If every key hashed to one bucket, all three operations would degrade to O(n); that is the worst case, and it is why the quality of the hash matters. Space is O(n + capacity).

⚠ The bugs that turn up in this question (1) A negative bucket index. Math.imul and | 0 keep the hash in 32 bits but leave it signed, and % keeps the sign of its left operand, so the index can be negative; in a check of 1,000 random eight-letter words, 498 raw hashes were negative. The >>> 0 at the end of hashOf reads it as unsigned. (2) Re-using old bucket indices while growing: every pair must be re-indexed against the new length, which is why _grow calls _bucket again. (3) Counting an overwrite as a new entry, so size drifts upward and the map resizes for no reason. (4) Returning -1 for "absent" when -1 can be a stored value. The classic statement promises non-negative values; if yours does not, return undefined and say why. (5) Forgetting that remove must also decrease size.

The main alternative is open addressing: no lists, each entry lives directly in the array, and a collision probes the next slot. It uses less memory per entry and has better locality, but deletion becomes subtle: you cannot just empty a slot, because a later key may have probed past it to get where it is, so a removed entry must leave a marker (a tombstone) that lookups skip over and inserts may reuse. Naming that trap is usually enough; writing both versions is rarely asked.

Say it like this → "Hash the key to a bucket index, and make each bucket a short list because collisions are unavoidable. To keep the lists short I track the load factor and double the array past 0.75, re-hashing every pair against the new size. Expected O(1) per operation, amortized because of the resize, O(n) in the worst case if everything collides."

Encode and decode strings: make the encoding say where each piece ends

Design encode(strs), which turns a list of strings into one string, and decode(text), which gives the list back. The strings can contain any character. Everything about the problem is in that last sentence.

The first idea is to join with a delimiter and split on it. It fails immediately, and it is worth showing why rather than asserting it. With "#" as the delimiter, ["a#b", "c"] decodes as ["a", "b", "c"]. Worse, [] and [""] both encode to the empty string, so decode has no way to tell them apart; in the naive version below, [""] comes back as [].

function encodeNaive(strs) {
  return strs.join("#");
}

function decodeNaive(text) {
  return text === "" ? [] : text.split("#");
}

Picking a rarer delimiter only moves the bug. Escaping works but is fiddly: once # is escaped, the escape character must be escaped too. The robust fix is a different idea: stop scanning for a boundary, and announce the length before the content. Write each string as its length, a separator, then exactly that many characters. The decoder reads the digits up to the first separator, then counts that many characters and does not look at what they are. A # or a digit inside the string cannot confuse it, because the decoder is never searching inside the string, only skipping it.

function encode(strs) {
  return strs.map((s) => s.length + "#" + s).join("");
}

function decode(text) {
  const out = [];
  let i = 0;
  while (i < text.length) {
    const hash = text.indexOf("#", i); // the first "#" after i ends the length: digits never contain one
    const length = Number(text.slice(i, hash));
    const start = hash + 1;
    out.push(text.slice(start, start + length)); // take exactly `length` characters, whatever they are
    i = start + length;
  }
  return out;
}

Dry run: decode of the text for ["lint", "c#de", "", "7#x"]

The encoded text is 4#lint4#c#de0#3#7#x.

Loopifirst "#" at or after ilengthpiece = slice(start, start + length)next i
1014"lint"6
2674"c#de" (its own "#" is skipped, not read)12
312130"" (an empty string is a real entry)14
414153"7#x" (digits and "#" inside are skipped)19, the end

Both functions are O(total length): each character is written once and examined once. The empty list encodes to "" and a list holding one empty string encodes to "0#", so those two inputs stay distinct. I round-tripped 3,000 random lists whose characters came from a deliberately hostile alphabet (#, digits, spaces, newlines, a NUL character, an accented letter, an emoji, a lone surrogate half, and the string "7#x") and every one decoded to what went in.

⚠ Four places this goes wrong (1) Searching for "#" from index 0 instead of from i, which finds the first piece's separator again. (2) Assuming the length is one digit: it can be 12 or 1000, which is why the separator exists after it. (3) Mixing units. In JavaScript length counts UTF-16 code units, so "😀".length is 2, and this code is consistent because both sides count the same way. If the encoded string is later sent as UTF-8 bytes, that same emoji is 4 bytes, so a decoder that counts bytes must be paired with an encoder that counts bytes. (4) Handling the empty list as a special case with a sentinel such as "EMPTY", which reintroduces a value a string could equal.

In real code you would reach for JSON.stringify and JSON.parse, which solve this problem with escaping. Say that first, then say why the interview version asks for something else: it is checking whether you can make an encoding unambiguous by construction. The same length-prefix idea is how many binary formats and network protocols frame their messages.

Say it like this → "A delimiter fails because the strings can contain anything, including the delimiter. So I stop searching for boundaries: each piece is written as its length, a separator, and then exactly that many characters. The decoder reads the length, takes that many characters without inspecting them, and moves on, so the content can never be mistaken for structure."

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 with lazy deletion or an 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
  • "The value as of time t" with writes arriving in time order → a sorted list per key and a binary search for the last version not after t; if writes can arrive out of order, you pay for the insert
  • "Return a random member" beside an O(1) delete → a dense array plus a map of positions, deleting by moving the last element into the hole
  • "Most and least frequent key" where each count changes by exactly 1 → buckets of keys in a linked list sorted by count, spliced rather than searched
  • "Implement X from scratch" or "make the encoding unambiguous" → name the collision or the ambiguity first, then remove it by construction: chaining plus a resize, or a length written before the content
  • 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
Before you move on
  • Write the LRU cache's get and put from memory, and explain why each node must store its own key.
  • Explain why the LFU cache's minFreq never needs to search for the new minimum after a promotion or an insert.
  • Construct the two-element input that exposes the x < min vs x <= min bug in the optimized Min Stack.
  • Explain why the auxiliary-stack trick for O(1) min breaks the moment popMax() (arbitrary removal) is required.
  • Given a new "design X" prompt, write the operation/complexity table first and name which two structures you'd compose before writing any code.
  • Write the random set's remove from memory, including the case where the removed value is the last element, and say which line has to come last.
  • Explain why the time-based store's get searches for the first version after the query and steps back one, and what happens when that index is 0.
  • State the two invariants of the two-heap median finder and the three steps in addNum that restore them.
  • Say what a hash map's load factor is for, and why every pair must be re-indexed when the array grows.
  • Say why a delimiter cannot encode a list of arbitrary strings, and how writing the length first fixes it.

13Practice this chapter

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

  • 8 testsLRU Cacheadvanced
  • 9 testsLFU Cacheadvanced
  • 9 testsMin Stackintermediate
  • 8 testsHit Counterintermediate
  • 8 testsTime Based Key-Value Storeadvanced
  • 8 testsTime Map With Out-of-Order Writesadvanced
  • 8 testsInsert Delete GetRandom O(1)advanced
  • 8 testsRandomized Collection With Duplicatesadvanced
  • 8 testsAll O(1) Data Structureadvanced
  • 8 testsFind Median from Data Streamadvanced
  • 8 testsMedian of Small Integersintermediate
  • 8 testsDesign HashMapadvanced
  • 8 testsEncode and Decode Stringsadvanced
All 159 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

A9

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. A8Previous · A8String algorithms
  2. A9you are here
  3. Next · A10Advanced backtrackingSame three lines as before — the advanced part is saying "no" earlier and storing state in bits.45 min read

01On this page

14 sections · 0%

  1. 01These questions are graded on the choice, not the code
  2. 02LRU cache: the canonical composition
  3. 03LFU cache: the same trick, one level deeper
  4. 04All O(1): LFU's buckets, with the list made explicit
  5. 05Min stack: O(1) getMin with an auxiliary stack
  6. 06Hit counter: designing for a stream
  7. 07Time-based key-value store: a sorted history per key
  8. 08Rate limiter: a sliding window counter
  9. 09Insert, delete, getRandom in O(1): a dense array with a map of positions
  10. 10Median finder: two heaps that meet in the middle
  11. 11Design HashMap: buckets, collisions and resizing
  12. 12Encode and decode strings: make the encoding say where each piece ends
  13. 13How to run the first two minutes of any design question
  14. 14Recognizing it in an unseen problem
Mark as read

[] chapters · t top