DSA DSA In Depth
0%
build build-lablrulfu

Build Lab: O(1) LRU and Frequency-Bucketed LFU Cache Engine

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
Designing an O(1) LFU Cache with Frequency-Bucketed Doubly Linked Lists
NeetCodeIO Designing an O(1) LFU Cache with Frequency-Bucketed Doubly Linked Lists Watch on YouTube ↗

Deep-dive lecture covering Build Lab: O(1) LRU and Frequency-Bucketed LFU Cache Engine.

0:00 / --:--

End-to-End State Machine of the $O(1)$ LFU Engine

Let us trace every edge case required to implement a zero-allocation, bug-free $O(1)$ LFU Cache of capacity $C$. Every cached item is represented by a Node containing {key, value, freq, prev, next}. We maintain:

  • key_table: Maps key -> Node* (or arena slot index).
  • freq_table: Maps freq -> DoublyLinkedList, where each list orders nodes of that exact frequency from Most Recently Used (front) to Least Recently Used (back).
  • min_freq: An integer tracking the smallest frequency $f \ge 1$ such that freq_table[f] is non-empty.
Verification Checklist: The 4 Invariant Transitions of O(1) LFU
  1. Transition 1 — Cache Hit on get(key): Find node $u$ with frequency $f = u.\text{freq}$. Unlink $u$ from freq_table[f]. If freq_table[f].empty() and $f == \text{min\_freq}$, set $\text{min\_freq} = f + 1$. Set $u.\text{freq} = f + 1$ and push $u$ to the MRU front of freq_table[f + 1]. Return $u.\text{value}$.
  2. Transition 2 — Cache Hit on put(key, new_val): Overwrite $u.\text{value} = \text{new\_val}$, and execute the exact same frequency promotion logic as Transition 1!
  3. Transition 3 — Cache Miss on put(key, val) when Full (size == capacity): Look up bucket freq_table[min_freq]. Pop its LRU tail node $v$, and erase $v.\text{key}$ from key_table. (Note: we do NOT need to update min_freq based on $v$'s removal because we are about to reset min_freq = 1 in Transition 4!)
  4. Transition 4 — Inserting the New Node on put(key, val): Initialize node $w$ with $\text{freq} = 1$, push $w$ to the MRU front of freq_table[1], insert key_table[key] = w, and unconditionally set min_freq = 1.

Operation Complexity & Edge-Case Matrix

Operation / Edge Case min_freq Mutation Bucket Lists Touched Time Complexity Common Implementation Bug
get(key) (Hit at $f == \text{min\_freq}$, last item in bucket) min_freq = f + 1 Unlink from $f$, push front $f+1$ $O(1)$ Forgetting to check if bucket $f$ became empty
get(key) (Hit at $f > \text{min\_freq}$) Unchanged Unlink from $f$, push front $f+1$ $O(1)$ Incrementing min_freq when $f \neq \text{min\_freq}$!
put(key, val) (New key when full) Reset min_freq = 1 Pop tail of min_freq, push front of $1$ $O(1)$ Evicting AFTER setting min_freq = 1!
capacity == 0 N/A (No-op) None $O(1)$ Attempting to evict from an empty freq_table[0]

Visualizing Eviction + Insertion Order on Cache-Full Miss

Critical Ordering When Cache Is Full (Suppose min_freq = 3 Before New Key X Arrives) Step 1: Evict FIRST Using OLD min_freq (3) victim = freq_lists[3].pop_lru_back() --> Step 2: Insert New Key X & Reset min_freq = 1 freq_lists[1].push_mru_front(X); min_freq = 1; Fatal Bug: If you insert X (or set min_freq = 1) BEFORE evicting, you will evict from freq_lists[1] (immediately evicting X itself!) instead of evicting the true victim from freq_lists[3]!

Complete Control Flow of LFUCache::put(key, value)

flowchart TD A["put(key, val)"] --> B{"capacity == 0?"} B -- "Yes" --> C["Return immediately"] B -- "No" --> D{"Is key in key_table?"} D -- "Yes (Update)" --> E["Update node.val = val
Call touch(node) to promote freq"] D -- "No (Miss)" --> F{"size == capacity?"} F -- "Yes (Full)" --> G["Evict LRU tail of freq_table[min_freq]
Erase victim.key from key_table"] F -- "No" --> H["Allocate/Reuse Node(key, val, freq=1)
Push to MRU front of freq_table[1]
Set min_freq = 1"] G --> H
Hardware Reality: Eliminating Empty Bucket Bloat & std::list Allocator Thrashing

A textbook C++ LFU implementation using std::unordered_map<int, std::list<Node>> freq_table suffers from two performance hazards in long-running services:

  1. Per-Promotion Heap Allocations: Calling freq_table[f].erase(it) and freq_table[f+1].push_front(...) deallocates a heap node and allocates a brand-new heap node on every single cache hit! Using std::list::splice (or intrusive doubly linked list nodes) moves the existing node between frequency buckets by rewiring 4 pointers with zero heap allocations.
  2. Unbounded Empty Frequency Buckets: If a hot item is accessed $10^7$ times, a naive freq_table creates $10^7$ empty std::list entries for frequencies $1, 2, \dots, 10^7 - 1$, exhausting gigabytes of RAM! In production, either erase empty buckets from freq_table when their size hits 0, or store frequency buckets as an intrusive doubly linked list of active frequency headers.
O(1) HashMap + Doubly-Linked List LRU & LFU Cache Eviction
O(1) Get & Put

Step through O(1) cache hits (splicing nodes to MRU head or promoting to freq+1 bucket list) and O(1) capacity evictions from the LRU tail / minFreq bucket.

Step 1 / 6
Custom Input:
Timeline

Invariant & State

HashMap + Intrusive DLL: size = 1/3 | Order (MRU → LRU): [A]
🔗 Live Pointer & State Transition Graph — O(1) HashMap + Doubly-Linked List LRU & LFU Cache Eviction Active Pointer Jump/Transition Matched/Optimal
MRU · f Key A [0]
[0]Key AMRU · f=1
Operation
PUT(A, 1)
MRU / LRU
A / A
Hits / Misses
0 / 0
Evicted Key
None
[Step 1] PUT(A, 1): Inserted A at MRU head.

Production Implementation: Zero-Allocation Intrusive O(1) LFU Cache Engine

#include <cstdint>
#include <unordered_map>
#include <vector>

class LFUCache {
  struct Node {
    int key = 0;
    int value = 0;
    int freq = 1;
    int prev = -1;
    int next = -1;
  };

  struct FreqList {
    int head = -1; // MRU front
    int tail = -1; // LRU back
    int count = 0;
  };

  int capacity_;
  int size_ = 0;
  int min_freq_ = 0;
  std::vector<Node> pool_;
  std::vector<int> free_slots_;
  std::unordered_map<int, int> key_to_slot_;
  std::unordered_map<int, FreqList> freq_lists_;

  void remove_from_list(int slot, int freq) {
    auto it = freq_lists_.find(freq);
    FreqList& list = it->second;
    const int p = pool_[slot].prev;
    const int n = pool_[slot].next;

    if (p != -1) pool_[p].next = n;
    else list.head = n;

    if (n != -1) pool_[n].prev = p;
    else list.tail = p;

    pool_[slot].prev = pool_[slot].next = -1;
    if (--list.count == 0) {
      freq_lists_.erase(it); // Prevent unbounded empty bucket memory leak!
      if (min_freq_ == freq) {
        ++min_freq_;
      }
    }
  }

  void push_front_list(int slot, int freq) {
    FreqList& list = freq_lists_[freq];
    pool_[slot].prev = -1;
    pool_[slot].next = list.head;
    if (list.head != -1) pool_[list.head].prev = slot;
    else list.tail = slot;
    list.head = slot;
    ++list.count;
  }

  void touch(int slot) {
    const int f = pool_[slot].freq;
    remove_from_list(slot, f);
    pool_[slot].freq = f + 1;
    push_front_list(slot, f + 1);
  }

public:
  explicit LFUCache(int capacity) : capacity_(capacity), pool_(capacity > 0 ? capacity : 0) {
    for (int i = capacity - 1; i >= 0; --i) {
      free_slots_.push_back(i);
    }
  }

  int get(int key) {
    auto it = key_to_slot_.find(key);
    if (it == key_to_slot_.end()) return -1;
    const int slot = it->second;
    touch(slot);
    return pool_[slot].value;
  }

  void put(int key, int value) {
    if (capacity_ <= 0) return;

    auto it = key_to_slot_.find(key);
    if (it != key_to_slot_.end()) {
      const int slot = it->second;
      pool_[slot].value = value;
      touch(slot);
      return;
    }

    int slot = -1;
    if (size_ == capacity_) {
      // Evict LRU tail of min_freq_ BEFORE resetting min_freq_ = 1!
      const int victim = freq_lists_[min_freq_].tail;
      remove_from_list(victim, min_freq_);
      key_to_slot_.erase(pool_[victim].key);
      slot = victim; // Reuse evicted slot in place!
    } else {
      slot = free_slots_.back();
      free_slots_.pop_back();
      ++size_;
    }

    pool_[slot].key = key;
    pool_[slot].value = value;
    pool_[slot].freq = 1;
    push_front_list(slot, 1);
    key_to_slot_[key] = slot;
    min_freq_ = 1;
  }
};
Production Pitfall: Evicting After Inserting & Leaking Empty Frequency Buckets

When implementing LFUCache::put on a cache miss when size == capacity, you must always evict the victim from freq_lists_[min_freq_] BEFORE inserting the new key and resetting min_freq_ = 1. If you insert the new key first (or set min_freq_ = 1 first), and the old min_freq_ was $> 1$, then evicting from min_freq_ == 1 will immediately evict the brand-new item you just inserted! Additionally, always erase a frequency bucket from freq_lists_ when its count drops to 0 so long-running caches do not leak millones of empty hash entries as item frequencies climb.

Check yourself Question 1 of 4
Concept Check Zero-Allocation Slab Handle vs Heap Pointer recall

In our production `FlatO1LfuCache` implementation, all cache nodes live in a pre-allocated contiguous vector `nodes[0..capacity-1]`, and `prev` / `next` / `freeHead` are stored as `int32_t` array indices (`NULL_IDX = -1`) instead of 64-bit `Node*` pointers. Which two major production hazards does this eliminate?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does S3-FIFO eliminate both multi-core lock contention and single-hit scan pollution without a doubly linked list?

Key Takeaways

In an O(1) LFU cache, updating an existing key via put(key, val) must increment its frequency and promote it just like get(key).
When the cache is full on a miss, always evict the LRU tail of freq_lists[min_freq] BEFORE inserting the new key at freq = 1 and setting min_freq = 1.
Reusing pre-allocated node slots (or using std::list::splice) moves nodes across frequency tiers in O(1) time with zero heap allocations.
Erasing empty frequency buckets from freq_lists when their count reaches 0 prevents unbounded memory growth when items are accessed millions of times.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue