Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 40 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free intro, Pro: 8 of 40 minutes free
Free: The problem: popular dishes at a canteen · The brute force: compare every dish. With a pass: the other 11 sections and 3 interview answers, about 32 minutes.

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

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.

  1. 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.
  2. 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.
  3. 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, with every dish compared on eviction counted Python · brute/brute_force.py
"""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

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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.