Low-Level Design · Note 4 of 9
Design an LRU Cache
Quick answer
How do you design an LRU cache with O(1) get and put?
Combine a hash map with a doubly linked list. The list keeps entries in order of use, most recent after a head sentinel and least recent before a tail sentinel; the map takes a key straight to its list node. A get moves the node to the front, a put adds at the front, and a full cache evicts the node before the tail, all in O(1).
On this page
A cache of fixed capacity that, when full, throws out the least recently used entry. It is the most common machine-coding question because it tests data-structure choice and clean classes in under an hour.
Requirements
get(key): the value, or a miss. A hit counts as a use.put(key, value): insert or update. An update counts as a use.- Fixed
capacity; when full, a new key evicts the least recently used one. - Both operations O(1). This one line decides the design.
- Clarify: value type, behaviour on capacity 0, single-threaded or not.
Hash map + doubly linked list
- Hash map
key → node: finds any entry in O(1), but has no order. - Doubly linked list: keeps order of use; moving or removing a known node is O(1).
- Together: the map finds the node, the list reorders it.
- Sentinels
headandtailremove every null check at the ends.
LRUCache is the class callers see: get and put, a fixed capacity, and two members that between them make both operations O(1).
Transcript · 4 steps
| Operation | Map | List |
|---|---|---|
| get hit | lookup | move node to front |
| put new | insert | add at front |
| put when full | delete evicted key | remove node before tail |
| put existing | lookup | update value, move to front |
Watch it run
Capacity 3, the same ten operations as the code below.
put(1, A): a new node is linked in right after head and its key goes in the map. 1 of 3 slots used; order 1.
Transcript · 10 steps
Moving a node in O(1)
getunlinks the node (2 pointer writes) and adds it after head (4 writes).- No walk through the list, whatever its length.
Before get(3) the order is 4, 1, 3, so key 3 is the least recently used. The map hands us node 3 directly: no search through the list.
Transcript · 4 steps
put on a full cache
The client calls put(4, D) on a cache holding 1, 3, 2 in that order, with all three slots used.
Transcript · 8 steps
- Evict before inserting, so the size never exceeds capacity.
- The evicted node carries its key, which is how the map entry is found.
Code
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
struct Node {
int key;
string value;
Node* prev = nullptr;
Node* next = nullptr;
Node(int k, string v) : key(k), value(v) {}
};
class LRUCache {
int capacity;
unordered_map<int, Node*> map;
Node* head = new Node(0, ""); // sentinels: no null checks at the ends
Node* tail = new Node(0, "");
void unlink(Node* n) { n->prev->next = n->next; n->next->prev = n->prev; }
void addFirst(Node* n) {
n->prev = head; n->next = head->next;
head->next->prev = n; head->next = n;
}
public:
explicit LRUCache(int cap) : capacity(cap) { head->next = tail; tail->prev = head; }
// Returns false on a miss.
bool get(int key, string& out) {
auto it = map.find(key);
if (it == map.end()) return false;
unlink(it->second); addFirst(it->second);
out = it->second->value;
return true;
}
// Returns the evicted key, or -1.
int put(int key, const string& value) {
auto it = map.find(key);
if (it != map.end()) {
it->second->value = value;
unlink(it->second); addFirst(it->second);
return -1;
}
int evicted = -1;
if ((int)map.size() == capacity) {
Node* last = tail->prev;
unlink(last); map.erase(last->key);
evicted = last->key;
delete last;
}
Node* n = new Node(key, value);
addFirst(n); map[key] = n;
return evicted;
}
void printOrder() {
cout << "order (most to least recent):";
for (Node* n = head->next; n != tail; n = n->next) cout << " " << n->key;
cout << "\n";
}
};
int main() {
LRUCache cache(3);
auto put = [&](int k, string v) {
int e = cache.put(k, v);
cout << "put(" << k << ", " << v << ")";
if (e != -1) cout << " evicts " << e;
cout << "\n";
};
auto get = [&](int k) {
string v;
cout << "get(" << k << ") = " << (cache.get(k, v) ? v : "miss") << "\n";
};
put(1, "A"); put(2, "B"); put(3, "C"); get(1); put(4, "D");
get(2); get(3); put(1, "Z"); put(5, "E"); get(4);
cache.printOrder();
}Output
put(1, A) put(2, B) put(3, C) get(1) = A put(4, D) evicts 2 get(2) = miss get(3) = C put(1, Z) put(5, E) evicts 4 get(4) = miss order (most to least recent): 5 1 3
Extensions
- Thread safety: even
getwrites (it reorders), so a read-write lock does not help; use one mutex around both, or shard keys across several locked LRUs. - TTL: store an expiry time in each node;
gettreats an expired node as a miss and removes it. Lazy expiry costs nothing extra; a background sweep frees memory sooner. - Eviction policy as a Strategy: put LRU behind an
EvictionPolicyinterface so LFU or FIFO is a new class (design patterns). - Library shortcut:
LinkedHashMap(Java) andOrderedDict(Python) already do this; mention them, then build it.
Interview questions
- Why not a heap of timestamps? Updating a node's position is O(log n), not O(1).
- Why sentinels? Insert and unlink never meet a null, so there are no edge cases.
- What breaks with a singly linked list? Unlinking needs the previous node, which costs a walk.
- How do you test it? Capacity 1, updating an existing key, a miss, and evicting right after a get.
Practise it: LRU Cache.
Next: Design a Parking Lot · Before this: Design Patterns in LLD
Common questions
Why does an LRU cache need a doubly linked list rather than a singly linked one?
Moving or removing a node needs its predecessor. With a prev pointer the node found through the map unlinks itself in O(1); a singly linked list would have to walk from the head to find the predecessor, which makes every get O(n).
Why does each node store the key as well as the value?
On eviction the cache takes the last node off the list and must also delete that entry from the map. The node is all it has at that moment, so the node has to carry its key.
What are sentinel nodes in an LRU cache?
Head and tail are dummy nodes that are never removed. Every real node always has a non-null prev and next, so insert and unlink need no special cases for an empty list or for the first and last entry.
Can I use LinkedHashMap or OrderedDict instead?
In real code, yes: Java's LinkedHashMap with access order and removeEldestEntry, or Python's OrderedDict with move_to_end and popitem(last=False), give an LRU cache in a few lines. In an interview, say so, then build the map and list yourself, because that is what is being tested.
How do you make an LRU cache thread-safe?
The simplest correct answer is one lock around get and put, since even get changes the list. Higher-throughput caches split keys into shards, each with its own lock and its own LRU list, at the cost of eviction being least recent per shard rather than globally.
What is the difference between LRU and LFU?
LRU evicts the entry used longest ago; LFU evicts the entry used least often. LRU adapts quickly when the working set shifts, while LFU protects entries that are popular over time but needs counters and more bookkeeping to stay O(1).