What is an LFU Cache?
LFU stands for Least Frequently Used. An LFU Cache is a fixed-capacity cache that evicts the item that has been accessed the fewest number of times when it runs out of space.
In simple words:
- The cache has a capacity (maximum number of key-value pairs it can hold).
- Every
get() or put() on a key increases that key's usage frequency by 1. - When the cache is full and a new key must be inserted, the key with the lowest frequency is evicted.
- If multiple keys share the same lowest frequency, the least recently used among them is evicted (this is the tie-breaking rule).
🧠 Think of it this way: LFU cares about how many times something was used, while LRU (its cousin problem) only cares about when it was last used. LFU is essentially "LRU, but frequency comes first."
2. Examples
Example 1: capacity = 2
| Operation |
Return |
Cache State (Key : Value, Frequency) |
Reason / Eviction |
| put(1, 1) |
null |
{1 : 1 (f=1)} |
Inserted key 1. |
| put(2, 2) |
null |
{1 : 1 (f=1), 2 : 2 (f=1)} |
Inserted key 2. |
| get(1) |
1 |
{1 : 1 (f=2), 2 : 2 (f=1)} |
Key 1 is accessed, frequency becomes 2. |
| put(3, 3) |
null |
{1 : 1 (f=2), 3 : 3 (f=1)} |
Cache full. Evict key 2 (lowest frequency = 1). Insert key 3. |
Example 2: capacity = 3
| Operation |
Return |
Cache State |
Reason / Eviction |
| put(A, 10) |
null |
{A:10 (f=1)} |
Inserted A. |
| put(B, 20) |
null |
{A:10 (f=1), B:20 (f=1)} |
Inserted B. |
| put(C, 30) |
null |
{A:10 (f=1), B:20 (f=1), C:30 (f=1)} |
Inserted C. |
| get(A) |
10 |
{A:10 (f=2), B:20 (f=1), C:30 (f=1)} |
A accessed, frequency becomes 2. |
| get(B) |
20 |
{A:10 (f=2), B:20 (f=2), C:30 (f=1)} |
B accessed, frequency becomes 2. |
| put(D, 40) |
null |
{A:10 (f=2), B:20 (f=2), D:40 (f=1)} |
Cache full. Evict key C (lowest frequency = 1). Insert D. |
3. Brute Force Approach
Intuition
The simplest way to track everything is to keep a dynamic array (or vector) of objects holding the key, value, frequency, and a timestamp for every element. When an eviction is needed, we scan the entire array to find the element with the minimum frequency and oldest timestamp.
Data Structures Used
- A structure
Item { int key, value, freq, time; }. std::vector<Item> to store the cache contents.- A global
timer integer to simulate timestamps.
Algorithm
get(key): Iterate through the vector. If found, update freq++, time = ++timer, and return value. Otherwise, return -1.put(key, value): Iterate through the vector. If found, update value, freq++, and time = ++timer. If not found:- If size == capacity: Scan the whole vector to find the item with the minimum
freq. If multiple exist, pick the one with the smallest time. Erase it. - Push new
Item(key, value, 1, ++timer) to the vector.
Step-by-step Dry Run
put(1,1): Vector: [{k:1, v:1, f:1, t:1}]put(2,2): Vector: [{1,1,1,1}, {2,2,1,2}]get(1): Found 1. Update. Vector: [{1,1,2,3}, {2,2,1,2}]. Returns 1.put(3,3): Cap full (2). Scan vector. Min freq is 1 (Key 2). Erase Key 2. Insert Key 3. Vector: [{1,1,2,3}, {3,3,1,4}].
C++ Implementation
#include <vector>
#include <climits>
using namespace std;
struct Item {
int key;
int val;
int freq;
int time;
};
class LFUCacheBrute {
int capacity;
int timer;
vector<Item> cache;
public:
LFUCacheBrute(int cap) : capacity(cap), timer(0) {}
int get(int key) {
for (auto& item : cache) {
if (item.key == key) {
item.freq++;
item.time = ++timer;
return item.val;
}
}
return -1;
}
void put(int key, int value) {
if (capacity == 0) return;
for (auto& item : cache) {
if (item.key == key) {
item.val = value;
item.freq++;
item.time = ++timer;
return;
}
}
if (cache.size() == capacity) {
int minFreq = INT_MAX;
int oldestTime = INT_MAX;
int evictIdx = -1;
for (int i = 0; i < cache.size(); i++) {
if (cache[i].freq < minFreq ||
(cache[i].freq == minFreq && cache[i].time < oldestTime)) {
minFreq = cache[i].freq;
oldestTime = cache[i].time;
evictIdx = i;
}
}
cache.erase(cache.begin() + evictIdx);
}
cache.push_back({key, value, 1, ++timer});
}
};
Time Complexity
get(): O(N) — Linear scan.put(): O(N) — Linear scan to find element, plus O(N) to find the eviction candidate and O(N) to erase from the vector. Overall O(N).
Space Complexity
- O(N) where N is the capacity of the cache.
Why this approach is inefficient
Every operation scales linearly with the cache capacity. For a high-performance system serving millions of requests, O(N) per operation is unacceptable.
4. Better Approach
Intuition
We can improve lookup time using a Hash Map and eviction time using a balanced Binary Search Tree (BST). By using a Red-Black tree (like std::set in C++), we can keep the elements sorted automatically by their frequency, and then by their timestamp.
Data Structures Used
std::unordered_map<int, Node>: Maps keys to their node data for O(1) lookups.std::set<Node>: Maintains nodes sorted by frequency (primary) and timestamp (secondary).
Algorithm
get(key): Check hash map. If found, remove from set, update freq and time, re-insert into set, update hash map. Return value.put(key, value):- If key exists: Update value, remove from
set, update freq/time, re-insert into set. - If new key: If at capacity, delete the first element in the
set (set.begin()) because it represents the minimum frequency/oldest time. Remove it from the hash map. Create new node, insert into set and hash map.
Detailed Dry Run
put(1,1): Map: {1}. Set: [(f:1, t:1, k:1)]put(2,2): Map: {1, 2}. Set: [(f:1, t:1, k:1), (f:1, t:2, k:2)]get(1): Find 1 in map. Remove from set. Freq=2, t=3. Insert to set. Set: [(f:1, t:2, k:2), (f:2, t:3, k:1)]. Returns 1.put(3,3): Cap full. set.begin() is (f:1, t:2, k:2). Evict Key 2. Insert Key 3. Set: [(f:1, t:4, k:3), (f:2, t:3, k:1)].
C++ Implementation
#include <unordered_map>
#include <set>
struct SetNode {
int key, val, freq, time;
bool operator<(const SetNode& other) const {
if (freq == other.freq) return time < other.time;
return freq < other.freq;
}
};
class LFUCacheBetter {
int capacity, timer;
std::unordered_map<int, SetNode> keyNode;
std::set<SetNode> bst;
public:
LFUCacheBetter(int cap) : capacity(cap), timer(0) {}
int get(int key) {
if (keyNode.find(key) == keyNode.end()) return -1;
SetNode node = keyNode[key];
bst.erase(node);
node.freq++;
node.time = ++timer;
bst.insert(node);
keyNode[key] = node;
return node.val;
}
void put(int key, int value) {
if (capacity == 0) return;
if (keyNode.find(key) != keyNode.end()) {
SetNode node = keyNode[key];
bst.erase(node);
node.val = value;
node.freq++;
node.time = ++timer;
bst.insert(node);
keyNode[key] = node;
return;
}
if (keyNode.size() == capacity) {
auto it = bst.begin();
keyNode.erase(it->key);
bst.erase(it);
}
SetNode newNode = {key, value, 1, ++timer};
bst.insert(newNode);
keyNode[key] = newNode;
}
};
Time Complexity
get(): O(logN) — Hash map lookup is O(1), but erasing and inserting into std::set takes O(logN).put(): O(logN) — Map operations are O(1), set insertion/deletion is O(logN).
Space Complexity
- O(N) for both the map and the set.
Advantages over brute force
Significantly faster for large capacities. O(logN) is highly scalable.
Remaining bottlenecks
O(logN) is great, but production caches (like those in OS, databases, or Redis) require O(1) constant time operations to handle ultra-high throughput.
5. Optimal Approach
The standard O(1) LFU Cache relies on grouping keys by their frequencies.
Core Intuition
Instead of ordering all elements in a single tree, we bucket them by frequency. If we have a list of items that have been accessed 1 time, a list for 2 times, etc., we can find the minimum frequency group instantly if we just track a minFreq variable.
Why two hash maps are required
- Map 1 (key to Node): To find any node instantly in O(1) time regardless of what frequency bucket it is currently in.
- Map 2 (frequency to Doubly Linked List): To group nodes that share the exact same frequency.
Why doubly linked lists (DLL) are used
When a node's frequency increases, it must be removed from its current frequency list and appended to the next. A Doubly Linked List allows O(1) node removal (since we have the node pointer directly from Map 1) and O(1) insertion.
Why LRU is maintained inside each frequency list
We append new/updated nodes to the head of the DLL. Thus, the tail of the DLL represents the oldest node in that specific frequency bucket. When a tie-break is needed, we simply pop the tail of the minFreq's DLL.
Explain minFreq
A variable minFreq tracks the smallest frequency currently present in the cache. When eviction occurs, we look up Map2[minFreq] and evict its LRU node. minFreq is updated to 1 on new insertions, or incremented if the DLL representing the current minFreq becomes empty.
Data Structures Separated
- Node: Contains
key, value, freq, prev, and next pointers. - Frequency List (DLL): Contains a dummy
head and tail, and a size counter. Supports O(1) addFront() and removeNode(). - key -> node map (
keyNode): std::unordered_map<int, Node*> maps a key to its physical memory address. - frequency -> linked list map (
freqListMap): std::unordered_map<int, List*> maps an integer frequency to its corresponding DLL. - minFreq: Integer pointing to the lowest active frequency.
6. Algorithm
Explain get() step-by-step
- Check if the key exists in
keyNode. If not, return -1. - Extract the
Node*. - Call
updateFrequency(node). - Return the node's value.
Explain put() step-by-step
- If
capacity == 0, exit. - If key exists in
keyNode, update its value, and call updateFrequency(node). - If key doesn't exist:
- If cache size == capacity, call the eviction process.
- Create a new Node.
- Add node to
freqListMap[1] (using insertAfterHead()). - Set
minFreq = 1. - Add node to
keyNode. - Increment cache size.
Explain removeNode()
Given a node pointer, rewire its neighbors:
node->prev->next = node->next
node->next->prev = node->prev
Decrement DLL size.
Explain insertAfterHead()
(Usually called addFront)
New node goes right after the dummy head.
node->next = head->next
node->prev = head
head->next->prev = node
head->next = node
Increment DLL size.
Explain updateFrequency()
- Identify node's current frequency:
freq = node->freq. - Remove node from
freqListMap[freq]. - Crucial check: If
freq == minFreq AND the DLL for this freq is now empty (size == 0), increment minFreq++. - Increment node's frequency:
node->freq++. - Add node to
freqListMap[node->freq] at the head.
Explain eviction process
- Find the DLL corresponding to
minFreq: List* list = freqListMap[minFreq]. - The LRU node is
list->tail->prev. - Remove this node from the DLL.
- Remove its key from
keyNode. - Decrement cache size.
- Delete the node from memory (avoid memory leaks).
7. Complete C++ Code
#include <unordered_map>
#include <iostream>
using namespace std;
struct Node {
int key, value, freq;
Node* prev;
Node* next;
Node(int k, int v) : key(k), value(v), freq(1), prev(nullptr), next(nullptr) {}
};
struct List {
int size;
Node* head;
Node* tail;
List() {
head = new Node(-1, -1);
tail = new Node(-1, -1);
head->next = tail;
tail->prev = head;
size = 0;
}
~List() {
delete head;
delete tail;
}
void addFront(Node* node) {
Node* nextNode = head->next;
node->next = nextNode;
node->prev = head;
head->next = node;
nextNode->prev = node;
size++;
}
void removeNode(Node* node) {
Node* prevNode = node->prev;
Node* nextNode = node->next;
prevNode->next = nextNode;
nextNode->prev = prevNode;
size--;
}
};
class LFUCache {
private:
unordered_map<int, Node*> keyNode;
unordered_map<int, List*> freqListMap;
int maxSizeCache;
int minFreq;
int curSize;
void updateFrequency(Node* node) {
int freq = node->freq;
freqListMap[freq]->removeNode(node);
if (freq == minFreq && freqListMap[freq]->size == 0) {
minFreq++;
}
node->freq++;
if (freqListMap.find(node->freq) == freqListMap.end()) {
freqListMap[node->freq] = new List();
}
freqListMap[node->freq]->addFront(node);
}
public:
LFUCache(int capacity) {
maxSizeCache = capacity;
minFreq = 0;
curSize = 0;
}
~LFUCache() {
for (auto& pair : keyNode) delete pair.second;
for (auto& pair : freqListMap) delete pair.second;
}
int get(int key) {
if (keyNode.find(key) == keyNode.end()) {
return -1;
}
Node* node = keyNode[key];
updateFrequency(node);
return node->value;
}
void put(int key, int value) {
if (maxSizeCache == 0) return;
if (keyNode.find(key) != keyNode.end()) {
Node* node = keyNode[key];
node->value = value;
updateFrequency(node);
return;
}
if (curSize == maxSizeCache) {
List* minFreqList = freqListMap[minFreq];
Node* lruNode = minFreqList->tail->prev;
keyNode.erase(lruNode->key);
minFreqList->removeNode(lruNode);
delete lruNode;
curSize--;
}
curSize++;
minFreq = 1;
Node* newNode = new Node(key, value);
if (freqListMap.find(1) == freqListMap.end()) {
freqListMap[1] = new List();
}
freqListMap[1]->addFront(newNode);
keyNode[key] = newNode;
}
};
8. Dry Run
Capacity = 2
Operation 1: put(1,1)
- Operation: Insert {1:1}
- Key frequencies: 1 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [1]
- Cache contents: {1:1}
- Node movements: Node 1 added to Map[1]
- Evicted node: None
- Current answer:
null
Operation 2: put(2,2)
- Operation: Insert {2:2}
- Key frequencies: 1 -> f1, 2 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [2, 1] (2 is at head)
- Cache contents: {1:1, 2:2}
- Node movements: Node 2 added to Map[1] front
- Evicted node: None
- Current answer:
null
Operation 3: get(1)
- Operation: Read Key 1
- Key frequencies: 1 -> f2, 2 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [2], Map[2] -> [1]
- Cache contents: {1:1, 2:2}
- Node movements: 1 moved from Map[1] to Map[2]
- Evicted node: None
- Current answer:
1
Operation 4: put(3,3)
- Operation: Insert {3:3}, cap is 2
- Key frequencies: 1 -> f2, 3 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [3], Map[2] -> [1]
- Cache contents: {1:1, 3:3}
- Node movements: Node 2 removed. Node 3 added to Map[1]
- Evicted node: Node 2 (LRU of minFreq 1)
- Current answer:
null
Operation 5: get(2)
- Operation: Read Key 2
- Key frequencies: 1 -> f2, 3 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [3], Map[2] -> [1]
- Cache contents: {1:1, 3:3}
- Node movements: None
- Evicted node: None
- Current answer:
-1 (Not found)
Operation 6: get(3)
- Operation: Read Key 3
- Key frequencies: 1 -> f2, 3 -> f2
- minFreq: 2 (Map[1] became empty)
- Frequency Lists: Map[2] -> [3, 1]
- Cache contents: {1:1, 3:3}
- Node movements: 3 moved from Map[1] to Map[2]
- Evicted node: None
- Current answer:
3
Operation 7: put(4,4)
- Operation: Insert {4:4}, cap is 2
- Key frequencies: 3 -> f2, 4 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [4], Map[2] -> [3]
- Cache contents: {3:3, 4:4}
- Node movements: Node 1 removed. Node 4 added to Map[1]
- Evicted node: Node 1 (LRU of minFreq 2)
- Current answer:
null
Operation 8: get(1)
- Operation: Read Key 1
- Current answer:
-1
Operation 9: get(3)
- Operation: Read Key 3
- Key frequencies: 3 -> f3, 4 -> f1
- minFreq: 1
- Frequency Lists: Map[1] -> [4], Map[3] -> [3]
- Current answer:
3
Operation 10: get(4)
- Operation: Read Key 4
- Key frequencies: 3 -> f3, 4 -> f2
- minFreq: 2 (Map[1] empty)
- Frequency Lists: Map[2] -> [4], Map[3] -> [3]
- Current answer:
4
9. Complexity Analysis
| Operation |
Time Complexity |
Space Complexity |
| get(key) |
O(1) average |
O(1) auxiliary |
| put(key, value) |
O(1) average |
O(1) auxiliary |
| Total Cache Storage |
N/A |
O(N), where N is the cache capacity |
Why every operation is O(1):
- Finding a node: O(1) via
keyNode hash map. - Deleting a node from current frequency: O(1) because we have the direct memory address of the node and it lives in a doubly linked list.
- Adding to the next frequency list: O(1) by injecting right behind the head of the DLL.
- Eviction: O(1) because
minFreq instantly points to the correct DLL, and its tail->prev instantly gives the LRU element to delete.
10. Visualization
Visualizing how node promotion works via DLLs.
Initial State (Key A freq 1, Key B freq 1):
11. Interview Tips
- Common Mistakes: Forgetting to update
minFreq when a node's frequency increments and it was the only node in the minFreq list. (Check if (freq == minFreq && freqListMap[freq]->size == 0) minFreq++;). - Edge Cases: Forgetting to handle
capacity == 0 right at the start of put(). This will cause segmentation faults. - Why LRU alone fails: LRU favors recency. An item hit 1000 times a minute ago would be evicted in favor of an item hit 1 time a second ago. LFU prevents this by locking high-value items into higher frequency buckets.
- Why LFU alone needs LRU: If multiple items have a frequency of 1, and the cache is full, LFU doesn't know which one to pick. You must fall back to LRU to break the tie.
- Frequently asked interview questions: "Can you optimize space?" (You can merge the DLL implementation directly into an array if memory fragmentation is a strict concern, though Hash Map + Pointers is standard).
12. Comparison Table
13. Important Edge Cases
capacity = 0: Cache size is zero. Every put should do nothing, every get should return -1. Guard against this at the very top of put().- Repeated
get(): A key accessed heavily will march up the frequency lists map (e.g., Map[100], Map[101]). This creates new DLLs dynamically. - Updating existing key: When calling
put() on an existing key, it should NOT reset the frequency to 1. It acts like a get() (increases frequency) but also overwrites the stored value. - Frequency overflow discussion: In reality, an integer
freq might overflow if a key is hit billions of times. In production systems (like Redis's LFU implementation), frequency is usually capped (e.g., at 255 using an 8-bit counter) or decays over time to prevent stagnant keys from permanently hogging the cache. - Evicting when multiple keys have same frequency: Fully handled by the DLL structure. The element closest to the
tail is implicitly the LRU.
14. Final Summary
- Main Intuition: To achieve O(1) performance, we group cache items into buckets (Doubly Linked Lists) based on their access frequency.
- Data Structures: We bind everything together using two maps: one mapping the
key to its physical Node* (for instant lookups), and another mapping a frequency integer to a DLL of nodes (to handle ties using LRU). - Algorithm:
get bumps the item to the next frequency bucket. put inserts at frequency 1 (resetting minFreq to 1). If full, put pops the tail of the minFreq DLL before inserting. - Complexity: Time O(1) for all core operations. Space O(N) where N is the capacity.
- Interview Takeaway: Mastery of this problem demonstrates a deep understanding of combining multiple fundamental data structures (Hash Maps + DLLs) to solve complex architectural bottlenecks. Nail the pointer rewiring and the
minFreq edge case, and you will ace the interview.
code link:
https://leetcode.com/problems/lfu-cache/