LRU Cache
Introduction
Caches live at every level of modern computing — L1/L2/L3 on the CPU, page cache in the kernel, buffer pools in databases, HTTP caches in the browser, memoization tables in your code. They all share the same problem: memory is finite, so when the cache fills up, something has to go. Least Recently Used is the most common policy: evict whatever hasn’t been touched for the longest time.
Implementing LRU is not an algorithm problem. It is a data-structure composition problem. Your job is to pick two primitives whose operations are individually and cross-link them so that every cache operation stays . That composition move — hash map for keyed lookup, doubly-linked list for ordered mutation, iterators to bridge them — shows up everywhere in systems code. Interviewers reach for LRU because recognizing it separates candidates who think in terms of algorithms from candidates who think in terms of data structures.
When no single data structure supports your operations at the target complexity, cross-link two that do.
If you’ve never composed stdlib containers this way, read the constant-time approach slowly. If you have, skip ahead to the splice and iterator-stability subsection — that’s where the interview signal hides.
What the interviewer is actually testing
Four signals, in priority order:
- API instinct. Do you pin down the semantics of
geton a miss,geton a hit (does it update order?),puton an existing key, andputat capacity — before writing a line? - Composition. Do you spot that no single standard container supports both lookup-by-key and ordered mutation? The leap from “I need one DS” to “I need two, cross-linked” is the core move.
- Iterator / pointer invariants. When you store a
std::listiterator inside a hash map, do you know why it stays valid acrosssplice,push_front, and neighbor erasures? If you don’t, you’ll write a subtle dangling-iterator bug. - Idiomatic C++.
splice,emplace_front,std::optionalreturns, move-only values,list::iteratoras a value type — fluency here is the senior signal.
Problem statement
Design a class LRUCache parameterized by a capacity . It must expose two operations, both running in time on average:
get(key)— returns the value forkeyif present, otherwise a sentinel (std::nulloptin our C++ port,-1in the LeetCode variant). A hit also markskeyas most-recently-used.put(key, value)— inserts or updates the pair. If inserting would exceed capacity, evict the least-recently-used entry first.
cap = 1; put(1, A); get(1); put(2, B); get(1); get(2) get(1) → A; get(1) → nullopt; get(2) → B cap = 2; put(a,1); put(b,2); get(a); get(b); put(c,3); get(a); get(b); get(c) get(a) → 1; get(b) → 2; get(a) → nullopt; get(b) → 2; get(c) → 3 cap = 2; put(1, A); put(2, B); put(1, A2); put(3, C) Cache state after each put: {1:A}, {2:B, 1:A}, {1:A2, 2:B}, {3:C, 1:A2} Clarifying questions — and why each one matters
LRU has more moving pieces than most one-function problems. Pinning semantics up front saves you from rewriting code mid-interview.
Clarification questions
- Q Does
geton a hit update the LRU order? - A The single most load-bearing semantic question. Standard answer: yes — a hit promotes the entry to most-recently-used. If the interviewer says no, the problem is almost trivial (you only need the insertion order, which a plain queue gives you).
- Q What does
getreturn on a miss? - A Ask. LeetCode's canonical variant returns
-1. An idiomatic C++ API returnsstd::optional<V>. Either is fine; pick one and be consistent. - Q Can the capacity be 0 or negative?
- A Usually n ≥ 1 is guaranteed. If not, decide: is cap = 0 a no-op cache (every put evicts nothing and every get misses), or an error? The constant-time code below is defensive to cap ≥ 1.
- Q Are keys unique?
- A Yes — a cache is a map, not a multimap. A second
putwith the same key updates; it does not insert a duplicate. - Q What types are keys and values? Are keys hashable?
- A For the canonical solution you need
std::hash<K>andoperator==on keys. Any LeetCode-style integer keys qualify. If keys are a custom type, ask whether the interviewer wants to see a custom hasher. - Q Is this thread-safe?
- A Interview default: no. Mention that concurrent access needs external synchronization or a more involved lock-free design. Don't write that code unless asked.
A mental framework — why one data structure is not enough
List every -capable container in the standard library. Hash maps give you keyed lookup. Arrays give you random access. Doubly-linked lists give you insertion, deletion, and move-to-front when you already hold a pointer into them.
Now list the operations we need:
| Operation | What it needs |
|---|---|
get hit | Find the node by key; move it to the MRU end. |
get miss | Confirm the key is not present. |
put new | Insert at MRU; possibly evict the LRU. |
put existing | Find the node; update the value; promote. |
| Evict | Find and remove the tail (LRU). |
Every row needs two things: a keyed lookup AND an ordered mutation. No single container covers both in :
- A hash map alone has no notion of order.
- A linked list alone has no notion of keyed lookup.
- An ordered map (like
std::map) gets you keyed access to sorted-by-timestamp data — better than linear, but still not constant.
The move: use two containers and cross-link them. The hash map resolves the key to a node in the list in . The list maintains the MRU → LRU order and allows splicing to its front. Neither half works alone; together they do.
Discussion
Brute force — array plus linear scan
A vector holding entries in MRU → LRU order. Every operation scans linearly. Correct, slow, your baseline.
The interview narration
Say this out loud:
“The straightforward way is a vector of
(key, value)pairs kept in MRU-to-LRU order. Bothgetandputscan for the key, then shuffle the entry to the front — so each is . I’ll code this as a baseline, then find something faster.”
How it works, step by step
The data structure is a single vector, ordered so that the most-recently-used entry sits at index 0 and the least-recently-used entry sits at the back. That ordering is the entire state — we can reconstruct the LRU policy from it alone.
get(key): walk the vector with std::find_if looking for the matching key. If we miss, return nullopt. If we hit, we’ve found the entry; remove it from its current position and re-insert at the front. Return the value. The “promotion to MRU” is physically moving the entry in the vector.
put(key, value): first, scan for the key. Three cases fall out:
- Key found (update): erase the old entry, then insert the new
(key, value)at the front. Size is unchanged. The old value is discarded. - Key absent, cache has room: insert
(key, value)at the front. Size grows by one. - Key absent, cache at capacity: drop the tail with
pop_back, then insert the new entry at the front. This is the eviction case — the LRU is exactly the last element of the vector.
Notice how the position in the vector is the LRU metadata. We never need a timestamp, a counter, or an external ordering. That’s the one nice property to carry into the better solutions: we only need an ordering, not a clock.
The code
#include <algorithm>#include <cstddef>#include <optional>#include <utility>#include <vector>
template <typename K, typename V>class LRUCacheBrute { public: explicit LRUCacheBrute(std::size_t capacity) : capacity_(capacity) {}
std::optional<V> get(const K& key) { auto it = find(key); if (it == items_.end()) return std::nullopt; auto entry = std::move(*it); items_.erase(it); items_.insert(items_.begin(), std::move(entry)); return items_.front().second; }
void put(const K& key, V value) { if (auto it = find(key); it != items_.end()) { items_.erase(it); } else if (items_.size() >= capacity_) { items_.pop_back(); } items_.insert(items_.begin(), {key, std::move(value)}); }
private: using Entry = std::pair<K, V>; std::vector<Entry> items_; // MRU front → LRU back std::size_t capacity_;
auto find(const K& key) { return std::find_if(items_.begin(), items_.end(), [&](const Entry& e) { return e.first == key; }); }};A vector in MRU-first order; find-and-hoist on every access. Linear time per op but shows the semantics crisply.
Why both operations are
Two distinct linear costs contribute:
std::find_ifon the vector scans up to entries. If we miss onget, we touched every element. Cost: .std::vector::insert(begin(), …)shifts every existing element right by one slot to make room. Cost: .
Both operations do both of those, so the worst case is work — which is still . Even the “lucky hit at position 0” case does a linear insert because we erase then re-insert at the front. If you replace the vector with a std::deque, the insert-at-front is but the scan is still linear — you’ve only cut the constant, not the asymptote.
Edge cases the mechanics handle for free
- Empty cache:
std::find_ifreturnsend(), we returnnullopt. No special case needed. puton existing key: the erase-then-insert dance updates the value and promotes in one path. We never special-case “exists” vs “new” beyond the size check.- Capacity 1: the vector holds at most one entry. Every new-key
putpops the old and pushes the new. No off-by-one traps becausepop_backon a non-empty vector is always valid.
Why it’s rejected
Production caches serve millions of get per second. per access doesn’t scale. And as a signal: we haven’t exercised any actual algorithmic insight yet — this is the version the interviewer wants us to improve, not to ship.
Log-time — ordered map on timestamps
Hash map for keyed lookup + std::map keyed on a monotonic timestamp for LRU retrieval. O(log n) per op. A useful intermediate stop.
The insight
We can trivially get keyed lookup with std::unordered_map. The hard part is ordering — finding the least-recently-used entry cheaply, and updating the “last touched” order on every access.
The move here: attach a monotonic timestamp to every entry. A global counter clock_ is incremented on every touch. Keeping the keys indexed both by key (for lookup) and by timestamp (for LRU retrieval) lets us answer both questions logarithmically.
Why two maps
Each of the two containers answers a different question:
unordered_map<K, {V, Stamp}>answers “given this key, what is its value and when was it last touched?” in .map<Stamp, K>answers “what key has the smallest timestamp?” in — it’s justtimes_.begin().
The two maps are mirrors of each other: every live key has exactly one entry in each. Every time we touch a key, we must update both. If they fall out of sync — a timestamp lingering in times_ after the entry is gone — the next eviction picks the wrong key. That invariant (“the two maps name the same set of live keys, and the stamps match”) is what you’d assert in a debug build.
The “touch” dance
Every get hit and every put on an existing key must refresh the timestamp. That refresh is four steps, and the order matters:
- Read the old timestamp from the hash-map entry.
- Erase the old timestamp from
times_(the ordered map). Skipping this step leaves a stale entry that will later be “evicted” for a key that still exists. - Bump the global clock; this is the new timestamp.
- Write the new timestamp back into both the hash-map entry and the ordered map.
In the touch(it) helper, those four steps are exactly the four lines. The critical one is step 2: the old ordered-map entry must be removed before the new one is inserted, or you’ll have two entries claiming the same key.
Walking through put at capacity
put on a new key when the cache is full is the only operation that evicts, and it’s worth tracing:
- Look up the key in
values_. Not found → go to the eviction branch. values_.size() == capacity_→ we must evict.- Read
*times_.begin()— that’s the(smallest stamp, LRU key)pair. - Erase the LRU entry from
values_(by key). - Erase the LRU entry from
times_(by iterator). - Bump the clock; insert the new
(key, value, timestamp)intovalues_and(timestamp, key)intotimes_.
Five of those six steps are or on the hash map. The is the ordered-map work; everything else is constant.
The code
#include <cstddef>#include <map>#include <optional>#include <unordered_map>#include <utility>
template <typename K, typename V>class LRUCacheLog { public: explicit LRUCacheLog(std::size_t capacity) : capacity_(capacity) {}
std::optional<V> get(const K& key) { auto it = values_.find(key); if (it == values_.end()) return std::nullopt; touch(it); return it->second.first; }
void put(const K& key, V value) { if (auto it = values_.find(key); it != values_.end()) { it->second.first = std::move(value); touch(it); return; } if (values_.size() == capacity_) { auto oldest = times_.begin(); values_.erase(oldest->second); times_.erase(oldest); } const Stamp t = ++clock_; values_.emplace(key, std::pair{std::move(value), t}); times_.emplace(t, key); }
private: using Stamp = std::size_t; using ValueIter = typename std::unordered_map<K, std::pair<V, Stamp>>::iterator;
std::unordered_map<K, std::pair<V, Stamp>> values_; std::map<Stamp, K> times_; std::size_t capacity_; Stamp clock_ = 0;
void touch(ValueIter it) { times_.erase(it->second.second); const Stamp t = ++clock_; it->second.second = t; times_.emplace(t, it->first); }};One hash map holds values and their last-touched timestamps; one ordered map holds the reverse, keyed on the timestamp for O(log n) LRU retrieval.
Why it is still beatable
in a cache hot path is measurable. Every get on a hit calls std::map::erase and std::map::emplace, which are two red-black-tree rebalances — allocations, rotations, pointer chases. For a cache serving millions of ops per second, that shows up in flamegraphs. The next approach swaps the ordered map for a doubly-linked list, which gives us the MRU → LRU ordering for free without any tree rebalancing.
Complexity
- Time: per op. Every public op does hash-map work plus one or two ordered-map operations.
- Space: — two containers, each with one entry per live key.
Constant time — std::list + std::unordered_map Recommended
A doubly-linked list of (key, value) pairs in MRU → LRU order. A hash map keyed on K, valued by an iterator into the list. Both get and put are O(1).
The insight, stated cleanly
Take the brute-force idea — a list in MRU → LRU order — and ask: what was expensive? The linear scan to find a key. Replace that scan with a hash-map lookup whose value is an iterator into the list. Now every operation is a constant number of pointer hops:
- Hash-map the key → list iterator.
splicethe node to the front of the list in .- On eviction, read the back of the list, erase from the hash map,
pop_backthe list.
The piece of magic that makes the whole thing work is that std::list iterators are stable across insertions, erasures of other nodes, and splices. The iterator you stored in the hash map ten operations ago still points at the right node today — even though the chain of prev/next pointers around it has been shuffled many times. Storing iterators in the hash map is safe, and that safety is what enables .
Capacity = 2. Cache empty. Both the map and the list hold nothing.
Walking through each operation
Let the list be items_ (holding pair<K, V>) and the map be index_ (holding K → list::iterator). Capacity is . Trace each case:
get(key) — hit. (1) index_.find(key) returns an iterator-into-map. (2) The stored value is a list::iterator pointing at the node for key. (3) items_.splice(items_.begin(), items_, node_it) moves that node to the head — a single pointer relink, no allocation. (4) Return the value. The map entry is never touched; the stored iterator still points at the same node, which is now at the head instead of wherever it was.
get(key) — miss. (1) index_.find(key) returns index_.end(). (2) Return std::nullopt. The list is untouched. This is the cheapest op — one hash lookup.
put(key, value) — existing key. (1) index_.find(key) hits; we have the node iterator. (2) Overwrite the value in place: node_it->second = std::move(value). (3) Splice to head. No eviction, no size change.
put(key, value) — new key, room to spare. (1) index_.find(key) misses. (2) items_.size() < capacity_, so no eviction. (3) items_.emplace_front(key, std::move(value)) constructs a new node at the head. (4) index_.emplace(key, items_.begin()) records the new node’s iterator.
put(key, value) — new key, at capacity. The only case that evicts. Execute in this exact order:
- Read the LRU key:
items_.back().first. This accesses the existing tail node. - Erase the map entry for that key:
index_.erase(/*lru key*/). items_.pop_back()— this destroys the tail node. The iterator that was in the map is now invalidated, but we already removed it in step 2.items_.emplace_front(key, std::move(value))inserts the new node at the head.index_.emplace(key, items_.begin())records its iterator.
The ordering of steps 1–3 is the correctness sensitivity of the whole design. If you pop_back first, step 1’s access is to freed memory (undefined behavior). If you erase the map entry after the pop_back, you’ve temporarily broken invariant #2 (a stored iterator points at a destroyed node) — still technically safe in this flow, but a code-review red flag.
The code
#include <cstddef>#include <list>#include <optional>#include <unordered_map>#include <utility>
template <typename K, typename V>class LRUCache { public: explicit LRUCache(std::size_t capacity) : capacity_(capacity) {}
std::optional<V> get(const K& key) { auto it = index_.find(key); if (it == index_.end()) return std::nullopt; items_.splice(items_.begin(), items_, it->second); return it->second->second; }
void put(const K& key, V value) { if (auto it = index_.find(key); it != index_.end()) { it->second->second = std::move(value); items_.splice(items_.begin(), items_, it->second); return; } if (items_.size() == capacity_) { index_.erase(items_.back().first); items_.pop_back(); } items_.emplace_front(key, std::move(value)); index_.emplace(key, items_.begin()); }
private: using Entry = std::pair<K, V>; using ListIt = typename std::list<Entry>::iterator;
std::list<Entry> items_; // MRU at front, LRU at back std::unordered_map<K, ListIt> index_; // key → node in the list std::size_t capacity_;};The production-quality LRU cache: one list, one hash map, splice to promote, pop_back to evict. Every public op runs a constant number of hash and pointer operations.
C++ features worth naming
Interviewers reward explicit mention of the language tools you’re leaning on.
std::list::splice(pos, self, it)— the move. Detaches the node atitfrom the list and re-links it beforepos. No allocation, no copy, no iterator invalidation. This single call replaces “erase from old position, insert at front.”- Iterator stability.
std::list<T>::iteratorremains valid as long as the node it points at is alive. Splicing a different node, inserting at the front, or erasing the tail all leave your stored iterator pointing at the same node.std::vectorhas no such guarantee — any reallocation invalidates every iterator. emplace_front(k, v)— constructs the pair in place, avoiding one copy.std::optional<V>as return type — signals “may be absent” without needing a sentinel value or exceptions.std::nulloptis the idiomatic miss.- One map, not two. A common first cut stores values in one hash map and iterators in a second. Collapsing to
list<pair<K, V>>plusunordered_map<K, iterator>halves the memory overhead and the lookup count per op.
Correctness, precisely
Invariant. After every operation:
items_holds exactly the live entries in strict MRU → LRU order.index_maps each live key to thestd::listiterator of its node initems_.items_.size() ≤ capacity_.
All three hold after construction (empty). Every public operation preserves them:
gethit: splice moves the node but doesn’t change which nodes are live;index_entries still point at the right nodes; size unchanged.putexisting: we update the value in place (iterator stable) then splice. Same argument.putnew with room: weemplace_fronta new node and insert its iterator intoindex_. Size grows by one — still ≤ capacity.putnew at capacity: we read the tail key, erase fromindex_,pop_backthe list (invalidating only the evicted iterator), then do the “with room” case. The erasedindex_entry matched thepop_backed node.
The subtlety is the order of the eviction steps. If you pop_back before reading the key, you read a dangling reference. If you erase the hash-map entry by the wrong key, the invariant breaks silently. Write the three lines in this exact order:
index_.erase(items_.back().first); // read key, then erase from mapitems_.pop_back(); // erase node (this iterator only)// ... now insert the new entryEdge cases
| Scenario | Behavior |
|---|---|
get on empty cache | Returns nullopt. index_.find misses; no other work. |
get on miss with non-empty cache | Returns nullopt. The list is untouched. |
put on existing key | Value is updated in place; node splices to front. No size change, no eviction. |
put at capacity 1 | Every new key evicts the previous one. Splice is a no-op (already at front). |
Repeated get of the same key | Each call splices; after the first, the node is already at front and splice is a no-op on begin(). |
Complexity
- Time: expected per op, amortized over the hash table’s load factor. Each operation does a constant number of hash lookups, pointer writes, and a single
splice. - Space: for capacity live entries. Each entry costs one list node and one hash-map bucket slot with an iterator.
Approach comparison
| Approach | get | put | Space | When to reach for it |
|---|---|---|---|---|
| Brute force (vector) | Baseline in the narration; never in production. | |||
| Log-time (hash + ordered) | Pedagogical middle step; mention it, don’t code it. | |||
| Constant (list + hash) | Default. The production answer. |
The constant-time solution is what you ship. State it out loud when you close.
Follow-ups to anticipate
| The interviewer asks… | What to do |
|---|---|
”What if std::list isn’t available?” | Hand-roll a doubly-linked list of nodes with prev/next pointers. Same algorithm, more code. Use sentinel head/tail nodes to avoid null checks. |
| ”Make it thread-safe.” | Coarsest: wrap every public method in a std::mutex. Finer: shard the cache by key hash to reduce contention. Lock-free LRU exists but is well beyond an interview. |
| ”Implement LFU instead.” | Different problem. LFU evicts by access count, not recency. The canonical LFU uses a list of lists keyed by frequency — roughly twice the code. Name it, sketch the idea. |
| ”Add TTL (time-to-live) eviction.” | Two eviction triggers now: LRU-on-capacity and TTL-on-time. Add an ordered structure on expiration timestamps (or run a sweep on access). Mention for the TTL side alongside for LRU. |
| ”What if values are large / non-movable?” | Store std::unique_ptr<V> in the list so the list owns one allocation per value. std::move into put. |
| ”Bounded by bytes, not entries.” | Track a running size; on put, evict from the tail until the new entry fits. Same amortized per op (each entry evicted once). |
| ”Concurrent readers, rare writers.” | std::shared_mutex: read-lock on get only if you can split get into a pure-read fast path (a miss or a hit that doesn’t need promotion) and a slow path. Tricky; mention as a design, don’t code. |
| ”LRU-K or ARC?” | Adaptive caches that track longer histories. Out of scope for most interviews but worth naming — signals you’ve read a database textbook. |
Pattern recognition — compose two primitives via an iterator
The move LRU teaches generalizes to any problem where a single container can’t give you every operation in the target complexity:
- O(1) insert + O(1) remove-min on arbitrary elements. Intrusive heap node + a hash map from key to heap-node pointer. Same move: two DSes, one iterator bridge.
- Ordered set with O(1) random sample.
std::unordered_setfor membership, astd::vectorfor sampling, cross-linked via index. See LeetCode “Insert Delete GetRandom O(1).” - LFU cache. Two layers of lists (keyed on frequency) bridged by a hash map.
- Median from a stream. Two heaps (max-heap lower half, min-heap upper half). No hash map needed, but the composition reflex is the same: no single heap gives you the median.
- Disjoint-set / union-find. Parent pointer array + rank-by-root — two arrays composed, one operation uses both.
The reflex you want: when the operations list has two or more entries whose implementations live in different data structures, compose them. Pick the cheapest bridge — usually an iterator, an index, or a pointer — and store it on the side.
What to say out loud — the delivery script
Ten beats. Rehearse until fluid.
- “Let me restate: a cache of capacity with
get(key)andput(key, value), both , with LRU eviction.” - Ask the three clarifying questions: “Does
getupdate order? What’s the return on a miss? Thread-safe?” - “The brute force is a vector in MRU-to-LRU order — per op. Let me name that and move on.”
- “I need keyed lookup AND ordered mutation. No single container does both.”
- “The composition: a doubly-linked list holds the entries in MRU-to-LRU order. A hash map maps each key to its node — specifically, to a
std::listiterator into the list.” - “On
gethit, I splice the node to the front in and return its value. Ongetmiss, I returnnullopt.” - “On
putwith an existing key, I update the value and splice to front. Onputwith a new key and no room, I evict the tail — read the key first, erase from the map, thenpop_backthe list — and then insert.” - Write the
get— six lines. Trace through a hit and a miss. - Write the
put— ten lines. Trace the eviction ordering: “critical: the read ofitems_.back().firstmust happen beforepop_back.” - “
std::listiterators are stable across splice and unrelated erasures. That’s what makes the stored iterator in the hash map safe. I’d ship this.”
Summary
LRU Cache looks like a systems-design question and turns out to be a data-structure composition drill. The move — combine a hash map and a doubly-linked list, bridge them with an iterator, promote on every touch with splice, evict from the tail — produces an cache with about thirty lines of idiomatic C++. The pattern generalizes: whenever no single container gives you every operation at the target complexity, compose two and cross-link.
The interview signal is not the code length. It is recognizing the composition, stating the iterator-stability invariant aloud, and writing the eviction steps in the one correct order. Candidates who nail all three walk out distinguished.