Algorithm

LFU Cache

Design Pattern

LFU Cache

Design a data structure that follows the constraints of a Least Frequently Used (LFU) cache. It must support O(1) time complexity for both get and put operations.

CONSTRAINTS
  • 0 <= capacity <= 10⁴
  • 0 <= key <= 10⁵
  • 0 <= value <= 10⁹
  • At most 2 × 10⁵ calls will be made to get and put
EXAMPLE 1
Input: ["LFUCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"] [[2], [1, 1], [2, 2], [1], [3, 3], [2], [3], [4, 4], [1], [3], [4]]
Output: [null, null, null, 1, null, -1, 3, null, -1, 3, 4]
Key 2 is evicted when 3 is put because it was least frequently used. Later, key 1 is evicted because it became the LFU after other gets.
How do we decide which key to evict when frequencies are tied?
We apply an LRU policy within that specific frequency bucket. The key that was accessed least recently among those with the same count is evicted.
Does updating an existing key's value count as a 'use'?
Yes. Any 'get' or 'put' operation on a key increments its frequency and promotes it to the next bucket.

This is LRU Cache with a harder eviction rule, so start from what changes. LRU evicts the key you touched longest ago. LFU evicts the key you've used the fewest times — it tracks a use count per key (every get and every put on a key bumps its count by one) and, when full, throws out the key with the smallest count. The catch: several keys can be tied at the lowest count. The tie-breaker is recency — among the least-frequently-used, drop the least-recently-used. So LFU is really "frequency first, recency as the tiebreaker," and everything must still be O(1).

Why LRU's single list isn't enough

LRU kept one recency-ordered list and evicted its back. But frequency adds a second dimension: we now need, instantly, the lowest count in the whole cache, and within that count the stalest key. One flat list can't order by two things at once and stay O(1). Scanning to find the minimum count would be O(N) — the exact trap we keep refusing.

The reframe: one recency list per frequency

Group keys by their current count. Keep a separate recency-ordered list for count 1, another for count 2, and so on — each behaving exactly like the LRU list you already built (freshest at the front, stalest at the back). Now the two dimensions are cleanly separated: which list you're in encodes frequency, and position within the list encodes recency. To evict, go to the lowest non-empty count's list and drop its stalest (back) element — both parts O(1) if we can find that lowest count fast.

To make every piece O(1) we hold three things:
- values: key → value (and we track each key's count).
- counts: key → current use count.
- lists: count → an ordered recency list of the keys at that count (an ordered map, so front/back access and removal are O(1)).
- min_freq: the smallest count that currently has any key — this is the shelf we evict from.

The one moving part: promotion

When a key is used, it graduates from count c to c + 1: remove it from the count-c list, append it to the (fresh end of the) count-c+1 list, bump its count. The only bookkeeping subtlety is min_freq: if the key we just promoted emptied the count-min_freq list, then the smallest non-empty count is now one higher, so min_freq += 1.

python
from collections import defaultdict, OrderedDict

class LFUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.values = {}                       # key -> value
        self.counts = {}                       # key -> use count
        self.lists = defaultdict(OrderedDict)  # count -> {key: None} in recency order
        self.min_freq = 0

    def _bump(self, key):                      # promote key from c to c+1
        c = self.counts[key]
        del self.lists[c][key]
        if not self.lists[c] and c == self.min_freq:
            self.min_freq += 1                 # that shelf emptied → min rises
        self.counts[key] = c + 1
        self.lists[c + 1][key] = None          # append = freshest at count c+1

    def get(self, key):
        if key not in self.values:
            return -1
        self._bump(key)
        return self.values[key]

    def put(self, key, value):
        if self.cap == 0:
            return
        if key in self.values:                 # update + count as a use
            self.values[key] = value
            self._bump(key)
            return
        if len(self.values) >= self.cap:       # full → evict LFU (ties: LRU)
            evict, _ = self.lists[self.min_freq].popitem(last=False)  # stalest at min count
            del self.values[evict]; del self.counts[evict]
        self.values[key] = value               # new keys always enter at count 1
        self.counts[key] = 1
        self.lists[1][key] = None
        self.min_freq = 1
Crucial Notea brand-new key always enters at count 1, so after inserting one you must set min_freq = 1 — the new key is the new minimum, no matter how high the counts of everything else climbed. Forget this and the next eviction points at an empty or wrong shelf. Symmetrically, min_freq only ever rises during a promotion (when the min shelf empties) and resets to 1 on any insertion; it never needs a search.
Worked Example:promotion, then an eviction that breaks a tie (capacity 2)
Show lists as {count: [stalest … freshest]}.
- put(1, A) → count 1. {1: [1]}, min_freq 1.
- put(2, B) → count 1. {1: [1, 2]}, min_freq 1. Both tied at count 1; key 1 is the staler.
- get(1) → returns A and promotes 1 to count 2. {1: [2], 2: [1]}, min_freq still 1 (shelf 1 not empty).
- put(3, C) → full. Evict from min_freq = 1: its stalest is key 2 → drop it. Insert 3 at count 1: {1: [3], 2: [1]}, min_freq 1. Key 2 lost not because it was oldest, but because it was least-used; the tie with key 1 was already broken when 1 got used.
- get(2) (non-happy path) → 2 was evicted → -1.
What it costs, and the reflex

Every operation is O(1): a fixed number of dictionary and ordered-map edits, and min_freq is maintained incrementally instead of searched. Memory is O(capacity). The big lesson generalizes past caches: when eviction (or any priority) depends on two ranked keys — a primary and a tiebreaker — bucket by the primary and keep each bucket ordered by the tiebreaker. Finding the extreme becomes "lowest bucket, then its end," both constant time. That two-level structure — LRU's map-plus-list, replicated once per frequency and indexed by a running minimum — is the standard way to squeeze a two-criteria eviction policy down to O(1).

Interactive Strategy Visualization
FREQUENCY-BASED CACHE

Hierarchical DLL Architecture

INITIALIZING
EMPTY SYSTEM
HASH MAP

Hierarchy

  • Frequency Buckets: Keys with the same access count stay together.
  • Internal LRU: Each bucket is a DLL. We evict from the back of the lowest freq bucket.
Logic TraceSTEP 1/6
LFU Cache: Evicts the least frequently used item. Ties are broken by LRU policy.
LFU+LRU

Optimal Cache Policy

LFU is superior for frequency-heavy workloads. The hierarchical DLL structure ensures that both "promotion" and "eviction" take O(1) time.

O(N) Scan for Min Frequency
O(1) Frequency Buckets + Per-Bucket Recency