What is an LRU Cache?
An LRU Cache (Least Recently Used Cache) is a data structure that stores a limited number of items and evicts the least recently accessed item when the cache reaches its capacity and a new item needs to be inserted. The core idea is simple: if an item hasn't been used for the longest time, it's the most expendable one.
An LRU cache must support two primary operations with specific performance guarantees:
- get(key) — Retrieve the value associated with a key. If the key exists, this access marks the item as "recently used" and returns the value. If the key doesn't exist, return -1 (or null).
- put(key, value) — Insert or update a key-value pair. If the key already exists, update its value and mark it as recently used. If the key is new and the cache is at capacity, evict the least recently used item before inserting the new one.
Both operations must run in O(1) time complexity on average — constant time regardless of cache size. This constraint is what makes the problem interesting and separates naive solutions from production-ready ones.
Why LRU Cache Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →LRU caching is ubiquitous in systems design and real-world software engineering. Here are some key scenarios where it shines:
- Web browsers cache rendered pages, images, and scripts. When memory fills up, the least recently visited page gets evicted first.
- Database query caches (like Redis, Memcached) use LRU eviction policies to keep hot data in memory while cold data spills to disk or gets dropped.
- CPU cache hierarchies — processor caches use LRU-like algorithms to decide which cache line to replace when fetching new data from RAM.
- CDN edge servers cache frequently accessed assets; LRU ensures popular content stays available while stale, unrequested content gets purged.
- API rate limiters and session stores often use LRU caches to bound memory usage while retaining the most active sessions.
Understanding how to build an LRU cache from scratch is also a classic interview question at companies like Google, Meta, Amazon, and Microsoft, testing your grasp of data structures, pointers, and algorithmic trade-offs.
Implementation Approaches
We'll explore three distinct solutions, moving from the simplest (leaning on language features) to the most explicit (building everything from scratch).
Solution 1: Using OrderedDict (Python)
Python's collections.OrderedDict maintains insertion order and has a move_to_end method that can reposition a key to the front or back in O(1) time. By combining this with a capacity check, we can implement an LRU cache with minimal code.
How it works:
- On
get(key), we callmove_to_end(key)to mark the item as recently used, then return the value. - On
put(key, value), if the key exists we update the value and move it to the end. If it's new and we're at capacity, we pop the first item (least recently used) from the OrderedDict. Then insert the new key-value pair at the end.
Complete implementation:
from collections import OrderedDict
class LRUCacheOrderedDict:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
# Mark as recently used by moving to end (right side)
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
# Update value and mark as recently used
self.cache[key] = value
self.cache.move_to_end(key)
return
# Evict least recently used if at capacity
if len(self.cache) >= self.capacity:
# popitem(last=False) removes the first inserted item (LRU)
self.cache.popitem(last=False)
# Insert new item (automatically at the end, most recent)
self.cache[key] = value
# Usage example
cache = LRUCacheOrderedDict(2)
cache.put(1, 10) # cache: {1: 10}
cache.put(2, 20) # cache: {1: 10, 2: 20}
print(cache.get(1)) # returns 10, cache: {2: 20, 1: 10}
cache.put(3, 30) # evicts key 2, cache: {1: 10, 3: 30}
print(cache.get(2)) # returns -1 (evicted)
cache.put(4, 40) # evicts key 1, cache: {3: 30, 4: 40}
print(cache.get(1)) # returns -1 (evicted)
print(cache.get(3)) # returns 30
print(cache.get(4)) # returns 40
Pros: Extremely concise, leverages Python's built-in efficient data structures, perfect for production Python code where you don't need custom node manipulation.
Cons: Tied to Python; doesn't demonstrate the underlying mechanics that interviewers often want to see. In languages without OrderedDict (or LinkedHashMap in Java), you need the manual approach.
Solution 2: Doubly Linked List + Hash Map (Manual Implementation)
This is the canonical implementation that satisfies the O(1) requirement from scratch. The insight is that we need two complementary data structures working together:
- Hash Map — maps keys directly to nodes in the linked list, giving O(1) lookup.
- Doubly Linked List — maintains the recency order. The head points to the most recently used item, the tail to the least recently used. Removing the tail (or repositioning a node) is O(1) because we have bidirectional pointers.
Why doubly linked? A singly linked list requires O(n) to remove an arbitrary node because you need to find its predecessor. A doubly linked list lets you remove any node in O(1) given a reference to it — you can reach its prev and next nodes directly.
Here's the full implementation with detailed comments:
class DLinkedNode:
"""Node in the doubly linked list."""
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCacheManual:
def __init__(self, capacity: int):
self.capacity = capacity
self.size = 0
self.cache = {} # key -> DLinkedNode
# Dummy head and tail to simplify edge cases
# head <-> ... <-> tail
# head always points to most recent, tail to least recent
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
def _add_node(self, node: DLinkedNode) -> None:
"""Add a node right after head (making it most recent)."""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _remove_node(self, node: DLinkedNode) -> None:
"""Remove an arbitrary node from the linked list."""
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
# Clean up pointers (optional but good practice)
node.prev = None
node.next = None
def _move_to_head(self, node: DLinkedNode) -> None:
"""Move existing node to head (mark as most recent)."""
self._remove_node(node)
self._add_node(node)
def _pop_tail(self) -> DLinkedNode:
"""Remove and return the least recently used node (before tail)."""
lru_node = self.tail.prev
self._remove_node(lru_node)
return lru_node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
# Move to head to mark as recently used
self._move_to_head(node)
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
# Update existing node's value and move to head
node = self.cache[key]
node.value = value
self._move_to_head(node)
return
# Evict if at capacity
if self.size >= self.capacity:
lru_node = self._pop_tail()
del self.cache[lru_node.key]
self.size -= 1
# Insert new node
new_node = DLinkedNode(key, value)
self.cache[key] = new_node
self._add_node(new_node)
self.size += 1
# Detailed walkthrough
def demonstrate_manual_lru():
cache = LRUCacheManual(2)
print("=== Starting LRU Cache (capacity=2) ===")
cache.put(1, 100)
print("put(1, 100) -> cache size:", cache.size, "| keys:", list(cache.cache.keys()))
cache.put(2, 200)
print("put(2, 200) -> cache size:", cache.size, "| keys:", list(cache.cache.keys()))
print("get(1) ->", cache.get(1), " (moves key 1 to most recent)")
cache.put(3, 300) # Should evict key 2 (LRU)
print("put(3, 300) -> evicted key 2 | keys:", list(cache.cache.keys()))
print("get(2) ->", cache.get(2), " (should be -1, evicted)")
cache.put(4, 400) # Should evict key 1 (now LRU after get(1) and put(3))
print("put(4, 400) -> evicted key 1 | keys:", list(cache.cache.keys()))
print("get(1) ->", cache.get(1), " (should be -1)")
print("get(3) ->", cache.get(3))
print("get(4) ->", cache.get(4))
demonstrate_manual_lru()
Let's trace through the linked list state after each operation to solidify understanding:
Initial state: HEAD <-> TAIL (dummy nodes only)
After put(1, 100):
HEAD <-> Node(1) <-> TAIL
Most recent: Node(1), Least recent: Node(1)
After put(2, 200):
HEAD <-> Node(2) <-> Node(1) <-> TAIL
Most recent: Node(2), Least recent: Node(1)
After get(1): (moves Node(1) to head)
HEAD <-> Node(1) <-> Node(2) <-> TAIL
Most recent: Node(1), Least recent: Node(2)
After put(3, 300): (evicts tail.prev = Node(2))
Remove Node(2), add Node(3) at head
HEAD <-> Node(3) <-> Node(1) <-> TAIL
Most recent: Node(3), Least recent: Node(1)
Key design decisions:
- Dummy head and tail nodes eliminate null-checking edge cases. The list is never truly empty — there are always at least two dummy nodes. This simplifies insertion and removal logic dramatically.
- Node stores its own key so that when we pop the tail, we know which key to delete from the hash map without needing a reverse lookup.
- Helper methods (
_add_node,_remove_node,_move_to_head,_pop_tail) keep the main API methods clean and focused on business logic.
Solution 3: Java LinkedHashMap (Bonus Language Perspective)
Java developers get an even more streamlined approach using LinkedHashMap with a custom constructor that sets accessOrder=true. This changes the map's ordering from insertion-order to access-order, meaning every get and put moves the entry to the end (most recent). Overriding removeEldestEntry handles eviction automatically.
import java.util.LinkedHashMap;
import java.util.Map;
class LRUCacheJava extends LinkedHashMap {
private final int capacity;
public LRUCacheJava(int capacity) {
// accessOrder=true: iteration order is from least-recent to most-recent
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// Evict the eldest entry when size exceeds capacity
return size() > capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
}
// Usage
// LRUCacheJava cache = new LRUCacheJava(2);
// cache.put(1, 10);
// cache.put(2, 20);
// int val = cache.get(1); // returns 10, moves 1 to most recent
This is essentially equivalent to the Python OrderedDict approach but baked into Java's standard library. The removeEldestEntry hook is called after every put and putAll; returning true causes the eldest entry to be evicted.
Solution 4: Using functools.lru_cache (Python Decorator)
Python's standard library provides functools.lru_cache as a decorator for automatic memoization with LRU eviction. It's not a general-purpose key-value store — it caches function return values based on arguments — but it's incredibly useful for dynamic programming and expensive computations.
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
"""Compute nth Fibonacci number with automatic memoization."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# The cache stores results keyed by argument values
print(fibonacci(100)) # Fast computation, cached intermediate results
# Inspect cache statistics
print(fibonacci.cache_info()) # hits, misses, maxsize, currsize
# Clear the cache if needed
fibonacci.cache_clear()
# Decorator with typed=True separates int and float arguments
@lru_cache(maxsize=256, typed=True)
def process_data(data_id: int, version: str) -> dict:
# Expensive computation here
return {"id": data_id, "version": version, "result": data_id * 42}
Important note: lru_cache is for memoization of function calls, not a general-purpose cache store. For arbitrary key-value caching with LRU eviction, use the OrderedDict approach or the manual linked list implementation.
Complexity Analysis
Let's break down the time and space complexity for each approach:
Solution 1: OrderedDict
- get(key): O(1) average — dictionary lookup plus
move_to_endwhich is O(1) in OrderedDict's internal doubly linked list. - put(key, value): O(1) average — dictionary insertion/update plus potential
popitem(last=False)which is O(1). - Space: O(n) where n is the cache capacity — stores up to n entries each with key, value, and linked list pointers internally.
Solution 2: Doubly Linked List + Hash Map
- get(key): O(1) — hash map lookup, then O(1) pointer manipulation to move node to head.
- put(key, value): O(1) — hash map insert/update, O(1) node addition at head, O(1) tail removal on eviction.
- Space: O(n) — the hash map stores n references, and the linked list stores n nodes each with key, value, prev, and next pointers. The overhead is higher than OrderedDict due to explicit node objects.
Solution 3: Java LinkedHashMap
- get(key): O(1) — standard hash map lookup with access-order reordering.
- put(key, value): O(1) amortized — insertion plus potential O(1) eviction via
removeEldestEntry. - Space: O(n) — similar to OrderedDict, internally uses a hash map with linked buckets.
Solution 4: functools.lru_cache
- Function calls: O(1) cache lookup per unique argument set (hash-based).
- Eviction: O(1) — internally uses a C implementation with doubly linked list semantics.
- Space: O(maxsize) — bounded by the specified maxsize parameter.
Comparative Table
| Approach | get() | put() | Space | Language Agnostic? |
|-----------------------|--------|--------|--------|--------------------|
| OrderedDict | O(1) | O(1) | O(n) | Python only |
| Linked List + HashMap | O(1) | O(1) | O(n) | Yes |
| Java LinkedHashMap | O(1) | O(1) | O(n) | Java only |
| functools.lru_cache | O(1) | O(1)* | O(m) | Python only |
* For lru_cache, "put" is implicit on function call; eviction happens during insertion.
Best Practices and Edge Cases
When implementing an LRU cache in production or during an interview, keep these considerations in mind:
1. Thread Safety
The basic implementations above are not thread-safe. In multi-threaded environments, concurrent get and put calls can corrupt the linked list pointers or the hash map. Solutions include:
- Wrapping all methods with a mutex lock (
threading.Lockin Python,synchronizedin Java). - Using concurrent data structures — Python's
functools.lru_cacheis thread-safe in CPython due to the GIL for most operations, but not guaranteed across Python implementations. - For high-concurrency scenarios, consider lock-free or sharded designs where different cache segments have independent locks.
2. Handling Capacity Zero
What if capacity=0? The cache should reject all items:
def put(self, key, value):
if self.capacity == 0:
return # Silently ignore, or raise an exception
# ... rest of logic
3. Handling Duplicate Keys Gracefully
When put is called with an existing key, the value should update and the item should become the most recently used. All three solutions handle this correctly, but it's worth explicitly testing:
cache = LRUCacheManual(2)
cache.put(1, 10)
cache.put(1, 999) # Update key 1
print(cache.get(1)) # Should return 999, not 10
4. Memory Overhead Awareness
The doubly linked list approach creates one node object per entry. For caches with millions of small entries, this overhead (two pointers + object header) can be significant. In memory-constrained environments:
- Use array-backed structures where possible (harder to implement O(1) eviction).
- Consider the OrderedDict approach which has optimized C-level memory layout in CPython.
- Profile memory usage before scaling to production traffic.
5. Testing Eviction Order
A common bug is implementing the linked list direction incorrectly. Write tests that verify exact eviction order:
def test_eviction_order():
cache = LRUCacheManual(3)
cache.put(1, 'a')
cache.put(2, 'b')
cache.put(3, 'c')
# Access 1 (now order: 2(LRU), 3, 1(MRU))
cache.get(1)
# Access 2 (now order: 3(LRU), 1, 2(MRU))
cache.get(2)
# Insert 4 — should evict 3 (the LRU)
cache.put(4, 'd')
assert cache.get(3) == -1, "Key 3 should have been evicted"
assert cache.get(1) == 'a', "Key 1 should still exist"
assert cache.get(2) == 'b', "Key 2 should still exist"
assert cache.get(4) == 'd', "Key 4 should exist"
print("All eviction order tests passed!")
test_eviction_order()
6. Choosing the Right Implementation for Your Context
- Interview setting: Implement the doubly linked list + hash map solution. It shows mastery of data structures and pointer manipulation. Practice drawing the list on a whiteboard as you code.
- Python production code: Use
collections.OrderedDictorfunctools.lru_cache(for memoization). They're battle-tested C-optimized implementations. - Java production code: Use
LinkedHashMapwithaccessOrder=trueand overrideremoveEldestEntry, or usecom.google.common.cache.CacheBuilderfrom Guava for more features like time-based expiration. - High-performance systems (C++/Rust): Implement the manual doubly linked list with arena allocators to reduce allocation overhead, or use libraries like
cachetools(Python) orcaffeine(Java) that offer advanced eviction policies like TinyLFU.
7. Time-Based Expiration Extension
Pure LRU only considers recency of access, not age of entries. In production, you often need hybrid eviction — LRU plus time-to-live (TTL). To extend the manual implementation:
import time
class DLinkedNodeWithTTL:
def __init__(self, key, value, ttl_seconds=None):
self.key = key
self.value = value
self.prev = None
self.next = None
self.created_at = time.time()
self.ttl = ttl_seconds # None means no expiration
def is_expired(self) -> bool:
if self.ttl is None:
return False
return time.time() - self.created_at > self.ttl
class LRUCacheWithTTL(LRUCacheManual):
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
if node.is_expired():
self._remove_node(node)
del self.cache[key]
self.size -= 1
return -1
self._move_to_head(node)
return node.value
def put(self, key: int, value: int, ttl_seconds=None) -> None:
# Check for expired entry first
if key in self.cache:
node = self.cache[key]
if node.is_expired():
self._remove_node(node)
del self.cache[key]
self.size -= 1
else:
node.value = value
node.created_at = time.time()
node.ttl = ttl_seconds
self._move_to_head(node)
return
# Evict if at capacity (could also scan for expired entries)
if self.size >= self.capacity:
lru_node = self._pop_tail()
del self.cache[lru_node.key]
self.size -= 1
new_node = DLinkedNodeWithTTL(key, value, ttl_seconds)
self.cache[key] = new_node
self._add_node(new_node)
self.size += 1
This hybrid approach gives you both recency-based and time-based eviction, which is closer to what production caching systems like Redis offer.
Conclusion
The LRU cache is a deceptively simple data structure that demands careful engineering to achieve true O(1) performance. The combination of a hash map for instant lookup and a doubly linked list for constant-time reordering is a beautiful example of algorithmic synergy — neither data structure alone can satisfy both requirements, but together they form a solution that has stood the test of time.
Whether you reach for Python's OrderedDict for rapid development, implement the manual linked-list approach to demonstrate deep understanding, or leverage functools.lru_cache for automatic memoization, the core principles remain the same: track recency, evict the coldest item, and do it all in constant time. Understanding these internals not only helps in technical interviews but also gives you intuition for designing cache layers, choosing eviction policies, and reasoning about memory-bound system performance in real-world applications.