← Bank
DSA

LRU Cache

DSASenior~15m
hash-maplinked-listcaching

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.

Signals — what a strong answer shows
  • 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
Follow-ups — where it goes next
  • 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