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.
14On this page
- 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
- Hit counter: designing for a stream
- Time-based key-value store: a sorted history per key
- Rate limiter: a sliding window counter
- Insert, delete, getRandom in O(1): a dense array with a map of positions
- Median finder: two heaps that meet in the middle
- Design HashMap: buckets, collisions and resizing
- Encode and decode strings: make the encoding say where each piece ends
- How to run the first two minutes of any design question
- 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.
| Requirement | Target | Structure that delivers it | What it cannot do |
|---|---|---|---|
| Find a value by key | O(1) | Hash map | Any ordering |
| Reorder / evict by recency | O(1) | Doubly linked list | Find a node by key |
| Min or max of a stack | O(1) | Parallel auxiliary stack | Arbitrary deletion |
| Min or max, arbitrary insert/delete | O(log n) | Heap (lazy deletion for arbitrary removal) | O(1) lookup by key |
| Kth smallest / median, streaming | O(log n) | Two heaps, balanced | Range queries |
| Prefix / range sums with updates | O(log n) | Fenwick or segment tree | Key lookup |
| Expire old events by time | O(1) amortized | Deque or ring buffer of buckets | Random access by key |
| Group by count, get the smallest count | O(1) | Frequency buckets (LFU) | Ordering within… unless the bucket is itself ordered |
| Value as of time t, writes in time order | O(log n) | Per-key sorted list and a binary search | Cheap writes that arrive out of order |
| Uniform random member plus O(1) removal | O(1) | Dense array and a map from value to index | Keeping the members in any order |
| Largest and smallest count when each count moves by 1 | O(1) | Linked list of count buckets | Jumps 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.
// 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
| Call | list, front → back (most → least recent) | map keys | evicted |
|---|---|---|---|
| put("C", 3) | C | C | — |
| put("B", 2) | B, C | C, B | — |
| put("A", 1) | A, B, C | C, B, A | — (matches the figure: A newest, C next to evict) |
| get("C") → 3 | C, A, B | C, B, A | — (C refreshed to the front; B is now the LRU) |
| put("D", 4) | D, C, A | C, A, D | B |
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
| Call | buckets, smallest count first | getMaxKey | getMinKey |
|---|---|---|---|
| inc("a") | 1:a | a | a |
| inc("b") | 1:a,b | a | a |
| inc("a") | 1:b | 2:a | a | b |
| inc("c") | 1:b,c | 2:a | a | b |
| inc("c") | 1:b | 2:a,c | a | b |
| inc("c") | 1:b | 2:a | 3:c | c | b |
| dec("a") | 1:b,a | 3:c (the empty 2-bucket was unlinked) | c | b |
| dec("b") | 1:a | 3:c | c | a |
| dec("c") | 1:a | 2:c (a new 2-bucket spliced in before the 3-bucket, which then emptied) | c | a |
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)].
| Step | lo | hi | mid | time at mid | Decision |
|---|---|---|---|---|---|
| start | 0 | 3 | 1 | 5 | 5 <= 6, so version 1 is not after the query: lo = 2 |
| 2 | 2 | 3 | 2 | 9 | 9 > 6, so version 2 is after the query: hi = 2 |
| end | 2 | 2 | - | - | 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
| Call | values | slot map (value → index) | What happened |
|---|---|---|---|
| 4 inserts | [10, 20, 30, 40] | 10→0, 20→1, 30→2, 40→3 | push and record |
| remove(20) | [10, 40, 30] | 10→0, 40→1, 30→2 | hole at 1; 40, the last, moves into it and its index changes from 3 to 1 |
| remove(40) | [10, 30] | 10→0, 30→1 | hole at 1; 30 moves into it, index 2 → 1 |
| remove(30) | [10] | 10→0 | 30 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
| addNum | lower 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)
| Call | index = hash % capacity | buckets after the call | size / 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.
| Loop | i | first "#" at or after i | length | piece = slice(start, start + length) | next i |
|---|---|---|---|---|---|
| 1 | 0 | 1 | 4 | "lint" | 6 |
| 2 | 6 | 7 | 4 | "c#de" (its own "#" is skipped, not read) | 12 |
| 3 | 12 | 13 | 0 | "" (an empty string is a real entry) | 14 |
| 4 | 14 | 15 | 3 | "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
| Step | What 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 it | Sentinels 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
getandputfrom memory, and explain why each node must store its own key. - Explain why the LFU cache's
minFreqnever needs to search for the new minimum after a promotion or an insert. - Construct the two-element input that exposes the
x < minvsx <= minbug 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
removefrom 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
getsearches 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
addNumthat 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.
Opens in the editor: write it, run it, and check it against real tests.
All 159 for this level →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.
Finished reading?
0 of 48 chapters read in this series