LFU Cache with O(1) get, put (and delete)
Published 24 September 2026
LRU is the cache everyone knows how to build: a hash map plus one doubly linked list, move-to-front on access, evict from the tail. LFU (least frequently used) sounds like a small twist on it: evict the key with the lowest access count. The obvious way to build it is a min-heap keyed on frequency, and that gets you O(log n) per operation.
The interesting part is that you can get O(1) for both get and put without a
heap. The idea fits in one sentence:
Keep a separate linked list of keys for every frequency, and track the minimum frequency currently in the cache.
This post builds that up, explains why min_freq is so cheap to maintain, and
closes with what changes once you also want delete.
The data structures¶
Three pieces of state:
key_to_node: key -> Node
freq_to_list: freq -> DoublyLinkedList of Nodes with that frequency
min_freq: smallest frequency among all cached keys
Each node carries its own frequency and its own list pointers:
So the cache looks like a set of horizontal lists, one per frequency:
freq = 1: head <-> A <-> C <-> D <-> tail
freq = 2: head <-> B <-> F <-> tail
freq = 5: head <-> E <-> tail
min_freq = 1
Order inside a list matters. Ties are common: lots of keys have been read exactly once. The usual tie-breaker is LRU, so each frequency list is kept in recency order: most recently used at the front, least recently used at the back. The eviction victim is then always
Why the node stores prev, next and freq¶
On get("foo"), foo's frequency goes from, say, 3 to 4. That means removing it from
the frequency-3 list and adding it to the frequency-4 list.
If you had to find foo inside the frequency-3 list, you'd be back to O(n). Instead,
key_to_node hands you the node directly, and the node knows:
- its
freq, which tells you which list it is in, and - its
prev/next, which let you unlink it without walking the list.
Unlinking is O(1), adding to the front of the next list is O(1), and the hash map lookups are O(1). You never search a list.
The operations¶
get(key)¶
Say B is in the frequency-2 list:
Accessing B bumps it to frequency 3:
The steps:
- Look up the node. Missing, return -1.
- Unlink it from
freq_to_list[node.freq]. - If that list is now empty and it was the
min_freqlist, domin_freq += 1. node.freq += 1, then push the node to the front of the new frequency's list.
Step 3 is the whole trick, and it's worth asking why += 1 is enough. It could look
like we need to search for the next non-empty frequency. We don't: the node we just
removed is about to land at exactly old_freq + 1. So that list is guaranteed to be
non-empty, and nothing can sit below it because old_freq was the minimum.
put(key, value)¶
Two cases.
Existing key. Update the value and treat it as an access (same frequency bump as
get).
New key. If the cache is full, evict the tail of the min_freq list, which is the
least recently used key among the least frequently used ones:
Then insert the new key at frequency 1 and set min_freq = 1.
That last assignment is the second half of the trick. Every new key starts at
frequency 1, so after any insert, min_freq is 1. You don't compute it, you just
know it.
Frequency gaps are fine¶
This confused me at first. Suppose a few keys get hammered and then a new key shows up:
There's nothing at frequencies 2 through 9. Does that break anything?
No. If the cache is full and X arrives, we evict from freq_to_list[1] (A), insert X
at frequency 1, and min_freq stays 1:
The gap only matters if min_freq ever has to jump across it, and with just get
and put it never does:
putof a new key resetsmin_freqto 1, which is the right answer.get/ update only increasesmin_freqby exactly 1, and only when the node being bumped emptied the minimum list. That node lands atmin_freq + 1, so the new minimum is always right next door.
So with get and put, min_freq moves up one step at a time or drops straight to 1,
and both cases are O(1). That's why it keeps working even though the frequencies are
sparse.
Complete Python implementation¶
class Node:
def __init__(self, key, value):
self.key = key
self.value = value
self.freq = 1
self.prev = None
self.next = None
class DoublyLinkedList:
def __init__(self):
# sentinels, so add/remove never special-case the ends
self.head = Node(None, None)
self.tail = Node(None, None)
self.head.next = self.tail
self.tail.prev = self.head
self.size = 0
def add_front(self, node):
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
self.size += 1
def remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
node.prev = node.next = None
self.size -= 1
def remove_last(self):
if self.size == 0:
return None
node = self.tail.prev
self.remove(node)
return node
def empty(self):
return self.size == 0
class LFUCache:
def __init__(self, capacity):
self.capacity = capacity
self.key_to_node = {} # key -> Node
self.freq_to_list = {} # freq -> DoublyLinkedList (MRU at front)
self.min_freq = 0
def _touch(self, node):
old_freq = node.freq
old_list = self.freq_to_list[old_freq]
old_list.remove(node)
if old_list.empty():
del self.freq_to_list[old_freq]
# node is about to land at old_freq + 1, so that is the new minimum
if self.min_freq == old_freq:
self.min_freq += 1
node.freq += 1
self.freq_to_list.setdefault(node.freq, DoublyLinkedList()).add_front(node)
def get(self, key):
node = self.key_to_node.get(key)
if node is None:
return -1
self._touch(node)
return node.value
def put(self, key, value):
if self.capacity == 0:
return
node = self.key_to_node.get(key)
if node is not None:
node.value = value
self._touch(node)
return
if len(self.key_to_node) == self.capacity:
lfu_list = self.freq_to_list[self.min_freq]
victim = lfu_list.remove_last() # LRU among the least frequent
if lfu_list.empty():
del self.freq_to_list[self.min_freq]
del self.key_to_node[victim.key]
node = Node(key, value)
self.key_to_node[key] = node
self.freq_to_list.setdefault(1, DoublyLinkedList()).add_front(node)
self.min_freq = 1
A couple of details that are easy to get wrong:
- Delete empty frequency lists. Otherwise
freq_to_listslowly fills with dead lists for every frequency a key has ever passed through. capacity == 0. Without the early return, the eviction branch readsfreq_to_list[0]on the very firstputand crashes.- Update counts as a use.
puton an existing key bumps its frequency. LeetCode 460 expects this, and it's the natural reading of "use".
I checked this against a brute-force reference (a plain dict of
(value, freq, last_used) that scans for the victim) over a few thousand random
sequences of operations, and the two agree.
Why it's actually O(1)¶
Trace a get:
hash lookup (key_to_node) O(1)
unlink from old freq list O(1) ← node has prev/next
maybe min_freq += 1 O(1) ← no search, see above
hash lookup (freq_to_list) O(1)
push to front of new freq list O(1)
put is the same, plus an eviction that's just remove_last() on a list we already
have a handle to. At no point do we walk a list or scan frequencies.
The mental model:
key_to_node
│
▼
┌───────────────┐
│ Node │
│ key, value │
│ freq │
│ prev / next │
└───────┬───────┘
│ lives in the list for its freq
┌──────────┴──────────┐
▼ ▼
freq = 2 freq = 5
A <-> B <-> C X <-> Y
min_freq = 2
Access B, and it moves to a fresh frequency-3 list (min_freq stays 2, since A and C
are still there). Access A and C too, and the frequency-2 list empties, so min_freq
becomes 3. Each step is local.
What about delete(key)?¶
Here's where it gets tricky. Everything above relied on one fact: min_freq either
resets to 1 (on insert) or steps up by exactly 1 (on access). Arbitrary deletion
breaks that.
Now delete(A). The frequency-1 list is empty, and the correct new min_freq is 10.
Nothing local tells you that. The naive fix scans 2, 3, …, 10, which is O(gap) and can
be arbitrarily large. A sorted set of active frequencies works but costs O(log n).
The fix is to add a vertical linked list over the frequencies: turn each frequency list into a bucket and chain the buckets together in increasing order. Only frequencies that currently hold at least one key get a bucket, so the chain stays sparse:
With that in place:
- Deleting the last key in a bucket unlinks the bucket from the chain in O(1), and
if it was the minimum, the new minimum is just
bucket.next. No searching, because the chain is the set of non-empty frequencies. - Bumping a node from frequency f to f+1 inserts a new bucket right after f's bucket if one doesn't already exist. That's O(1) too, since f+1 can only go immediately after f.
- Inserting a new key goes into the bucket at the head of the chain (creating it if the head isn't frequency 1).
In fact, once you have the bucket chain you don't need min_freq at all. The head
of the chain is the minimum. This is the design from the well-known O(1) LFU paper
(Shah, Mitra & Matani, 2010), and it's what you'd reach for if deletes are part of the
API.
Should the cache support delete at all? For a pure eviction cache you can often get
away without it. But real caches need invalidation ("this user's profile changed, drop
it"), so in practice you want it, and the bucket chain is how you keep it O(1).
Critique¶
The implementation above is correct (it matches a brute-force reference), and it's the answer an interviewer expects. Here's where it falls short of something I'd ship.
Interface problems¶
-1means "miss", and-1is also a value. Afterput("a", -1),get("a")andget("missing")both return-1, and the caller can't tell them apart. That's a LeetCode convention leaking into the API. ReturnNoneor a caller-supplied default, or raiseKeyErrorlike a dict does.- Negative capacity is silently unbounded.
capacity == 0is handled, but withcapacity = -1the checklen(...) == self.capacitynever fires, so the cache grows forever (100 puts leave 100 keys). Rejectcapacity < 0in__init__. - The title promises
delete; the code doesn't have it. The bucket chain is only described in prose. Withoutdeletethere's no invalidation path, and a cache with no invalidation path will serve stale data. - No thread safety.
_touchis several separate mutations across two dicts and two lists. The GIL makes each bytecode atomic, not the sequence, so two threads callinggetcan corrupt the lists. A real cache needs a lock around every public method, or a design that buffers reads and applies them in batches (which is what Caffeine does).
The hand-rolled linked list is the slow part¶
DoublyLinkedList exists because the textbook version is written for C++ or Java. In
Python, collections.OrderedDict already is a hash map over a doubly linked list,
implemented in C. del od[key], od[key] = None (append at the end) and
od.popitem(last=False) (pop the oldest) are all O(1). Swapping it in removes Node,
DoublyLinkedList and every pointer update:
from collections import OrderedDict, defaultdict
class LFUCache:
def __init__(self, capacity):
self.capacity = capacity
self.vals = {} # key -> (value, freq)
self.lists = defaultdict(OrderedDict) # freq -> keys, LRU first
self.min_freq = 0
def _touch(self, key):
value, freq = self.vals[key]
del self.lists[freq][key]
if not self.lists[freq]:
del self.lists[freq]
if self.min_freq == freq:
self.min_freq += 1
self.vals[key] = (value, freq + 1)
self.lists[freq + 1][key] = None
def get(self, key):
if key not in self.vals:
return -1
self._touch(key)
return self.vals[key][0]
def put(self, key, value):
if self.capacity <= 0:
return
if key in self.vals:
self.vals[key] = (value, self.vals[key][1])
self._touch(key)
return
if len(self.vals) == self.capacity:
lfu = self.lists[self.min_freq]
victim, _ = lfu.popitem(last=False)
if not lfu:
del self.lists[self.min_freq]
del self.vals[victim]
self.vals[key] = (value, 1)
self.lists[1][key] = None
self.min_freq = 1
It agrees with the original on 3,000 random operation sequences, and on 200,000 mixed
operations against a 500-entry cache it runs about 2x faster (0.04 s against 0.08 s
on my machine). It also allocates less. The original creates a Node with a full
instance __dict__ per key (no __slots__), plus a new list with two sentinel nodes
every time a key reaches a frequency nobody else is at.
The recency order is flipped (oldest first, since OrderedDict appends at the end), but
the rule is the same. The -1 is kept here only so the two versions can be compared.
LFU itself is the bigger problem¶
Even a perfect implementation inherits LFU's weaknesses as a policy:
- Old popularity never expires. Counts only go up. A key that was hammered yesterday and is never read again keeps its high count and outlives everything new. In a test with capacity 3, two keys read 1,000 times each and then never again stayed cached, while a new working set of five keys cycling through the last slot got 0 hits in 1,000 reads.
- New keys can't get established. Every new key enters at frequency 1, which is the eviction end. If the cache is full of established keys, each newcomer is evicted by the next newcomer before it has a chance to earn a second hit.
Production caches fix the policy, not the data structure. Redis's allkeys-lfu keeps a
small logarithmic counter per key that decays over time, so old popularity fades.
Caffeine's W-TinyLFU estimates frequency in a compact count-min sketch that is
periodically halved, and puts a small LRU window in front so new keys get a trial
period before they compete on frequency. If you need LFU behaviour in practice, start
there rather than with an exact per-key count.
Summary¶
| get / put only | get / put / delete | |
|---|---|---|
| Per-key lookup | key -> Node |
key -> Node |
| Per-frequency lists | freq -> DLL |
buckets, each holding a DLL |
| Finding the minimum | min_freq int |
head of the bucket chain |
| Why it's O(1) | min only resets to 1 or steps +1 | empty bucket unlinks, next is the new min |
The broader lesson: an O(1) structure usually depends on an invariant that makes the "hard" query (here, what's the minimum?) answerable from local information. Adding an operation that breaks the invariant means adding structure to restore it.