Data Structure Design · Caching Fundamentals

LFU Cache Design

Evicting by access frequency instead of recency - and what extra bookkeeping that requires over an LRU cache.

  • 5 min read
  • 9 practice questions
  • 2 graded problems
On this page
  1. Description
  2. The pattern
  3. Where you'll see it
  4. Which tool for which job?
  5. Common mistakes
  6. Check yourself
  7. Key takeaways
  8. Quick revise
  9. Practice

Description

An LFU (least frequently used) cache evicts the entry that has been used the fewest times, rather than the one used longest ago. It protects items that are popular over time from being pushed out by something touched once, which suits workloads with stable popularity - and it's a classic hard design-interview question because O(1) eviction needs careful bookkeeping. This skill covers that design, its one notorious bug, and when LFU beats LRU.

Tracking "how often has each key been used" is easy; finding the least-frequently-used key quickly is not, if the only tool is a plain frequency map:

Java

class NaiveLFUCache {
    private Map<Integer, Integer> values = new HashMap<>();
    private Map<Integer, Integer> freq = new HashMap<>();
    private int capacity;

    public int get(int key) {
        if (!values.containsKey(key)) return -1;
        freq.merge(key, 1, Integer::sum);
        return values.get(key);
    }

    public void put(int key, int value) {
        if (!values.containsKey(key) && values.size() == capacity) {
            int leastFreqKey = Collections.min(freq.entrySet(), Map.Entry.comparingByValue()).getKey(); // scans every entry
            values.remove(leastFreqKey);
            freq.remove(leastFreqKey);
        }
        values.put(key, value);
        freq.merge(key, 1, Integer::sum);
    }
}

Python

class NaiveLFUCache:
    def __init__(self, capacity: int):
        self._values: dict[int, int] = {}
        self._freq: dict[int, int] = {}
        self._capacity = capacity

    def get(self, key: int) -> int:
        if key not in self._values:
            return -1
        self._freq[key] += 1
        return self._values[key]

    def put(self, key: int, value: int) -> None:
        if key not in self._values and len(self._values) == self._capacity:
            least = min(self._freq, key=self._freq.get)     # scans every entry
            del self._values[least]
            del self._freq[least]
        self._values[key] = value
        self._freq[key] = self._freq.get(key, 0) + 1

Collections.min (Python: min(freq, key=freq.get)) has to walk every single entry in freq to find the minimum, every time the cache is full and a new key arrives - the exact same O(n)-per-eviction problem naive LRU has, just triggered by a frequency comparison instead of a recency scan.

We'll go from simple to more technical:

  1. The design: values, frequencies, buckets of keys per frequency, and minFreq.
  2. The "touch" operation that keeps everything in sync.
  3. Where frequency beats recency, and where it misleads.
  4. The minFreq bug, decay, and choosing between LFU and LRU.

The pattern

Java

Map<Integer, Integer> keyToVal;
Map<Integer, Integer> keyToFreq;
Map<Integer, LinkedHashSet<Integer>> freqToKeys; // insertion order breaks ties by recency within a frequency
int minFreq;

Python

key_to_val: dict[int, int]
key_to_freq: dict[int, int]
freq_to_keys: defaultdict[int, dict[int, None]]   # a dict keeps insertion order: ties break by recency
min_freq: int

On every get/put of an existing key ("touch"): remove the key from freqToKeys[oldFreq] (bumping minFreq if that was the last key at minFreq), increment its frequency, and re-add it to freqToKeys[newFreq]. On eviction: pull any key out of freqToKeys[minFreq] (the front, if you want strict recency tie-breaking) - it's guaranteed to be a least-frequently-used entry by definition of minFreq.

The "touch" is the heart of it:

Java

private void touch(int key) {
    int oldFreq = keyToFreq.get(key);
    LinkedHashSet<Integer> bucket = freqToKeys.get(oldFreq);
    bucket.remove(key);
    if (bucket.isEmpty() && minFreq == oldFreq) minFreq++;       // emptied the lowest bucket
    keyToFreq.put(key, oldFreq + 1);
    freqToKeys.computeIfAbsent(oldFreq + 1, f -> new LinkedHashSet<>()).add(key);   // newest at the end
}

Python

def _touch(self, key: int) -> None:
    old_freq = self._key_to_freq[key]
    bucket = self._freq_to_keys[old_freq]
    del bucket[key]
    if not bucket and self._min_freq == old_freq:
        self._min_freq += 1                               # emptied the lowest bucket
    self._key_to_freq[key] = old_freq + 1
    self._freq_to_keys[old_freq + 1][key] = None          # newest at the end

minFreq++ is safe because the key that emptied the bucket just landed at exactly oldFreq + 1. A brand-new key resets minFreq to 1, and eviction takes freqToKeys.get(minFreq).iterator().next() (Python: next(iter(freq_to_keys[min_freq]))) - the oldest key at the lowest frequency.

Where you'll see it

CDN and cache-tier eviction sometimes prefers frequency over recency specifically because a resource "recently touched exactly once" (LRU would protect it) can be less valuable to keep than one "touched constantly but not in the last second" (LRU would evict it first) - the right choice depends on the actual access pattern, not a universal rule that one policy beats the other.

Which tool for which job?

WorkloadPolicy
Popularity is stable over time (product images, static assets)LFU
What's hot changes quickly (news, trending items)LRU, or LFU with decay
Mix of both, plus one-off scansW-TinyLFU (Caffeine) or SLRU
Tie between equally frequent keysevict the least recently used of them
Bounded memory for counting many keysan approximate counter (Count-Min Sketch)

Common mistakes

MistakeWhat happensDo this instead
Not bumping minFreq when its bucket emptieseviction looks in an empty bucketupdate it inside every touch
Finding the victim with a scan over all frequenciesO(n) per evictionbuckets per frequency + minFreq
Plain sets for bucketsties between equal frequencies break randomlyordered buckets (LinkedHashSet, a dict)
No decayonce-popular items stay forever; new items churnperiodically halve counts or age them
A remove(key) that ignores minFreqminFreq points at an empty bucketrescan upward on the next eviction

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. A key's frequency bucket has 3 other keys still in it after this key's frequency increases. Does minFreq need to change?

Show answer

No - minFreq only needs to change when the bucket at the current minimum frequency becomes completely empty. If other keys remain at that frequency, the minimum hasn't moved.

2. Why does put() reset minFreq to 1 whenever a brand-new key is inserted, regardless of what it was before?

Show answer

A newly inserted key always starts at frequency 1, and 1 is the lowest possible frequency - so it's always true that the new minimum is 1 the moment that key exists, no comparison needed.

3. You need a peek(key) that returns a value without affecting eviction order. Why is it wrong to implement it by calling get(key) and just ignoring whatever side effects that has?

Show answer

Because get's side effect - bumping the key's frequency - isn't something you can "just ignore" after the fact; it already happened and changed the cache's real internal state, which affects which key gets evicted later. peek has to read the value some other way that never triggers that side effect in the first place, not undo it afterward.

Key takeaways

  • Eviction always pulls from the current minFreq bucket - that's the entire reason minFreq is tracked instead of recomputed by scanning.
  • minFreq must be recomputed (bumped) exactly when its bucket empties - not on every touch, only when that specific bucket runs out.
  • Ties within a frequency break by recency, which is why each bucket is a LinkedHashSet, not a plain HashSet - in Python, a dict with None values, not a set.
  • A new key always starts at frequency 1, which is always the new minimum - no comparison needed on insert.
  • LFU trades more bookkeeping than LRU for immunity to "recently touched once" beating "constantly touched" - at the cost of old popularity being able to protect a now-cold entry.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

WorkloadPolicy
Stable popularityLFU
Fast-changing popularityLRU, or LFU with decay
Mixed, with scansW-TinyLFU (Caffeine), SLRU
Counting many keys in bounded memoryCount-Min Sketch
Ties at equal frequencyleast recently used first

Snippets worth knowing by heart

Java

private void touch(int key) {
    int oldFreq = keyToFreq.get(key);
    LinkedHashSet<Integer> bucket = freqToKeys.get(oldFreq);
    bucket.remove(key);
    if (bucket.isEmpty() && minFreq == oldFreq) minFreq++;
    keyToFreq.put(key, oldFreq + 1);
    freqToKeys.computeIfAbsent(oldFreq + 1, f -> new LinkedHashSet<>()).add(key);
}
// evict: int victim = freqToKeys.get(minFreq).iterator().next();

Python

def _touch(self, key: int) -> None:
    old = self._key_to_freq[key]
    bucket = self._freq_to_keys[old]
    del bucket[key]
    if not bucket and self._min_freq == old:
        self._min_freq += 1
    self._key_to_freq[key] = old + 1
    self._freq_to_keys[old + 1][key] = None
# evict: victim = next(iter(self._freq_to_keys[self._min_freq]))

The traps

  • Stale minFreq after a touch empties the bucket.
  • Scanning for the minimum on each eviction.
  • Unordered buckets - random tie-breaks.
  • No decay - once-popular items never leave.
  • remove(key) can leave minFreq far from the next bucket.

Staff-level points, one line each

  • Popularity shift: plain LFU hit 6.0% in the new phase; halving counts every 1,000 requests hit 79.1%.
  • New items start at 1 and are evicted first - admission windows and filters fix it.
  • Count-Min Sketch: fixed memory, overestimates only; halving gives decay.
  • W-TinyLFU = window LRU + segmented main area + sketch-based admission (Caffeine's default).
  • Redis LFU: 8-bit logarithmic counters (lfu-log-factor) that decay (lfu-decay-time), sampled eviction.
  • After an explicit removal, scan upward for the next non-empty bucket.

Say it in an interview

  • "Each frequency has its own ordered bucket, so eviction is O(1) from minFreq."
  • "minFreq moves up only when its bucket empties during a touch."
  • "Without decay, last month's hits would crowd out this week's."
  • "In production I'd reach for Caffeine's W-TinyLFU rather than plain LFU."

Practice

This skill comes with 9 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice LFU Cache Design free

Design an LFU Cache

HardProCoding problemJava · Python

Design a Least Frequently Used (LFU) cache with a fixed capacity, supporting get(key) and put(key, value) in O(1) average time. On eviction, remove the entry with the lowest access frequency; if several entries share that lowest frequency, evict the least recently used among…

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

LFU Cache: Adding a Non-Disturbing Peek

HardProCoding problemJava · Python

Extend the LFU cache with a third operation: peek(key), which looks up a key's value without counting as a use - it must not increment the key's access frequency. get and put still behave exactly as before.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice LFU Cache Design free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime