Skip to content
DSA advanced 9 min read

LRU Cache: Design with Hash Map + Doubly Linked List

An LRU (Least Recently Used) cache is a fixed-capacity key-value store that, when full, evicts the item that was used least recently to make room for a new one. The design challenge — a classic interview question — is to make both get and put run in O(1). The trick is to combine two structures: a hash map for instant lookup and a doubly linked list to track usage order.

Why two structures?

  • A hash map gives O(1) lookup by key — but has no notion of order.
  • A doubly linked list gives O(1) insertion and removal if you already hold the node — and naturally maintains an ordering (most-recent at the front, least-recent at the back).

Store each value in a list node, and let the hash map point key → node. On any access you can find the node in O(1) (map) and move it to the front in O(1) (list). Eviction removes the back node.

// Sketch of the layout:
// map:  key -> node
// list: [most recent] <-> ... <-> [least recent]
// get(k):  node = map[k]; move node to front; return node->val
// put(k):  if exists, update + move to front;
//          else add at front; if over capacity, drop the back node

Full implementation

We use two sentinel nodes (head and tail) so insertion and removal never hit a null edge case. C++ and Java build the doubly linked list explicitly; JavaScript and Python lean on their insertion-ordered maps (Map / dict), which give the same O(1) behavior with less code.

#include <list>
#include <unordered_map>

class LRUCache {
    int cap;
    std::list<std::pair<int,int>> items;             // front = most recent
    std::unordered_map<int, std::list<std::pair<int,int>>::iterator> pos;
public:
    LRUCache(int capacity) : cap(capacity) {}

    int get(int key) {
        auto it = pos.find(key);
        if (it == pos.end()) return -1;
        items.splice(items.begin(), items, it->second); // move to front
        return it->second->second;
    }

    void put(int key, int value) {
        auto it = pos.find(key);
        if (it != pos.end()) {
            it->second->second = value;
            items.splice(items.begin(), items, it->second);
            return;
        }
        if ((int)items.size() == cap) {              // evict least recent
            pos.erase(items.back().first);
            items.pop_back();
        }
        items.emplace_front(key, value);
        pos[key] = items.begin();
    }
};

Going deeper: Python’s OrderedDict.move_to_end and JS’s delete-then-set both achieve O(1) reordering; under the hood they’re doing exactly what the explicit C++/Java linked list does. The explicit version is worth knowing for interviews that ban built-in ordered maps.

Complexity

OperationTimeSpace
getO(1)
putO(1)
TotalO(capacity)

Common pitfalls

  • Forgetting to reorder on get. A read counts as a use — move the item to the front, or your eviction order is wrong.
  • Updating an existing key. A put on a present key must refresh both the value and its recency; don’t double-insert.
  • Evicting before inserting the new key. Check capacity and evict the back node first, then add the new front node.
  • Stale map entries. When you evict a list node, remove its key from the map too, or lookups will dereference dead nodes.

When to use it

LRU is the default eviction policy for caches everywhere — CPU caches, database buffer pools, web/CDN caches, and memoization with a memory budget. Want to build one end to end? See the LRU cache project, or practice the design on the advanced exercises.

Last updated June 25, 2026
Was this helpful?