Design an LRU cache
Bounded capacity. get(k) and put(k, v) both run in O(1). When capacity overflows, evict the key that was least recently used. The classic interview problem — the data-structure trick is a hash map of keys plus a doubly-linked list of usage order, with each map entry pointing at its node in the list.
1 · Clarifying questions
| Capacity? | Fixed at construction. |
| Key + value types? | Generic; for the interview answer assume int → int unless asked otherwise. |
Does get count as a use? | Yes. A get moves the key to the most-recently-used end. |
| Does updating a value count? | Yes. A put on an existing key updates the value and marks it most-recently-used. |
| Thread-safe? | Single-threaded by default. Discuss locking near the end. |
| TTL? | Out of scope unless asked — it's a separate eviction policy. |
2 · Why hash map + doubly-linked list
A hash map alone gives O(1) lookup but can't tell you the recency order. A linked list gives recency order but has O(n) lookup. Combine them: the map stores key → node, the list stores node ↔ node. To move a node to the front, unlink it and re-insert at the head — both O(1) given direct pointers.
A singly-linked list would force O(n) unlink (you'd have to find the predecessor). Doubly-linked is what makes the move O(1).
3 · Class diagram
4 · Interface
class LRUCache<K, V>:
def __init__(capacity: int)
def get(key: K) -> V | None # also moves to MRU
def put(key: K, value: V) # also moves/inserts at MRU; evicts LRU if full
class Node<K, V>:
key: K
value: V
prev: Node | None
next: Node | NoneThe list keeps two sentinel nodes (head and tail) so insertion at the front and deletion at the back never require null-checks. Sentinels are the under-rated trick of linked-list code.
Operations
get(k):
if k not in map: return None
node = map[k]
remove(node) # unlink from current position
add_front(node) # re-insert as MRU
return node.value
put(k, v):
if k in map:
node = map[k]
node.value = v
remove(node)
add_front(node)
return
if len(map) == capacity:
lru = tail.prev
remove(lru)
del map[lru.key]
node = Node(k, v)
add_front(node)
map[k] = node
remove(node):
node.prev.next = node.next
node.next.prev = node.prev
add_front(node):
node.next = head.next
node.prev = head
head.next.prev = node
head.next = node5 · Edge cases
- Capacity 0 or 1. Capacity 0 is degenerate — every
putevicts what it just inserted; reasonable to reject in the constructor. Capacity 1 works fine but exercises every remove/insert path. - Updating an existing key. Must update the value and move to MRU. A common bug is to update the value but forget the move.
- Get on a missing key. Return
None(or sentinel, or throw). Don't accidentally insert. - Eviction during
put. Evict first, then insert — otherwise you over-allocate transiently.
6 · Thread-safety
The naive answer: wrap every method in one mutex. Correct, simple, scales poorly.
Better answers, in increasing complexity:
- Shard the cache. N independent LRUCaches, key hashed to a shard. Each shard has its own lock. Locks contend N× less.
- Read-write lock. Doesn't help here — every
getmutates the list. RWLock works only for the rare read-only workload. - Lock-free. Specialised structures (Caffeine in Java) use compare-and-swap on the linked list. Real, but past the bar for an OOD round; mention it as a stretch.
7 · Extensions
- TTL eviction. Each node carries an expiry; eviction checks expiry first, falls back to LRU. A min-heap of expiries gives O(log n) periodic cleanup.
- LFU (least-frequently-used). Each node tracks a counter; eviction picks the lowest. The clean implementation uses a list of frequency buckets, each holding an LRU list.
- Distributed. Hash partition across nodes. Each node runs an LRUCache for its slice. Consistent hashing if you want to add/remove nodes without remapping most keys.
- Persistence. Write-through to disk for survivable cache.
putwaits on the disk write; trades latency for durability.
8 · What gets graded
- Did you recognise the hash-map-plus-doubly-linked-list pattern fast?
- Did you handle the "update existing key" case correctly?
- Did you use sentinel nodes (or notice why you should)?
- Did you separate
removeandadd_frontas helper methods, or smear the pointer plumbing acrossgetandput? - Did you talk about thread-safety unprompted?