Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
LFU Cache
The cache that evicts what you use least often — and, on ties, least recently. Harder than LRU because you must track frequency and recency together, yet still hit O(1) per operation. Replay a sequence of get/put ops and return the outputs. Solve it in Python or TypeScript, with hidden tests.
The problem
Design an LFU (least-frequently-used) cache with a fixed capacity. On get(key), return the value (and bump its use count) or -1 if absent. On put(key, value), insert/update it; if the cache is full, evict the least-frequently-used key, breaking ties by evicting the least-recently-used among those. You are given a list of ops like ["put",1,1] and ["get",1]; return the list of outputs from every get, in order. Every operation must be O(1).
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement lfu_ops(cap, ops): keep value/frequency maps plus, for each frequency, an insertion-ordered bucket of keys. Track min_freq. A use moves a key up one bucket; eviction pops the oldest key from the min_freq bucket. Return the outputs of every get.
Examples and constraints 4 examples
cap = 2, ops = [["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["get",3]][1, -1, 3]cap = 2, ops = [["put",1,1],["put",2,2],["put",3,3],["get",2],["get",3]][2, 3]cap = 2, ops = [["put",1,1],["put",2,2],["put",1,9],["put",3,3],["get",2],["get",1]][-1, 9]cap = 1, ops = [["put",1,1],["get",1],["put",2,2],["get",1],["get",2]][1, -1, 2]- 1 ≤ capacity; a
getand aputof an existing key both count as a use (raise its frequency). - Ties in frequency are broken by recency: evict the least-recently-used key among those with the minimum frequency.
- All operations must be O(1) — track a per-frequency ordered bucket and the current minimum frequency; do not scan.
Hints 4 ideas
- You need two things per key: its value and its frequency. And per frequency, the keys at that frequency in use order (oldest first).
- An ordered dict (Python
OrderedDict) or a Map (JS preserves insertion order) makes each frequency bucket an O(1) LRU queue. - On a use: remove the key from its current frequency bucket, add it to the (freq+1) bucket. If you just emptied the
min_freqbucket, incrementmin_freq. - On eviction (cache full, inserting a new key): pop the oldest key from the
min_freqbucket, then insert the new key at frequency 1 and setmin_freq = 1.
Approach, complexity & discussion — open after you solve
Start from brute force
Store each key’s value, use count and the time of its last use, and on eviction scan every key for the smallest (count, last_used) pair. Gets and updates are O(1), and the scan always finds the right victim, because that tuple order is exactly “least frequent, then least recent”. But each eviction costs O(capacity) time, so a workload where every put misses a full cache pays it on every operation; space is O(capacity). A heap keyed by (count, last_used) cuts eviction to O(log n), but every use changes a key’s priority, so it needs lazy deletion of stale entries, and it is still not the O(1) the problem demands.
The approach
Group keys by frequency, and keep each group in use order. The reference lfu_ops holds val (key to value), freq (key to use count), buckets, a defaultdict(OrderedDict) from each count to its keys oldest-use first, and min_freq, the smallest non-empty count. The helper use(k) deletes k from buckets[f], drops that bucket if it emptied (raising min_freq to f + 1 if it was the minimum), and appends k to the end of buckets[f + 1], the most recent position.
A get hit records the value and calls use; a put on an existing key updates the value and calls use. A new key arriving at a full cache evicts with buckets[min_freq].popitem(last=False), the least recently used of the least frequently used, then enters at count 1 with min_freq = 1. No scan is ever needed: a use raises the minimum by at most one, an insert resets it to 1, and nothing else changes it. The TypeScript version uses one Map per bucket, since a Map iterates in insertion order.
Walk through an example
On the first example, key 1 earns a second use before key 3 arrives, so the eviction falls on key 2.
| step | op | action | freq | buckets, oldest first | min_freq | out |
|---|---|---|---|---|---|---|
| 1 | put 1, 1 | insert at count 1 | {1: 1} | {1: [1]} | 1 | [] |
| 2 | put 2, 2 | insert at count 1 | {1: 1, 2: 1} | {1: [1, 2]} | 1 | [] |
| 3 | get 1 | hit; use(1) moves key 1 to count 2 | {1: 2, 2: 1} | {1: [2], 2: [1]} | 1 | [1] |
| 4 | put 3, 3 | full: popitem(last=False) on bucket 1 evicts key 2; insert key 3 | {1: 2, 3: 1} | {1: [3], 2: [1]} | 1 | [1] |
| 5 | get 2 | miss | {1: 2, 3: 1} | {1: [3], 2: [1]} | 1 | [1, -1] |
| 6 | get 3 | hit; use(3) empties bucket 1, so min_freq becomes 2 | {1: 2, 3: 2} | {2: [1, 3]} | 2 | [1, -1, 3] |
Complexity
Every operation is O(1) on average: a constant number of dict and OrderedDict lookups, inserts and deletes, plus at most one popitem. Space is O(capacity), since each key sits in val, freq and exactly one bucket. The scan-on-evict design is O(1) for hits but O(capacity) per eviction, with the same O(capacity) space.
Edge cases to test
cap = 2: put 1, put 2, put 3, get 2, get 3 →[2, 3]. Keys 1 and 2 tie at count 1, so the least recently used, key 1, is evicted.cap = 2: put 1, put 2, put 1 (value 9), put 3, get 2, get 1 →[-1, 9]. Updating key 1 counts as a use, so key 2 is the victim.cap = 1: put 1, get 1, put 2, get 1, get 2 →[1, -1, 2]. With one slot, a key used twice is still evicted by any new key.cap = 2: put 1, get 1, get 1, put 2, put 3, get 2 →[1, 1, -1]. Key 2 entered at count 1, under key 1’s count of 3, so it was the next victim; a new key is always the most exposed.cap = 2: put 1, put 2, get 1, get 2, put 3, get 1 →[1, 2, -1]. Both keys reached count 2 and bucket 1 emptied, somin_freqmoved to 2 and the oldest key there, key 1, was evicted.- A
getof a missing key returns -1 and changes nothing: a miss is not a use. The reference also skips every put whencapis 0.
Common mistakes
- Scanning the whole cache for the least-frequent victim — O(n) per eviction.
- Not breaking frequency ties by recency, so eviction is ambiguous.
- Forgetting to bump the frequency (and move the entry to the next bucket) on a read.
Follow-ups an interviewer asks next
- Add delete(key) without losing O(1).
- Deleting a key can empty the minimum bucket and leave no way to find the next minimum without a scan, because the minimum may jump by more than one. The classic O(1) LFU design fixes this by linking the frequency buckets themselves in a doubly linked list ordered by count, so the head is always the minimum and removing an empty bucket is an O(1) unlink.
- Keys that were popular once never leave. How do you age frequencies?
- Plain LFU lets a key that was hot last week outrank everything new. Common fixes decay the counts, such as halving every count after a fixed number of operations, or count only recent activity. W-TinyLFU, the policy in the Java caching library Caffeine, estimates frequencies with a compact sketch, halves them periodically, and passes new keys through a small LRU window first.
- Make it safe for concurrent use.
- A get is a write here, because it bumps a count and moves the key between buckets, so a read-write lock buys nothing; the simple answer is one mutex around every operation. To cut contention, shard the key space into several independent caches by key hash, accepting that eviction becomes per-shard LFU rather than global.
Where this shows up
LFU suits caches where popularity is stable — a hot set that stays hot, like CDN edges or some database buffer pools. The choice between LFU (frequency) and LRU (recency) comes down to which better predicts the next access for your workload; getting O(1) on either is the textbook data-structure win.
Reference solution
Reference solution — Python
from collections import OrderedDict, defaultdict
def lfu_ops(cap, ops):
val = {} # key -> value
freq = {} # key -> use count
buckets = defaultdict(OrderedDict) # freq -> {key: None} in use order
min_freq = 0
out = []
def use(k): # bump k's frequency by one
nonlocal min_freq
f = freq[k]
del buckets[f][k]
if not buckets[f]:
del buckets[f]
if min_freq == f:
min_freq = f + 1
freq[k] = f + 1
buckets[f + 1][k] = None
for op in ops:
if op[0] == "put":
k, v = op[1], op[2]
if cap <= 0:
continue
if k in val: # update + count as a use
val[k] = v
use(k)
continue
if len(val) >= cap: # evict LFU (ties: LRU)
ek, _ = buckets[min_freq].popitem(last=False)
del val[ek]
del freq[ek]
val[k] = v
freq[k] = 1
buckets[1][k] = None
min_freq = 1
else: # get
k = op[1]
if k in val:
out.append(val[k])
use(k)
else:
out.append(-1)
return out
print(lfu_ops(2, [["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["get",3]])) # -> [1, -1, 3]Reference solution — TypeScript
function lfuOps(cap: number, ops: any[][]): number[] {
const val = new Map<number, number>(); // key -> value
const freq = new Map<number, number>(); // key -> use count
const buckets = new Map<number, Map<number, true>>(); // freq -> ordered keys
let minFreq = 0;
const out: number[] = [];
function bucket(f: number): Map<number, true> {
if (!buckets.has(f)) buckets.set(f, new Map());
return buckets.get(f)!;
}
function use(k: number): void { // bump frequency by one
const f = freq.get(k)!;
bucket(f).delete(k);
if (bucket(f).size === 0) {
buckets.delete(f);
if (minFreq === f) minFreq = f + 1;
}
freq.set(k, f + 1);
bucket(f + 1).set(k, true);
}
for (const op of ops) {
if (op[0] === "put") {
const k = op[1] as number, v = op[2] as number;
if (cap <= 0) continue;
if (val.has(k)) { val.set(k, v); use(k); continue; }
if (val.size >= cap) { // evict LFU (ties: LRU)
const b = bucket(minFreq);
const ek = b.keys().next().value as number; // oldest in the bucket
b.delete(ek); val.delete(ek); freq.delete(ek);
}
val.set(k, v); freq.set(k, 1); bucket(1).set(k, true); minFreq = 1;
} else { // get
const k = op[1] as number;
if (val.has(k)) { out.push(val.get(k)!); use(k); }
else out.push(-1);
}
}
return out;
}
console.log(lfuOps(2, [["put",1,1],["put",2,2],["get",1],["put",3,3],["get",2],["get",3]])); // -> [1,-1,3]Questions about LFU Cache
What is the difference between an LRU cache and an LFU cache?
Both evict one entry when the cache is full, but they choose differently. LRU, least recently used, evicts the entry that has gone longest without being touched, betting that recent activity predicts the next access. LFU, least frequently used, evicts the entry with the fewest uses, usually breaking ties by recency, betting that long-run popularity predicts the next access. LFU holds on to a stable hot set through bursts of one-off requests that would flush an LRU cache, but it adapts slowly when popularity shifts, and new entries are the first to go.
How does an LFU cache get O(1) get and put?
By never searching for the victim. Keep a map from key to value and count, a map from each count to the keys with that count, ordered from least to most recently used, and a variable holding the smallest count in use. A use moves a key from bucket f to the end of bucket f + 1, a constant number of hash operations. Eviction removes the first key of the minimum bucket. Because a use can raise the minimum by at most one and an insert resets it to one, the minimum never has to be recomputed.
Why does a put on an existing key count as a use in LFU?
Because writing a key is evidence that it is still in demand, and the problem defines it that way: a get and a put of an existing key both raise its count, the same rule the common interview version uses. It changes eviction order. In a cache of two, if key 1 is put, key 2 is put, key 1 is updated and then key 3 arrives, key 1 has count 2 and key 2 has count 1, so key 2 is evicted and a later get of key 1 returns the updated value, 9 in this problem’s tests.
When should you use an LFU cache instead of LRU?
Use LFU when popularity is stable over time, so the items requested often today are the ones requested often tomorrow, and when the workload includes scans or one-off requests that would push a hot item out of an LRU cache, such as a fixed set of popular media files or reference data. Prefer LRU when interest shifts quickly, as with news or session data, because plain LFU keeps old favorites long after demand moves on. Many production caches blend the two, admitting new keys through a small recency window and aging counts over time.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- LRU CacheThe eviction policy behind every size-limited cache: when you run out of room, throw out whatever was used least recently. The trick is O(1) get and put — an ordered hash map (Python dict / JS Map) gives you exactly that. Solve it in Python or TypeScript.Read →
- Streaming MedianLatency dashboards do this every second: maintain the median of a stream without re-sorting per event. The classic two-heap trick — a max-heap for the low half, a min-heap for the high half, the median always at the boundary. Solve it in Python or TypeScript.Read →
- Token Bucket Rate LimiterThe algorithm inside most production rate limiters — and it never runs a timer. Refill the bucket lazily from the time elapsed since the last request, cap at capacity, spend one token or reject. Two numbers of state per client, exactly like the Redis version. Solve it in Python or TypeScript.Read →