Coding Interview Patterns Module 29 – Design-a-data-structure problems
LFU cache in O(1) with frequency buckets
Design an LFU cache that evicts the least frequently used key, with ties going to the least recent: an original canteen problem, its brute force and its signal.
What you will learn
- Implement LFU eviction with recency tie-breaks, every call in O(1) on average
- Maintain the smallest use count across gets, updates and evictions without a search
- Compare frequency buckets with an O(log n) heap and its stale entries
- Test an LFU cache with random call sequences checked against a slow model
- Explain how production caches age old counts so that new favourites can enter
Before you start
What you’ll learn · Before you start
What you will learn
- Implement LFU eviction with recency tie-breaks, every call in O(1) on average
- Maintain the smallest use count across gets, updates and evictions without a search
- Compare frequency buckets with an O(log n) heap and its stale entries
- Test an LFU cache with random call sequences checked against a slow model
- Explain how production caches age old counts so that new favourites can enter
Before you start
On this page
An LFU cache evicts the key used the fewest times, and among keys tied on uses, the least recently used one. It keeps steady favourites that an LRU cache would drop after a burst of one-off requests. The hard part is speed: finding the least used key in O(1) while every call changes some key’s count. This lesson states an original problem, prices the brute force, and names the signal behind the O(1) design.
The problem: popular dishes at a canteen
The problem
A canteen’s ordering app reads dish prices from a slow menu service and caches the prices of at most capacity
dishes. Write DishCache(capacity). get(dish) returns the cached price, or −1. put(dish, price) stores the
price, replacing the old price of a cached dish. Each call on a cached dish counts one use, and a dish added by
put starts with one use. When put adds a new dish to a full cache, it first removes the dish with the fewest
uses since it entered the cache, and among those, the one whose last use is the oldest. A capacity of 0 caches
nothing. Limits: 0 ≤ capacity ≤ 10⁵, dishes from 1 to 10⁹, up to 2 × 10⁵ calls, each O(1) on average.
DishCache(2),put(101, 40),put(102, 25),get(101),put(103, 60),get(102),get(103),put(104, 30),get(101),get(103),get(104)gives[null, null, null, 40, null, -1, 60, null, -1, 60, 30]. Dish 102, with one use, goes first; later 101 and 103 tie on two uses, and 101 goes because its last use is older.DishCache(1),put(7, 15),put(7, 18),get(7),put(9, 20),get(7),get(9)gives[null, null, null, 18, null, -1, 20]: dish 7 has three uses, but the only slot must take dish 9.DishCache(0),put(5, 10),get(5)gives[null, null, -1].
The brute force: compare every dish
Keep each dish’s price, use count and last-use time, and on an eviction compare every cached dish to find the
smallest pair of count and last use. A look-up or an update is O(1), but an eviction is O(n) for n cached dishes,
and a rush of new dishes evicts on every put.
"""The brute force: each cached dish keeps its price, its use count and the time of its last use, and an eviction
compares every cached dish to find the smallest (count, last use). The counter `compared` adds those comparisons up."""
import json
class BruteDishCache:
def __init__(self, capacity):
self.capacity = capacity
self.entries = {} # dish -> [price, count, last_use]
self.clock = 0 # one tick per call, so a larger last_use is a more recent use
self.compared = 0
def _use(self, dish):
self.clock += 1
entry = self.entries[dish]
entry[1] += 1
entry[2] = self.clock
def get(self, dish):
if dish not in self.entries:
return -1
self._use(dish)
return self.entries[dish][0]
def put(self, dish, price):
if self.capacity == 0:
return None
if dish in self.entries:
self.entries[dish][0] = price
self._use(dish)
return None
if len(self.entries) == self.capacity:
self.compared += len(self.entries)
victim = min(self.entries, key=lambda d: (self.entries[d][1], self.entries[d][2]))
del self.entries[victim]
self.clock += 1
self.entries[dish] = [price, 1, self.clock]
return None
def replay(ops, args):
"""Runs a test written as in the statement: operation names, their arguments, one answer per call."""
cache, answers = None, []
for op, a in zip(ops, args):
if op == "DishCache":
cache = BruteDishCache(*a)
answers.append(None)
else:
answers.append(getattr(cache, op)(*a))
return answers
def lunch_rush(capacity):
"""Fills the cache, then puts 2 x capacity dishes it has never seen: every one of them evicts."""
cache = BruteDishCache(capacity)
for dish in range(1, capacity + 1):
cache.put(dish, dish % 300 + 20)
for dish in range(capacity + 1, 3 * capacity + 1):
cache.put(dish, dish % 300 + 20)
return cache.compared
if __name__ == "__main__":
print(json.dumps(replay(
["DishCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"],
[[2], [101, 40], [102, 25], [101], [103, 60], [102], [103], [104, 30], [101], [103], [104]],
)))
print(json.dumps(replay(["DishCache", "put", "put", "get", "put", "get", "get"], [[1], [7, 15], [7, 18], [7], [9, 20], [7], [9]])))
print(json.dumps(replay(["DishCache", "put", "get"], [[0], [5, 10], [5]])))
print()
print(f"{'capacity':>8} {'new dishes':>11} {'dishes compared':>16} {'per call':>9}")
for capacity in (250, 500, 1_000, 2_000):
compared = lunch_rush(capacity)
print(f"{capacity:>8,} {2 * capacity:>11,} {compared:>16,} {compared / (2 * capacity):>9,.0f}")
print(f"at capacity 100,000 with 200,000 new dishes: {100_000 * 200_000:,} comparisons") Output
[null, null, null, 40, null, -1, 60, null, -1, 60, 30]
[null, null, null, 18, null, -1, 20]
[null, null, -1]
capacity new dishes dishes compared per call
250 500 125,000 250
500 1,000 500,000 500
1,000 2,000 2,000,000 1,000
2,000 4,000 8,000,000 2,000
at capacity 100,000 with 200,000 new dishes: 20,000,000,000 comparisons
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 brute_force.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
At the limits that is 2 × 10¹⁰ comparisons, minutes even in compiled code. A min-heap of count and last use makes an eviction O(log n), but every use changes a count, which a heap cannot update in place.
What to notice. Counts change in a narrow way: a use raises one dish’s count by exactly one, and a new dish starts at one, the smallest count there is. An eviction only needs the dishes with the smallest count, in order of last use. Both point to grouping the dishes by count and remembering which count is the smallest.
Frequency buckets: every call in O(1) Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Why min_count is always right: the invariant
- A dry run of the first example
- The code in five languages
- The heap version and its stale entries
- Five bugs reproduced against the brute force
- Variants: letting old favourites go
- Exercise: the canteen cache
- An LFU cache of dish prices with every call in O(1)
- A practice set with answers
- Check yourself
- Interview questions
- Which key does an LFU cache evict, and how is that different from an LRU cache?
- A product cache holds 50,000 entries and serves a hot set of 40,000 products all day. Every night a report job reads 2 million other products once each through the same cache. Compare what an LRU cache and an LFU cache do to the next morning's hit ratio, and say what you would deploy.
- An LFU cache on a news site never forgets: an article read 80,000 times last month still outranks today's stories, which are evicted before they can build up counts. How do you fix it, and how do you test the fix?
- Your LFU cache keeps a min-heap of (count, last use, key) and pushes a new entry on every use, leaving the old one in place. At a capacity of 1,000 and a million gets, memory keeps growing. Why, and what are your options?
- Key takeaways
1 diagram · 5 runnable examples (C++, Go, Java, JavaScript, Python, lfu_session.py, session.py) · 1 exercise with sample tests · 3 interview answers · about 27 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
References
- collections: OrderedDict and defaultdict (The Python Standard Library) (Python Software Foundation)
- heapq: Priority Queue Implementation Notes (The Python Standard Library) (Python Software Foundation)
- LinkedHashSet (Java SE 25 API) (Oracle)
- std::list::splice (cppreference.com) (cppreference.com)
- std::unordered_map::operator[] (cppreference.com) (cppreference.com)
- Package container/list (The Go Authors)
- Set (JavaScript reference) (MDN Web Docs)
- Key eviction: LFU eviction (Redis documentation) (Redis)
- TinyLFU: A Highly Efficient Cache Admission Policy (G. Einziger, R. Friedman and B. Manes) (arXiv)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress