Skip to main content

LRU / LFU Cache

Difficulty: Problems | Track: LLD

Problem statement

Design a generic Least Recently Used (LRU) cache that supports get(key) and put(key, value) in O(1) time. The cache holds a fixed number of entries; when a new entry is inserted beyond that capacity, the entry that was accessed least recently is evicted. Optionally extend the design to be safe for concurrent access from multiple threads.

Requirements

Functional:

  • get(key) returns the cached value in O(1); marks the entry as most recently used.
  • put(key, value) inserts or updates an entry in O(1); evicts the LRU entry when at capacity.
  • Eviction policy is strictly LRU: the entry with the oldest last-access timestamp is removed first.
  • Optional thread-safe variant: concurrent get calls should not block each other; put uses an exclusive write lock.

Out of scope:

  • TTL-based expiration (time-to-live).
  • Distributed or multi-node caching (single-process only).

Key classes

class Node<K, V> {
K key; V value;
Node<K, V> prev, next;
}

class DoublyLinkedList<K, V> {
Node<K, V> head, tail; // sentinel nodes — head = MRU end, tail = LRU end
void addToFront(Node<K, V> node) { ... }
void remove(Node<K, V> node) { ... }
Node<K, V> removeLast() { ... } // evict LRU
}

class LRUCache<K, V> {
int capacity;
Map<K, Node<K, V>> map; // O(1) lookup
DoublyLinkedList<K, V> list; // O(1) move-to-front / evict

V get(K key) {
// lookup in map, move node to front, return value
}
void put(K key, V value) {
// if exists: update + move to front
// if new + at capacity: evict tail, then insert at front
}
}

// Thread-safe wrapper (Proxy pattern)
class ThreadSafeLRUCache<K, V> {
LRUCache<K, V> delegate;
ReadWriteLock lock = new ReentrantReadWriteLock();
V get(K key) { /* readLock */ }
void put(K key, V value){ /* writeLock */ }
}

Patterns used

  • Iterator: Traversing the doubly linked list from head to tail visits entries in MRU-to-LRU order, which is useful for inspection, serialisation, and debugging.
  • Proxy: ThreadSafeLRUCache wraps LRUCache and adds locking without touching the core eviction logic — a clean separation of concurrency concern from cache concern.

Key design decisions

  • HashMap + doubly linked list combo: The HashMap gives O(1) node lookup; the doubly linked list gives O(1) node removal and insertion at front. Neither structure alone achieves both; together they make get and put both O(1).
  • Sentinel head/tail nodes: Using dummy sentinel nodes eliminates all null-pointer edge cases when adding or removing from either end of the list, making the list operations cleaner.
  • Store the key in the node: When the LRU node is evicted from the list's tail, the cache must also delete it from the HashMap. Storing the key inside the Node avoids a reverse-lookup scan.
  • LFU variant: If eviction should prefer the least frequently used entry instead, add a minFreq counter and replace the single linked list with a HashMap<freq, DoublyLinkedList> of frequency buckets. get and put remain O(1) but the bookkeeping is more involved.

What the interviewer is looking for

  • Proof that both get and put are truly O(1) — ability to trace through each operation step by step.
  • Correct handling of edge cases: capacity 1, put on an existing key, get on a missing key.
  • Thread-safety discussion: why a ReadWriteLock is preferable to synchronized here (readers don't block each other).
  • Awareness of the LFU extension and when you'd choose LFU over LRU (workloads with skewed access frequency distributions).

Resources