LFU Cache
A fixed-capacity cache that evicts the entry with the lowest access count (ties broken by least recent), in O(1) using a map of frequency buckets.
Definition
An LFU cache evicts the key that has been accessed the fewest times; when several keys tie, the least recently used among them goes. It rewards keys that are hot over the long term, resisting the scan pollution that hurts an LRU Cache.
The O(1) design keeps three structures: keyToNode (key → node with value and frequency), freqToList (frequency → doubly linked list of nodes in LRU order), and minFreq (the smallest frequency currently present). Every access removes the node from its current frequency list and appends it to the list for freq + 1; eviction pops the LRU end of freqToList[minFreq].
The subtlety is minFreq maintenance: after a get/put on an existing key, if the old list became empty and it was minFreq, increment minFreq. After inserting a brand-new key, minFreq is always 1.
Intuition
A mental model before the formal terms.
A library sorts books onto shelves by how many times they have been checked out: shelf 1, shelf 2, shelf 3, … Each checkout moves the book one shelf up, placing it at the "newest" end of that shelf. When space runs out, the librarian goes to the lowest non-empty shelf and removes the book that has been sitting there the longest. A catalogue card (hash map) records which shelf and position every book is on, so no searching is needed.
How it works
- Node:
key,value,freq,prev,next. Bucket: a doubly linked list with sentinels for one frequency. get(key): if absent → miss. Otherwisetouch(node)and return the value.touch(node): unlink fromfreqToList[node.freq]; if that list is now empty andnode.freq == minFreq,minFreq++;node.freq++; append to the front (MRU end) offreqToList[node.freq], creating the bucket if needed.put(key, value): if the key exists, set the value andtouch. Otherwise, if at capacity, evict: take the tail (LRU) node offreqToList[minFreq], unlink it, delete fromkeyToNode. Then create a node withfreq = 1, insert into bucket 1, setminFreq = 1.- Delete empty buckets from
freqToList(or leave them; both are correct ifminFreqlogic checks for emptiness).
Why it works
Each bucket is ordered by recency, so within a frequency the tail is the LRU — giving the required tiebreak.
minFreq can only rise by exactly one after an access (the touched node moves from f to f + 1, and only if bucket f was minFreq and is now empty) or reset to 1 on insertion, so it is maintained in O(1) without scanning.
All list operations are pointer swaps with sentinels; all map operations are O(1) average.
Operations
| Operation | Description | Cost |
|---|---|---|
| get(key) | Lookup; move node to the next frequency bucket. | O(1) |
| put(key, value) | Insert with freq 1 or update; evict LRU of minFreq bucket if full. | O(1) |
| evict() | Pop tail of freqToList[minFreq]. | O(1) |
| frequency(key) | Read node.freq. | O(1) |
Recognition
How to tell a problem wants this.
- "Evict the least frequently used", "count accesses", "ties broken by recency".
- Follow-up to the LRU design question.
- Workloads where a few keys are accessed far more than the rest and should never be evicted by a one-off scan.
Interactive demo
Play, step, change the input. ← → and space work too.
No interactive visualization for this topic yet
Related visualizations are linked under Related.
Pseudocode
1get(k): if k not in nodes: return -1; touch(nodes[k]); return value2touch(n): remove n from bucket[n.freq]; if bucket empty and n.freq == minFreq: minFreq += 13 n.freq += 1; push n to front of bucket[n.freq]4put(k, v): if k in nodes: update, touch; return5 if size == cap: victim = tail of bucket[minFreq]; remove; delete nodes[victim.key]6 n = Node(k, v, freq=1); push front of bucket[1]; nodes[k] = n; minFreq = 1Implementation
1from collections import defaultdict2from typing import Generic, Hashable, Optional, TypeVar3 4K = TypeVar("K", bound=Hashable)5V = TypeVar("V")6 7 8class _Entry(Generic[V]):9 __slots__ = ("val", "freq")10 11 def __init__(self, val: V):12 self.val = val13 self.freq = 114 15 16class LFUCache(Generic[K, V]):171 · State — key→entry map, frequency buckets (front = most recent), minFreq18 def __init__(self, capacity: int):19 self.cap = capacity20 self.entries: dict[K, _Entry[V]] = {}21 # freq -> insertion-ordered dict used as an ordered set (first key = least recent)22 self.buckets: defaultdict[int, dict[K, None]] = defaultdict(dict)23 self.min_freq = 024 252 · touch — move an entry to the next frequency bucket26 def _touch(self, key: K, e: _Entry[V]) -> None:27 bucket = self.buckets[e.freq]28 del bucket[key]29 if not bucket:30 del self.buckets[e.freq]31 if self.min_freq == e.freq:32 self.min_freq += 1 # min_freq can only rise by exactly one33 e.freq += 134 self.buckets[e.freq][key] = None # appended -> most recent in its bucket35 363 · get — look up and touch37 def get(self, key: K) -> Optional[V]:38 e = self.entries.get(key)39 if e is None:40 return None41 self._touch(key, e)42 return e.val43 444 · put — update existing, or evict LRU of the minFreq bucket and insert at freq 145 def put(self, key: K, val: V) -> None:46 if self.cap == 0:47 return48 e = self.entries.get(key)49 if e is not None:50 e.val = val51 self._touch(key, e)52 return53 if len(self.entries) >= self.cap: # evict BEFORE inserting the new key54 bucket = self.buckets[self.min_freq]55 victim = next(iter(bucket)) # first inserted = least recent56 del bucket[victim]57 if not bucket:58 del self.buckets[self.min_freq]59 del self.entries[victim]60 self.entries[key] = _Entry(val)61 self.buckets[1][key] = None62 self.min_freq = 163 645 · Size65 def __len__(self) -> int:66 return len(self.entries)- Buckets are plain dicts used as ordered sets (
dict[K, None]): insertion order is guaranteed, deletion anywhere is O(1), andnext(iter(bucket))yields the oldest key — the LRU victim within a frequency. _touchremoves the key from its old bucket, deletes the bucket if it emptied (bumpingmin_freqif it was the minimum), then appends the key to the bucket forfreq + 1viadefaultdict.getreturnsNoneon a miss; otherwise it touches the entry and returnse.val.putat capacity picksnext(iter(self.buckets[self.min_freq])), deletes it from both structures, then inserts the new_Entryat frequency 1 and setsmin_freq = 1._Entryuses__slots__to keep the per-key overhead at two attributes.
One dict per distinct frequency in use; empty buckets are deleted eagerly.
- A
dict[K, None]is the standard ordered-set idiom —setiterates in arbitrary order, so it cannot break frequency ties by recency. defaultdict(dict)auto-creates a bucket on first append; the explicitdel self.buckets[...]calls keep empty buckets from lingering (and from makingmin_freqpoint at an empty dict).- Unlike LRU, no stdlib type implements LFU for you —
OrderedDictgives recency, not frequency; this composition of dicts is the idiomatic build. next(iter(d))is O(1);list(d)[0]copies the whole bucket.
- Using
setfor buckets — arbitrary iteration order breaks the recency tiebreak. - Evicting after inserting the new key, which can evict the key just added.
- Reading
self.buckets[self.min_freq]through thedefaultdictafter forgetting to delete an empty bucket — it silently returns{}andnext(iter(...))raisesStopIteration. - Forgetting
self.min_freq = 1on insert.
- The recency list inside each bucket: C++ uses
std::list<K>with stored iterators; JS/TS exploit insertion-orderedSet; Python uses adict[K, None]as an ordered set —setitself is unordered and would break the tiebreak. - Miss signalling: C++
std::optional<V>, TSV | undefined, PythonNone, JS-1(the LeetCode contract). - C++
entries.emplacesidestepsoperator[]'s requirement that the value type be default-constructible — no analogue exists (or is needed) in the other languages. - No language has LFU in its standard library (unlike LRU in Python); all four compose it from maps plus an ordered per-frequency structure.
Complexity
| Operation | Average | Worst | Note |
|---|---|---|---|
| Access | O(1) | O(1) | By key. |
| Search | O(1) | O(n) | Hash-map worst case. |
| Insert | O(1) | O(1) | |
| Delete | O(1) | O(1) | Eviction. |
| Update | O(1) | O(1) | |
| Get | O(1) | O(1) | |
| Put | O(1) | O(1) | |
| Evict | O(1) | O(1) | |
| Space | O(capacity) | Plus one bucket header per distinct frequency in use. | |
Advantages & disadvantages
- Keeps long-term hot keys resident regardless of transient scans.
O(1)for every operation with the bucket design.- Provides access counts as a by-product.
- More code and memory than LRU: a second map and one list per frequency.
- Stale popularity: a key that was hot last week keeps a high count and is hard to evict (mitigated by aging/decay, which complicates the
O(1)design). - New keys enter at frequency 1 and are evicted first, so bursty new working sets struggle to get in.
Use cases
- CDN and object caches with stable popularity distributions.
- Database block caches (with aging) and JIT compilers deciding what to keep compiled.
- Interview design question LFU Cache (LeetCode 460).
- Popularity is skewed and stable; hot keys should survive scans.
- The interviewer asks for LFU with
O(1)operations.
- Recency is the better predictor (most workloads) — LRU Cache is simpler and often hits more.
- Popularity shifts over time and you cannot afford aging logic.
- Capacity is tiny; the extra structures outweigh the benefit.
Alternatives
Common mistakes
- Using a Min-Heap keyed on frequency —
O(log n)per operation and awkward recency tiebreaks. - Not resetting
minFreq = 1after inserting a new key. - Incrementing
minFreqwhen the old bucket is empty even if it was not theminFreqbucket. - Evicting from the head (MRU) end of the bucket instead of the tail (LRU).
- Forgetting the
capacity == 0edge case. - Evicting *after* inserting the new key, which can evict the key just added.
Interview patterns
- LFU Cache (LeetCode 460): the bucket-of-lists design above.
- Compare LRU vs LFU behaviour on a scan followed by a hot-key workload.
- Discuss aging/decay and approximate LFU (TinyLFU, count-min sketches).
- Recognizing the approach from an array and a targetIntermediate
- Deciding whether O(n²) can be improvedIntermediate
- Hash map or array?Beginner
- Array versus linked listBeginner
- Two SumBeginner
- Longest Substring Without Repeating CharactersIntermediate
- Subarray Sum Equals KIntermediate