← All Posts
DSA · Linked Lists · Part 19 of 28

LRU Cache: Hash Map + Doubly Linked List

The LRU cache is the single most asked "design a data structure" question, and for good reason: it is the smallest problem that forces you to compose two structures because neither one alone can meet the requirements. Solve it properly and you have the template for every hybrid structure that follows. This is LeetCode 146 — but we will derive the answer, not memorise it.

Start From the Requirements

The specification is three lines, and every design decision falls out of them:

Two demands are in tension. "O(1) lookup by key" screams hash map. "Evict the least-recently-used" screams an ordering by recency — which a hash map does not have. No single textbook structure gives you both O(1) keyed access and O(1) maintenance of a recency order. So you compose.

Why Two Structures, and How They Fit

Look at each half honestly:

StructureLookup by keyMaintain recency order
Hash mapO(1) ✓none — unordered
Array / vectorO(1) by index, O(n) by keyreorder is O(n) (shifting)
Doubly linked listO(n) — must walkO(1) move-to-front / remove-back ✓

The list can hold entries in recency order — most-recently-used at the front, least at the back — and move any node it already holds to the front in O(1). Its only weakness is finding a node by key. But that is exactly the hash map's strength. So the combination is:

A doubly linked list ordered by recency, and a hash map from key to the node pointer in that list. The map answers "where is this key's node?" in O(1); the list answers "reorder this node" and "who is least-recently-used?" in O(1). Neither stores the data twice — the map stores a pointer to the one node the list owns.

This is the payoff of a point made back in the memory model: a linked list is at its best precisely when you already hold a pointer to the node you want to move, because then the reorder is a couple of pointer writes and the O(n) walk never happens. The hash map exists to guarantee that precondition — every operation starts by looking up the node it needs, so the list is only ever asked to do the O(1) thing it is good at.

Why a Singly Linked List Cannot Work

This is the most important sentence in the whole problem, and the one interviewers probe. Both get and eviction must remove a node from the middle or back of the list in O(1). To unlink a node n you must connect n's predecessor to n's successor — which means you need a pointer to the predecessor. A singly linked list only stores next, so finding the predecessor of an arbitrary node requires walking from the head: O(n). Every promotion would be linear, and the whole O(1) guarantee collapses.

The interview line: "I use a doubly linked list, not singly, because promotion and eviction both unlink an interior node, and unlinking is O(1) only if the node knows its predecessor. A singly linked list would force an O(n) walk to find prev." Saying this unprompted is often the whole signal.

From Scratch: Two Sentinels, No Branches

Build the doubly linked list with two dummy nodes — a head sentinel and a tail sentinel that never hold data. Their entire purpose is to remove edge cases: with sentinels, every real node always has a non-null prev and next, so addFront and remove are branch-free — no "is this the first node?" or "is this the last node?" checks ever.

class LRUCache {
    struct Node {
        int key, val;
        Node* prev;
        Node* next;
        Node(int k, int v) : key(k), val(v), prev(nullptr), next(nullptr) {}
    };

    int cap;
    std::unordered_map<int, Node*> table;   // key -> node in the list
    Node* head;   // sentinel; head->next is the MRU node
    Node* tail;   // sentinel; tail->prev is the LRU node

    void remove(Node* n) {                  // O(1), branch-free
        n->prev->next = n->next;
        n->next->prev = n->prev;
    }
    void addFront(Node* n) {                // O(1), becomes MRU
        n->next = head->next;
        n->prev = head;
        head->next->prev = n;
        head->next = n;
    }

public:
    LRUCache(int capacity) : cap(capacity) {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head->next = tail;
        tail->prev = head;
    }
    ~LRUCache() {                           // own the nodes: free them all
        Node* p = head;
        while (p) { Node* nx = p->next; delete p; p = nx; }
    }

    int get(int key) {
        auto it = table.find(key);
        if (it == table.end()) return -1;   // miss -> -1
        Node* n = it->second;
        remove(n);
        addFront(n);                        // a read is a use: promote
        return n->val;
    }

    void put(int key, int value) {
        if (cap == 0) return;               // capacity-0 stores nothing
        auto it = table.find(key);
        if (it != table.end()) {
            Node* n = it->second;
            n->val = value;
            remove(n);
            addFront(n);                    // update is also a use: promote
            return;
        }
        if ((int)table.size() == cap) {     // full: evict LRU BEFORE inserting
            Node* lru = tail->prev;
            remove(lru);
            table.erase(lru->key);
            delete lru;
        }
        Node* n = new Node(key, value);
        table[key] = n;
        addFront(n);
    }
};

Every operation is a constant number of pointer writes plus one hash lookup: O(1) time for both get and put. The destructor walks the chain once and deletes every node including the sentinels — the cache owns its nodes, so it must free them.

▶ A Capacity-3 Cache in Motion

Front is MRU, back is LRU. Each Step issues one operation; a get or updating put promotes to the front, and a put at capacity evicts the back node (shown fading red). The side panel is the hash map.

Dry Run: Capacity 2

Trace put(1,1) put(2,2) get(1) put(3,3) get(2) put(4,4) get(1) get(3) get(4), writing the list most-recently-used first:

OperationReturnsList (MRU → LRU)Evicted
put(1,1)[1]
put(2,2)[2, 1]
get(1)1[1, 2]
put(3,3)[3, 1]2
get(2)-1[3, 1]
put(4,4)[4, 3]1
get(1)-1[4, 3]
get(3)3[3, 4]
get(4)4[4, 3]

Note get(1) at step 3 is what saves key 1 from eviction at step 4 — had it not been touched, 2 would have survived instead. Recency is entirely about order of last touch.

The std::list::splice One-Liner

In production C++ you rarely hand-roll the list. std::list is a doubly linked list, and splice moves a node between positions without allocating, copying, or invalidating iterators. Store key → iterator and promotion becomes one line:

class LRUCache {
    int cap;
    std::list<std::pair<int, int>> items;   // front = MRU, back = LRU
    std::unordered_map<int, std::list<std::pair<int, int>>::iterator> table;

public:
    LRUCache(int capacity) : cap(capacity) {}

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

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

The reason this works — and the reason you cannot swap in a std::vector — is iterator stability. The map stores iterators into the list. std::list::splice only relinks pointers, so the iterator to a moved element stays valid and keeps pointing at the same element. A std::vector stores elements contiguously; any insert or growth can reallocate the buffer and invalidate every iterator, pointer, and reference — so the map's stored positions would rot the moment the vector resized. Stability under structural change is precisely why the list is the correct container here.

Correctness Details People Miss

What Production Caches Actually Do

The textbook LRU is rarely shipped verbatim. Real systems bend it:

The theme: exact LRU needs O(1) bookkeeping on every access, and at scale that bookkeeping — the pointer writes, the lock, the cache-line bouncing — costs more than the small hit-rate gain over a good approximation. So production trades exactness for cheaper, contention-free updates.

Complexity and Memory

OperationTimeWhy
getO(1)one hash lookup + constant pointer surgery.
putO(1)hash lookup + at most one eviction, all O(1).
MemoryO(capacity)one node + one map entry per stored key.

Check Yourself

Six situations. Pick the statement that is actually true.

Practice