Skip to content

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 O(1)O(1).

The recurring move is always the same:

A hash map gives O(1)O(1) lookup but no order. A linked list gives O(1)O(1) 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 O(1)O(1) 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 OrderedDictOrderedDict shortcut.
  • LFU: frequency buckets, and the min_freqmin_freq trick that keeps eviction O(1)O(1).
  • 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

StructureLookup by keyOrderingRemove a known element
Hash mapO(1)O(1)❌ noneO(1)O(1)
Array / listO(n)O(n)O(n)O(n) ❌ (shifts)
Singly linked listO(n)O(n)O(n)O(n) ❌ (need the predecessor)
Doubly linked listO(n)O(n)O(1)O(1) ✅ given the node
Map + doubly linked listO(1)O(1)O(1)O(1)

The doubly linked list is essential rather than incidental: to unlink a node in O(1)O(1) you need its predecessor, and only a prevprev pointer gives you that without searching.

The LRU template

lru_manual.py
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 THIS
lru_manual.py
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 THIS

The OrderedDictOrderedDict shortcut

Python’s collections.OrderedDictcollections.OrderedDict is a hash map plus a doubly linked list, with the two operations you need exposed directly:

lru_ordereddict.py
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 oldest
lru_ordereddict.py
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 oldest

LFU — frequency buckets

LFU evicts the least frequently used item, breaking ties by least recently used. The naive approach scans for the minimum frequency: O(n)O(n) per eviction.

The O(1)O(1) 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, an OrderedDictOrderedDict of keys in LRU order. That inner ordering is what breaks ties.

Plus one integer: min_freqmin_freq.

LRULFU
Structuresmap + doubly linked listmap + freq map + bucket of OrderedDictOrderedDicts
Eviction keyback of the listmin_freqmin_freq bucket, oldest entry
getget / putputO(1)O(1)O(1)O(1)
SpaceO(capacity)O(\text{capacity})O(capacity)O(\text{capacity})

The variant map

VariantThe compositionCanonical problem
Hash map from scratchArray of buckets + chaining706 · 705
LRU evictionMap + doubly linked list (or OrderedDictOrderedDict)146
LFU evictionMap + frequency buckets + min_freqmin_freq460
Min/max count queriesCount buckets as a doubly linked list432 All O`one
O(1)O(1) random memberMap + dense array (swap-with-last)380
Time-bounded evictionMap + queue933 · 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 O(1)O(1) average, O(n/buckets)O(n / \text{buckets}) worst case per operation. Space O(buckets+n)O(\text{buckets} + n).

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 O(1)O(1) 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 O(1)O(1) 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 O(1)O(1) lookup. An OrderedDictOrderedDict provides both.

Time O(1)O(1) for both operations. Space O(capacity)O(\text{capacity}).

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 O(1)O(1) 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_val and key_to_freqkey_to_freq — the obvious bookkeeping.
  • freq_to_keys[f]freq_to_keys[f] — an OrderedDictOrderedDict of the keys at frequency ff, whose insertion order is oldest-first. That inner ordering is what resolves ties by least-recently-used, and it is why an OrderedDictOrderedDict rather than a setset is required here.
  • min_freqmin_freq — the current lowest frequency present.

Time O(1)O(1) for both operations. Space O(capacity)O(\text{capacity}).

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 = 1 at 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 allows capacity = 0capacity = 0, in which case putput must 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

#ProblemDifficultyThe twist
705Design HashSetEasyChaining without values
706Design HashMapEasyChaining; putput must overwrite, not append
146LRU CacheMediumOrderedDictOrderedDict, or map + doubly linked list with sentinels
460LFU CacheHardFrequency buckets + min_freqmin_freq; guard capacity 0
432All O`one Data StructureHardSame bucket idea, but a doubly linked list of counts so min and max are O(1)O(1)

Interview follow-ups

They askWhat they’re checkingThe answer
“Why both a map and a list?”The core compositionThe map gives O(1)O(1) lookup, the list gives ordering and O(1)O(1) unlinking; neither alone does both
“Why doubly linked?”PrecisionUnlinking in O(1)O(1) needs the predecessor, which only a prevprev pointer supplies
“Why store the key in the node?”Whether you have built itEviction gives you the node but must delete the map entry, which needs the key
“Does getget count as a use?”Clarifying the specFor LRU and LFU, yes — and the eviction order changes if you get it wrong
“How is min_freqmin_freq O(1)O(1)?”The LFU insightIt is 11 after an insert, and f + 1f + 1 when a bump empties the old minimum bucket
“Is OrderedDictOrderedDict acceptable?”Honesty about shortcutsYes, and it is this composition — but be ready to implement it manually
“Make it thread-safe / add TTL”Production thinkingA lock per operation; expiry timestamps with lazy purge on read

Edge-case checklist

  • Capacity 0 — legal in LC 460; putput must be a no-op. The most-failed detail.
  • Capacity 1 — every insert evicts; exercises the eviction path immediately.
  • putput on an existing key — must update the value and count as a use, not insert a duplicate.
  • getget on 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.
  • putput overwriting in a hash bucket (LC 706) — replace, never append.
  • Hash collisions (LC 706) — 11 and 10000011000001 share a bucket; the in-bucket key check matters.
  • Evicting then inserting — set min_freq = 1min_freq = 1 after the eviction.

Recap

  • Design problems are about composing structures, not clever algorithms. A hash map gives lookup; a doubly linked list gives order and O(1)O(1) unlinking. Together they give O(1)O(1) 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 OrderedDictOrderedDict is exactly this compositionmove_to_endmove_to_end and popitem(last=False)popitem(last=False) are both O(1)O(1). Know it, and be ready to build it by hand.
  • LFU adds frequency buckets of OrderedDictOrderedDicts (so ties break by recency) plus a min_freqmin_freq integer that never needs searching.
  • Guard capacity == 0capacity == 0 and 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 coffee

Was this page helpful?

Let us know how we did