Data Structure Design · Caching Fundamentals

LRU Cache Design

Combining a hash map with a doubly linked list so both lookup and recency-tracking are O(1).

  • 7 min read
  • 10 practice questions
  • 2 graded problems
On this page
  1. Description
  2. The pattern
  3. The pieces in code
  4. Where you'll see it
  5. Which tool for which job?
  6. Common mistakes
  7. Check yourself
  8. Key takeaways
  9. Quick revise
  10. Practice

Description

An LRU (least recently used) cache keeps a fixed number of entries and, when it's full, throws away the one that hasn't been used for the longest time. It's the most common eviction policy - in-process caches, database buffer pools and CDN edges all use it or a close relative - and "design an LRU cache with O(1) operations" is one of the most frequent object-oriented design interview questions. This skill covers the classic hash map plus doubly linked list design, the library shortcuts, and the bugs to avoid.

A cache with no eviction strategy either grows without bound, or evicts using a scan that gets slower exactly as the cache gets more useful:

Java

class NaiveLRUCache {
    private Map<Integer, Integer> map = new HashMap<>();
    private List<Integer> usageOrder = new ArrayList<>();
    private int capacity;

    public int get(int key) {
        if (!map.containsKey(key)) return -1;
        usageOrder.remove((Integer) key);   // O(n) - scans the whole list to find key
        usageOrder.add(key);
        return map.get(key);
    }

    public void put(int key, int value) {
        if (map.containsKey(key)) {
            usageOrder.remove((Integer) key);   // O(n) again
        } else if (map.size() == capacity) {
            int oldest = usageOrder.remove(0);    // O(n) - shifts every remaining element down
            map.remove(oldest);
        }
        map.put(key, value);
        usageOrder.add(key);
    }
}

Python

class NaiveLRUCache:
    def __init__(self, capacity: int):
        self._map: dict[int, int] = {}
        self._usage_order: list[int] = []
        self._capacity = capacity

    def get(self, key: int) -> int:
        if key not in self._map:
            return -1
        self._usage_order.remove(key)           # O(n) - scans the whole list to find key
        self._usage_order.append(key)
        return self._map[key]

    def put(self, key: int, value: int) -> None:
        if key in self._map:
            self._usage_order.remove(key)       # O(n) again
        elif len(self._map) == self._capacity:
            oldest = self._usage_order.pop(0)   # O(n) - shifts every remaining element down
            del self._map[oldest]
        self._map[key] = value
        self._usage_order.append(key)

At a handful of entries this is invisible. Under real load - thousands of get/put calls per second against a cache holding thousands of entries - every single operation re-scans (and often re-shifts) the entire usage-order list, and the cache's own bookkeeping becomes slower than just not caching at all.

We'll go from simple to more technical:

  1. The design: a hash map for lookup, a doubly linked list for recency.
  2. The two list operations every method uses, with sentinel nodes.
  3. The library versions: LinkedHashMap, OrderedDict and functools.lru_cache.
  4. The bugs that break it, and what changes with threads.

The pattern

Keep a doubly linked list ordered from most-recently-used (right after a dummy head) to least-recently-used (right before a dummy tail), plus a HashMap<Integer, Node> for O(1) key lookup into that list.

get(key):
    if key is not in the map: return -1
    unlink its node, re-insert it right after head
    return its value

put(key, value):
    if key is in the map:
        update its node's value, then unlink it and re-insert it right after head
    else:
        create a node, insert it right after head, add it to the map
        if the cache is now over capacity:
            remove the node just before tail, and its map entry

Dummy head/tail sentinel nodes (rather than tracking null specially) are what keep the insert/remove code free of edge-case branches for "list is empty" or "removing the only node."

The pieces in code

With sentinels, the two list operations every method needs are a few lines each, with no null checks anywhere:

Java

private void remove(Node n) {            // unlink n from wherever it is
    n.prev.next = n.next;
    n.next.prev = n.prev;
}

private void insertFront(Node n) {       // link n in right after head (most recent)
    n.next = head.next;
    n.prev = head;
    head.next.prev = n;
    head.next = n;
}

Python

def _remove(self, n: Node) -> None:          # unlink n from wherever it is
    n.prev.next = n.next
    n.next.prev = n.prev

def _insert_front(self, n: Node) -> None:    # link n in right after head (most recent)
    n.next = self._head.next
    n.prev = self._head
    self._head.next.prev = n
    self._head.next = n

get is a lookup plus remove plus insertFront; eviction is remove(tail.prev) plus deleting that key from the map - which is why each node stores its own key.

In production Java you rarely write this by hand: LinkedHashMap already threads a doubly linked list through its entries. Python's twin is collections.OrderedDict: move_to_end(key) marks a key as the newest and popitem(last=False) evicts the oldest - and @functools.lru_cache(maxsize=...) wraps a whole function in an LRU cache.

Java

class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    LruCache(int capacity) { super(16, 0.75f, true); this.capacity = capacity; }   // true = access order
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;                                                  // checked after each put
    }
}

Python

from collections import OrderedDict


class LruCache:
    def __init__(self, capacity: int):
        self._capacity = capacity
        self._data: OrderedDict[int, int] = OrderedDict()   # oldest first, newest last

    def get(self, key: int) -> int:
        if key not in self._data:
            return -1
        self._data.move_to_end(key)                         # a read makes it the newest
        return self._data[key]

    def put(self, key: int, value: int) -> None:
        self._data[key] = value
        self._data.move_to_end(key)                         # an update must move it too
        if len(self._data) > self._capacity:
            self._data.popitem(last=False)                  # evict the oldest

Without the true flag the map keeps insertion order and this becomes a FIFO cache; with size() >= capacity it quietly holds one entry fewer than intended.

The Python trap is the mirror image: a plain dict remembers insertion order too, but assigning to an existing key does not move it. Forget move_to_end on an update and a key that was just rewritten is still the first to be evicted.

Where you'll see it

This is the actual data structure behind an in-process LRU cache (e.g. a bounded LinkedHashMap in accessOrder mode in Java, or an application-level cache in front of a slower store like a database or a remote API) — anywhere you need to bound memory use by evicting the coldest entries first, without a full memory-usage scan on every access.

Which tool for which job?

You needJavaPython
An LRU cache in an interview, from scratchhash map + doubly linked list with sentinelsthe same
An LRU cache in production code, one threadLinkedHashMap with accessOrder = true + removeEldestEntryOrderedDict with move_to_end / popitem(last=False)
Memoize a function with a bounda small cache class, or a library like Caffeine@functools.lru_cache(maxsize=...)
A cache shared by many threadsone lock around both structures, or Caffeineone threading.Lock around both structures
A cache shared by many serversRedis or MemcachedRedis or Memcached

Common mistakes

MistakeWhat happensDo this instead
Moving a node on put but not on getfrequently read keys are evicted as if coldevery access moves the node to the front
Updating the map but not the list (or the reverse)the two disagree; ghosts or leakschange both in the same operation
Nodes that don't store their keyeviction can't remove the map entrykeep the key in the node
LinkedHashMap without accessOrder = truea FIFO cache, not LRUpass true as the third constructor argument
Python: assigning to an existing key without move_to_enda just-updated key is evicted firstmove it on every write and read

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. Why does removing the O(n) usageOrder.remove((Integer) key) scan matter in practice, if the cache never grows past a few dozen entries?

Show answer

It doesn't, in that specific case - the O(1) design earns its keep at scale (thousands of entries, high request rates), not at a handful of items where an O(n) scan is invisibly fast anyway. Reaching for the doubly-linked-list design on a cache you know will always stay tiny is optimizing for a load that will never arrive.

2. A get(key) is called for a key that doesn't exist in the cache. Should this affect the linked list at all?

Show answer

No - there's nothing to move, since the key was never in the list to begin with. get on a missing key should just return -1 without touching head, tail, or any node.

3. Why can't a single synchronized keyword around get/put alone make this design both safe and fast for a read-heavy workload?

Show answer

synchronized fixes correctness by serializing every access - but a read-heavy workload (many gets, few puts) then has all its reads waiting on each other unnecessarily, since two gets don't actually conflict the way two puts might. A ReadWriteLock (letting concurrent readers proceed together, only serializing writers) fixes the performance cost synchronized leaves on the table - but not for this design: an LRU get moves a node to the front, so it mutates the list, and running gets concurrently under a shared read lock would corrupt it. get needs the write lock too; high-throughput caches instead record reads in a buffer and replay the reordering later, under one lock.

Key takeaways

  • O(1) get/put needs both a hashmap (for lookup) and a doubly linked list (for O(1) reordering and eviction) - neither alone is enough.
  • Sentinel head/tail nodes remove every "is the list empty?" special case from insert/remove.
  • get must move a node to the front too, not just put - otherwise frequently-read keys look cold and get evicted.
  • The map and list must be updated together on every operation, or they'll disagree about what's actually in the cache.
  • This design isn't thread-safe by default; making it safe for concurrent access (synchronized, ConcurrentHashMap, ReadWriteLock) is a separate decision with its own tradeoffs.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You needUse
An interview LRUhash map + doubly linked list with sentinels
Production, one thread (Java)LinkedHashMap(16, 0.75f, true) + removeEldestEntry
Production, one thread (Python)OrderedDict + move_to_end + popitem(last=False)
Memoizing a functionfunctools.lru_cache(maxsize=...)
Many threadsone lock around both structures, or Caffeine
Scan-heavy workloadsSLRU / 2Q / W-TinyLFU
Many serversRedis / Memcached

Snippets worth knowing by heart

Java

class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    LruCache(int capacity) { super(16, 0.75f, true); this.capacity = capacity; }
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; }
}

Python

class LruCache:
    def __init__(self, capacity: int):
        self._capacity, self._data = capacity, OrderedDict()

    def get(self, key):
        if key not in self._data:
            return None
        self._data.move_to_end(key)
        return self._data[key]

    def put(self, key, value) -> None:
        self._data[key] = value
        self._data.move_to_end(key)
        if len(self._data) > self._capacity:
            self._data.popitem(last=False)

The traps

  • No reordering on get.
  • Map and list out of sync.
  • Nodes without keys - eviction can't clean the map.
  • LinkedHashMap without accessOrder - FIFO.
  • Python update without move_to_end.

Staff-level points, one line each

  • Scans flush LRU: hot-key hit rate 78.8% (LRU) vs 85.1% (SLRU) in a simulation with periodic 400-key scans.
  • Scan-resistant policies: SLRU, 2Q, LRU-K, ARC, W-TinyLFU (Caffeine).
  • Approximations: CLOCK (a reference bit per entry), Redis sampling (maxmemory-samples, default 5).
  • Bound by weight (bytes), not count, when entry sizes vary.
  • Every LRU read is a write: no read locks; one lock, segments, or buffered reordering.
  • Concurrent misses: get-then-put computed 8 times, computeIfAbsent once, lru_cache 8 times - coalesce.
  • Overhead: 10,000 keys took ~295 KB as a dict, ~746 KB as an OrderedDict.

Say it in an interview

  • "The map finds the node in O(1); the list moves and evicts it in O(1)."
  • "Sentinel head and tail nodes remove every empty-list special case."
  • "Under threads, get needs the exclusive lock - it reorders the list."
  • "If nightly scans hurt the hit rate, I'd switch to a segmented LRU or W-TinyLFU."

Practice

This skill comes with 10 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice LRU Cache Design free

Design an LRU Cache

MediumProCoding problemJava · Python

Design a data structure for a Least Recently Used (LRU) cache with a fixed capacity, supporting two operations, both in O(1) average time:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

LRU Cache: Adding a Non-Disturbing Peek

MediumProCoding problemJava · Python

Extend the LRU cache with a third operation: peek(key), which looks up a key's value without marking it as recently used. get and put still behave exactly as before (either one counts as "using" a key and moves it to the front); peek must leave the recency ordering completely…

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice LRU Cache Design free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime