LRU Cache
Build a fixed-capacity key-value store where reads and writes are both O(1), and when it overflows the entry that was used least recently is the one discarded.
An empty cache of capacity 3: a map from key to node, and a list kept in order of use.
1class Node:2 def __init__(self, key, val):3 self.key, self.val = key, val4 self.prev = self.next = None5 6class LRUCache:7 def __init__(self, capacity):8 self.cap = capacity9 self.map = {}10 self.head, self.tail = Node(0, 0), Node(0, 0)11 self.head.next, self.tail.prev = self.tail, self.head12 13 def _remove(self, node):14 node.prev.next, node.next.prev = node.next, node.prev15 16 def _push_front(self, node):17 node.prev, node.next = self.head, self.head.next18 self.head.next.prev = node19 self.head.next = node20 21 def get(self, key):22 if key not in self.map:23 return -124 node = self.map[key]25 self._remove(node)26 self._push_front(node)27 return node.val28 29 def put(self, key, value):30 if key in self.map:31 self._remove(self.map[key])32 node = Node(key, value)33 self.map[key] = node34 self._push_front(node)35 if len(self.map) > self.cap:36 lru = self.tail.prev37 self._remove(lru)38 del self.map[lru.key]Read the 10 steps as text
- 1An empty cache of capacity 3: a map from key to node, and a list kept in order of use.
- 2put(A) โ new node goes straight to the front, the most-recent end.
- 3put(B) โ new node goes straight to the front, the most-recent end.
- 4put(C) โ new node goes straight to the front, the most-recent end.
- 5get(A) โ a hit. Reading counts as a use, so it moves from position 2 to the front.
- 6put(D) โ new node goes straight to the front, the most-recent end.
- 7Over capacity. The last node, B, is the least recently used โ evict it.
- 8Unlink B and delete its key from the map. Both must happen, or the map leaks.
- 9get(B) โ not in the map. It was evicted earlier, so the answer is -1.
- 10B was evicted because it was last in line โ not because it was the oldest write, but the oldest use.
The idea
Two requirements pull in opposite directions. O(1) lookup by key says hash map. 'Which entry was used least recently' is a question about *order*, and a hash map has none. Neither structure alone can do this, which is the actual insight: use both, over the same nodes.
Keep the entries in a list ordered by recency, most recent at the front. Then the least recently used is always the last element โ no searching, no timestamps, no scanning for a minimum.
The list has to be doubly linked, and this is the part worth being able to defend. Every operation moves a node from the middle of the list to the front, and to unlink a node in O(1) you need its neighbour on *both* sides. With only next-pointers you'd have to walk from the head to find the predecessor, which makes the whole thing O(n) and defeats the point.
The map doesn't store values, it stores *nodes*. That's what makes the jump from a key straight into the middle of the list O(1). Storing values instead leaves you with no way to find the node to move.
Both operations count as a use, which is easy to overlook: a get must promote its entry to the front, not just return it. An LRU that only reorders on writes is a different, wrong cache.
The approach
- 1Keep a hash map from key to list node, and a doubly linked list ordered most-recent to least-recent.
- 2Use sentinel head and tail nodes so insertion and removal never need a null check.
- 3get: miss returns -1. On a hit, unlink the node, push it to the front, and return its value.
- 4put: if the key exists, unlink the old node. Create the node, store it in the map, push it to the front.
- 5If the map is now over capacity, take the node before the tail sentinel, unlink it, and delete its key from the map.
Complexity
Both operations are constant time: a hash lookup plus a fixed number of pointer writes. The space is one node per stored entry.
Code
class Node:
def __init__(self, key, val):
self.key, self.val = key, val
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {}
self.head, self.tail = Node(0, 0), Node(0, 0)
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node):
node.prev.next, node.next.prev = node.next, node.prev
def _push_front(self, node):
node.prev, node.next = self.head, self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key):
if key not in self.map:
return -1
node = self.map[key]
self._remove(node)
self._push_front(node)
return node.val
def put(self, key, value):
if key in self.map:
self._remove(self.map[key])
node = Node(key, value)
self.map[key] = node
self._push_front(node)
if len(self.map) > self.cap:
lru = self.tail.prev
self._remove(lru)
del self.map[lru.key]What goes wrong
- โขNot promoting on get. The cache then evicts by write-recency, which passes small tests and fails the moment a key is read repeatedly and never rewritten.
- โขStoring values in the map instead of nodes, leaving no O(1) way to find the node to unlink.
- โขUsing a singly linked list. Unlinking then costs a walk from the head and every operation quietly becomes O(n) โ interviewers ask about this specifically.
- โขForgetting to delete the evicted key from the map. The list shrinks but the map doesn't, so the map grows without bound and a later get returns a node that isn't in the list any more.
- โขOn a put to an existing key, pushing a second node without unlinking the first. The map points at the new one and the old one is stranded in the list, corrupting the eviction order.
- โขReaching for Python's OrderedDict or Java's LinkedHashMap without being able to describe what's underneath. They're the right production answer and often accepted, but the follow-up is always 'now implement it'.