LRU Cache
Prompt
We're putting a fixed-size cache in front of a hot lookup, and when it fills we
want to evict whatever was used least recently. Design an LRU cache with get
and put, and make both O(1).
How this round runs
I want you driving this — clarify the contract, name the data structures and why,
and reason about the O(1) requirement. Once it works single-threaded I'll add a
constraint that breaks a naive implementation.
Model answer
Clarify first: does a get count as a use (so it refreshes recency), or only
put? What does get return on a miss — a sentinel, or do we throw? Is capacity
fixed at construction, and can it be zero?
The O(1) requirement is the whole design. We need two things in constant time:
look up a key, and reorder by recency. A hash map alone gives the lookup but not
the ordering; a list alone gives ordering but O(n) lookup. So combine them: a
hash map from key to node, plus a doubly-linked list holding the nodes in
recency order. Map finds the node in O(1); the doubly-linked list lets us
unlink and re-insert that node in O(1) (a singly-linked list can't, because you
can't splice out a node without its predecessor).
Sentinel head and tail nodes remove every null-boundary special case. Most-recent
sits next to head; the eviction victim is always tail.prev.
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map();
this.head = { prev: null, next: null }; // most-recent side
this.tail = { prev: null, next: null }; // least-recent side
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key) {
const node = this.map.get(key);
if (!node) return -1;
this._moveToFront(node); // a read counts as a use
return node.value;
}
put(key, value) {
let node = this.map.get(key);
if (node) { node.value = value; this._moveToFront(node); return; }
node = { key, value, prev: null, next: null };
this.map.set(key, node);
this._addFront(node);
if (this.map.size > this.capacity) {
const victim = this.tail.prev;
this._unlink(victim);
this.map.delete(victim.key);
}
}
_unlink(n) { n.prev.next = n.next; n.next.prev = n.prev; }
_addFront(n) { n.next = this.head.next; n.prev = this.head; this.head.next.prev = n; this.head.next = n; }
_moveToFront(n) { this._unlink(n); this._addFront(n); }
}
Both operations are O(1): one map lookup plus a constant number of pointer
rewires. Space is O(capacity). The detail people miss is that get must also
move the node to the front — otherwise recency only reflects writes and the
eviction policy is wrong.
- Asked whether get refreshes recency and what a miss returns before designing
- Derived the hash-map + doubly-linked-list combo from the O(1) requirement
- Explained why a doubly-linked list (not singly) is needed for O(1) unlink
- Used head/tail sentinels and moved nodes to front on get as well as put
- Make it safe under concurrent access → a lock around get/put, or a sharded/striped design to cut contention; note read-then-reorder is a mutation
- Add TTL so entries also expire by age → store an expiry per node, treat expired entries as misses, and lazily evict (or sweep) them