Module 18: LRU/LFU Cache & Rate Limiter
Why this matters
Three classic problems, back to the multi-problem format of modules 12–14 — but with a different question driving the design than any module so far. For LRU and LFU, the requirement isn't a behavior to model, it's a complexity bound: every operation must run in O(1). No single data structure gets you there alone — a hash map alone can't track recency or frequency order in O(1); a linked list alone can't look up a key in O(1). The design is the combination. This is the module where "what pattern applies here?" is the wrong first question, and "what data structures, combined, give me the required complexity?" is the right one. The Rate Limiter closes the module with a smaller but genuine point: its clock is passed in as a dependency rather than called directly, which is exactly what makes it possible to test deterministically — a preview of module 19's dependency-injection theme, arrived at here for a very concrete reason (you cannot unit-test time-based behavior against the real clock).
Problem 1: LRU Cache
Requirements
Functional: a fixed-capacity cache supports get(key) (returns
the value, or "miss") and put(key, value). Every access to a key
marks it as most-recently-used; when a put would exceed capacity,
the least-recently-used key is evicted.
Non-functional: both get and put must run in O(1) time.
Assumptions stated up front (module 11, step 1): keys and values are simple comparable types (ints, in the examples below); capacity is fixed at construction, not resizable.
Entities and relationships
Applying module 11's steps 3–4: LRUCache o-- Node (composition, many
— each cached entry is one node in an internal doubly linked list);
LRUCache --> Node via a hash map for O(1) lookup (association, not
ownership duplication — the map and the list both reference the same
node objects, they don't hold separate copies).
Class diagram
┌──────────────┐ ┌──────────────┐
│ LRUCache │ o── │ Node │
├──────────────┤ ├──────────────┤
│ - capacity │ │ - key, value │
│ - cache │ │ - prev, next │
│ - head, tail │ └──────────────┘
├──────────────┤
│ + get() │
│ + put() │
└──────────────┘
Implementation
The combination that makes O(1) possible: a hash map from key to
Node gives O(1) lookup; a doubly linked list of those same
Nodes, ordered by recency, gives O(1) reordering (unlink + relink,
no shifting) and O(1) eviction (remove from the tail end). Neither
structure alone provides both lookup and order in O(1) — together they
do.
class Node:
def __init__(self, key=None, value=None):
self.key = key
self.value = value
self.prev: "Node | None" = None
self.next: "Node | None" = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache: dict[int, Node] = {}
self.head = Node() # sentinel — most-recently-used side
self.tail = Node() # sentinel — least-recently-used side
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node: Node):
node.prev.next = node.next
node.next.prev = node.prev
def _insert_at_front(self, node: Node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key: int) -> "int | None":
if key not in self.cache:
return None
node = self.cache[key]
self._remove(node)
self._insert_at_front(node) # touched — now most recently used
return node.value
def put(self, key: int, value: int):
if key in self.cache:
self._remove(self.cache[key])
elif len(self.cache) >= self.capacity:
lru = self.tail.prev # least-recently-used node
self._remove(lru)
del self.cache[lru.key]
node = Node(key, value)
self._insert_at_front(node)
self.cache[key] = node
# usage
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 1 -> touches 1: order becomes 1(MRU), 2(LRU)
cache.put(3, 3) # evicts 2 (LRU)
print(cache.get(2)) # None
cache.put(4, 4) # evicts 1 (LRU)
print(cache.get(1)) # None
print(cache.get(3)) # 3
print(cache.get(4)) # 4
Tradeoffs and extensions
- Sentinel
head/tailnodes remove every "is this the first/last node?" edge case from_remove/Removeand_insert_at_front/InsertAtFront— every real node always has a genuineprevandnextto relink, even at the boundaries. This is a small technique worth naming: it trades two always-allocated dummy nodes for the removal of an entire class of off-by-one bugs. - The hash map stores
Nodereferences, not copies —cacheand the linked list both point at the same objects, so moving a node in the list is instantly visible through the map with no synchronization needed between two data structures pretending to be independent.
Problem 2: LFU Cache
Requirements
Functional: like LRU, but eviction is driven by access frequency instead of recency — the least-frequently-used key is evicted on overflow. Ties (multiple keys at the same minimum frequency) are broken by recency: the least-recently-used key among the tied minimum-frequency keys is evicted.
Non-functional: both get and put must run in O(1) time — same
bound as LRU, harder to hit because frequency introduces a second
ordering dimension.
Assumptions: same as LRU — simple key/value types, fixed capacity;
a capacity of 0 means every put is a no-op (named edge case, tested
below).
Class diagram
┌─────────────────────┐
│ LFUCache │
├─────────────────────┤
│ - capacity, minFreq │
│ - keyToValue │
│ - keyToFreq │
│ - freqToKeys │
├─────────────────────┤
│ + get() │
│ + put() │
└─────────────────────┘
Implementation
The idea that makes O(1) possible here: instead of one ordered
structure, keep one ordered structure per frequency. freqToKeys
maps each frequency to an ordered collection of the keys currently at
that frequency (oldest-touched first) — an LRU list within each
frequency bucket. Tracking minFreq separately means eviction never
has to search for the minimum; it's always known.
from collections import OrderedDict, defaultdict
class LFUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.min_freq = 0
self.key_to_value: dict[int, int] = {}
self.key_to_freq: dict[int, int] = {}
self.freq_to_keys: dict[int, OrderedDict] = defaultdict(OrderedDict) # freq -> keys, insertion order = recency
def _bump_freq(self, key: int):
freq = self.key_to_freq[key]
del self.freq_to_keys[freq][key]
if not self.freq_to_keys[freq] and self.min_freq == freq:
self.min_freq += 1
self.key_to_freq[key] = freq + 1
self.freq_to_keys[freq + 1][key] = None
def get(self, key: int) -> "int | None":
if key not in self.key_to_value:
return None
self._bump_freq(key)
return self.key_to_value[key]
def put(self, key: int, value: int):
if self.capacity == 0:
return
if key in self.key_to_value:
self.key_to_value[key] = value
self._bump_freq(key)
return
if len(self.key_to_value) >= self.capacity:
evict_key, _ = self.freq_to_keys[self.min_freq].popitem(last=False) # least-recently-used at min frequency
del self.key_to_value[evict_key]
del self.key_to_freq[evict_key]
self.key_to_value[key] = value
self.key_to_freq[key] = 1
self.freq_to_keys[1][key] = None
self.min_freq = 1
# usage
cache = LFUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 1 -> freq(1)=2, freq(2)=1
cache.put(3, 3) # evicts key 2 (min freq, only key there)
print(cache.get(2)) # None
print(cache.get(3)) # 3 -> freq(3)=2
cache.put(4, 4) # freq(1)=2, freq(3)=2 tie -> evicts 1 (touched before 3)
print(cache.get(1)) # None
print(cache.get(3)) # 3
print(cache.get(4)) # 4
Tradeoffs and extensions
minFreqis tracked incrementally, never recomputed by scanning. It only ever increases by 1 at a time (when a key's old frequency bucket empties out) or resets to 1 (on a fresh insert) — an O(n) "find the minimum" scan would silently break the O(1) requirement this whole design exists to satisfy.- Frequency ties are broken by recency, using the exact same ordered-collection technique as the LRU cache above — this is effectively "an LRU cache per frequency bucket." Problem 2 doesn't introduce a new eviction-ordering idea, it reuses Problem 1's inside each bucket.
- Python's
OrderedDictand C#'sLinkedList<T>+ node-reference-map play the same role asLRUCache's hand-rolled doubly linked list —OrderedDictalready is a hash-map-plus- order structure, so Python gets it from the standard library, while C# needs the explicitLinkedList<int>+Dictionary<int, LinkedListNode<int>>pairing to get the same O(1) guarantees.
Problem 3: Rate Limiter
Requirements
Functional: given a maximum request rate, decide for each incoming request whether to allow or reject it. Requests may burst up to a capacity, then must wait for capacity to refill over time (token bucket algorithm).
Non-functional: must be correct under concurrent callers (module
10); must be testable without depending on real wall-clock time or
sleep-based tests, which are slow and flaky.
Assumptions stated up front (module 11, step 1): a single shared bucket per limiter instance (per-user or per-endpoint limiting is a named extension); token refill is continuous (fractional tokens accumulate between requests), not a fixed per-second reset.
Class diagram
┌────────────────────────┐
│ TokenBucketRateLimiter │
├────────────────────────┤
│ - capacity │
│ - refillRate │
│ - tokens │
│ - lastRefill │
│ - clock │
├────────────────────────┤
│ + allowRequest() │
└────────────────────────┘
Implementation
The clock is a constructor parameter, not a direct call to
time.monotonic()/DateTime.Now inside the class — this is
dependency injection (a preview of module 19) for a concrete reason:
the tests below drive a fake clock manually instead of sleeping in
real time, making the refill behavior deterministic and fast to test.
import threading
import time
class TokenBucketRateLimiter:
def __init__(self, capacity: int, refill_rate_per_sec: float, clock=time.monotonic):
self.capacity = capacity
self.refill_rate = refill_rate_per_sec
self._clock = clock # injected time source — swap for a fake clock in tests
self._tokens = float(capacity)
self._last_refill = self._clock()
self._lock = threading.Lock()
def _refill(self):
now = self._clock()
elapsed = now - self._last_refill
self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate)
self._last_refill = now
def allow_request(self) -> bool:
with self._lock: # critical section: check-then-consume over shared token count
self._refill()
if self._tokens >= 1:
self._tokens -= 1
return True
return False
# usage — with a fake, manually-advanced clock: deterministic, no real sleeping
class FakeClock:
def __init__(self):
self.now = 0.0
def advance(self, seconds):
self.now += seconds
def __call__(self):
return self.now
clock = FakeClock()
limiter = TokenBucketRateLimiter(capacity=3, refill_rate_per_sec=1.0, clock=clock)
print(limiter.allow_request()) # True
print(limiter.allow_request()) # True
print(limiter.allow_request()) # True
print(limiter.allow_request()) # False -> bucket empty, no time has passed
clock.advance(1.0) # 1 second passes -> 1 token refills
print(limiter.allow_request()) # True
print(limiter.allow_request()) # False
Tradeoffs and extensions
- The clock is injected, not hardcoded — the single design choice
that makes this class testable at all without real
sleepcalls in the test suite. This is dependency injection earning its keep for a concrete reason, ahead of module 19's fuller treatment of the idea. - The lock protects the same check-then-consume shape every
concurrency-critical module in this track has used (
Level. find_and_assign,Show.book_seats,RideManager's driver match): refill-then-check-then-decrement happens inside one locked method, never split across calls a race could interleave. - Token bucket vs. fixed window: a simpler "reset counter every N
seconds" rate limiter is easier to implement but allows a burst of
2×limitrequests right at a window boundary (a flood just before the reset, followed immediately by another flood just after). Token bucket's continuous refill avoids that edge case — named here as the reason to prefer it, not just an arbitrary implementation choice. - One bucket per limiter instance is a named simplification — a
real API gateway keys a separate bucket per user or per endpoint
(extension: wrap this class in a
dict[key, TokenBucketRateLimiter]keyed by whatever dimension needs independent limits).
Hands-on exercises
1. LRU: verify O(1) is really being achieved
Add a counter to _remove/Remove and
_insert_at_front/InsertAtFront that counts how many nodes each call
touches. Confirm it's always a small constant, regardless of cache
size — never proportional to the number of cached entries.
2. LFU: handle the zero-capacity edge case explicitly
Confirm (or add a test proving) that LFUCache(0) makes every put a
no-op and every get a miss, without raising an error.
3. LFU: implement get_frequency(key)
Add a debug method returning a key's current access frequency, useful for writing your own tie-breaking tests.
4. Rate limiter: implement a fixed-window variant
Implement FixedWindowRateLimiter (reset a counter every N seconds
instead of continuous refill), and write a test using the same
fake-clock technique that demonstrates the boundary-burst problem named
in the tradeoffs above — a flood of requests right before a window
reset, followed by another flood right after.
5. Rate limiter: per-key limiting
Wrap TokenBucketRateLimiter in a class that maintains one bucket per
key (e.g., per user ID), creating buckets lazily on first use, all
sharing the same capacity and refill rate.
Independent challenge
No code given.
Task: Implement an LRU cache with per-entry TTL (time-to-live).
Each put(key, value, ttl_seconds) should make the entry inaccessible
via get once ttl_seconds have elapsed, in addition to normal
LRU eviction on capacity overflow — whichever happens first. Use the
same injected-clock technique as the rate limiter for testability
(don't call the real clock directly inside the cache). Confirm a key
that's still within capacity but past its TTL returns a miss, and a
key that's evicted for capacity reasons is unaffected by whether its
TTL had expired.
Hint
Store an expiry timestamp (clock() + ttl_seconds) alongside each
Node's key/value. On get, before doing the normal recency-touch
logic, check whether clock() >= expiry for that node — if so, treat
it exactly like a miss (and evict the node, since it's now known to be
dead weight) instead of touching it as most-recently-used. TTL
expiry and LRU capacity eviction are two independent removal reasons
that both operate on the same underlying node/map structure — they
don't need to know about each other.
Common mistakes & troubleshooting
- Using a plain (singly linked or array-backed) list for LRU ordering. Removing an arbitrary node from an array means shifting every element after it — O(n), not O(1). The doubly linked list is what makes removal from the middle O(1); losing that is the most common way this problem's complexity requirement quietly breaks.
- Forgetting sentinel nodes and hand-rolling null checks for
"is this the head/tail?" at every
_remove/_insert_at_frontcall site. This is exactly the class of edge-case bug sentinels are designed to eliminate — every real node always has a genuine neighbor to relink. - LFU: recomputing
min_freqby scanning all frequencies instead of tracking it incrementally. This silently turns an O(1) operation into O(n) (or O(number of distinct frequencies)) and defeats the entire point of the design. - LFU: forgetting the frequency-tie-breaks-by-recency rule, and evicting an arbitrary key at the minimum frequency instead of the least-recently-used one among them. This is the detail that makes LFU meaningfully different from "just group by frequency."
- Rate limiter: calling the real clock directly inside the class
instead of accepting it as a parameter. This makes correct,
deterministic tests of time-based behavior effectively impossible
without slow, flaky
sleep-based tests.
Checkpoint quiz
- Why does LRU need both a hash map and a doubly linked list — what does each one provide that the other can't?
- Why are
head/tailsentinel nodes used instead of lettinghead/tailbeNonewhen the list is empty? - Why is
min_freqtracked incrementally in LFU instead of recomputed on demand? - What extra rule does LFU apply that a plain "group keys by frequency" design would miss?
- Why is the rate limiter's clock passed in as a parameter instead of called directly inside the class?
Answers
- The hash map gives O(1) lookup from key to node but has no
intrinsic ordering; the doubly linked list gives O(1) reordering
and O(1) removal from either end but has no O(1) lookup by key.
Combined, a
getcan find a node instantly (map) and re-position it instantly (list) — neither structure alone provides both. - So every real node always has a genuine
prevandnextto relink against, even when inserting into an empty list or removing the only node. Without sentinels, every removal/insertion site would need special-case branches for "is this the first or last real node?" — a common source of off-by-one bugs. - Because recomputing it by scanning every frequency bucket would be O(n) (or O(distinct frequencies)) per operation, breaking the O(1) requirement the entire design exists to satisfy. Incrementally, it only ever increases by 1 (when a bucket empties) or resets to 1 (on a fresh insert) — both O(1) updates.
- Breaking ties among multiple keys at the minimum frequency by recency — evicting the least-recently-used key among the minimum-frequency keys, not an arbitrary one. Without this, "LFU" would just be "group by frequency, evict something," which isn't the specified behavior.
- So the class can be tested deterministically with a fake,
manually-advanced clock instead of real
sleepcalls — dependency injection (previewed here, covered fully in module 19) applied for the concrete reason that time-based logic is otherwise very hard to test reliably and quickly.
Interview questions
- "How would you implement an LRU cache with O(1) get and put?" A hash map from key to node for O(1) lookup, combined with a doubly linked list (with sentinel head/tail nodes) ordered by recency for O(1) reordering and O(1) eviction from the least-recently-used end. Neither structure alone achieves both requirements; the combination does.
- "How is an LFU cache different from an LRU cache, and how do you
still get O(1)?"
LFU evicts by access frequency instead of recency, with ties broken
by recency. O(1) comes from keeping one ordered (LRU-style)
structure per frequency bucket, plus an incrementally-tracked
minFreqso the eviction target is always known without scanning. - "Walk me through the token bucket algorithm."
A bucket holds up to
capacitytokens, refilling continuously atrefillRatetokens per second based on elapsed time since the last check. Each allowed request consumes one token; a request is rejected if fewer than one token is available. This permits bursts up to the bucket's capacity while enforcing a long-run average rate. - "Why prefer token bucket over a simple fixed-window counter for
rate limiting?"
A fixed window (reset a counter every N seconds) allows up to
2×limitrequests clustered right around a window boundary — a flood just before reset, immediately followed by another flood just after. Token bucket's continuous refill has no such boundary to exploit. - "How would you test time-dependent logic like a rate limiter
without making the test suite slow or flaky?"
Inject the time source as a constructor parameter instead of calling
the real clock directly inside the class, then drive a fake,
manually-advanced clock in tests — deterministic and instant, with
no real
sleepcalls involved.
Further reading & sources
- LeetCode 146: LRU Cache - the canonical version of Problem 1, useful for timed practice after working through this module.
- LeetCode 460: LFU Cache - the canonical version of Problem 2, including the exact tie-breaking rule implemented here.
- Token bucket (Wikipedia) - the general algorithm Problem 3 implements, including the burst-vs-average-rate tradeoff discussed above.
Next
19-api-library-design-and-di — designing a clean, versioned, fluent public API/SDK, and applying dependency injection for testability (the theme this module's rate limiter previewed).