Blind Plus 100 Visualized

#98 ยท Design

LRU Cache

Design a data structure for a Least Recently Used cache with get(key) and put(key, value) running in O(1) average time. When the capacity is exceeded, evict the least recently used key.

MediumLeetCode
  • Current node
  • Link just rewired
  • Done / in result
  • Marked
  • Rejected
  • Dummy head
step 1 / 1

Pointers

Variables

Hash map / table

Stack / queue

Foundations & data-structure selection

Hash map (key โ†’ node)

Finds the entry for a key in O(1) and, if the value is the list node itself, gives direct access to the position in the recency order, so no searching is needed.

Memory layout

A hash table with at most capacity entries; each value is a pointer or index of a list node.

OperationCost
lookup / insert / deleteO(1) average

Doubly linked list with dummy ends

Keeps entries ordered by recency: the node after the head dummy is most recent, the node before the tail dummy is the next to evict. A node knows both neighbours, so it can be removed in O(1) without a search; dummy nodes make insertion and removal uniform.

Memory layout

Nodes with key, value, prev and next. An array-backed version stores them as parallel arrays and links by index.

OperationCost
unlink / insert at front / read tailO(1)

Free list of slots (array version)

With all nodes preallocated in arrays, a stack of unused slot numbers replaces allocation and garbage collection: evicting returns a slot, inserting takes one.

Memory layout

An integer stack of at most capacity entries.

OperationCost
push / popO(1)

Why it fits

  • Each of the two needs (find by key, reorder by recency) has a structure that does it in O(1); the trick is wiring them together so each points into the other.
  • A singly linked list or an array cannot remove an arbitrary node in O(1); the back pointer of a doubly linked list can.
  • The hash map stores nodes (not values) so a hit leads directly to the node that must be moved to the front.

Why alternatives degrade

Array of entries:
Finding and reordering costs O(n) per operation.
Hash map + array of keys:
Lookups are fast but moving a key to the front shifts the array: O(n).
Min-heap by timestamp:
Updating a timestamp on every hit costs O(log n), and a heap cannot delete by key without extra indexing.

Four approaches, from naive to production

1 ยท beginner

Array of entries, scan on every call

time O(n) per operationspace O(capacity)

Keep [key, value] pairs in an array ordered by recency. get and put scan for the key, remove it, and re-insert at the front; when full, drop the last entry.

class LRUCache {
    list<pair<int, int>> entries; // front = most recently used
    int capacity;
public:
    LRUCache(int capacity) : capacity(capacity) {}
    int get(int key) {
        auto it = find_if(entries.begin(), entries.end(), [&](const pair<int, int>& e) { return e.first == key; });
        if (it == entries.end()) return -1;
        pair<int, int> entry = *it; entries.erase(it);
        entries.push_front(entry);
        return entry.second;
    }
    void put(int key, int value) {
        auto it = find_if(entries.begin(), entries.end(), [&](const pair<int, int>& e) { return e.first == key; });
        if (it != entries.end()) entries.erase(it);
        else if ((int)entries.size() == capacity) entries.pop_back();
        entries.push_front({key, value});
    }
};

Flaws & bottlenecks

  • Every call scans the array and shifts elements: O(n).
  • Capacity 10โต with 10โต operations is about 10ยนโฐ steps.
  • No separate fast path for misses (they still scan everything).

Interviewer cross-questions

Why order by recency instead of storing timestamps?

Say: The position encodes recency, so the victim is simply the last element; timestamps would need a scan or a heap to find the oldest.

Does put on an existing key count as a use?

Say: Yes: it updates the value and makes the key the most recently used, as in this code.

How do you make lookups fast?

Say: Add a hash map from key to value (or node).

2 ยท intermediate

Hash map for values + array for recency

time O(1) lookup, O(n) reorderspace O(capacity)

A map answers get hits and misses in O(1) and holds the values; a separate array of keys records recency. touch() finds the key in the array, removes it and puts it at the front.

class LRUCache {
    unordered_map<int, int> values;
    list<int> order; // front = most recently used
    int capacity;
    void touch(int key) {
        auto it = find(order.begin(), order.end(), key);
        if (it != order.end()) order.erase(it);
        order.push_front(key);
    }
public:
    LRUCache(int capacity) : capacity(capacity) {}
    int get(int key) {
        if (!values.count(key)) return -1;
        touch(key);
        return values[key];
    }
    void put(int key, int value) {
        if (!values.count(key) && (int)values.size() == capacity) {
            int victim = order.back(); order.pop_back();
            values.erase(victim);
        }
        values[key] = value;
        touch(key);
    }
};

Flaws & bottlenecks

  • touch() still searches and shifts the order array: O(n).
  • Two structures that must be kept in sync (map and order).
  • A miss is O(1), but a hit is O(n).

Interviewer cross-questions

Why is a miss cheaper than a hit?

Say: A miss ends at the hash map check; a hit must also move the key in the order array.

How can the reorder be O(1)?

Say: Replace the order array by a doubly linked list and store the list node in the map, so no search is needed.

Could a language-provided ordered map do this?

Say: Yes: structures that keep insertion order (JavaScript Map, Python OrderedDict, Java LinkedHashMap) are exactly this combination internally.

3 ยท advanced

Hash map + doubly linked list

time O(1) per operationspace O(capacity)

The map stores key โ†’ node. The list holds nodes from most to least recent between dummy head and tail. get unlinks the node and re-inserts it after the head; put evicts tail.prev when full.

struct Node {
    int key, value; Node *prev, *next;
    Node(int k = 0, int v = 0) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
class LRUCache {
    unordered_map<int, Node*> map_;
    Node* head = new Node();
    Node* tail = new Node();
    int capacity;
    void remove(Node* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }
    void addFront(Node* node) {
        node->next = head->next;
        node->prev = head;
        head->next->prev = node;
        head->next = node;
    }
public:
    LRUCache(int capacity) : capacity(capacity) {
        head->next = tail;
        tail->prev = head;
    }
    int get(int key) {
        auto it = map_.find(key);
        Node* node = it == map_.end() ? nullptr : it->second;
        if (!node) return -1;
        remove(node);
        addFront(node);
        return node->value;
    }
    void put(int key, int value) {
        auto it = map_.find(key);
        Node* existing = it == map_.end() ? nullptr : it->second;
        if (existing) {
            existing->value = value;
            remove(existing);
            addFront(existing);
            return;
        }
        if ((int)map_.size() == capacity) {
            Node* victim = tail->prev;
            remove(victim);
            map_.erase(victim->key);
            delete victim;
        }
        Node* node = new Node(key, value);
        map_[key] = node;
        addFront(node);
    }
};

Flaws & bottlenecks

  • One heap-allocated node per entry: allocation and garbage-collection cost.
  • Pointer surgery is easy to get wrong (order of the four assignments).
  • Not thread safe: concurrent hits would race on the list.

Interviewer cross-questions

Why dummy head and tail nodes?

Say: They guarantee every real node has a prev and a next, so remove and addFront need no null checks or special cases for the first and last node.

Why store the key inside the node?

Say: When the tail node is evicted we must also delete its key from the hash map; the node tells us which key that is.

How would you make it thread safe?

Say: Guard every get and put with one lock (both mutate the list), or shard the cache by key hash so each shard has its own lock.

4 ยท expert

Array-backed list with a free list

time O(1) per operationspace O(capacity), allocated once

Store nodes in preallocated arrays (key, value, prev, next) addressed by slot number; link them by index; recycle slots through a free stack. Clamp non-positive capacity so put does nothing and get always misses.

class LRUCache {
    unordered_map<int, int> slotOf;
    vector<int> keyAt;
    vector<int> valAt;
    vector<int> prev;
    vector<int> next;
    vector<int> freeSlots;
    int head, tail;
    int capacity;
    void unlink(int s) {
        next[prev[s]] = next[s];
        prev[next[s]] = prev[s];
    }
    void linkFront(int s) {
        int first = next[head];
        next[s] = first;
        prev[s] = head;
        prev[first] = s;
        next[head] = s;
    }
public:
    LRUCache(int capacity) : capacity(capacity) {
        int slots = max(capacity, 0) + 2;
        keyAt.assign(slots, 0);
        valAt.assign(slots, 0);
        prev.assign(slots, -1);
        next.assign(slots, -1);
        head = slots - 2;
        tail = slots - 1;
        next[head] = tail;
        prev[tail] = head;
        for (int s = slots - 3; s >= 0; s--) freeSlots.push_back(s);
    }
    int get(int key) {
        auto it = slotOf.find(key);
        int s = it == slotOf.end() ? -1 : it->second;
        if (s == -1) return -1;
        unlink(s);
        linkFront(s);
        return valAt[s];
    }
    void put(int key, int value) {
        if (capacity <= 0) return;
        auto it = slotOf.find(key);
        int s = it == slotOf.end() ? -1 : it->second;
        if (s != -1) {
            valAt[s] = value;
            unlink(s);
            linkFront(s);
            return;
        }
        if (freeSlots.empty()) {
            int victim = prev[tail];
            unlink(victim);
            slotOf.erase(keyAt[victim]);
            freeSlots.push_back(victim);
        }
        s = freeSlots.back(); freeSlots.pop_back();
        keyAt[s] = key;
        valAt[s] = value;
        slotOf[key] = s;
        linkFront(s);
    }
};

Flaws & bottlenecks

  • More code and index bookkeeping than the pointer version.
  • Fixed capacity: resizing would need a rebuild.
  • The hash map is still a general-purpose map; an open-addressing table would remove the last allocations.

Interviewer cross-questions

Why use arrays instead of node objects?

Say: No allocation or GC pauses on the hot path, better cache locality, and a compact, predictable memory footprint.

What does the free list do?

Say: It holds slot numbers not currently used. Eviction pushes the victim's slot; insertion pops one, so exactly capacity slots are ever needed.

What are the edge cases?

Say: Capacity 1 (every new key evicts the previous), capacity 0 (nothing is stored), updating an existing key (no eviction), and reading a key just after it was evicted (miss).

Synchronized code walkthrough

Showing: Array of entries, scan on every call โ€” shares the player above; click any step to jump.

class LRUCache {
    list<pair<int, int>> entries; // front = most recently used
    int capacity;
public:
    LRUCache(int capacity) : capacity(capacity) {}
    int get(int key) {
        auto it = find_if(entries.begin(), entries.end(), [&](const pair<int, int>& e) { return e.first == key; });
        if (it == entries.end()) return -1;
        pair<int, int> entry = *it; entries.erase(it);
        entries.push_front(entry);
        return entry.second;
    }
    void put(int key, int value) {
        auto it = find_if(entries.begin(), entries.end(), [&](const pair<int, int>& e) { return e.first == key; });
        if (it != entries.end()) entries.erase(it);
        else if ((int)entries.size() == capacity) entries.pop_back();
        entries.push_front({key, value});
    }
};