Coding Interview Patterns

Course Content

Coding Interview Patterns

20 sections · 146 lessons

LRU Cache


A cache keeps a small number of recent results so you do not recompute or refetch them. When it is full, something must go. Least recently used (LRU) eviction throws out the entry that has gone untouched the longest, on the bet that it is the least likely to be needed soon. Browsers, databases and CDNs all use variations of it.

As an interview problem it is a design question in disguise. A hash map gives O(1) lookup, but it has no idea which key is oldest. The solution pairs the map with a second structure that tracks recency, and the challenge is making both O(1). It is the most-asked problem that combines this section with the next one, Linked Lists.

Recency order after put(9, 90) evicts 5oldestsentinel7 : 719 : 90newestsentinelnullevictednextmost recentThe map holds key to node, so a used node is unlinked and re-appended in O(1).
The hash map finds the node and the doubly linked list moves it — neither structure can do both alone.

The problem

Design a class LRUCache with a fixed capacity:

  • get(key) returns the value stored for key, or −1 if it is not there. A successful get counts as a use.
  • put(key, value) stores the value, replacing any old value for that key. It also counts as a use. If this adds a new key to a full cache, first evict the least recently used key.

Both operations must run in O(1) average time. Example with capacity 2:

operationreturnscache after, least recent first
put(7, 70)—7:70
put(3, 30)—7:70, 3:30
get(7)703:30, 7:70
put(5, 50)— (evicts 3)7:70, 5:50
get(3)−17:70, 5:50
put(7, 71)— (updates 7)5:50, 7:71
put(9, 90)— (evicts 5)7:71, 9:90
get(5)−17:71, 9:90
get(7)719:90, 7:71

The get(7) in row 3 is why 3, not 7, is evicted in row 4. And put(7, 71) updates a key that already exists, so nothing is evicted even though the cache is full.

Constraints: capacity up to 3,000; up to 2 × 10⁵ calls.

Clarifying questions

  • Does put on an existing key count as a use? Yes — it moves that key to most recent.
  • Does a failed get change anything? No.
  • Can capacity be 0? Assume capacity ≥ 1.
  • Thread safety? Not needed here; mention it as a follow-up.

Approach 1: a dict plus a list of keys in recency order

Keep values in a dict and a list of keys ordered from least to most recent. On every use, remove the key from the list and append it at the end; to evict, pop the front.

Python
class LRUCacheSimple:    """Dict for values, list for recency. get and put are O(capacity)."""    def __init__(self, capacity: int) -> None:        self.capacity = capacity        self.values: dict[int, int] = {}        self.order: list[int] = []            # least recent first    def get(self, key: int) -> int:        if key not in self.values:            return -1        self.order.remove(key)                # O(capacity) scan        self.order.append(key)        return self.values[key]    def put(self, key: int, value: int) -> None:        if key in self.values:            self.order.remove(key)        elif len(self.values) == self.capacity:            oldest = self.order.pop(0)        # O(capacity) shift            del self.values[oldest]        self.values[key] = value        self.order.append(key)

It is correct, and it is the right first thing to say. But list.remove(key) scans for the key and then shifts everything after it, and pop(0) shifts the whole list. Each operation is O(capacity). With capacity 3,000 and 2 × 10⁵ calls, that is up to about 6 × 10⁸ element moves — and the problem explicitly asks for O(1).

The key insight

We need three operations to be O(1): find a key, move a key to the most-recent end, and remove the least-recent key. A hash map does the first. A list fails at the other two because removing from the middle means shifting.

A doubly linked list can remove any node in O(1) — if you already hold the node. Each node knows the node before it and the node after it, so unlinking it is two pointer writes, with no search and no shift. And "if you already hold the node" is exactly what the hash map provides: store key → node instead of key → value.

So the design is:

  • A map from key to list node, for O(1) lookup.
  • A doubly linked list of nodes in recency order: least recent at the front, most recent at the back.
  • Each node stores its key as well as its value, so that when we evict the front node we know which map entry to delete.
  • Two sentinel nodes, one at each end, so the list is never empty and inserting or removing never needs an "is this the first node?" branch. This is the dummy-head idea from the Linked Lists section, used at both ends.

Approach 2: OrderedDict

Python's collections.OrderedDict is exactly a hash map plus a doubly linked list, with O(1) move_to_end and popitem(last=False).

Python
from collections import OrderedDictclass LRUCacheOrdered:    """OrderedDict remembers order and moves a key to the end in O(1)."""    def __init__(self, capacity: int) -> None:        self.capacity = capacity        self.data: OrderedDict[int, int] = OrderedDict()    def get(self, key: int) -> int:        if key not in self.data:            return -1        self.data.move_to_end(key)            # now the most recent        return self.data[key]    def put(self, key: int, value: int) -> None:        if key in self.data:            self.data.move_to_end(key)        self.data[key] = value        if len(self.data) > self.capacity:            self.data.popitem(last=False)     # drop the least recent

Time: O(1) average for both operations. Space: O(capacity).

Offer this, and say what it is made of. Many interviewers accept it; many then say "now build it without the library". Be ready for that.

Approach 3: hash map plus a hand-built doubly linked list

Python
class Node:    """A doubly linked node that carries its own key, for eviction."""    def __init__(self, key: int = 0, value: int = 0) -> None:        self.key = key        self.value = value        self.prev: Node | None = None        self.next: Node | None = Noneclass LRUCache:    """Hash map for O(1) lookup + doubly linked list for O(1) reordering."""    def __init__(self, capacity: int) -> None:        self.capacity = capacity        self.node_of: dict[int, Node] = {}        self.oldest = Node()                  # sentinel before the least recent        self.newest = Node()                  # sentinel after the most recent        self.oldest.next = self.newest        self.newest.prev = self.oldest    def _unlink(self, node: Node) -> None:        node.prev.next = node.next        node.next.prev = node.prev    def _append(self, node: Node) -> None:        """Insert just before the newest sentinel."""        last = self.newest.prev        last.next = node        node.prev = last        node.next = self.newest        self.newest.prev = node

The two helpers are the only places that touch pointers. _unlink makes the neighbours of node point at each other. _append puts node between the current last real node and the newest sentinel. Because the sentinels always exist, neither helper needs a check for None.

Python
    def get(self, key: int) -> int:        node = self.node_of.get(key)        if node is None:            return -1        self._unlink(node)        self._append(node)                    # touched, so now the most recent        return node.value    def put(self, key: int, value: int) -> None:        node = self.node_of.get(key)        if node is not None:                  # existing key: update and refresh            node.value = value            self._unlink(node)            self._append(node)            return        if len(self.node_of) == self.capacity:            victim = self.oldest.next         # least recent real node            self._unlink(victim)            del self.node_of[victim.key]      # why the node stores its key        node = Node(key, value)        self.node_of[key] = node        self._append(node)

"Move to most recent" is always unlink, then append. Eviction takes the node right after the oldest sentinel. Updating an existing key returns early, before the capacity check, so an update never evicts.

Running the example operations through this class gives exactly the table in the problem section: 70, −1, −1, 71 for the four get calls, with keys 3 and then 5 evicted. The state after each call, printed from the linked list itself, matched the "cache after" column row for row.

Time: O(1) average per operation — one map lookup plus a constant number of pointer writes. Space: O(capacity) — one map entry and one node per key, plus two sentinels.

Edge cases

  • Capacity 1: every new key evicts the previous one; the sentinels keep this branch-free.
  • Updating a key in a full cache: must not evict anything. The early return in put guarantees it.
  • get on a missing key: returns −1 and changes no order.
  • Putting the same key repeatedly: each call updates the value and moves the node to the back; the map never grows.

Follow-ups

  • LFU (least frequently used) cache: evict the key with the fewest uses, breaking ties by recency. Keep a map from key to node, a map from use-count to its own doubly linked list, and the current minimum count. Still O(1) per operation.
  • Entries that expire after a time limit: store an expiry time in each node; on get, treat an expired node as missing and remove it.
  • Many threads: wrap get and put in one lock. Note that get also writes (it reorders), so a read-write lock does not help as much as it seems.