The most-asked design interview question, period. Build a cache with O(1) get and put that automatically evicts the least recently (or least frequently) used item when full.
IntermediateA cache stores frequently accessed data in fast storage (usually RAM) to avoid expensive operations โ database queries, network calls, disk reads, or heavy computations. Your browser caches web pages so it doesn't re-download them every visit. Your CPU caches memory regions so it doesn't hit RAM every clock cycle. Caches are everywhere.
But caches have limited space. When the cache is full and you need to store something new, you have to evict (remove) something old to make room. The question is: what do you kick out? Different eviction strategies optimize for different access patterns.
LRU (Least Recently Used) evicts the item that hasn't been accessed for the longest time. The assumption: if you haven't touched it recently, you probably won't need it soon. This works well for most real-world access patterns, which tend to exhibit temporal locality โ recently accessed items are likely to be accessed again soon.
LFU (Least Frequently Used) evicts the item with the fewest total accesses. The assumption: rarely-accessed items are less valuable than frequently-accessed ones. This works well for stable popularity distributions (a few items are always popular), but it has a weakness: an item accessed 1,000 times last week but never again will linger in the cache forever while newer, more relevant items get evicted. LFU has a "stale popularity" problem.
Other policies you should know about:
The LRU concept dates back to the 1960s, when it was studied in the context of virtual memory page replacement โ the operating system needs to decide which memory pages to swap to disk when physical RAM is full. Lรกszlรณ Bรฉlรกdy proved in 1966 that the optimal strategy (minimizing page faults) is to evict the page that won't be used for the longest time in the future. Since we can't predict the future, LRU approximates this by looking at the recent past. It remains the most widely used cache eviction policy over 60 years later.
maxmemory-policy allkeys-lru), and Memcached all use LRU variants.functools.lru_cache โ a built-in decorator that caches function results using LRU. One line of code to memoize any pure function.The core challenge: you need O(1) for both get(key) and put(key, value). No data structure does both on its own. A hash map (a data structure that maps keys to values using a hash function, giving O(1) average lookup and insertion) gives O(1) lookup by key, but can't track ordering. A doubly linked list (a linked list where each node has pointers to both the next and previous nodes, enabling O(1) insertion and deletion at any position) gives O(1) move-to-front and remove-from-tail, but can't do O(1) lookup by key. Combine them.
Every key is in both structures simultaneously. The hash map lets you find any node instantly; the linked list lets you move nodes and identify the LRU element instantly.
Use sentinel (dummy) head and tail nodes โ nodes that hold no real data but simplify the code enormously. The real data lives between these two sentinels. This eliminates null checks everywhere โ you never have to handle "is this the first node?" or "is this the last node?" as special cases.
Without sentinels, every list operation needs 3-4 conditionals. With sentinels, every operation is the same 4 lines of pointer manipulation. This trick cuts your code in half and eliminates an entire class of bugs.
LFU is trickier because you need to track access frequency per item, and among items with the same frequency, evict the least recently used one (LRU as tiebreaker). The standard O(1) approach:
min_freq tracks the current minimum frequency.Watch the doubly linked list and hash map work together. When you access an item, it moves to the front. When the cache overflows, the tail gets evicted.
| Operation | LRU Cache | LFU Cache | Notes |
|---|---|---|---|
| get(key) | O(1) | O(1) | Hash map lookup + linked list move |
| put(key, val) | O(1) | O(1) | Hash map insert + linked list ops |
| Eviction | O(1) | O(1) | Remove tail (LRU) or min-freq tail (LFU) |
| Space | O(capacity) | O(capacity) | Hash map + doubly linked list nodes |
Everything is O(1). That's the whole point. If any operation is worse than O(1), you've designed it wrong. The hash map handles key lookup; the linked list handles ordering. Neither structure alone achieves O(1) for both tasks.
class Node:
"""Doubly linked list node holding a cache entry.
Stores key (needed for hash map deletion on eviction) and value."""
__slots__ = ('key', 'val', 'prev', 'next') # Memory optimization
def __init__(self, key=0, val=0):
self.key = key
self.val = val
self.prev = None
self.next = None
class LRUCache:
"""Least Recently Used cache with O(1) get and put.
Data structure: HashMap + Doubly Linked List
- HashMap: key โ Node (for O(1) lookup)
- DLL: ordered by recency (head = most recent, tail = least recent)
- Sentinel head/tail eliminate null-check edge cases
"""
def __init__(self, capacity: int):
self.cap = capacity
self.cache = {} # key โ Node
# Sentinel (dummy) head and tail nodes
# Real data lives between these two sentinels
self.head = Node() # Dummy head โ most recent end
self.tail = Node() # Dummy tail โ least recent end
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node):
"""Unlink a node from wherever it is in the list. O(1).
Works for any position โ no special cases needed thanks to sentinels."""
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_front(self, node):
"""Insert node right after the dummy head (most recent position). O(1)."""
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key: int) -> int:
"""Retrieve value by key. Moves accessed item to front (most recent).
Returns -1 if key not found."""
if key not in self.cache:
return -1
node = self.cache[key]
# This key was just accessed โ make it the most recently used
self._remove(node)
self._add_to_front(node)
return node.val
def put(self, key: int, value: int) -> None:
"""Insert or update a key-value pair.
If cache is at capacity, evicts the least recently used item."""
if key in self.cache:
# Key exists โ update value and move to front
self._remove(self.cache[key])
# Create new node (or replacement node if key existed)
node = Node(key, value)
self.cache[key] = node
self._add_to_front(node)
# Check capacity โ evict LRU if over limit
if len(self.cache) > self.cap:
lru = self.tail.prev # The node just before dummy tail = least recent
self._remove(lru)
del self.cache[lru.key] # This is why nodes store the key!
# Usage โ matches LeetCode 146 exactly
cache = LRUCache(2)
cache.put(1, 1) # cache: {1=1}
cache.put(2, 2) # cache: {1=1, 2=2}
print(cache.get(1)) # 1 โ moves key 1 to front
cache.put(3, 3) # evicts key 2 (LRU), cache: {1=1, 3=3}
print(cache.get(2)) # -1 (evicted)
cache.put(4, 4) # evicts key 1 (LRU, since 3 was accessed more recently via put)
print(cache.get(1)) # -1 (evicted)
print(cache.get(3)) # 3 (still in cache)
print(cache.get(4)) # 4 (still in cache)
from collections import OrderedDict
class LRUCacheSimple(OrderedDict):
"""LRU Cache using Python's OrderedDict.
OrderedDict maintains insertion order AND supports move_to_end().
This is a legitimate 10-line implementation. Some interviewers
accept it; others want the from-scratch version. Know both.
In production, this is actually what you'd use โ no need to
reinvent doubly linked lists when the stdlib has you covered.
"""
def __init__(self, capacity: int):
super().__init__()
self.cap = capacity
def get(self, key: int) -> int:
if key not in self:
return -1
self.move_to_end(key) # Mark as most recently used
return self[key]
def put(self, key: int, value: int) -> None:
if key in self:
self.move_to_end(key)
self[key] = value
if len(self) > self.cap:
self.popitem(last=False) # Remove oldest (leftmost) item
class LRUCache {
constructor(capacity) {
this.cap = capacity;
this.cache = new Map();
// Sentinel nodes โ the dummy trick
this.head = { key: null, val: null, prev: null, next: null };
this.tail = { key: null, val: null, prev: null, next: null };
this.head.next = this.tail;
this.tail.prev = this.head;
}
_remove(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
_addToFront(node) {
node.next = this.head.next;
node.prev = this.head;
this.head.next.prev = node;
this.head.next = node;
}
get(key) {
if (!this.cache.has(key)) return -1;
const node = this.cache.get(key);
this._remove(node);
this._addToFront(node);
return node.val;
}
put(key, value) {
if (this.cache.has(key)) this._remove(this.cache.get(key));
const node = { key, val: value, prev: null, next: null };
this.cache.set(key, node);
this._addToFront(node);
if (this.cache.size > this.cap) {
const lru = this.tail.prev;
this._remove(lru);
this.cache.delete(lru.key);
}
}
}
// Note: JavaScript's Map preserves insertion order, so you could
// also implement LRU by deleting and re-inserting on access.
// But the explicit DLL approach matches what interviewers expect.
from collections import defaultdict, OrderedDict
class LFUCache:
"""Least Frequently Used cache with O(1) get and put (LC 460).
Three data structures working together:
1. key_to_val: key โ value (for O(1) value lookup)
2. key_to_freq: key โ frequency (for O(1) frequency lookup)
3. freq_to_keys: frequency โ OrderedDict of keys (for O(1) LRU within each frequency)
Plus min_freq to track which frequency bucket to evict from.
"""
def __init__(self, capacity: int):
self.cap = capacity
self.key_to_val = {}
self.key_to_freq = {}
self.freq_to_keys = defaultdict(OrderedDict) # freq โ {key: None, ...}
self.min_freq = 0
def _update_freq(self, key):
"""Increment a key's frequency and move it to the right bucket."""
freq = self.key_to_freq[key]
self.key_to_freq[key] = freq + 1
# Remove from current frequency bucket
del self.freq_to_keys[freq][key]
if not self.freq_to_keys[freq]:
del self.freq_to_keys[freq]
if self.min_freq == freq:
self.min_freq += 1 # This bucket is empty โ min moves up
# Add to new frequency bucket (at the end = most recent)
self.freq_to_keys[freq + 1][key] = None
def get(self, key: int) -> int:
if key not in self.key_to_val:
return -1
self._update_freq(key)
return self.key_to_val[key]
def put(self, key: int, value: int) -> None:
if self.cap <= 0:
return
if key in self.key_to_val:
self.key_to_val[key] = value
self._update_freq(key)
return
# Evict if at capacity
if len(self.key_to_val) >= self.cap:
# Remove the LRU item from the min-frequency bucket
evict_key, _ = self.freq_to_keys[self.min_freq].popitem(last=False)
del self.key_to_val[evict_key]
del self.key_to_freq[evict_key]
# Insert new item with frequency 1
self.key_to_val[key] = value
self.key_to_freq[key] = 1
self.freq_to_keys[1][key] = None
self.min_freq = 1 # New item always has frequency 1
Production caches usually combine LRU eviction with TTL (Time-To-Live โ an expiration time after which entries are considered stale and must be refreshed). Redis, Memcached, and CDN edge caches all support both mechanisms.
import time
class TTLLRUCache:
"""LRU Cache with Time-To-Live expiration.
Expiration is handled lazily: expired entries are only removed
when accessed. Simple and sufficient for most use cases.
"""
def __init__(self, capacity: int, default_ttl: float = 60.0):
self.cap = capacity
self.default_ttl = default_ttl
self.cache = {}
self.head = {'key': None, 'val': None, 'expires': 0, 'prev': None, 'next': None}
self.tail = {'key': None, 'val': None, 'expires': 0, 'prev': None, 'next': None}
self.head['next'] = self.tail
self.tail['prev'] = self.head
self.hits = 0
self.misses = 0
def _remove(self, node):
node['prev']['next'] = node['next']
node['next']['prev'] = node['prev']
def _add_to_front(self, node):
node['next'] = self.head['next']
node['prev'] = self.head
self.head['next']['prev'] = node
self.head['next'] = node
def get(self, key):
if key not in self.cache:
self.misses += 1
return None
node = self.cache[key]
if time.time() > node['expires']: # Lazy expiration
self._remove(node)
del self.cache[key]
self.misses += 1
return None
self._remove(node)
self._add_to_front(node)
self.hits += 1
return node['val']
def put(self, key, value, ttl=None):
if ttl is None: ttl = self.default_ttl
if key in self.cache: self._remove(self.cache[key])
node = {'key': key, 'val': value, 'expires': time.time() + ttl,
'prev': None, 'next': None}
self.cache[key] = node
self._add_to_front(node)
while len(self.cache) > self.cap:
lru = self.tail['prev']
self._remove(lru)
del self.cache[lru['key']]
@property
def hit_rate(self):
total = self.hits + self.misses
return self.hits / total if total > 0 else 0.0
api_cache = TTLLRUCache(capacity=100, default_ttl=30.0)
api_cache.put("user:123", {"name": "Alice", "role": "admin"})
api_cache.put("product:456", {"name": "Widget", "price": 29.99}, ttl=300)
print(api_cache.get("user:123")) # Cache hit
print(f"Hit rate: {api_cache.hit_rate:.1%}")
Python ships with an LRU cache built into the standard library. The @lru_cache decorator caches function return values based on arguments. It's used for memoizing expensive computations โ recursive Fibonacci, database lookups, API calls, anything where the same inputs produce the same outputs. One decorator, massive speedup.
from functools import lru_cache
import time
# Without caching: exponential time O(2^n)
def fib_slow(n):
if n < 2: return n
return fib_slow(n - 1) + fib_slow(n - 2)
# With caching: O(n) time, O(n) space
@lru_cache(maxsize=128) # Cache up to 128 unique inputs
def fib_fast(n):
if n < 2: return n
return fib_fast(n - 1) + fib_fast(n - 2)
start = time.time()
print(fib_fast(100)) # 354224848179261915075 โ instant
print(f"Time: {time.time() - start:.6f}s")
# Check cache statistics
print(fib_fast.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
# 98 cache hits = 98 recursive calls skipped!
# Real-world: memoize expensive database queries
@lru_cache(maxsize=1000)
def get_user_permissions(user_id):
"""Cache user permissions to avoid repeated DB queries.
First call: hits the database. Subsequent calls: instant."""
# Imagine this queries a database (100ms round trip)
return {"user_id": user_id, "roles": ["admin", "editor"]}
Every time you visit a website, your computer translates the domain name (like "google.com") to an IP address. This DNS lookup takes 10-100ms. Your operating system, browser, and ISP all maintain LRU caches of recent DNS lookups. Without DNS caching, every page load would be measurably slower โ especially pages that load resources from multiple domains (CDNs, analytics, ads).
import time
class DNSCache:
"""Simplified DNS resolver cache with LRU eviction and TTL.
Real DNS caches (in your OS, browser, and ISP) work similarly:
- Cache DNS responses with their TTL (time-to-live)
- Evict least recently used entries when full
- A cache hit saves 10-100ms per lookup
Typical sizes: browser cache ~1000 entries, OS cache ~10000
"""
def __init__(self, capacity=1000):
self.cache = {} # domain โ (ip, expiry, prev, next)
self.cap = capacity
self.hits = 0
self.misses = 0
# Using OrderedDict for simplicity here
from collections import OrderedDict
self.lru = OrderedDict()
def resolve(self, domain):
"""Resolve a domain name to an IP address."""
now = time.time()
if domain in self.lru:
ip, expiry = self.lru[domain]
if now < expiry:
self.lru.move_to_end(domain)
self.hits += 1
return ip # Cache hit! Saved ~50ms
else:
del self.lru[domain] # Expired
# Cache miss โ do actual DNS lookup (simulated)
self.misses += 1
ip, ttl = self._dns_lookup(domain)
self.lru[domain] = (ip, now + ttl)
self.lru.move_to_end(domain)
# Evict if over capacity
while len(self.lru) > self.cap:
self.lru.popitem(last=False)
return ip
def _dns_lookup(self, domain):
"""Simulate a real DNS lookup (would take 10-100ms)."""
# Returns (ip_address, ttl_seconds)
return (f"93.184.216.{hash(domain) % 256}", 300)
@property
def hit_rate(self):
total = self.hits + self.misses
return self.hits / total if total else 0
dns = DNSCache(capacity=100)
# First visit โ cache miss
dns.resolve("google.com") # Miss: ~50ms lookup
# Second visit โ cache hit
dns.resolve("google.com") # Hit: <0.001ms (instant)
print(f"DNS cache hit rate: {dns.hit_rate:.0%}") # 50%
Redis is the most widely-used in-memory cache in production. When configured with a memory limit, it uses LRU (or LFU) eviction to decide what to drop. Here's how you'd configure and interact with Redis as an LRU cache โ this is production code that runs at companies like Twitter, GitHub, and Airbnb.
# Redis LRU configuration (redis.conf):
# maxmemory 256mb
# maxmemory-policy allkeys-lru
#
# Policies available:
# allkeys-lru โ LRU across ALL keys (most common)
# volatile-lru โ LRU only on keys with TTL set
# allkeys-lfu โ LFU across all keys (Redis 4.0+)
# allkeys-random โ Random eviction (simple, sometimes sufficient)
# noeviction โ Return error when memory is full
import redis
# Connect to Redis configured as LRU cache
r = redis.Redis(host='localhost', port=6379)
# Cache an API response with 5-minute TTL
r.setex("api:user:123", 300, '{"name": "Alice", "role": "admin"}')
# Read from cache โ O(1)
cached = r.get("api:user:123")
if cached:
print(f"Cache hit: {cached.decode()}")
else:
print("Cache miss โ fetch from database")
# ... fetch from DB, then cache the result
# Check memory usage
info = r.info("memory")
print(f"Used memory: {info['used_memory_human']}")
print(f"Evicted keys: {info.get('evicted_keys', 0)}")
LRU Cache (LeetCode 146) has been asked at Google, Amazon, Meta, Microsoft, Apple, Netflix, and basically every company that conducts technical interviews. If you learn one data structure implementation by heart, make it this one. You should be able to write it in 15 minutes on a whiteboard with zero bugs. Practice until the sentinel node setup is muscle memory.
This is THE pattern for LRU. The hash map gives O(1) key lookup. The doubly linked list gives O(1) reordering. Together they achieve O(1) for everything. The sentinel head/tail trick is non-negotiable โ learn it, internalize it, use it every time. Write exactly two helper methods: _remove(node) and _add_to_front(node). Every operation becomes a combination of these two.
Python's collections.OrderedDict already combines a dict with insertion order. You can call move_to_end(key) to bump items and popitem(last=False) to remove the oldest. Some interviewers accept this; others want the from-scratch implementation. Know both โ use OrderedDict in production, write the DLL version in interviews. The from-scratch version shows you understand the mechanics; the OrderedDict version shows you know the tools.
Keep the Node class minimal โ just key, val, prev, next. Use __slots__ for memory optimization if the interviewer cares about production code. The code should read almost like pseudocode: "remove the node, add it to front." If your get() or put() method is longer than 8 lines, you're probably not using helpers.
LFU Cache (LC 460) is a harder variant. The trick: maintain a hash map of frequency โ LRU list. Each list is ordered by recency within that frequency. Track the minimum frequency globally. When evicting, remove the tail of the min-frequency list. When incrementing a key's frequency, move it to the next bucket. The min_freq update logic is the tricky part โ it only increments when the current min-freq bucket becomes empty.
A common follow-up: "add expiration to your cache." Two approaches: lazy deletion (check TTL on every get(), remove if expired โ simple but doesn't proactively free memory) or active expiration (background thread periodically scans for expired entries โ more complex but frees memory faster). Redis uses a hybrid: lazy deletion on every read, plus a probabilistic active sweep that runs 10 times per second and randomly samples 20 keys with TTL, deleting expired ones.
Follow-up question: "make it thread-safe." The simplest approach: wrap get() and put() with a mutex lock. More sophisticated: use a read-write lock (multiple readers, single writer). Even more advanced: use a concurrent hash map + lock-free linked list, or shard the cache by key hash (each shard has its own lock). Java's ConcurrentLinkedHashMap and Google's Caffeine cache use lock striping (16-64 shards). Know at least the mutex approach for interviews.
Production caches track: hit rate (% of gets that return data), eviction count, average latency, and memory usage. A low hit rate means your cache is too small or your access pattern is pathological (e.g., sequential scan through data larger than the cache). Mentioning these metrics in a system design interview shows you think about production operations, not just code. A good target: 90%+ hit rate for read-heavy workloads.
| Problem | Difficulty | Key Idea |
|---|---|---|
| 146. LRU Cache | Medium | The classic โ HashMap + Doubly Linked List |
| 460. LFU Cache | Hard | Frequency buckets, each bucket is an LRU list |
| 707. Design Linked List | Medium | Practice doubly linked list ops โ prerequisite for LRU |
| 432. All O'one Data Structure | Hard | Similar pattern โ HashMap + Doubly Linked List of frequency buckets |
| 1472. Design Browser History | Medium | Doubly linked list navigation โ back/forward with truncation |
| 706. Design HashMap | Easy | Build the hash map component from scratch |
| 1381. Design a Stack With Increment Operation | Medium | Practice custom data structure design with O(1) operations |
| 981. Time Based Key-Value Store | Medium | Key-value store with timestamp โ cache concepts + binary search |