1. Home
  2. Data Structures
  3. LRU Cache (Hash Map + Doubly Linked List)

LRU Cache (Hash Map + Doubly Linked List)

Keep the most recently used items and evict the least recently used one, with get and put in O(1). See how a hash map and a doubly linked list work together in 3D, step by step through the classic example.

Interactive 3DIntermediate13 min readDSAUpdated

Drag to rotate · Right-drag to pan · Click, then scroll to zoom · Space play · ←→ step

What's happening

Pseudocode

    Try this in the 3D model

    • Press Run the example. Which key is evicted by put(3, 3), and why not key 1?
    • After put(4, 4), what does get(1) return, and why?
    • With capacity 3, put keys 1, 2 and 3, then get(1), then put(4). Which key is evicted now?
    • Why does the list need links in BOTH directions? Which step would be slow with a singly linked list?

    What is an LRU cache?

    A cache keeps a small number of items close at hand: web pages in a browser, database rows in memory, results of expensive function calls. When it is full and a new item arrives, something must go. LRU (least recently used) evicts the item that has gone unused for the longest time, betting that recently used items will be used again soon. The same idea appears in page replacement and in CPU caches.

    The challenge is to make both operations O(1):

    • get(key): return the value (or −1 if absent) and mark the key as just used.
    • put(key, value): insert or update, and if the cache is over capacity, evict the least recently used key.

    Two structures working together

    Structure Gives
    Hash map key → node find any key’s node in O(1)
    Doubly linked list of nodes the usage order: front = most recent, back = least recent; move or remove any node in O(1)
    HEAD ⇄ [MRU] ⇄ … ⇄ [LRU] ⇄ TAIL          map: key → its node in the list

    HEAD and TAIL are sentinel (dummy) nodes, so a real node always has neighbours on both sides and no special cases are needed.

    • get(key): look up the node in the map; unlink it; push it to the front; return its value.
    • put(key, value): if the key exists, update and move to the front. Otherwise, if the cache is full, remove tail.prev (the LRU node) from the list and from the map; then create a node, add it to the map and push it to the front.

    Why doubly linked?

    Moving a node to the front means unlinking it from the middle: its previous node must point past it. A doubly linked node knows its previous node, so that is O(1). In a singly linked list you would have to walk from the head to find the previous node, which is O(n).

    The classic example (capacity 2)

    Operation List (front → back) Returns Note
    put(1, 1) 1
    put(2, 2) 2, 1
    get(1) 1, 2 1 1 moves to the front
    put(3, 3) 3, 1 full: evict 2 (the back)
    get(2) 3, 1 −1 miss
    put(4, 4) 4, 3 full: evict 1
    get(1) 4, 3 −1 miss
    get(3) 3, 4 3
    get(4) 4, 3 4

    The gets return 1, −1, −1, 3, 4.

    Variations

    Policy Evicts Note
    LRU least recently used the standard choice
    LFU least frequently used needs use counts (O(1) with frequency lists)
    FIFO the oldest inserted ignores how often an item is used
    Clock / second chance approximates LRU with a reference bit used by operating systems
    TTL items older than a time limit used by web caches and DNS

    Code

    class Node:
        def __init__(self, key=None, value=None):
            self.key, self.value = key, value
            self.prev = self.next = None
    
    class LRUCache:
        """Hash map (key -> node) + doubly linked list (most recent at the front)."""
        def __init__(self, capacity):
            self.capacity, self.map = capacity, {}
            self.head, self.tail = Node(), Node()          # sentinels: no special cases at the ends
            self.head.next, self.tail.prev = self.tail, self.head
    
        def _unlink(self, node):                           # O(1): the node knows both neighbours
            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                                  # miss
            node = self.map[key]
            self._unlink(node)                             # hit: it is now the most recently used
            self._push_front(node)
            return node.value
    
        def put(self, key, value):
            if key in self.map:                            # update: change the value, move to the front
                node = self.map[key]
                node.value = value
                self._unlink(node)
                self._push_front(node)
                return
            if len(self.map) == self.capacity:             # full: evict the least recently used (back)
                lru = self.tail.prev
                self._unlink(lru)
                del self.map[lru.key]
            node = Node(key, value)
            self.map[key] = node
            self._push_front(node)
    
        def keys_by_recency(self):
            out, n = [], self.head.next
            while n is not self.tail:
                out.append(n.key)
                n = n.next
            return out
    
    cache = LRUCache(2)
    results = []
    for op in [('put', 1, 1), ('put', 2, 2), ('get', 1), ('put', 3, 3), ('get', 2), ('put', 4, 4), ('get', 1), ('get', 3), ('get', 4)]:
        if op[0] == 'put':
            cache.put(op[1], op[2])
        else:
            results.append(cache.get(op[1]))
    print(results, cache.keys_by_recency())
    # [1, -1, -1, 3, 4] [4, 3]

    In real Python code, collections.OrderedDict (with move_to_end and popitem(last=False)) does the same in a few lines, and the decorator functools.lru_cache(maxsize=…) caches function results with LRU eviction. The class above was checked against an OrderedDict version on 300 random sequences of operations.

    Common mistakes

    • Forgetting to update the order on get. A read counts as a use; the key must move to the front.
    • Evicting from the wrong end. New and recently used items go to the front, so the victim is at the back (tail.prev).
    • Removing the node from the list but not from the map (or the other way round). Both must stay in sync.
    • Evicting before checking whether the key already exists. Updating an existing key must never evict anything.
    • Using a singly linked list. Then unlinking a middle node is O(n).

    Complexity at a glance

    Case / operationTimeWhy
    get(key)O(1)Hash-map lookup, then unlink and push to the front.
    put(key, value)O(1)Possibly evict the node just before TAIL, then insert at the front.
    Find the least recently used itemO(1)It is always tail.prev.
    Extra spaceO(capacity)

    Quick check

    Test yourself — pick an answer to see if you got it.

    1. An LRU cache with capacity 2 runs put(1,1), put(2,2), get(1), put(3,3). Which key is evicted?

    2. Why does an LRU cache use a hash map?

    3. Why must the linked list be DOUBLY linked?

    4. What are the HEAD and TAIL sentinel nodes for?

    5. Which Python standard-library tools behave like an LRU cache?

    Saved in this browser.
    Spotted a mistake or a bug in the 3D model?

    Report a mistake

    in LRU Cache (Hash Map + Doubly Linked List). Thank you — every report makes the lesson better for the next reader.

    We'll also include a link to the step of the 3D model you're on and your browser type, so we can reproduce it.