LFU Cache is a classic hard design problem. The twist over LRU is that we evict by how often an entry has been used, not by how recently — and we still have to make every get and put run in O(1).
Problem. Design a cache with a fixed capacity. get(key) returns the value or -1. put(key, val)
inserts or updates a value. When the cache is full, evict the least-frequently-used entry first; if
several entries tie on frequency, evict the least-recently-used among them. Both operations must be O(1).
Example: capacity 2. Cache holds A and B at frequency 1. get(A) bumps A to frequency 2. Then put(C)
is full, so it evicts B (the lowest-frequency entry) and inserts C at frequency 1.
The slow way first
The naive cache stores entries plus a use-count, and on eviction it scans every entry to find the minimum count. That scan is O(n) per put — too slow. Sorting entries by frequency does not help either: a single get changes one count, and re-sorting is O(n log n).
The question to ask: can I jump straight to the lowest-frequency entry without scanning? If I keep entries grouped by frequency, I can.
The idea: group by frequency, track the minimum
Keep two maps and one number:
key_to_node— maps a key directly to its node, so any lookup is O(1).freq_buckets— maps a frequency to a doubly linked list of every node at that frequency, kept in most-recently-used order. Adding or removing a node from a DLL is O(1).min_freq— the smallest non-empty frequency. The eviction victim always lives at the front of themin_freqbucket.
A get or put uses an entry, so it bumps that node from its bucket to the next-higher bucket. Eviction reads straight from the min_freq bucket — no scan.
The key insight: min_freq only ever rises when a get empties the current min bucket, and it resets to 1 on every fresh insert (a new entry always starts at frequency 1).
Walk through it
Step through the animation. A and B start in the freq-1 bucket. get(A) slides A into the freq-2 bucket, but min_freq stays 1 because B is still there. Then put(C) finds the cache full, evicts B from the min_freq bucket, inserts C at frequency 1, and resets min_freq to 1.
Pseudocode
maps: key_to_node, freq_buckets # freq -> doubly linked list
min_freq = 0
get(key):
if key not in key_to_node: return -1
bump(key) # freq += 1, move node to next bucket
if min_freq bucket is now empty: min_freq += 1
return node.value
put(key, val):
if capacity == 0: return
if size >= capacity:
victim = front of min_freq bucket # least-freq, least-recent
remove victim from key_to_node and its bucket
add new node at frequency 1
min_freq = 1 # a fresh insert is always freq 1The Python solution
class LFUCache:
def __init__(self, capacity):
self.cap = capacity
self.key_to_node = {} # key -> node
self.freq_buckets = {} # freq -> DLL of nodes
self.min_freq = 0
def get(self, key):
if key not in self.key_to_node:
return -1
self._bump(key) # freq += 1, move bucket
if not self.freq_buckets[self.min_freq]:
self.min_freq += 1
return self.key_to_node[key].val
def put(self, key, val):
if self.cap == 0:
return
if len(self.key_to_node) >= self.cap:
victim = self.freq_buckets[self.min_freq].pop_lru()
del self.key_to_node[victim.key]
node = self._add(key, val) # new node at freq 1
self.freq_buckets[1].push(node)
self.min_freq = 1key_to_nodeis the index that makes any lookup O(1) — no walking lists to find an entry.freq_buckets[f]is a doubly linked list of every node at frequencyf, ordered most-recent-first, so pushing a bumped node or popping the LRU victim is O(1)._bumpremoves the node from its current bucket and appends it to thefreq+1bucket — that is the slide you see in the animation.- After a bump, if the old
min_freqbucket became empty,min_freqrises by one (line 12-13). - On
put, eviction reads the victim straight from themin_freqbucket (line 20) — no scan. - Every fresh insert lands at frequency 1, so
min_freqresets to 1 (line 24).
Complexity
| Case | Time | Notes |
|---|---|---|
| Scan-for-min cache | O(n) (moderate) | every put scans all entries |
| Buckets + min_freq (this) | O(1) (fast) | map lookup + DLL splice |
O(n) (moderate)We spend O(n) space on the two maps and the bucket lists, and in return every get and put is constant time. The trick — group items by a key so the extreme is always at a known spot — is the same move behind bucket sort and the LRU cache.
When this pattern shows up
Whenever a design problem needs O(1) access and O(1) removal of a specific element, reach for a hash map paired with a doubly linked list. LRU Cache, LFU Cache, and "all O(1) data structure" are all the same shape: the map finds the node, the linked list reorders it in constant time.
The easy bug is mishandling min_freq. It only rises inside get when a bump empties the current min
bucket, and it must reset to 1 on every insert. Forget the reset and you evict the wrong entry the next
time the cache fills.
Practice
Cache (cap 2) holds A at freq 2 and C at freq 1. You call put(D). Which entry is evicted, and what is min_freq afterward?
1. Why group cache entries into a doubly linked list per frequency?
2. What does min_freq point to?
3. After inserting a brand-new key, what is min_freq?
4. When two entries tie on frequency, which is evicted?