Design LRU and LFU Caches
Design problems are a distinct interview category. They rarely need a clever algorithm; they need you to compose two ordinary data structures so that every required operation is .
The recurring move is always the same:
A hash map gives lookup but no order. A linked list gives insert and remove at a known position but no lookup. Put them together — the map storing pointers into the list — and you get both.
That single combination is the answer to LRU, LFU, and a surprising number of “design X with everything” questions.
What you’ll learn
- Why a hash map alone, or a list alone, cannot meet the requirements.
- The map-to-node pattern, and Python’s
OrderedDictOrderedDictshortcut. - LFU: frequency buckets, and the
min_freqmin_freqtrick that keeps eviction . - How to talk about design problems — clarify, state the invariant, then code.
- Three real LeetCode problems solved in the browser: 706, 146, 460.
The cue
Why one structure is not enough
| Structure | Lookup by key | Ordering | Remove a known element |
|---|---|---|---|
| Hash map | ✅ | ❌ none | ✅ |
| Array / list | ❌ | ✅ | ❌ (shifts) |
| Singly linked list | ❌ | ✅ | ❌ (need the predecessor) |
| Doubly linked list | ❌ | ✅ | ✅ given the node |
| Map + doubly linked list | ✅ | ✅ | ✅ |
The doubly linked list is essential rather than incidental: to unlink a node in
you need its predecessor, and only a prevprev pointer gives you that
without searching.
The LRU template
class Node:
def __init__(self, key=0, val=0):
self.key, self.val = key, val
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {} # key -> Node
# sentinel head/tail remove every empty-list special case
self.head, self.tail = Node(), Node()
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_front(self, node): # front == most recently used
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
if key not in self.map:
return -1
node = self.map[key]
self._remove(node) # a read counts as a use
self._add_front(node)
return node.val
def put(self, key, value):
if key in self.map:
self._remove(self.map[key])
node = Node(key, value)
self.map[key] = node
self._add_front(node)
if len(self.map) > self.cap:
lru = self.tail.prev # back == least recently used
self._remove(lru)
del self.map[lru.key] # the node stores its key FOR THISclass Node:
def __init__(self, key=0, val=0):
self.key, self.val = key, val
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {} # key -> Node
# sentinel head/tail remove every empty-list special case
self.head, self.tail = Node(), Node()
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_front(self, node): # front == most recently used
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
if key not in self.map:
return -1
node = self.map[key]
self._remove(node) # a read counts as a use
self._add_front(node)
return node.val
def put(self, key, value):
if key in self.map:
self._remove(self.map[key])
node = Node(key, value)
self.map[key] = node
self._add_front(node)
if len(self.map) > self.cap:
lru = self.tail.prev # back == least recently used
self._remove(lru)
del self.map[lru.key] # the node stores its key FOR THISThe OrderedDictOrderedDict shortcut
Python’s collections.OrderedDictcollections.OrderedDict is a hash map plus a doubly linked list,
with the two operations you need exposed directly:
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # O(1): mark most-recently used
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.cap:
self.data.popitem(last=False) # O(1): evict the oldestfrom collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # O(1): mark most-recently used
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.cap:
self.data.popitem(last=False) # O(1): evict the oldestLFU — frequency buckets
LFU evicts the least frequently used item, breaking ties by least recently used. The naive approach scans for the minimum frequency: per eviction.
The design keeps three structures:
key_to_valkey_to_val— the values.key_to_freqkey_to_freq— each key’s use count.freq_to_keysfreq_to_keys— for each frequency, anOrderedDictOrderedDictof keys in LRU order. That inner ordering is what breaks ties.
Plus one integer: min_freqmin_freq.
| LRU | LFU | |
|---|---|---|
| Structures | map + doubly linked list | map + freq map + bucket of OrderedDictOrderedDicts |
| Eviction key | back of the list | min_freqmin_freq bucket, oldest entry |
getget / putput | ||
| Space |
The variant map
| Variant | The composition | Canonical problem |
|---|---|---|
| Hash map from scratch | Array of buckets + chaining | 706 · 705 |
| LRU eviction | Map + doubly linked list (or OrderedDictOrderedDict) | 146 |
| LFU eviction | Map + frequency buckets + min_freqmin_freq | 460 |
| Min/max count queries | Count buckets as a doubly linked list | 432 All O`one |
| random member | Map + dense array (swap-with-last) | 380 |
| Time-bounded eviction | Map + queue | 933 · 362 |
Practice — real LeetCode problems
LC 706 — Design HashMap · Easy
Problem. Design a MyHashMapMyHashMap without using any built-in hash table
library. Support put(key, value)put(key, value), get(key)get(key) (returning -1-1 if absent), and
remove(key)remove(key).
Constraints. 0 <= key, value <= 10^60 <= key, value <= 10^6, up to 10^410^4 calls.
Examples. put(1,1)put(1,1), put(2,2)put(2,2), get(1)get(1) gives 11, get(3)get(3) gives -1-1,
put(2,1)put(2,1), get(2)get(2) gives 11, remove(2)remove(2), get(2)get(2) gives -1-1
Editorial — approach, complexity, follow-ups
Separate chaining. Hash the key to a bucket index, and store colliding entries in a list within that bucket.
Time average, worst case per operation. Space .
The detail that catches people is that putput must overwrite, not append.
Appending creates two entries for the same key; getget happens to return the
first, so the bug is invisible until a removeremove deletes one copy and the stale
one resurfaces. The put(2,1)put(2,1) step in the tests is there to catch it.
get(1000001)get(1000001) returning -1-1 exercises a genuine collision: 1000001 % 1000 == 11000001 % 1000 == 1, the same bucket as key 11. Without the k == keyk == key check inside the bucket
scan you would wrongly return 11.
Choosing the bucket count matters: a prime modulus distributes keys with regular patterns better than a round number like 1000. Mentioning that shows you understand hashing rather than just chaining.
Follow-ups you should expect: “What if the load factor grows?” — resize:
double the bucket count and rehash everything, amortised per insert, the
same amortisation as
dynamic arrays.
“Open addressing instead?” — linear or quadratic probing; no per-bucket lists,
but deletion needs tombstones. “How does Python’s own dictdict work?” — open
addressing with a compact, insertion-ordered layout since 3.6.
LC 146 — LRU Cache · Medium
Problem. Design a cache with a fixed positive capacitycapacity supporting
get(key)get(key) (returning -1-1 if absent) and put(key, value)put(key, value), evicting the
least recently used key when capacity is exceeded. Both operations must run
in average time.
Constraints. 1 <= capacity <= 30001 <= capacity <= 3000, 0 <= key <= 10^40 <= key <= 10^4,
0 <= value <= 10^50 <= value <= 10^5, up to 2 * 10^52 * 10^5 calls.
Examples. With capacity 2: put(1,1)put(1,1), put(2,2)put(2,2), get(1)get(1) gives 11,
put(3,3)put(3,3) evicts key 2, get(2)get(2) gives -1-1, put(4,4)put(4,4) evicts key 1,
get(1)get(1) gives -1-1, get(3)get(3) gives 33, get(4)get(4) gives 44
Editorial — approach, complexity, follow-ups
Maintain a most-recent-to-least-recent ordering alongside lookup. An
OrderedDictOrderedDict provides both.
Time for both operations. Space .
The behaviour that the test sequence pins down is that getget is a use. After
put(1,1)put(1,1), put(2,2)put(2,2), get(1)get(1), the order is 22 then 11 — so put(3,3)put(3,3)
evicts 22, not 11. A solution that only refreshes on putput evicts 11 and
fails here. Always clarify this: “does a read count as a use?” It does for LRU
by definition, but asking shows you are thinking about the spec.
Be ready for the manual doubly-linked-list implementation — interviewers often require it, and the two details are sentinel nodes and storing the key inside each node (so eviction can clean up the map). Both are covered in the template above.
Follow-ups you should expect:
- “Implement it without
OrderedDictOrderedDict.” The manual version; the likeliest follow-up. - “Make it thread-safe.” A lock around each operation, or finer-grained striping. Worth naming; real caches need it.
- “Add a TTL.” Store an expiry timestamp per entry and treat expired entries as absent on read; a background sweep or lazy purge handles removal.
- “LFU instead?” LC 460, next.
- “Capacity zero?” LC 146 guarantees
capacity >= 1capacity >= 1, but a robust implementation should discard immediately rather than divide by zero or evict endlessly.
LC 460 — LFU Cache · Hard
Problem. Design a cache with capacitycapacity supporting getget and putput in
average time, evicting the least frequently used key. When several
keys tie on frequency, evict the least recently used among them. A getget
or a putput of an existing key both count as a use.
Constraints. 0 <= capacity <= 10^40 <= capacity <= 10^4, 0 <= key, value <= 10^50 <= key, value <= 10^5, up to
2 * 10^52 * 10^5 calls.
Examples. With capacity 2: put(1,1)put(1,1), put(2,2)put(2,2), get(1)get(1) gives 11,
put(3,3)put(3,3) evicts key 2 (frequency 1 versus key 1’s 2), get(2)get(2) gives
-1-1, get(3)get(3) gives 33, put(4,4)put(4,4) evicts key 1, get(1)get(1) gives -1-1,
get(3)get(3) gives 33, get(4)get(4) gives 44
Editorial — approach, complexity, follow-ups
Three maps plus one integer:
key_to_valkey_to_valandkey_to_freqkey_to_freq— the obvious bookkeeping.freq_to_keys[f]freq_to_keys[f]— anOrderedDictOrderedDictof the keys at frequencyff, whose insertion order is oldest-first. That inner ordering is what resolves ties by least-recently-used, and it is why anOrderedDictOrderedDictrather than asetsetis required here.min_freqmin_freq— the current lowest frequency present.
Time for both operations. Space .
The whole difficulty is keeping min_freqmin_freq correct without searching. It is
maintainable because of the argument in the note above: inserts force it to
11, and a bump can only ever raise it to f + 1f + 1.
Two details worth calling out:
- Set
min_freq = 1min_freq = 1at the end of an insert, after any eviction. Setting it before evicting would make the eviction look in the wrong bucket. - Guard
capacity <= 0capacity <= 0. LC 460 explicitly allowscapacity = 0capacity = 0, in which caseputputmust do nothing. Without the guard, the eviction branch tries to pop from an empty bucket and raises. This is the most common failure on the problem.
The _bump_bump helper is worth extracting, because getget and putput-on-existing-key
need identical behaviour — both count as a use. Duplicating that logic is where
subtle divergences creep in.
Follow-ups you should expect: “Why an OrderedDictOrderedDict per frequency and not a
set?” — ties must break by recency, which a set cannot express. “Implement it
with manual linked lists?” — a doubly linked list of frequency buckets, each
holding its own doubly linked list of nodes; this is also the structure behind
LC 432. “What about LFU with ageing, so old popularity decays?” — a real-world
concern; typically periodic halving of all counts, or a windowed variant.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 705 | Design HashSet | Easy | Chaining without values |
| 706 | Design HashMap | Easy | Chaining; putput must overwrite, not append |
| 146 | LRU Cache | Medium | OrderedDictOrderedDict, or map + doubly linked list with sentinels |
| 460 | LFU Cache | Hard | Frequency buckets + min_freqmin_freq; guard capacity 0 |
| 432 | All O`one Data Structure | Hard | Same bucket idea, but a doubly linked list of counts so min and max are |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Why both a map and a list?” | The core composition | The map gives lookup, the list gives ordering and unlinking; neither alone does both |
| “Why doubly linked?” | Precision | Unlinking in needs the predecessor, which only a prevprev pointer supplies |
| “Why store the key in the node?” | Whether you have built it | Eviction gives you the node but must delete the map entry, which needs the key |
“Does getget count as a use?” | Clarifying the spec | For LRU and LFU, yes — and the eviction order changes if you get it wrong |
“How is min_freqmin_freq ?” | The LFU insight | It is 11 after an insert, and f + 1f + 1 when a bump empties the old minimum bucket |
“Is OrderedDictOrderedDict acceptable?” | Honesty about shortcuts | Yes, and it is this composition — but be ready to implement it manually |
| “Make it thread-safe / add TTL” | Production thinking | A lock per operation; expiry timestamps with lazy purge on read |
Edge-case checklist
- Capacity 0 — legal in LC 460;
putputmust be a no-op. The most-failed detail. - Capacity 1 — every insert evicts; exercises the eviction path immediately.
putputon an existing key — must update the value and count as a use, not insert a duplicate.getgeton a missing key — return-1-1, and do not create an entry or disturb frequencies.- Eviction ties in LFU — equal frequencies break by least-recently-used; requires ordered buckets.
putputoverwriting in a hash bucket (LC 706) — replace, never append.- Hash collisions (LC 706) —
11and10000011000001share a bucket; the in-bucket key check matters. - Evicting then inserting — set
min_freq = 1min_freq = 1after the eviction.
Recap
- Design problems are about composing structures, not clever algorithms. A hash map gives lookup; a doubly linked list gives order and unlinking. Together they give everything.
- Sentinel head/tail nodes remove every empty-list special case; store the key in each node so eviction can clean the map.
- Python’s
OrderedDictOrderedDictis exactly this composition —move_to_endmove_to_endandpopitem(last=False)popitem(last=False)are both . Know it, and be ready to build it by hand. - LFU adds frequency buckets of
OrderedDictOrderedDicts (so ties break by recency) plus amin_freqmin_freqinteger that never needs searching. - Guard
capacity == 0capacity == 0and remember a read counts as a use. - Talk through the interface, the structures, and the invariant before coding — that is most of the score on a design question.
Next: Design with Stacks and Queues — building one primitive out of another, and the amortised argument that makes it efficient.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
