Intermediate30 min readMachine Coding Practicelive prototype

Cache (LRU / LFU)

Every other problem in this set is about modelling nouns. This one is about a data structure. A hash map gives you O(1) lookup and no order; a linked list gives you order and O(n) lookup. Bolt them together and both get and evict become O(1) — and once the eviction rule lives behind an interface, “now make it LFU” costs you a new class instead of a rewrite.

The idea

What it is

“Design an LRU cache.” Four words, and the interviewer will not add a fifth. It is the most-asked machine-coding problem there is, which means the bar is not “can you produce something that works” — it is “can you produce the right thing, quickly, and then extend it.”

A cache sits between a caller and something slow. When the caller asks for a key, the cache either has it — and answers in about 100 nanoseconds — or it does not, and someone has to pay a database round trip of roughly 10 milliseconds. That is a hundred thousand to one. The cache is small on purpose, so the only interesting question is: when it is full, which key do you throw away?

«caller» Client get(k) Cache - store : Map<K,V> - capacity : 3 entries - policy : EvictionPolicy - hits / misses / evictions «the class you are asked to write» HIT value, now ~100 ns MISS Database ~10 ms · 100,000× a miss fills the cache — and that is the only place eviction ever happens
Look at the dashed arrow. Reads are the easy half; the miss path is where the design lives, because that is where something has to die.

The whole system in three sentences

A map holds key → value so lookup is O(1). A policy holds an ordering of the keys so that “who dies next” is also O(1). Cache owns the map, the capacity and the counters; the policy owns the order — and nothing else in the cache knows whether that order means recently used or frequently used.

What is actually being graded

  1. Is it genuinely O(1)? Not “fast” — O(1). If any path scans the entries to find a victim, or walks a list to find a predecessor, you have failed the only quantitative requirement the problem has.
  2. Can you draw the pointers? The interviewer will ask you to talk through moveToFront. “It moves the node to the front” is not an answer. The six assignments are the answer.
  3. Did you use sentinels? A HEAD and a TAIL node that hold no data delete every null check in the file. Skipping them is where the NullPointerException comes from.
  4. Is the eviction rule swappable? “Now make it LFU” is the follow-up, every time. If the list logic is welded into get(), that request is a rewrite. If it is behind an interface, it is a new class and zero edits.
  5. Does it run, and can you show it working? A main() that replays a fixed access trace and prints the contents and the hit ratio after each step. Numbers, not claims.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • What is the capacity measured in — entries or bytes? — say entries, and add the one sentence that shows you know better: “real caches weigh entries, because a 2 KB value and a 2 MB value are not the same tenant of a fixed memory budget.”
  • What should get return on a miss? — an empty Optional (or null, or undefined). Not an exception. A miss is the normal case, not an error.
  • Does the cache load on a miss, or does the caller? — simplest is: the caller loads and calls put. Mention that a loading cache which takes a supplier is the nicer API and is what Caffeine and Guava do.
  • LRU only, or should the policy be pluggable? — this is the question that decides the shape of your answer. Ask it early and they will usually say “start with LRU, then we will talk.” That is your cue to build the seam from the first minute.
  • Will several threads use it? — say yes, one lock, and be ready for the twist in the follow-ups: get mutates.
  • TTL, persistence, distribution, stampede protection? — out of scope in one sentence. TTL is expiry, which is a different axis from eviction, and mixing them up costs you time. Depth on that lives in In-memory key-value store.
✓ IN SCOPE — 60 minutes buys you this get(key) · put(key, value) a fixed capacity, counted in entries LRU and LFU behind one interface O(1) for get, put AND evict hits · misses · evictions · hit ratio safe for concurrent callers a main() that replays a trace and prints the numbers ✗ OUT OF SCOPE — name each in one sentence persistence to disk TTL expiry — a different axis distribution across machines byte-accurate sizing / serialization async refresh · write-behind thundering herd on a cold key saying these out loud costs 20 seconds and buys you the whole hour
The right column is not wasted breath. Naming TTL and distribution as separate axes is what stops the interviewer from wondering whether you know they exist.

Step 2 · Why neither structure alone works

Start from the two operations and ask what each costs. get(key) needs to find a value by key. evict() needs to find the least-recently-used entry. There is no single ordinary structure that does both in constant time — and understanding why is the entire design.

✗ HASHMAP ALONE A → v C → v B → v D → v buckets in hash order — no order get(key) O(1) ✓ find LRU O(n) ✗ you must scan every entry ✗ LINKED LIST ALONE D → C → B → A newest first, oldest last get(key) O(n) ✗ find LRU O(1) ✓ you must walk it to find a key ✓ BOTH, POINTING AT EACH OTHER map: key → Node list: Node ⇄ Node ⇄ Node get(key) O(1) ✓ find LRU O(1) ✓ map finds it · list orders it The map answers “where is this key?”. The list answers “who has been idle longest?”. Neither question is answerable by the other structure, so you keep both and make them point at each other. Cost: one extra pointer per entry, and every mutation has to update both. That is the trade, and it is worth it.
This is the sentence to say out loud in minute 6: “I need O(1) lookup and O(1) ordering, so I need a map and a list, and the map's values are the list's nodes.” Everything after this is mechanics.
HashMap — key → Node "A" ⬤ "B" ⬤ "C" ⬤ no order at all — that is what the list is for doubly-linked list — ordered by recency MRU end LRU end — evict here HEAD sentinel C value B value A value TAIL sentinel → next ← prev ⋯ the map's values ARE these nodes evictCandidate() is literally tail.prev — no scan, no search, no comparison
Trace one blue arrow. The map hands you a node, not an index — which is the only reason you can unlink it without knowing where it sits.

Step 3 · The pointer surgery, spelled out

“And then it moves to the front” is where most candidates stop. Do not. Moving a node is two operations — unlink and insert at head — and between them there are exactly six assignments. Say them, write them, and the interviewer stops probing.

① BEFORE — you got N from the map in O(1); you do not know where it sits, and you do not need to HEAD X N Y TAIL map.get("N") handed you this node directly — no walking, no index, no search ② UNLINK — two assignments, and X and Y now hold hands across the gap HEAD X N Y TAIL 1. n.prev.next = n.next; 2. n.next.prev = n.prev; no null checks — X and Y always exist, because HEAD and TAIL are always there ③ INSERT AT HEAD — four assignments, in this order HEAD N X Y TAIL 3. n.next = head.next; 4. n.prev = head; 5. head.next.prev = n; 6. head.next = n; swap 5 and 6 and you have lost the old first node six assignments · no loop · no length this is the O(1) the problem is about get() = one map lookup + these six lines. That is the entire hot path.
Read assignment 6 last. head.next = n overwrites the pointer you needed in step 3 and step 5 — which is why the order is not arbitrary and why writing it out beats waving at it.

The sentinels are not decoration

HEAD and TAIL are two real nodes that hold no key and no value, wired to each other at construction. With them, n.prev and n.next are never null for any node in the list, so unlink and insertAtHead have zero branches. Without them, every one of those six lines needs an “is this the first node? is this the only node?” guard, and the first thing that breaks in the interview is the empty-list case. Two wasted objects, an entire class of bugs deleted.

the whole of LRU, in one screen
private void unlink(Node<K> n) {
    n.prev.next = n.next;      // 1
    n.next.prev = n.prev;      // 2
    n.prev = null; n.next = null;
}

private void insertAtHead(Node<K> n) {
    n.next = head.next;        // 3
    n.prev = head;             // 4
    head.next.prev = n;        // 5   <- must happen BEFORE line 6
    head.next = n;             // 6
}

// "this key was just used" — the whole of LRU
void recordAccess(K key) {
    Node<K> n = nodes.get(key);
    if (n == null) return;
    unlink(n);
    insertAtHead(n);
}

// "who dies next?" — no scan, no comparison, no loop
K evictCandidate() {
    return tail.prev == head ? null : tail.prev.key;
}

Why the list must be doubly linked

This is the question that separates people who memorised the answer from people who understand it. You found the node through the map in O(1). To unlink it you need to change its predecessor's next pointer. With only next pointers, the node has no idea who its predecessor is — and the only way to find out is to walk from the head until you find the node whose next is you.

✗ ONLY next POINTERS HEAD P Q N Y map.get("N") ⬤ the map gets you to N instantly … 1 2 3 — found it: Q.next == N … but N cannot name its predecessor. Walking from HEAD to find Q is O(n), so get() is O(n) and the design is dead. ✓ WITH prev POINTERS — n.prev IS Q. One field read replaces the whole walk. The second pointer costs one machine word per entry. It buys the O(1) that the entire problem is about.
The extra prev field is not a nicety — it is the difference between the design working and the design being a linear scan with extra steps.

Step 4 · The two flows, precisely

Client Cache store: Map LruPolicy HIT — get("A") store.get("A") value · hits++ policy.recordAccess("A") unlink(n) insertAtHead(n) value MISS — get("Z") store.get("Z") → null · misses++ empty — the policy is NOT touched on a miss
Look at what a read does: it calls recordAccess, which writes to the linked list. There is no such thing as a read-only get in this design, and that fact comes back in the concurrency follow-up.
Client Cache store: Map policy PATH 1 — the key is already there: put("B", v2) containsKey("B") → true · store.put("B", v2) policy.recordAccess("B") size unchanged · nothing is evicted · this is the path people get wrong PATH 2 — a new key, and the cache is full: put("D", v) store.size() == capacity → make room FIRST policy.evictCandidate() "C" ← tail.prev, in O(1) store.remove("C") · evictions++ policy.recordRemove("C") store.put("D", v) · policy.recordInsert("D") → D is now the MRU
Path 1 is the trap. put on a key that is already present must not grow the cache and must not evict anything — but it must count as a use. Candidates who write evictIfFull(); insert(); unconditionally throw away a live entry on every overwrite.

Evict before you insert, not after

Inserting first and then trimming works, but for one moment the cache holds capacity + 1 entries — and if the new key itself is chosen as the victim (possible in some policies), you have just inserted and deleted the thing you were asked to store. Check size() >= capacity, evict, then insert. Same principle as do everything that can fail before the first irreversible step in Coffee Machine.

Step 5 · The seam — EvictionPolicy is an interface

Here is the moment the problem stops being a LeetCode exercise and becomes a design answer. Cache should own storage, capacity and counters. It should not own an opinion about who dies. Give the opinion its own type, with four methods:

the only interface in the file
interface EvictionPolicy<K> {
    void recordInsert(K key);   // a brand-new key entered the cache
    void recordAccess(K key);   // an existing key was read or overwritten
    void recordRemove(K key);   // a key left the cache
    K    evictCandidate();      // who dies next — null if empty
}

Now read Cache.get() again: it looks up the value, bumps a counter, and calls recordAccess. It never says the word recency. Swap new LruPolicy() for new LfuPolicy() in the constructor and zero lines inside Cache change. That is Strategy doing exactly what it is for, and it is the Open/Closed (OCP) principle stated as code: open to a new policy, closed to editing the cache.

Cache<K,V> - store : Map<K,V> - capacity : int - policy : EvictionPolicy<K> - hits, misses, evictions : long + synchronized get(K) : V + synchronized put(K, V) «strategy» owns one «interface» EvictionPolicy<K> + recordInsert(K) + recordAccess(K) + recordRemove(K) + evictCandidate() : K the only decision that varies LruPolicy map + DLL LfuPolicy freq buckets FifoPolicy a queue Node<K> «private to LruPolicy» - key : K - prev : Node · next : Node plus two sentinels: HEAD, TAIL Cache never mentions LRU, LFU, recency, frequency or a linked list. That is the seam. One interface, four methods, and Cache is closed to change.
Cover the right half of this diagram with your hand. What is left — Cache plus Node — is a complete, working LRU cache. The right half is what makes “now make it LFU” a five-minute answer. Notation: Class diagrams; the flows above are Sequence diagrams.

The trap most candidates fall into

One class called LRUCache, with unlink and moveToFront written directly inside get(). It works, it passes the test, and then the interviewer says “now make it LFU” — and there is nowhere to put the change. You end up either rewriting the class or adding an if (mode == LFU) branch through the hot path. Ten extra minutes at minute 30 to pull the interface out is the single highest-value spend in this round.

The honest cost of the seam

In the welded version the map stores key → Node and the node holds the value, so a hit is one hash lookup. With a policy interface, Cache keeps key → value and the policy keeps its own key → Node, so a hit is two hash lookups and one extra object per entry. Still O(1), still nothing to worry about at interview scale — but say it out loud. Knowing what your abstraction costs is worth more than pretending it is free.

Step 6 · LFU is a different structure, not a tweak

“Now evict the least-frequently-used key instead.” The naive move is to add a count field to each node and scan for the minimum — which is O(n) and undoes everything you just built. LFU needs its own three pieces:

  1. key → frequency — how many times each key has been touched.
  2. frequency → a doubly-linked list of the keys at that frequency, ordered by recency inside the bucket.
  3. minFreq — a single integer holding the lowest frequency currently in use. This is the whole trick: without it, finding the victim means searching the buckets.

On access, a key moves from bucket f to bucket f+1. If bucket f is now empty and f == minFreq, then minFreq++ — and that is the only place minFreq ever increases, because a key can only ever gain one frequency at a time. On insert, the new key goes into bucket 1 and minFreq = 1, because a fresh key is always the new minimum. Eviction takes the oldest entry in bucket minFreq — so the tie inside a frequency bucket is broken by recency, which is to say LFU has an LRU hiding inside it. Say that sentence; interviewers ask about the tie-break specifically.

key → freq "E" → 1 "B" → 3 "A" → 3 a counter alone is useless — finding the minimum would be O(n) freq → doubly-linked list of keys (newest on the left) minFreq ▸ 1 E ← the victim: last node of bucket minFreq 2 empty bucket 2 just emptied — but minFreq is 1, not 2, so minFreq does NOT move 3 B A newest in the bucket sits on the left get("B") promotes it 2 → 3 recordInsert: freq = 1 · push into bucket 1 · minFreq = 1 (a new key is always the new minimum) recordAccess: move key from bucket f to bucket f+1 · if bucket f is now empty AND f == minFreq → minFreq++ evictCandidate: last node of bucket[minFreq] — every one of these is O(1) minFreq only ever moves up by ONE, because a single access raises a frequency by exactly one.
Point at minFreq when you explain this. Everything else in LFU is bookkeeping; that one integer is what turns “find the minimum” from a search into a lookup.

LFU's real flaw — say it before they ask

A key that got hammered a thousand times during a one-off batch job at 3am has a frequency of 1000 forever, and it will outlive keys that are genuinely hot today. That is cache pollution, and pure LFU has no cure for it. The fixes are all forms of forgetting: aging (periodically halve every count), a decay factor on each access, or a windowed count that only remembers the recent past. That last one is what TinyLFU does, and it is what modern caches like Caffeine actually ship — a small window LRU in front, admitting into a large LFU-governed main region using a sketch of recent frequencies.

capacity 3 · the identical 10 accesses · two policies access A A A B C B D E B A LRU MRU first A A A B A C B A B C A D B C E D B B E D A B E ✗ A ✗ C ✗ D 4 hits / 10 LFU key·freq A·1 A·2 A·3 A·3 B·1 A·3 B·1 C·1 A·3 B·2 C·1 A·3 B·2 D·1 A·3 B·2 E·1 A·3 B·3 E·1 A·4 B·3 E·1 ✗ C ✗ D 5 hits / 10 Step 7: LRU throws away A — the key it had just used three times — because it had gone quiet for three accesses. LFU keeps A and kills C instead. At step 10 that pays: LRU misses on A, LFU hits. 40% vs 50%. C died before D under LFU because both sat at frequency 1 and C was the less recently used — the tie-break is LRU.
Run this exact trace in the prototype under 🔁 LRU, reset, then under 📊 LFU. Same 10 clicks, different key in the bin, different hit ratio. Neither policy is right — they bet on different futures.

The one-line answer to “LRU or LFU?”

LRU bets that what you touched recently you will touch again; LFU bets that what you touch often you will touch often. LRU is cheap, adapts instantly to a change in workload, and is destroyed by a single full scan that sweeps the whole cache. LFU resists that scan, and in exchange it clings to yesterday's hot keys. Real systems use a hybrid for exactly this reason.

Thread safety — where the surprise is

Ask any candidate to make a cache thread-safe and they reach for a read-write lock: “many readers, one writer.” It is the wrong instinct here, and knowing why is worth a point. get() is not a read. It calls recordAccess, which unlinks a node and relinks it at the head — six pointer writes on a structure shared by everyone. Two concurrent gets on different keys can corrupt the list into a cycle, and then a later traversal never terminates.

  • The 60-minute answer: one lock (synchronized, or a ReentrantLock) around get and put. Four characters of code and it is correct. Say it, write it, move on. Background: Locks, Mutex, Semaphore.
  • Why a ReadWriteLock does not help: everything takes the write lock anyway, because every operation mutates. You get the complexity of Read-Write locks and none of the concurrency.
  • How you would actually scale it: striping — split the key space into N independent segments, each with its own map, list and lock, so keys that hash to different segments never contend. The cost is that eviction becomes per-segment, so the policy is now approximate across the whole cache. That is a fine trade and it is what real caches do.
  • What production caches do instead: Caffeine keeps the reads lock-free by writing each access into a small ring buffer and replaying those buffers onto the linked list later, in one thread. The reordering leaves the read path entirely. Mention it in one sentence; do not attempt it.

Capacity in entries or in bytes?

Counting entries is fine for an interview and wrong for production: 1000 entries could be 4 MB or 4 GB. Real caches take a weigher — a function from an entry to its cost — and enforce a total weight. It is a one-line extension of the same design: evict while totalWeight > maxWeight instead of while size > capacity. Mention it; do not build it. And TTL is a separate axis — an entry can be evicted for being cold or dropped for being stale, and the two mechanisms do not know about each other. Depth on that belongs in In-memory key-value store.

Metrics — the answer to “how would you know it is working?”

Three counters and one derived number: hits, misses, evictions, and hit ratio = hits / (hits + misses). It costs four lines and it is the only evidence that the cache is earning its memory. A hit ratio near zero means the cache is too small or the workload has no locality; a huge eviction count with a decent hit ratio means it is working hard but running hot. Print them in your main() — it turns “it works” into a number the interviewer can read.

What the seam actually buys you

the change ✗ welded into Cache ✓ behind EvictionPolicy add FIFO edit get() + put(), retest both 1 class, ~12 lines · Cache untouched add Random edit get() + put() again 1 class, ~8 lines · Cache untouched add LFU a rewrite, or an if (mode) branch 1 class · Cache untouched add segmented LRU rewrite the whole hot path 1 class · Cache untouched add TTL expiry edit get() still edit get() — honestly add hit-ratio metrics 3 fields on Cache 3 fields on Cache The TTL row is the honest one: a seam only pays off for changes along the axis it was cut. Expiry is a different axis.
Four green rows and one amber one. Say the amber row out loud — an interface that you claim solves everything is a red flag; one you can name the limits of is not.

The 60 minutes

0 min 60 min clarify 5 LRU core: map + DLL + sentinels 18 LFU: buckets + minFreq 12 main() + run it 6 API + class sketch 5 extract EvictionPolicy 8 lock + metrics 6 If you are at minute 30 without a working LRU, skip LFU entirely and describe it out loud. Running beats complete.
The orange block is the one that must not slip. Everything to the right of it is optional; everything to the left of it is talking.

The follow-ups

  • “Java has this built in, doesn't it?” → yes: new LinkedHashMap<>(cap, 0.75f, true) with removeEldestEntry overridden is a six-line LRU, and the true is access order. Name it — it shows you know the library. Then say the quiet part: “in this round I assume you want the hand-rolled version, since that is what is being tested.” Offering the shortcut and declining it reads better than not knowing it.
  • “Now make it LFU.” → the whole point of the seam. New class, buckets plus minFreq, Cache untouched. If they push further: the tie inside a bucket is LRU, and pure LFU pollutes.
  • “Make it thread-safe.” → one lock; explain that get mutates so a read-write lock buys nothing; mention striping and Caffeine's buffered replay as how you would scale it.
  • “Distribute it across ten servers.” → a per-node LRU means ten caches with ten independent hit ratios and ten copies of the hot keys. Either route keys by consistent hashing so each key lives on one node, or accept the duplication and treat it as ten small caches. Invalidation across nodes is the genuinely hard part.
  • “Write-through, write-back or write-around?” → write-through updates cache and store together (simple, always consistent, slower writes); write-back updates the cache and flushes later (fast, and you can lose data); write-around writes straight to the store and lets the next read populate the cache (good when writes are rarely re-read soon).
  • “What happens when a hot key expires and a thousand requests miss at once?” → the thundering herd. One request loads and the rest wait on the same future, or you refresh slightly before expiry. One sentence, then stop — it is a whole problem of its own.
  • “How would you test it?” → a fixed access trace with expected contents after each step, plus the boundary cases: capacity 1, put on an existing key, get on a missing key, and evicting down to empty.

How this round is lost

  • A singly-linked list. It looks fine until you try to unlink a node found through the map, and then the whole O(1) claim collapses into a walk from the head.
  • No sentinels. Every pointer assignment grows a null check, the empty-list and single-node cases get their own branches, and the first NullPointerException lands about four minutes later.
  • Forgetting to move the node on get. This is the worst failure in the whole problem, because the code still works. Nothing throws. Nothing looks wrong. Your LRU is quietly a FIFO, and the only symptom is a hit ratio that is worse than it should be. The prototype has a chip for exactly this — see what you notice.
  • put on an existing key evicting something. Overwriting a value must not change the size and must not kill a neighbour. Test it; it is one line and everybody skips it.
  • The policy welded into the cache class. It works, and then “now make it LFU” has nowhere to land, and you spend the last fifteen minutes untangling get() instead of writing a second policy.
  • Scanning for the LFU minimum. Adding a count field and calling Collections.min is the single most common way to answer the LFU follow-up wrongly. minFreq exists precisely so you never search.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A live capacity-3 cache with the linked list drawn as real prev/next pointers between sentinel HEAD and TAIL. Click a key on the pad — on a hit the node lifts out, the neighbours' arrows close the gap, and it drops in at the MRU end; on a miss it loads and the tail flashes red and dies. Press ▶ Replay trace under 🔁 LRU, hit ↺ Reset, switch to 📊 LFU and replay the identical trace: a different key gets evicted and the hit ratio changes. Then try ⚠️ FIFO bugrecordAccess() does nothing, nothing looks broken, and the hit ratio quietly drops. cap 2 / 3 / 4 changes the pressure.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch the map and the list disagree

Start on 🔁 LRU at cap 3. Click A, then B, then C — each is a miss, each is loaded and inserted at the MRU end. Now look at the two panels: the HashMap on the left lists the keys in a fixed, meaningless order, while the list on the right is ordered by recency. That is the point of the whole design. The map cannot answer “who is oldest?” and the list cannot answer “where is B?”

try 02

Watch the pointer surgery

Click A again — a hit. It happens in two beats: first the node lifts out of the list and the two neighbours' arrows close the gap (n.prev.next = n.next, n.next.prev = n.prev), then it drops in at the head. The .callline shows policy.recordAccess("A") while it happens. Notice that at no point does anything walk the list — the map handed the node straight over.

try 03

Force an eviction

With A, B and C in a cap-3 cache, click D. Miss, and the cache is full: the tail node flashes red and dies before D is inserted. Watch the order — evict first, then insert, so the cache never briefly holds four. Then click cap 2 and see two nodes evicted immediately, and cap 4 and see the pressure disappear.

try 04

Run the trace under LRU

Hit ↺ Reset and press ▶ Replay trace. It replays A A A B C B D E B A one step at a time. Watch step 7: A is evicted — the key that was used three times at the start, thrown away because it had gone quiet. Note the final numbers: 4 hits, 6 misses, 3 evictions, 40%.

try 05

Run the identical trace under LFU

Hit ↺ Reset, click 📊 LFU, and press ▶ Replay trace again. Same ten clicks. This time the nodes carry a freq badge and regroup into frequency buckets with a minFreq pointer, and step 7 evicts C instead of A. A survives, so step 10 is a hit rather than a miss: 5 hits, 5 misses, 2 evictions, 50%. Also notice which of C and D dies first — both sit at frequency 1, and the tie is broken by recency.

try 06

Find the bug that works

Reset, click ⚠️ FIFO bug, and replay once more. In this mode recordAccess() does nothing — the exact line a tired candidate forgets. Nothing throws, nothing looks broken, the animation still runs. The only symptom is the number: 3 hits, 30%, the worst of the three. This is why “it works” is not evidence, and why you print the hit ratio in your main().

try 07

Build it from memory

Blank file, in this order: Node with key, prev, nextLruPolicy with the two sentinels wired together in the constructor, then unlink, insertAtHead, recordAccess, evictCandidate → the EvictionPolicy interface pulled out of it → Cache with the map, the capacity, the counters and a put that handles the existing-key path separately → main() replaying the trace and printing the hit ratio. Then add LfuPolicy without touching Cache. If you had to change one line inside Cache, your interface is in the wrong place.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the caching away and there are two reusable ideas here. The first: when one structure cannot answer both of your questions, keep two and make them point at each other. The second: when a rule is likely to change, give the rule its own type — because the caller then never has to know which rule is in force.

  • Any LRU-ish eviction — connection pools that close the idlest connection, session stores, image and tile caches, browser back/forward stacks.
  • Ordered maps in general — a hash map plus a linked list is exactly what LinkedHashMap and Python's dict are, and now you know why insertion order is free in one and not the other.
  • Task schedulers — the “which task runs next” decision is the same seam as “which key dies next”, and it belongs behind an interface for exactly the same reason. See Strategy.
  • Rate limiters and quota buckets — a counter per key with a way to find the extreme value quickly; the minFreq trick generalises to “maintain the answer instead of searching for it”.
  • Undo stacks and MRU file lists — the same list, the same move-to-front, the same tail-drop when the list gets too long.

The 20-second version to say out loud

“A map gives me O(1) lookup but no order; a doubly-linked list gives me O(1) reordering but no lookup. I keep both — the map's values are the list's nodes — so get is a lookup plus six pointer assignments and eviction is tail.prev. Sentinels remove the null checks. The eviction rule lives behind an interface, so LFU is a new class rather than a rewrite.”

Where this design stops working

  • At high concurrency. One lock around a structure that every read mutates is a hard serialisation point. The escapes are striping (approximate eviction, real parallelism) or moving the reordering off the read path entirely, which is what Caffeine's buffered replay does.
  • When entries have wildly different sizes. Counting entries stops meaning anything; you need a weigher and a byte budget, and then eviction is “drop until under the limit” rather than “drop one”.
  • When the workload scans. One pass over a large table walks LRU's entire contents out of the cache and leaves nothing useful behind. This is precisely what LFU and TinyLFU exist to resist.
  • When the cache is distributed. A per-node LRU means N independent caches, N hit ratios, and N copies of the hot keys. Routing keys to owners fixes the duplication and introduces invalidation, which is a harder problem than eviction ever was.
  • When staleness matters more than memory. Then you want expiry, not eviction, and the two mechanisms are independent — an entry can be evicted while fresh and go stale while resident.

If you only remember one thing

The map finds it, the list orders it, the interface decides who dies. Those three clauses are the whole design, in that order — and the third one is what turns a correct LeetCode answer into a machine-coding answer.

What it gives you

  • A hash map plus a doubly-linked list gives genuinely O(1) get, put and evict, with no scan anywhere in the design.
  • Sentinel head and tail nodes delete every null check from the pointer code, which removes the most common source of a crash in this round.
  • Putting eviction behind an interface makes LRU, LFU, FIFO and Random four small classes that share one unchanged Cache.
  • LFU's minFreq integer turns finding the least-used key from an O(n) search into a single lookup, and it can only ever move up by one.
  • Hit, miss and eviction counters cost four lines and are the only objective evidence that the cache is earning the memory it occupies.

Common mistakes

  • The policy seam costs one extra hash lookup and one extra node object per entry compared with the welded version where the map's value is the node.
  • Every operation mutates shared state, so one lock is the honest answer and a read-write lock buys nothing — throughput is capped until you stripe.
  • LRU is destroyed by a single scan of a large dataset, which sweeps every useful entry out of the cache in one pass.
  • Pure LFU never forgets, so a key that was hot once during a batch job outlives keys that are genuinely hot today unless you add aging or a window.
  • Capacity counted in entries says nothing about memory, so a cache of 1000 entries can be four megabytes or four gigabytes without any code changing.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

/** WHICH KEY DIES NEXT — the only decision that varies between caches. */
interface EvictionPolicy<K> {
    void recordInsert(K key);    // a brand-new key entered the cache
    void recordAccess(K key);    // an existing key was read or overwritten
    void recordRemove(K key);    // a key left the cache
    K    evictCandidate();       // who dies next; null when empty
    List<K> victimOrder();       // demo only: first element dies first
    String name();
}

/**
 * LRU. A doubly-linked list of keys with the most recently used at the head.
 * The two sentinels are the whole trick: head.next and tail.prev always exist,
 * so unlink() and insertAtHead() contain zero null checks and zero branches.
 */
final class LruPolicy<K> implements EvictionPolicy<K> {
    private static final class Node<K> {
        final K key;
        Node<K> prev, next;
        Node(K key) { this.key = key; }
    }

    private final Map<K, Node<K>> nodes = new HashMap<>();
    private final Node<K> head = new Node<>(null);   // MRU side — sentinel, holds no data
    private final Node<K> tail = new Node<>(null);   // LRU side — sentinel, holds no data

    LruPolicy() { head.next = tail; tail.prev = head; }

    /* ---- the pointer surgery: two assignments out, four assignments in ---- */
    private void unlink(Node<K> n) {
        n.prev.next = n.next;        // 1
        n.next.prev = n.prev;        // 2
        n.prev = null; n.next = null;
    }

    private void insertAtHead(Node<K> n) {
        n.next = head.next;          // 3
        n.prev = head;               // 4
        head.next.prev = n;          // 5  must run BEFORE 6
        head.next = n;               // 6
    }

    @Override public void recordInsert(K key) {
        Node<K> n = new Node<>(key);
        nodes.put(key, n);
        insertAtHead(n);
    }

    /** THE line people forget. Delete it and LRU silently becomes FIFO. */
    @Override public void recordAccess(K key) {
        Node<K> n = nodes.get(key);
        if (n == null) return;
        unlink(n);
        insertAtHead(n);
    }

    @Override public void recordRemove(K key) {
        Node<K> n = nodes.remove(key);
        if (n != null) unlink(n);
    }

    @Override public K evictCandidate() {
        return tail.prev == head ? null : tail.prev.key;   // no scan, no comparison
    }

    @Override public List<K> victimOrder() {
        List<K> out = new ArrayList<>();
        for (Node<K> n = tail.prev; n != head; n = n.prev) out.add(n.key);
        return out;
    }

    @Override public String name() { return "LRU"; }
}

/**
 * LFU. Three pieces and nothing else:
 *   key  -> frequency
 *   freq -> the keys at that frequency, oldest first
 *   minFreq — so finding the victim is a lookup, never a search
 * LinkedHashSet IS a hash map plus a doubly-linked list, so add / remove /
 * first-element are all O(1). Re-adding a key moves it to the end = newest.
 */
final class LfuPolicy<K> implements EvictionPolicy<K> {
    private final Map<K, Integer> freq = new HashMap<>();
    private final Map<Integer, LinkedHashSet<K>> buckets = new HashMap<>();
    private int minFreq = 0;

    @Override public void recordInsert(K key) {
        freq.put(key, 1);
        buckets.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key);
        minFreq = 1;                       // a fresh key is always the new minimum
    }

    @Override public void recordAccess(K key) {
        Integer f = freq.get(key);
        if (f == null) return;
        LinkedHashSet<K> from = buckets.get(f);
        from.remove(key);
        if (from.isEmpty()) {
            buckets.remove(f);
            if (minFreq == f) minFreq = f + 1;   // the ONLY place minFreq goes up
        }
        freq.put(key, f + 1);
        buckets.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
    }

    @Override public void recordRemove(K key) {
        Integer f = freq.remove(key);
        if (f == null) return;
        LinkedHashSet<K> bucket = buckets.get(f);
        if (bucket == null) return;
        bucket.remove(key);
        if (bucket.isEmpty()) buckets.remove(f);
    }

    /** The tie inside a bucket is broken by recency — LFU has an LRU inside it. */
    @Override public K evictCandidate() {
        LinkedHashSet<K> bucket = buckets.get(minFreq);
        if (bucket == null || bucket.isEmpty()) return null;
        return bucket.iterator().next();
    }

    @Override public List<K> victimOrder() {
        List<Integer> levels = new ArrayList<>(buckets.keySet());
        Collections.sort(levels);
        List<K> out = new ArrayList<>();
        for (int f : levels) out.addAll(buckets.get(f));
        return out;
    }

    @Override public String name() { return "LFU"; }
}

/** FIFO is LRU with recordAccess() emptied out. That is the entire difference. */
final class FifoPolicy<K> implements EvictionPolicy<K> {
    private final Deque<K> queue = new ArrayDeque<>();
    @Override public void recordInsert(K key) { queue.addLast(key); }
    @Override public void recordAccess(K key) { /* nothing — insertion order only */ }
    @Override public void recordRemove(K key) { queue.remove(key); }
    @Override public K evictCandidate()       { return queue.peekFirst(); }
    @Override public List<K> victimOrder()    { return new ArrayList<>(queue); }
    @Override public String name()            { return "FIFO (the bug)"; }
}

/** Owns storage, capacity and counters. Owns NO opinion about who dies. */
final class Cache<K, V> {
    private final int capacity;
    private final Map<K, V> store = new HashMap<>();
    private final EvictionPolicy<K> policy;
    private long hits, misses, evictions;

    Cache(int capacity, EvictionPolicy<K> policy) {
        if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0");
        this.capacity = capacity;
        this.policy = policy;
    }

    /** A READ MUTATES: recordAccess rewrites the list, so get() is guarded too. */
    public synchronized Optional<V> get(K key) {
        V value = store.get(key);
        if (value == null) { misses++; return Optional.empty(); }
        hits++;
        policy.recordAccess(key);
        return Optional.of(value);
    }

    public synchronized void put(K key, V value) {
        if (store.containsKey(key)) {         // PATH 1 — update. Size unchanged, nobody dies.
            store.put(key, value);
            policy.recordAccess(key);
            return;
        }
        if (store.size() >= capacity) {       // PATH 2 — new key: make room FIRST
            K victim = policy.evictCandidate();
            if (victim != null) {
                store.remove(victim);
                policy.recordRemove(victim);
                evictions++;
            }
        }
        store.put(key, value);
        policy.recordInsert(key);
    }

    public synchronized int size()             { return store.size(); }
    public synchronized List<K> victimOrder()  { return policy.victimOrder(); }
    public synchronized String stats() {
        long total = hits + misses;
        double ratio = total == 0 ? 0 : (100.0 * hits) / total;
        return String.format("hits=%d misses=%d evictions=%d hitRatio=%.0f%%", hits, misses, evictions, ratio);
    }
}

public class CacheDemo {
    private static final String[] TRACE = { "A", "A", "A", "B", "C", "B", "D", "E", "B", "A" };

    /** The slow thing the cache exists to avoid. */
    private static String load(String key) { return "value-of-" + key; }

    private static void run(EvictionPolicy<String> policy, int capacity) {
        Cache<String, String> cache = new Cache<>(capacity, policy);
        System.out.println("---- " + policy.name() + " - capacity " + capacity + " ----");
        for (int step = 0; step < TRACE.length; step++) {
            String key = TRACE[step];
            boolean hit = cache.get(key).isPresent();
            String note = "";
            if (!hit) {
                List<String> before = cache.victimOrder();
                String victim = (cache.size() >= capacity && !before.isEmpty()) ? before.get(0) : null;
                cache.put(key, load(key));
                if (victim != null) note = "   evicted " + victim;
            }
            System.out.printf("  %2d  get(%s) %-4s  next-to-die %s%s%n",
                step + 1, key, hit ? "HIT" : "MISS", cache.victimOrder(), note);
        }
        System.out.println("  " + cache.stats());
        System.out.println();
    }

    public static void main(String[] args) {
        run(new LruPolicy<>(), 3);
        run(new LfuPolicy<>(), 3);
        run(new FifoPolicy<>(), 3);   // LRU with recordAccess() removed — watch the hit ratio
    }
}

/* ---- expected output ------------------------------------------------------
---- LRU - capacity 3 ----
   1  get(A) MISS  next-to-die [A]
   2  get(A) HIT   next-to-die [A]
   3  get(A) HIT   next-to-die [A]
   4  get(B) MISS  next-to-die [A, B]
   5  get(C) MISS  next-to-die [A, B, C]
   6  get(B) HIT   next-to-die [A, C, B]
   7  get(D) MISS  next-to-die [C, B, D]   evicted A
   8  get(E) MISS  next-to-die [B, D, E]   evicted C
   9  get(B) HIT   next-to-die [D, E, B]
  10  get(A) MISS  next-to-die [E, B, A]   evicted D
  hits=4 misses=6 evictions=3 hitRatio=40%

---- LFU - capacity 3 ----
   6  get(B) HIT   next-to-die [C, B, A]
   7  get(D) MISS  next-to-die [D, B, A]   evicted C
   8  get(E) MISS  next-to-die [E, B, A]   evicted D
   9  get(B) HIT   next-to-die [E, A, B]
  10  get(A) HIT   next-to-die [E, B, A]
  hits=5 misses=5 evictions=2 hitRatio=50%

---- FIFO (the bug) - capacity 3 ----
  hits=3 misses=7 evictions=4 hitRatio=30%
--------------------------------------------------------------------------- */

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

Why is a HashMap on its own not enough to build an LRU cache?

question 02 / 08

Why must the linked list be doubly linked rather than singly linked?

question 03 / 08

What do the sentinel HEAD and TAIL nodes actually buy you?

question 04 / 08

A candidate implements the list correctly but forgets to move the node on a cache hit. What happens?

question 05 / 08

What must put(key, value) do when the key is already present?

question 06 / 08

In an O(1) LFU cache, how do you find the least-frequently-used key without scanning?

question 07 / 08

The interviewer asks you to make the cache thread-safe. Why does a ReadWriteLock not help the way people expect?

question 08 / 08

You have a working LRU cache and the interviewer says “now make it LFU”. What determines whether that is a five-minute answer or a rewrite?

0/8 answered