DSA DSA In Depth
0%
design lru-cachelfu-cachedata-structure-design

Composing Hash Maps + Doubly Linked Lists: O(1) LRU & LFU Caches

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
LRU & LFU Cache Design: Hash Maps + Doubly Linked Lists
NeetCode LRU & LFU Cache Design: Hash Maps + Doubly Linked Lists Watch on YouTube ↗
0:00 / --:--

Data Structure Composition: Why Hash Map + Doubly Linked List?

In an LRU (Least Recently Used) cache of capacity $C$:

  • get(key) must look up the key in $O(1)$ time and move its entry to the Most Recently Used (MRU) head of the recency order in $O(1)$ time.
  • put(key, val) must insert or update the key at the MRU head in $O(1)$ time, and if size exceeds $C$, evict the Least Recently Used (LRU) tail entry in $O(1)$ time.
Why must the recency list be doubly linked rather than singly linked? Because when a cache hit occurs on an arbitrary interior node $u$ via the hash map (map[key] -> u), unlinking $u$ from its current position requires modifying $u$'s predecessor (u->prev->next = u->next). Without a prev pointer, finding $u$'s predecessor would take $O(N)$ time!

Algorithmic Invariant: Why O(1) LFU Cache Tracks min_freq Without a Min-Heap

In an LFU (Least Frequently Used) cache, eviction removes the item with the smallest access count freq (breaking ties by Least Recently Used among items of that same freq). Using a Min-Heap of frequencies would cost $O(\log C)$ per access. In 2010, Shah, Mitra, and Matani proved how to achieve strictly $O(1)$ LFU by grouping nodes into a hash map of doubly linked lists indexed by frequency (freq_lists[f]) and maintaining a single scalar integer min_freq:

  1. On put(new_key) (after evicting freq_lists[min_freq].back() if full): The new key starts with frequency $1$, so we insert it into freq_lists[1] and set min_freq = 1 in $O(1)$ time!
  2. On get(key) or updating an existing key of frequency $f$: We unlink the node from freq_lists[f] and push it to the MRU front of freq_lists[f + 1]. What if $f == \text{min\_freq}$ and freq_lists[f] just became empty? Then the node we just promoted was the last item at min_freq, and its new frequency is $f + 1$—so we simply increment ++min_freq in $O(1)$ time!

Because frequency only ever jumps to $1$ (on new insert) or increments by $+1$ (on access), min_freq never needs to search for the next minimum!

Cache Eviction Policies & Production Systems Comparison

Eviction Policy get / put Time Metadata per Entry Scan Resistance (One-Hit Wonders) Production Adopters
Strict LRU (Hash + DLL) $O(1)$ 2 pointers (prev, next) + key/val Poor: A sequential scan of $C$ items flushes the entire cache! Local in-memory caches, Guava
Strict LFU (Freq Lists + min_freq) $O(1)$ 2 pointers + freq + list bucket High, but suffers from frequency pollution (old stale items never decay) CDN static asset caches
CLOCK / Second-Chance (Ring + 1 Bit) $O(1)$ amortized 1 reference bit (no pointer chasing on hit!) Moderate Linux VM Page Cache, Postgres Buffer Pool
W-TinyLFU (Window LRU + Count-Min Sketch) $O(1)$ 4-bit decaying frequency counter Near-Optimal (blocks scan pollution & decays old frequencies) Java Caffeine, RocksDB, Cloudflare

Visualizing O(1) LFU Frequency-Bucketed Doubly Linked Lists

LFU Architecture: key_map[key] --> Node*, Grouped by Frequency Bucket Lists (min_freq = 1) freq = 1 (min) MRU: Key C (f=1) LRU Victim: Key A (f=1) freq = 2 MRU: Key D (f=2) LRU: Key B (f=2) freq = 5 MRU: Key E (f=5) Invariant: Eviction ALWAYS pops the LRU tail of freq_lists[min_freq] in O(1) time!

LFU Promotion & min_freq State Transition Diagram

flowchart LR A["Access Node u
with f = u.freq"] --> B["Unlink u from
freq_lists[f]"] B --> C{"Is freq_lists[f] empty
AND f == min_freq?"} C -- "Yes" --> D["min_freq = f + 1"] C -- "No" --> E["Keep min_freq"] D --> F["u.freq = f + 1
Push u to MRU front of freq_lists[f + 1]"] E --> F
Hardware Reality: Multi-Core Lock Contention on the LRU Head Pointer

In a multi-threaded server with 64 CPU cores, textbook strict LRU is a scalability disaster: even on a 100% read-only workload, every get(key) cache hit mutates the doubly linked list's head pointers—requiring an exclusive mutex lock and causing L1 cache-line bouncing (MESI invalidation storms) on the sentinel node across all 64 cores! Production systems solve this in three ways:

  1. Sharded Caches (RocksDB BlockCache): Partition keys by hash prefix into 64 independent LRU shards, reducing lock contention by $64\times$.
  2. CLOCK / Approximate LRU (Linux Page Cache, Redis): Only set an atomic 1-bit reference flag on read hits (or sample 5 random keys at eviction time in Redis) so read hits never mutate linked list pointers.
  3. Deferred Ring-Buffer Replay (Java Caffeine): Record read hits into lock-free per-thread ring buffers and replay promotions asynchronously in batches.

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: Contiguous Arena-Backed O(1) LRU Cache

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

// High-locality O(1) LRU Cache using a pre-allocated Node pool (0 mallocs after construction!)
class FastLRUCache {
  struct Node {
    int key = 0;
    int value = 0;
    int prev = 0;
    int next = 0;
  };

  int cap_;
  std::vector<Node> pool_; // Index 0 is the circular dummy sentinel!
  std::vector<int> free_list_;
  std::unordered_map<int, int> index_map_;

  void unlink(int u) noexcept {
    const int p = pool_[u].prev;
    const int n = pool_[u].next;
    pool_[p].next = n;
    pool_[n].prev = p;
  }

  void push_mru_front(int u) noexcept {
    const int first = pool_[0].next;
    pool_[u].prev = 0;
    pool_[u].next = first;
    pool_[first].prev = u;
    pool_[0].next = u;
  }

public:
  explicit FastLRUCache(int capacity) : cap_(capacity), pool_(capacity + 1) {
    pool_[0].prev = 0;
    pool_[0].next = 0;
    free_list_.reserve(capacity);
    for (int i = capacity; i >= 1; --i) {
      free_list_.push_back(i);
    }
    index_map_.reserve(capacity * 2);
  }

  int get(int key) noexcept {
    auto it = index_map_.find(key);
    if (it == index_map_.end()) return -1;
    const int u = it->second;
    unlink(u);
    push_mru_front(u);
    return pool_[u].value;
  }

  void put(int key, int value) {
    if (cap_ <= 0) return;
    auto it = index_map_.find(key);
    if (it != index_map_.end()) {
      const int u = it->second;
      pool_[u].value = value;
      unlink(u);
      push_mru_front(u);
      return;
    }

    int u = 0;
    if (!free_list_.empty()) {
      u = free_list_.back();
      free_list_.pop_back();
    } else {
      // Reuse the LRU victim node at pool_[0].prev directly without freeing!
      u = pool_[0].prev;
      unlink(u);
      index_map_.erase(pool_[u].key);
    }

    pool_[u].key = key;
    pool_[u].value = value;
    push_mru_front(u);
    index_map_[key] = u;
  }
};
Production Pitfall: Forgetting to Store key Inside the Doubly Linked List Node

When designing an LRU or LFU cache, developers frequently store only value inside the doubly linked list Node (since key is already stored in the hash map). Then, when the cache reaches capacity and selects the LRU tail node Node* victim = sentinel.prev for eviction, they realize they have no way to remove the victim from index_map_ in $O(1)$ time without knowing victim->key! Every doubly linked list node in an LRU/LFU cache must store both key and value.

Check yourself Question 1 of 4
Concept Check $O(1)$ LFU `minFreq` Maintenance Rule recall

In the $O(1)$ LFU cache algorithm (Shah, Mitra, & Matani), we maintain a hash map from `key -> Node`, a hash map of frequency buckets `freq -> DoublyLinkedList`, and an integer `minFreq`. When a key currently at frequency $f$ is accessed (`get` or `put` update), it is moved from bucket $f$ to bucket $f + 1$. Under what exact condition does `minFreq` change?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

Why does strict LRU scale poorly on multi-socket/multi-core CPUs even on 100% read hits, and what is the systems fix?

Key Takeaways

An O(1) LRU Cache composes a Hash Map (key -> Node*) with a Circular Sentinel Doubly Linked List ordered from Most Recently Used (head) to Least Recently Used (tail).
Every doubly linked list Node must store its own key alongside its value so that evicting the tail node can erase the corresponding hash map entry in O(1) time.
An O(1) LFU Cache replaces the single LRU list with a Hash Map of Doubly Linked Lists keyed by frequency, plus a scalar min_freq that resets to 1 on new inserts and increments by 1 when freq_lists[min_freq] empties.
In multi-threaded systems, strict LRU suffers from severe cache-line lock contention on read hits; production systems use sharded LRU, CLOCK bits, or W-TinyLFU with buffered replay.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue