460. LFU Cache - Explanation

Problem Link

Description

Design and implement a data structure for a Least Frequently Used (LFU) cache.

Implement the LFUCache class:

  • LFUCache(int capacity) Initializes the object with the capacity of the data structure.
  • int get(int key) Gets the value of the key if the key exists in the cache. Otherwise, returns -1.
  • void put(int key, int value) Update the value of the key if present, or inserts the key if not already present. When the cache reaches its capacity, it should invalidate and remove the least frequently used key before inserting a new item. For this problem, when there is a tie (i.e., two or more keys with the same frequency), the least recently used key would be invalidated.

To determine the least frequently used key, a use counter is maintained for each key in the cache. The key with the smallest use counter is the least frequently used key.

When a key is first inserted into the cache, its use counter is set to 1 (due to the put operation). The use counter for a key in the cache is incremented either a get or put operation is called on it.

The functions get and put must each run in O(1) average time complexity.

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]

Explanation:
// cnt(x) = the use counter for key x
// cache=[] will show the last used order for tiebreakers (leftmost element is most recent)
LFUCache lfu = new LFUCache(2);
lfu.put(1, 1); // cache=[1,_], cnt(1)=1
lfu.put(2, 2); // cache=[2,1], cnt(2)=1, cnt(1)=1
lfu.get(1); // return 1
// cache=[1,2], cnt(2)=1, cnt(1)=2
lfu.put(3, 3); // 2 is the LFU key because cnt(2)=1 is the smallest, invalidate 2.
// cache=[3,1], cnt(3)=1, cnt(1)=2
lfu.get(2); // return -1 (not found)
lfu.get(3); // return 3
// cache=[3,1], cnt(3)=2, cnt(1)=2
lfu.put(4, 4); // Both 1 and 3 have the same cnt, but 1 is LRU, invalidate 1.
// cache=[4,3], cnt(4)=1, cnt(3)=2
lfu.get(1); // return -1 (not found)
lfu.get(3); // return 3
// cache=[3,4], cnt(4)=1, cnt(3)=3
lfu.get(4); // return 4
// cache=[4,3], cnt(4)=2, cnt(3)=3

Constraints:

  • 1 <= capacity <= 10,000.
  • 0 <= key <= 100,000
  • 0 <= value <= 1,000,000,000
  • At most 200,000 calls will be made to get and put.


Topics

Company Tags

Please upgrade to NeetCode Pro to view company tags.



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Hash Maps - O(1) key-value lookups for storing cache entries
  • Doubly Linked Lists - O(1) insertion and deletion for maintaining element order
  • LRU Cache Concept - Understanding eviction based on recency (this problem extends it with frequency)
  • Cache Design Patterns - Combining multiple data structures for efficient operations

1. Brute Force

Intuition

An LFU (Least Frequently Used) cache evicts the element that has been accessed the fewest times. When there's a tie in frequency, we evict the least recently used among them. The simplest approach stores each key's value, frequency, and a timestamp. On eviction, we scan all entries to find the one with the minimum frequency (and earliest timestamp for ties).

Algorithm

  1. Store each cache entry as [value, frequency, timestamp] in a hash map.
  2. For get(key): if the key exists, increment its frequency, update its timestamp, and return the value. Otherwise return -1.
  3. For put(key, value): if the key exists, update its value, increment frequency, and update timestamp.
  4. If inserting a new key and the cache is full, scan all entries to find the one with the lowest frequency. Among entries with the same frequency, pick the one with the smallest timestamp. Remove it.
  5. Insert the new key with frequency 1 and the current timestamp.
class LFUCache:

    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}  # key -> [value, frequency, timestamp]
        self.timestamp = 0

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1

        self.cache[key][1] += 1
        self.timestamp += 1
        self.cache[key][2] = self.timestamp
        return self.cache[key][0]

    def put(self, key: int, value: int) -> None:
        if self.capacity <= 0:
            return

        self.timestamp += 1
        if key in self.cache:
            self.cache[key][0] = value
            self.cache[key][1] += 1
            self.cache[key][2] = self.timestamp
            return

        if len(self.cache) >= self.capacity:
            min_freq = float('inf')
            min_timestamp = float('inf')
            lfu_key = None

            for k, (_, freq, ts) in self.cache.items():
                if freq < min_freq or (freq == min_freq and ts < min_timestamp):
                    min_freq = freq
                    min_timestamp = ts
                    lfu_key = k
            if lfu_key is not None:
                del self.cache[lfu_key]

        self.cache[key] = [value, 1, self.timestamp]

Time & Space Complexity

  • Time complexity:
    • O(1)O(1) time for initialization.
    • O(1)O(1) time for each get()get() function call.
    • O(n)O(n) time for each put()put() function call.
  • Space complexity: O(n)O(n)

2. Doubly Linked List

Intuition

To achieve O(1) operations, we need fast access to both the least frequent element and the least recently used element within each frequency group. We use a hash map from frequency to a doubly linked list. Each linked list maintains insertion order, so the head is the least recently used. We also track the current minimum frequency to quickly find which list to evict from.

Algorithm

  1. Maintain three maps: valMap (key to value), countMap (key to frequency), and listMap (frequency to doubly linked list of keys).
  2. Track lfuCount, the current minimum frequency in the cache.
  3. For get(key): if the key exists, move it from its current frequency list to the next frequency list, update the frequency, and adjust lfuCount if needed.
  4. For put(key, value): if the key exists, update its value and treat it like a get operation. If inserting a new key and at capacity, pop the leftmost (oldest) element from the lfuCount list.
  5. Insert the new key into frequency-1 list and set lfuCount to 1.
class ListNode:

    def __init__(self, val, prev=None, next=None):
        self.val = val
        self.prev = prev
        self.next = next

class LinkedList:

    def __init__(self):
        self.left = ListNode(0)
        self.right = ListNode(0, self.left)
        self.left.next = self.right
        self.map = {}

    def length(self):
        return len(self.map)

    def pushRight(self, val):
        node = ListNode(val, self.right.prev, self.right)
        self.map[val] = node
        self.right.prev = node
        node.prev.next = node

    def pop(self, val):
        if val in self.map:
            node = self.map[val]
            next, prev = node.next, node.prev
            next.prev = prev
            prev.next = next
            self.map.pop(val, None)

    def popLeft(self):
        res = self.left.next.val
        self.pop(self.left.next.val)
        return res

    def update(self, val):
        self.pop(val)
        self.pushRight(val)

class LFUCache:

    def __init__(self, capacity: int):
        self.cap = capacity
        self.lfuCnt = 0
        self.valMap = {} # Map key -> val
        self.countMap = defaultdict(int) # Map key -> count
        # Map count of key -> linkedlist
        self.listMap = defaultdict(LinkedList)

    def counter(self, key):
        cnt = self.countMap[key]
        self.countMap[key] += 1
        self.listMap[cnt].pop(key)
        self.listMap[cnt + 1].pushRight(key)

        if cnt == self.lfuCnt and self.listMap[cnt].length() == 0:
            self.lfuCnt += 1


    def get(self, key: int) -> int:
        if key not in self.valMap:
            return -1
        self.counter(key)
        return self.valMap[key]

    def put(self, key: int, value: int) -> None:
        if self.cap == 0:
            return

        if key not in self.valMap and len(self.valMap) == self.cap:
            res = self.listMap[self.lfuCnt].popLeft()
            self.valMap.pop(res)
            self.countMap.pop(res)

        self.valMap[key] = value
        self.counter(key)
        self.lfuCnt = min(self.lfuCnt, self.countMap[key])

Time & Space Complexity

  • Time complexity:
    • O(1)O(1) time for initialization.
    • O(1)O(1) time for each get()get() and put()put() function calls.
  • Space complexity: O(n)O(n)

Common Pitfalls

Forgetting to Update Minimum Frequency After Eviction

When inserting a new key after eviction, the minimum frequency must be reset to 1 (since the new key starts with frequency 1). Failing to update lfuCount causes subsequent evictions to look in the wrong frequency bucket, potentially evicting recently accessed items or causing errors.

Not Incrementing Frequency on Both Get and Put

Both get() and put() (when updating an existing key) should increment the frequency. A common mistake is only incrementing on get(), which causes keys that are repeatedly updated but never retrieved to have artificially low frequencies and get evicted prematurely.

Incorrect LRU Tie-Breaking Within Same Frequency

When multiple keys share the same minimum frequency, the least recently used among them should be evicted. This requires maintaining insertion order within each frequency bucket. Using a regular set or unordered structure loses this ordering and leads to incorrect evictions.

Not Handling Zero Capacity Edge Case

When the cache capacity is 0, all put() operations should be no-ops since nothing can be stored. Forgetting this check causes attempts to evict from an empty cache, leading to errors or incorrect behavior.

Failing to Move Keys Between Frequency Lists

When a key's frequency increases, it must be removed from its current frequency list and added to the next frequency list. Forgetting to remove it from the old list causes the key to appear in multiple frequency buckets, corrupting the data structure and causing incorrect evictions.