Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
LRU Cache, Constraint by Constraint
The incremental-coding round, packaged: a cache that becomes bounded, then least-recently-used, then instrumented — each stage invalidating the shape of the last. Tests whether your first version can absorb the next requirement. Hidden tests cover the final behavior.
The problem
Implement a class LRU that grows through four stated stages. (1) LRU(capacity) with get(key) returning the value or None/null, and put(key, value). (2) Never hold more than capacity entries. (3) When full, an insert evicts the least recently used key; both get and put count as a use, and writing an existing key updates its value and refreshes recency without changing the size. (4) stats() returns hits and misses: a get that finds a key is a hit, one that does not is a miss, and put affects neither.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement LRU with get, put, size() and stats() meeting all four stages at once.
Examples and constraints 4 examples
c = LRU(2); c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3); (c.get("b"), c.get("a"))(None, 1)c = LRU(2); c.put("a", 1); c.put("a", 9); (c.size(), c.get("a"))(1, 9)c = LRU(2); c.put("a", 1); c.get("a"); c.get("zz"); c.stats(){'hits': 1, 'misses': 1}c = LRU(0); c.put("a", 1); (c.size(), c.get("a"))(0, None)getandputmust both count as a use for recency purposes.- Writing an existing key updates the value and refreshes recency; the size must not change.
stats()returns hits and misses fromgetonly —putcounts as neither.- A capacity of 0 must hold nothing: every
getmisses.
Hints 4 ideas
- Insertion-ordered maps give you recency for free: on a use, delete the key and re-insert it so it becomes most-recent.
- Evict by taking the first key of the map — that is the least recently used one.
- Handle the existing-key write before the capacity check, or you will evict on a write that adds nothing.
- Capacity 0 is the edge case that catches most implementations: the insert must be a no-op, not an eviction of itself.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious first version keeps values in a dict and recency in a separate list: on every use, order.remove(key) then order.append(key), and to evict, order.pop(0). It is correct, because the list always runs from least to most recently used, and it takes O(capacity) space. But remove searches the list and pop(0) shifts every remaining element, so each get and put costs O(capacity) time, around 100,000 element moves per hit on a 100,000-entry cache. In the incremental round it has a second cost: two structures must stay in sync through every later stage, so each new rule means two edits that can disagree.
The approach
The insight is that an insertion-ordered map is both the lookup table and the recency list, so one structure absorbs every stage. The invariant: iterating self.d yields keys from least to most recently used, and len(self.d) <= self.cap. Each stage then adds a few lines instead of forcing a rewrite: stage 2 a length check, stage 3 a move-to-end, stage 4 two counters.
In the reference, _touch does self.d[key] = self.d.pop(key), which moves a key to the most-recent end. get counts a hit, touches the key and returns its value, or counts a miss and returns None. put handles an existing key first: it writes the new value, touches the key and returns, so a rewrite can never evict anything or change the size. Only then does it return early when self.cap <= 0, evict next(iter(self.d)), the least recently used key, when the map is full, and insert. stats() reports the two counters, which only get ever changes.
Walk through an example
Trace the reference’s own demo on LRU(2), where the map column lists keys from least to most recently used.
| step | call | returns | self.d | hits, misses |
|---|---|---|---|---|
| 1 | put("a", 1) | None | {a: 1} | 0, 0 |
| 2 | put("b", 2) | None | {a: 1, b: 2} | 0, 0 |
| 3 | get("a") | 1 | {b: 2, a: 1}, a touched | 1, 0 |
| 4 | put("c", 3) | None | {a: 1, c: 3}, b evicted | 1, 0 |
| 5 | get("b") | None | {a: 1, c: 3} | 1, 1 |
| 6 | get("a") | 1 | {c: 3, a: 1} | 2, 1 |
| 7 | stats() | {"hits": 2, "misses": 1} | unchanged | 2, 1 |
Complexity
Optimal: get, a rewrite and a non-evicting insert are average O(1), and space is O(capacity). An evicting put calls next(iter(self.d)), which in CPython’s dict and V8’s Map steps over slots left by earlier deletions until the table is compacted, so under steady eviction it slows as capacity grows. Brute force (dict plus list): O(capacity) per operation.
Edge cases to test
- Capacity 0:
LRU(0),put("a", 1), then(size(), get("a"))→(0, None), and thegetcounts a miss. Without theself.cap <= 0guard, Python’snext(iter({}))raisesStopIteration, and theMapversion quietly stores one entry. - Rewriting a key while full: with
aandbcached,put("a", 9)keeps the size at 2 and evicts nothing, and because it refreshesa, the next new key evictsb. - A stored
None: afterput("k", None),get("k")returnsNoneexactly like a miss, butstats()counts it as a hit. A caller that must tell them apart needs a sentinel value or a separate membership check. - A miss changes only the counter:
get("zz")on a cache holdinga, bleaves the ordera, band the size at 2. - Capacity 1: every new key evicts the previous one, so
put("a", 1)thenput("b", 2)leaves onlyb. - A negative capacity behaves like 0, because the guard is
self.cap <= 0rather than== 0.
Common mistakes
- Treating a write to an existing key as an insert — the size must not change and the key must become most-recent.
- Counting a miss as a hit in
stats(), or counting an eviction as a miss. - Refreshing recency on
putbut not onget, so a hot read-only key gets evicted. - Writing a clever compressed first version that cannot absorb the next constraint — the exact failure this round format is designed to catch.
Follow-ups an interviewer asks next
- Every operation must now be O(1) in the worst case. What changes?
- Use a hash map from keys to nodes of a doubly linked list with sentinel head and tail nodes. A use unlinks a node and relinks it before the tail; eviction removes the node after the head. Every step is a few pointer updates, with none of the deleted-slot skipping an insertion-ordered map can do. In Python,
OrderedDict.move_to_endandpopitem(last=False)give the same guarantee. - Stage 5: entries expire after a time to live. How do you add it?
- Store
(value, expires_at)and treat an expired entry as a miss onget, deleting it then (lazy expiry). On its own that lets dead entries hold capacity, so also prefer expired keys when evicting, or sweep periodically. Say out loud whether reading an expired key counts as a miss instats(): raising that ambiguity is exactly what this round rewards. - Stage 6: many threads share the cache. What do you do?
- The simplest correct version guards
getandputwith one lock, because a hit reorders recency, so even reads mutate the structure. Under contention, shard by key hash into independent caches, each with its own lock and a share of the capacity. Recency stays exact within a shard but only approximate across the whole cache. - What would you report beyond hits and misses?
- The hit rate,
hits / (hits + misses), which is what capacity decisions turn on, and an eviction count, because many evictions of keys that are requested again soon afterward is the sign the cache is too small. Keep evictions as their own counter rather than folding them into misses, which is one of the mistakes listed above.
Where this shows up
Anthropic’s reported coding assessment and similar live-coding rounds at other AI-native companies escalate constraints on a small problem rather than asking one hard question. The signal is adaptability: interviewers watch whether your first solution was over- or under-engineered and how gracefully you refactor when the ground moves. Narrating what breaks before you refactor is worth more than the refactor itself.
Reference solution
Reference solution — Python
class LRU:
def __init__(self, capacity):
self.cap = capacity
self.d = {} # insertion order == recency order (oldest first)
self.hits = 0
self.misses = 0
def _touch(self, key):
self.d[key] = self.d.pop(key) # move to most-recent end
def get(self, key):
if key in self.d:
self.hits += 1
self._touch(key)
return self.d[key]
self.misses += 1
return None
def put(self, key, value):
if key in self.d: # update, not insert
self.d[key] = value
self._touch(key)
return
if self.cap <= 0:
return
if len(self.d) >= self.cap:
oldest = next(iter(self.d))
del self.d[oldest]
self.d[key] = value
def size(self):
return len(self.d)
def stats(self):
return {"hits": self.hits, "misses": self.misses}
c = LRU(2)
c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3)
print(c.get("b"), c.get("a"), c.stats()) # -> None 1 {'hits': 2, 'misses': 1}Reference solution — TypeScript
class LRU {
cap: number;
d: Map<string, any>; // Map preserves insertion order == recency (oldest first)
hits = 0;
misses = 0;
constructor(capacity: number) { this.cap = capacity; this.d = new Map(); }
private touch(key: string) {
const v = this.d.get(key);
this.d.delete(key);
this.d.set(key, v); // move to most-recent end
}
get(key: string): any {
if (this.d.has(key)) {
this.hits++;
const v = this.d.get(key);
this.touch(key);
return v;
}
this.misses++;
return null;
}
put(key: string, value: any): void {
if (this.d.has(key)) { // update, not insert
this.d.set(key, value);
this.touch(key);
return;
}
if (this.cap <= 0) return;
if (this.d.size >= this.cap) {
const oldest = this.d.keys().next().value as string;
this.d.delete(oldest);
}
this.d.set(key, value);
}
size(): number { return this.d.size; }
stats(): { hits: number; misses: number } { return { hits: this.hits, misses: this.misses }; }
}
const c = new LRU(2);
c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3);
console.log(c.get("b"), c.get("a"), c.stats()); // -> null 1 { hits: 2, misses: 1 }Questions about LRU Cache, Constraint by Constraint
Why is updating an existing key not an insert in an LRU cache?
Because the number of entries does not change. If a put on a key that is already cached took the insert path, a full cache would evict some other key to make room it never needed, and the size would drift from reality. The correct behavior is to overwrite the value and mark the key as most recently used, since writing it is a use. In the reference solution this check comes first in put, before the capacity guard and the eviction, which is why rewriting a key in a full cache leaves every other key in place.
Should a get count as a use in an LRU cache?
Yes. Least recently used means least recently accessed, and in a typical cache most accesses are reads. If only writes refreshed recency, a key that is read constantly but written once would drift to the oldest position and be evicted while it is still the most popular entry, which is the opposite of what the policy is for. In this challenge both get and put count as a use, so a hit moves the key to the most recent end before returning its value, while a miss changes nothing except the miss counter.
What is an incremental coding interview round?
It is a live-coding format in which the interviewer states a small problem, lets you solve it, then adds requirements one at a time, and each new requirement can invalidate the shape of your previous answer. This challenge follows that pattern in four stages: a plain map, a size bound, least recently used eviction, then hit and miss statistics. The signal is less about the final data structure than about whether your first version could absorb the next rule without a rewrite, and how clearly you explain what had to change when it could not.
What should an LRU cache do when its capacity is zero?
Hold nothing. A put should do nothing at all, and every get should miss and be counted as a miss. It is a common place for bugs: an implementation that checks whether the cache is full and then evicts the oldest key will try to evict from an empty cache, which raises StopIteration in Python when it calls next on an empty iterator, and quietly stores one entry in a JavaScript Map version. The reference solution returns early from put when the capacity is zero or below, right after handling the existing-key case.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →