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.
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!
min_freq Without a Min-HeapIn 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:
- On
put(new_key)(after evictingfreq_lists[min_freq].back()if full): The new key starts with frequency $1$, so we insert it intofreq_lists[1]and setmin_freq = 1in $O(1)$ time! - On
get(key)or updating an existing key of frequency $f$: We unlink the node fromfreq_lists[f]and push it to the MRU front offreq_lists[f + 1]. What if $f == \text{min\_freq}$ andfreq_lists[f]just became empty? Then the node we just promoted was the last item atmin_freq, and its new frequency is $f + 1$—so we simply increment++min_freqin $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 Promotion & min_freq State Transition Diagram
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
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:
- Sharded Caches (RocksDB BlockCache): Partition keys by hash prefix into 64 independent LRU shards, reducing lock contention by $64\times$.
- 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.
- Deferred Ring-Buffer Replay (Java Caffeine): Record read hits into lock-free per-thread ring buffers and replay promotions asynchronously in batches.
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.
Invariant & State
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;
}
};
key Inside the Doubly Linked List NodeWhen 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.