Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
LRU Cache
The eviction policy behind every cache with a size limit: when you run out of room, throw out whatever was used least recently. The trick is doing get and put in O(1) — an ordered hash map (Python dict / JS Map) gives you exactly that.
The problem
Implement a fixed-capacity LRU (least-recently-used) cache as a driver: lru_cache(capacity, ops) replays a list of operations and returns the results of the get calls (in order). Each op is a list: ["get", key] returns the stored value or -1 if absent, and counts as a use (the key becomes most-recently-used). ["put", key, value] inserts or updates the key (also most-recently-used); if that pushes the cache over capacity, evict the least-recently-used key. Only get results go into the returned list.
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_cache(capacity, ops). Replay the ops against an LRU cache of the given capacity and return the list of results from the get calls.
Examples and constraints 4 examples
capacity=2, ops=[["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["put",4,4],["get",1],["get",3],["get",4]][1, -1, -1, 3, 4]capacity=1, ops=[["put",1,1],["put",2,2],["get",1],["get",2]][-1, 2]capacity=2, ops=[["get",5]][-1]capacity=2, ops=[["put",1,1],["put",2,2],["put",1,10],["put",3,3],["get",2],["get",1]][-1, 10]- Both
getandputmark the key as most-recently-used. - On overflow, evict the least-recently-used key (the one untouched the longest).
- A Python
dict(or JSMap) preserves insertion order — pop and re-insert a key to move it to the most-recently-used end; the first key is the LRU. - Return only the values produced by
getoperations, in order.
Hints 4 ideas
- Keep a dict/Map from key → value; its iteration order is your recency order (oldest first).
- On get: if present, pop the key and re-insert it (now newest) before returning its value; else return -1.
- On put: if the key exists, pop it first; set key → value (newest); then if size > capacity, delete the first (oldest) key.
- The oldest key is next(iter(cache)) in Python / cache.keys().next().value in JS.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious first version stores values in a dict and recency in a separate Python list order, oldest first. Every get hit and every put calls order.remove(key) and order.append(key), and an overflow evicts order.pop(0) and deletes that key from the dict. It is correct, because the list is exactly the recency order. It costs O(capacity) time per operation, since remove searches the list and both remove and pop(0) shift every later element, and O(capacity) space. That is the wrong cost for a cache, whose whole job is to be the fast path: at capacity 100,000 a single hit can scan and shift 100,000 entries.
The approach
An LRU cache needs three operations to be cheap: look up a key, mark it most recently used, and find the least recently used key. The insight is that an insertion-ordered hash map provides all three if its order is read as recency: popping a key and inserting it again moves it to the newest end, and the first key is the oldest. The invariant after every op: iterating cache lists keys from least to most recently used, and len(cache) <= capacity.
The reference keeps that order in cache and appends get answers to results. A hit does val = cache.pop(key) then cache[key] = val, so a read counts as a use; a miss appends -1 and touches nothing. A put pops an existing key first, inserts cache[key] = val at the newest end, and only if len(cache) > capacity evicts oldest = next(iter(cache)). An update never evicts, because the size did not grow. The classic equivalent, and what OrderedDict does internally, is a hash map from keys to nodes of a doubly linked list.
Walk through an example
The first example with capacity 2, one row per op; the cache column lists keys from least to most recently used.
| step | op | what happens | cache after (oldest first) | results |
|---|---|---|---|---|
| 1 | put(1, 1) | insert | {1: 1} | [] |
| 2 | put(2, 2) | insert | {1: 1, 2: 2} | [] |
| 3 | get(1) | hit; 1 moves to newest | {2: 2, 1: 1} | [1] |
| 4 | put(3, 3) | size 3 is over 2: evict oldest, 2 | {1: 1, 3: 3} | [1] |
| 5 | get(2) | miss | {1: 1, 3: 3} | [1, -1] |
| 6 | put(4, 4) | size 3 is over 2: evict oldest, 1 | {3: 3, 4: 4} | [1, -1] |
| 7 | get(1) | miss | {3: 3, 4: 4} | [1, -1, -1] |
| 8 | get(3) | hit; 3 moves to newest | {4: 4, 3: 3} | [1, -1, -1, 3] |
| 9 | get(4) | hit; 4 moves to newest | {3: 3, 4: 4} | [1, -1, -1, 3, 4] |
Complexity
Ordered dict: a get and the insert half of a put are average O(1), and space is O(capacity). Eviction’s next(iter(cache)) skips deleted slots, so under steady eviction it slows as capacity grows (see the follow-ups); a doubly linked list or OrderedDict makes every step O(1). Brute force: O(capacity) per operation from remove and pop(0), O(capacity) space.
Edge cases to test
- Capacity 1: every put of a new key evicts the previous one, so
put(1,1),put(2,2),get(1),get(2)gives[-1, 2]. - Updating a key on a full cache refreshes it and evicts nothing:
put(1,1),put(2,2),put(1,10),put(3,3)evicts 2, soget(2),get(1)give[-1, 10]. A barecache[key] = valleaves 1 in the oldest slot and gives[2, -1]. - A get saves a key:
put(1,1),put(2,2),get(1),put(3,3),get(1),get(2)gives[1, 1, -1]. A get that does not refresh recency evicts 1 instead and gives[1, -1, 2]. - A miss changes nothing:
get(5)returns -1, inserts nothing and leaves the order as it was. - A stored value of -1 looks like a miss but is a hit: after
put(1,-1),put(2,2),get(1), aput(3,3)evicts 2, not 1, and the full run…, get(2), get(1)returns[-1, -1, -1]for two different reasons.
Common mistakes
- Backing it with an array or plain list — moving an element to the front is then O(n), losing the O(1) guarantee.
- Updating recency only on
putand not onget— a read must also mark the entry as recently used. - Updating with a bare
cache[key] = val: assigning to an existing key keeps its old position in a dict orMap, so the update does not refresh recency. Pop the key first. - Evicting before inserting whenever the cache is full: when the key already exists the size does not grow, so
put(2, 20)on a full cache holding 1 and 2 wrongly throws out 1. - Off-by-one on capacity — evict when size exceeds capacity, and remember to update the map, not just the list.
Follow-ups an interviewer asks next
- Why does this solution slow down at large capacity, and how do you make eviction truly O(1)?
- Python’s
dictand JavaScript’sMapkeep insertion order in an array and leave deleted slots in place until the table is rebuilt, sonext(iter(cache))must skip every deleted slot in front of the oldest key. Under steady eviction that work grows with capacity. Usecollections.OrderedDictwithmove_to_end(key)andpopitem(last=False), or a hash map from keys to doubly linked list nodes. - Make it safe for many threads.
- Every operation, even a
get, rewrites the recency order, so a read-write lock buys nothing: reads are writes. Put one lock around each operation, or shard the cache by key hash into independent LRU caches with their own locks, which trades exact global recency for less contention. Busy caches often approximate LRU instead, for example with the clock algorithm, where a hit only sets a flag. - Evict the least frequently used key instead.
- That is LFU. Keep each key’s value and use count, plus a map from each count to an insertion-ordered set of keys, and track the minimum count. A hit moves its key from bucket
counttocount + 1; eviction removes the oldest key in the minimum-count bucket. Every step is O(1), and recency breaks ties within a bucket. - Entries should also expire after a time-to-live.
- Store an expiry time with each value. On
get, treat an expired entry as a miss and delete it. Expired entries that are never read still take space, so sweep them periodically or let LRU push them out, since an unread entry drifts to the old end anyway. Pass the clock in as a parameter so tests can control time.
Where this shows up
LRU and its approximations are the standard eviction policies across the stack: CPU caches use pseudo-LRU, Memcached evicts least-recently-used items, Redis offers sampled LRU policies such as allkeys-lru (its default policy is noeviction), and database buffer pools use variants such as PostgreSQL’s clock sweep or InnoDB’s midpoint-insertion LRU. Python’s own functools.lru_cache is a dict plus a doubly linked list. That combo is the textbook way to get O(1) on both ordering and lookup, and it shows up any time you must bound memory while keeping the hot set.
Reference solution
Reference solution — Python
def lru_cache(capacity, ops):
cache = {} # insertion order = recency (oldest first)
results = []
for op in ops:
if op[0] == "get":
key = op[1]
if key in cache:
val = cache.pop(key)
cache[key] = val # re-insert → most recently used
results.append(val)
else:
results.append(-1)
else: # "put"
key, val = op[1], op[2]
if key in cache:
cache.pop(key) # remove old position
cache[key] = val # insert as most recently used
if len(cache) > capacity:
oldest = next(iter(cache))
cache.pop(oldest) # evict least recently used
return results
print(lru_cache(2, [["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["put",4,4],["get",1],["get",3],["get",4]]))
# -> [1, -1, -1, 3, 4]Reference solution — TypeScript
function lruCache(capacity: number, ops: (string | number)[][]): number[] {
const cache = new Map<number, number>(); // insertion order = recency
const results: number[] = [];
for (const op of ops) {
if (op[0] === "get") {
const key = op[1] as number;
if (cache.has(key)) {
const val = cache.get(key)!;
cache.delete(key);
cache.set(key, val); // re-insert → most recently used
results.push(val);
} else {
results.push(-1);
}
} else { // "put"
const key = op[1] as number;
const val = op[2] as number;
if (cache.has(key)) cache.delete(key);
cache.set(key, val); // insert as most recently used
if (cache.size > capacity) {
const oldest = cache.keys().next().value as number;
cache.delete(oldest); // evict least recently used
}
}
}
return results;
}
console.log(lruCache(2, [["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["put",4,4],["get",1],["get",3],["get",4]]));
// -> [1, -1, -1, 3, 4]Questions about LRU Cache
Why does an LRU cache need both a hash map and a doubly linked list?
Because it needs three things in constant time: find a key, move that key to the most recently used end, and remove the least recently used key. A hash map alone finds keys but has no order. A linked list alone keeps order but needs a linear search to find a key. Together, the map points straight at the list node for a key, and a doubly linked node can be unlinked in constant time because it knows both of its neighbors. Python’s OrderedDict and functools.lru_cache are built this way.
Is a Python dict enough to build an O(1) LRU cache?
Almost. A dict keeps insertion order, so popping a key and inserting it again moves it to the newest end, and next(iter(cache)) returns the oldest key, which is how the reference solution here works. Hits and updates are constant time on average. Eviction is the weak spot: deleted slots stay in place until the dict resizes, and finding the first key skips all of them, so a cache that evicts constantly slows down as its capacity grows. collections.OrderedDict, with move_to_end and popitem(last=False), keeps every operation at O(1).
Does a get count as a use in an LRU cache?
Yes. Least recently used means least recently accessed, and a read is an access, so a successful get must move the key to the most recently used end, exactly like a put. Forgetting this turns the cache into first-in-first-out for read-heavy workloads and evicts the entries that are read the most. In this challenge, putting 1 and 2, reading 1, then putting 3 must evict 2, so a later get of 1 still returns 1. A get on a missing key changes nothing.
What is the difference between an LRU cache and an LFU cache?
LRU evicts the entry that has gone longest without being used; LFU evicts the entry used the fewest times. LRU adapts quickly when the working set changes, but one large scan of items read only once can flush everything useful out of it. LFU protects items that stay popular over the long run, but it is slow to forget an item that was popular earlier and is now dead, so practical LFU caches age or decay their counts. Both can be implemented with O(1) operations.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Streaming MedianLatency dashboards do this every second: maintain the median of a stream without re-sorting per event. The classic two-heap trick — a max-heap for the low half, a min-heap for the high half, the median always at the boundary. Solve it in Python or TypeScript.Read →
- Token Bucket Rate LimiterThe algorithm inside most production rate limiters — and it never runs a timer. Refill the bucket lazily from the time elapsed since the last request, cap at capacity, spend one token or reject. Two numbers of state per client, exactly like the Redis version. Solve it in Python or TypeScript.Read →
- Sliding-Window Rate LimiterAllow at most N requests per rolling window — the rate limiter that guards real APIs. A sliding log of accepted timestamps gives exact limits without fixed-window bursts. Decide accept/reject for a stream. Solve it in Python or TypeScript, with hidden tests.Read →
- LFU CacheThe cache that evicts what you use least often — and, on ties, least recently. Harder than LRU: track frequency and recency together, still O(1) per op. Replay get/put operations. Solve it in Python or TypeScript, with hidden tests.Read →
- Bloom FilterA tiny bit-array that answers "have I seen this?" in a fraction of a set’s memory — with occasional false positives but never a false negative. Build one with double hashing. Provided hashes keep both languages in sync. Hidden tests.Read →
- HyperLogLog Cardinality EstimatorCount how many distinct items a stream held — billions of them — using a few kilobytes, not a giant set. Turn "the longest run of leading zeros" into an estimate. Powers COUNT(DISTINCT) in Redis and BigQuery. Solve it in Python or TypeScript, with hidden tests.Read →