30. LRU Semantic Cache for Repeated Queries
Problem
A lab's serving layer sees many exact-repeat queries in a short span — users re-asking the same canonical prompt, or an eval harness replaying the same fixture. Rather than re-running an expensive model call each time, cache recent responses and serve repeats straight from memory.
Implement a bounded-capacity cache with true O(1) get/put where the least-recently-used entry is evicted once capacity is exceeded — a hashmap plus a doubly linked list, not a wrapper around collections.OrderedDict.
Source: src/30_lru_semantic_cache.py
class LRUQueryCache:
def __init__(self, capacity: int): ...
def get(self, key) -> int: ... # returns -1 if key not present
def put(self, key, value) -> None: ... # evicts LRU entry if over capacity
>>> cache = LRUQueryCache(2)
>>> cache.put("query_a", 101)
>>> cache.put("query_b", 102)
>>> cache.get("query_a")
101
>>> cache.put("query_c", 103) # capacity 2 is full, query_b is LRU -> evicted
>>> cache.get("query_b")
-1
Step-by-Step Approach
- Recognize the two requirements that rule out simpler structures: O(1) lookup by key (needs a hashmap) and O(1) reordering-by-recency plus O(1) eviction of the least-recently-used item (needs a doubly linked list, since arrays/lists cost O(n) to remove from the middle).
- Maintain a hashmap from key to linked-list node, and a doubly linked list ordered by recency: most-recently-used at the front, least-recently-used at the back.
- Use two sentinel nodes (a dummy head and dummy tail) so every real node always has both a
prevand anext— this removes every None-check that would otherwise litter the remove/insert logic. - Implement two small helpers first:
_remove(node)unlinks a node from wherever it sits, and_add_to_front(node)splices a node in right after the head. Every other operation is built from these two. get(key): if the key is missing, return -1. Otherwise, touching a key makes it most-recently-used — remove the node and re-add it to the front, then return its value.put(key, value): if the key already exists, update its value and move it to the front (same recency-refresh as get). Otherwise, if the cache is at capacity, evict the node just before the tail sentinel (the true LRU); then create and front-insert the new node.
The key insight is that a doubly linked list turns "move this node to the front" into a constant number of pointer rewrites regardless of where the node currently sits, and pairing it with a hashmap turns "find this node by key" into O(1) as well — together they give O(1) worst-case (not amortized) time for both operations.
Reference solution
class _Node:
def __init__(self, key=None, value=None):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUQueryCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.map: dict = {}
# sentinel head/tail so every real node always has both neighbors -- no None-checks
self.head = _Node()
self.tail = _Node()
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node):
# O(1): unlink node from wherever it currently sits
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_front(self, node):
# O(1): splice node in right after head (the MRU slot)
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.map:
return -1
node = self.map[key]
# touching a key makes it most-recently-used
self._remove(node)
self._add_to_front(node)
return node.value
def put(self, key, value):
if key in self.map:
node = self.map[key]
node.value = value
self._remove(node)
self._add_to_front(node)
return
if len(self.map) >= self.capacity:
# tail.prev is the least-recently-used real node -- evict it
lru = self.tail.prev
self._remove(lru)
del self.map[lru.key]
node = _Node(key, value)
self.map[key] = node
self._add_to_front(node)
Key Functions & Tricks
- Sentinel head/tail nodes — eliminate every edge-case None-check around list boundaries
_remove(node)/_add_to_front(node)— two small O(1) primitives that every public method composesself.tail.prev— the LRU entry is always exactly the node just before the tail sentinel- Hashmap of key → node — turns "find by key" from O(n) list traversal into O(1)
- Refreshing recency on both
getandput-on-existing-key — a common spec detail that's easy to miss
How to Recognize This Pattern
Any time a problem asks for a capacity-bounded cache with O(1) access and eviction of "the entry that hasn't been used in the longest time," that's the LRU cache pattern — hashmap for lookup, doubly linked list for O(1) reordering/removal. Common variations swap the eviction policy (LFU — least-frequently-used — needs an extra frequency-bucketed structure; TTL-based expiry needs timestamps checked lazily or via a background sweep) or ask you to support additional operations like peeking the LRU entry without touching recency. A common pitfall is reaching for collections.OrderedDict (which does solve this in a few lines, and is a legitimate answer once you've demonstrated you understand the underlying structure) when an interviewer has explicitly asked you to build it "from scratch" to prove you understand *why* a linked list is needed; another is forgetting to refresh recency on put when the key already exists, not just on get.