Linked Lists
Advanced 12 min read

Designing an LRU cache

In one line

An LRU cache combines a hash map (key to node, for O(1) lookup) with a doubly linked list ordered by recency (for O(1) move-to-front and evict-from-back), so get and put are both O(1).

What it is

A cache holds a limited number of items so repeated requests are fast. When it is full and a new item arrives, it must evict something. LRU (least recently used) evicts the item that has gone unused the longest, betting that recently used items will be used again soon.

To make get and put O(1), you need two structures working together. A hash map finds the node for a key in O(1). A doubly linked list keeps nodes in order of use: the most recent at the front, the least recent at the back. Because each node knows its prev and next, it can be unlinked from anywhere and moved to the front in O(1), and the back node is the eviction candidate.

Each node stores its key as well as its value, so that when the back node is evicted, you know which map entry to delete.

Why interviewers ask

LRU Cache is one of the most frequently asked design-style coding questions. It tests whether you can combine data structures to meet complexity targets, write careful pointer code, and talk about real systems: eviction policies, approximate LRU in Redis, thread safety and TTLs. It bridges coding rounds and system design rounds.

How it works

  1. 1Keep capacity, a dict key -> node, and a doubly linked list with dummy head and tail sentinels. head.next is the most recently used node; tail.prev is the least recently used.
  2. 2get(key): if the key is missing return -1. Otherwise look up the node, unlink it, insert it right after head (it is now the most recent) and return its value.
  3. 3put(key, value): if the key exists, unlink its old node. Create a node, store it in the map and insert it after head.
  4. 4If the map size now exceeds capacity, take tail.prev (the least recently used), unlink it, and delete its key from the map.
  5. 5Unlink is two pointer updates (node.prev.next = node.next, node.next.prev = node.prev); insert after head is four. With sentinels there are never null checks.
  6. 6Every operation does O(1) map work and O(1) pointer work, so get and put are O(1); space is O(capacity).

Worked example · Capacity 2: put(1), put(2), get(1), put(3), get(2), put(4), get(1), get(3), get(4)

  1. put(1, 1): list [1]. put(2, 2): list [2, 1] (front is most recent).
  2. get(1) returns 1 and moves 1 to the front: [1, 2].
  3. put(3, 3): list [3, 1, 2] exceeds capacity 2, so evict the back node 2: [3, 1]. get(2) returns -1.
  4. put(4, 4): list [4, 3, 1] exceeds capacity, evict the back node 1: [4, 3]. get(1) returns -1.
  5. get(3) returns 3, list [3, 4]. get(4) returns 4, list [4, 3]. Outputs in order: 1, -1, -1, 3, 4.

Picture it

A small desk that fits only a few books. Every time you use a book you put it on top of the stack. When you need space for a new book, you return the one at the bottom, the one you have not touched for the longest time. An index card telling you where each book is in the stack is the hash map.

Reference code

class Node:
    def __init__(self, key=0, val=0):
        self.key, self.val, self.prev, self.next = key, val, None, None

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.map = {}                              # key -> Node
        self.head, self.tail = Node(), Node()      # dummies; head.next is most recent
        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 _add_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)                         # mark as most recently used
        self._add_front(node)
        return node.val
    def put(self, key, value):
        if key in self.map:
            self._remove(self.map.pop(key))
        node = Node(key, value)
        self.map[key] = node
        self._add_front(node)
        if len(self.map) > self.cap:               # evict least recently used
            lru = self.tail.prev
            self._remove(lru)
            del self.map[lru.key]

Dummy head and tail mean every real node always has neighbours, so _remove and _add_front never check for None. The node stores its key so eviction can delete the map entry. get and put are O(1) time; space is O(capacity).

Key patterns

Hash map + doubly linked list

When: Any O(1) ordered structure with lookups (LRU, ordered dict)

How: Map key -> node; list keeps order; unlink and relink nodes in O(1).

OrderedDict shortcut

When: Python, when the interviewer allows library use

How: OrderedDict with move_to_end(key) on access and popitem(last=False) to evict; still O(1). Be ready to implement the manual version.

LFU cache

When: Evict the least frequently used, ties broken by recency

How: Map key -> node, map frequency -> doubly linked list, and track the minimum frequency; all operations O(1).

Approximate LRU

When: Huge caches where exact ordering costs too much memory or locking

How: Sample a few keys and evict the oldest (Redis), or use a clock / second-chance algorithm (operating systems).

TTL plus LRU

When: Data that goes stale

How: Store an expiry time per node; treat expired entries as misses on get, and evict them first.

Real-world use

Python functools.lru_cache

functools.lru_cache memoises function results with a dict plus a circular doubly linked list, giving O(1) hits and evicting the least recently used entry when maxsize is reached.

Redis eviction

With maxmemory-policy set to allkeys-lru, Redis evicts keys by approximated LRU: it samples a few keys and evicts the least recently used among them, trading exactness for memory and speed.

Memcached

Memcached keeps LRU lists per slab class (a segmented LRU with hot, warm and cold queues in modern versions) and evicts from the cold end when memory for that class is full.

Operating system page cache

The Linux page cache approximates LRU with active and inactive lists, so recently used file pages stay in memory and cold pages are reclaimed first.

In a project

A product-details API calls a slow pricing service (about 120 ms per call). Traffic is skewed: about 5% of products get most views. You can hold 50,000 entries in memory per instance.

  1. Add an in-process LRU cache keyed by product id with capacity 50,000, using a hash map plus doubly linked list (or a library such as functools.lru_cache or Caffeine in Java).
  2. On a hit, return the cached price and move the entry to the front; on a miss, call the pricing service, put the result and evict the least recently used entry if full.
  3. Add a time-to-live per entry so prices are never more than a few minutes stale, and invalidate an entry when a price-change event arrives.
  4. Protect it with a lock or use a concurrent cache implementation, and track hit rate, evictions and p99 latency.

Impact: With a hot set that fits in the cache, a hit rate around 90% is typical for skewed traffic, so most requests skip the 120 ms call: median latency drops to a few milliseconds and load on the pricing service falls by roughly an order of magnitude.

Trade-offs

Use it when

  • Access has temporal locality: recently used items are likely to be used again
  • You need predictable O(1) get and put with a fixed memory budget
  • Caching results of expensive calls such as database queries, API calls or computations

Avoid it when

  • Access is a large sequential scan larger than the cache: LRU evicts everything useful (scan pollution); consider LFU, segmented LRU or scan-resistant policies
  • Popularity is stable over time and frequency matters more than recency: LFU can do better
  • Many threads hit one cache: a single lock around the list becomes a bottleneck; use sharded or concurrent caches

Cost: get and put O(1) time; O(capacity) space, with two pointers plus a map entry per item.

Common mistakes

  • Using a singly linked list, which cannot unlink a node in O(1) without its predecessor.
  • Not storing the key in the node, so eviction cannot find which map entry to delete.
  • Forgetting to move an item to the front on get, so reads do not count as use.
  • Updating an existing key by adding a second node without removing the old one, so the map and list disagree.
  • Using a list or array and searching it for the LRU item, which makes operations O(n).

Interview questions

Quick revision

  • LRU = hash map (key -> node) + doubly linked list (recency order).
  • Front = most recent, back = least recent (evict tail.prev).
  • get: miss -> -1; hit -> move to front, return value.
  • put: remove old node if present, add new at front, evict if over capacity.
  • Node stores key and value (key needed for eviction).
  • Sentinels: no null checks; unlink = 2 updates, insert = 4.
  • O(1) get and put, O(capacity) space.
  • Python shortcut: OrderedDict.move_to_end and popitem(last=False).

Check yourself

1. Why does each node in an LRU cache store its key?

2. Capacity 2: put(1, 1), put(2, 2), get(1), put(3, 3). Which key is evicted?

3. Which combination gives O(1) get and put?

4. What access pattern makes LRU perform badly?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free