Implement LRU without an ordered-map helperLESSON 10.03 · 3 OF 20 IN CHAPTER
PART C / Data systems at scale
Step 156 of 252
LESSON 10.03 · 3 OF 20 IN CHAPTERTry it, then open the solution

Implement LRU without an ordered-map helper

THE PROBLEM

“A preview service caches a fixed number of decoded objects. A successful read or overwrite makes that key most recently used. When a new key overflows capacity, evict the least recently used one. Implement both lookup and recency yourself; an ordered-dictionary library would hide the pointer work we want to inspect.”

Constructed practice question. Prerequisites: maps and LRU behavior. A doubly linked node holds previous and next pointers, allowing removal from the middle when the node is already known.

Contract Required behavior
Input Nonnegative entry capacity; hashable keys and arbitrary values
Output get returns value; put returns evicted (key,value) or None
Boundaries Miss raises KeyError; stored None is valid; overwrite refreshes recency
Zero capacity Every put returns its input as immediately evicted; retains nothing
Failure/scope Invalid capacity raises ValueError; single-threaded, entry count only
Optional refresher · the underlying tool

An LRU cache evicts the entry least recently used by either a read or write. A hash map finds a node by key; a doubly linked list changes its recency position in constant time:

# Concept: key -> node; head is most recent, tail is eviction candidate
nodes = {"a": node_a, "b": node_b}

For capacity 2: put a, put b, get a, put c must evict b. State which pointers change for moving a node and for deleting the old tail.

A design choice worth saying aloud

The dictionary maps cache key → list node; the linked list orders recency, with a read moving its node to the front. Neither structure can replace the other without changing the cost. Decide who owns the node mutation and whether concurrent callers need a lock before claiming O(1) operations.

What the interviewer expects

The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.

Done means: get returns value; put returns evicted (key,value) or None.

Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.

Test-case scenarios to settle before coding

01 · Eviction

Input / starting state
capacity 2; put a,b; get a; put c
Expected result
evict b

What it is testing: A successful read refreshes recency.

02 · Overwrite

Input / starting state
put existing a with new value
Expected result
no size growth; a becomes most recent

What it is testing: Update and insert differ.

03 · Stored None

Input / starting state
put key with value None
Expected result
get returns None

What it is testing: None cannot stand in for a miss.

04 · Miss

Input / starting state
get absent key
Expected result
KeyError

What it is testing: Miss behavior is explicit.

05 · Zero capacity

Input / starting state
put a into capacity 0
Expected result
returns a as immediately evicted

What it is testing: The structure retains nothing.

06 · Invalid construction

Input / starting state
negative/noninteger capacity
Expected result
ValueError

What it is testing: Capacity is validated once.

For each case, show which branch or state change produces that result.

At capacity 2: put(a,1), put(b,2), get(a), put(c,3) evicts (b,2). Reading b then raises KeyError. Ask whether reads that miss affect recency (no) and whether an overwrite should count as a third entry (no).

Diagram: Test-case scenarios to settle before coding

Implement unlink and append before integrating eviction. Draw all four pointer writes needed to remove an interior node and append it at the tail.

Solution, coupled invariants, and follow-ups

A dictionary plus list of keys gives fast lookup but O(C) removal or recency search at capacity C. A linked list alone gives O(1) movement once a node is known, but O(C) lookup. Combine the two: a map points to the same nodes linked between head and tail sentinels. Sentinels eliminate special cases for first/last real nodes.

On a hit, unlink the node and append just before tail. On overwrite, update its value and perform the same movement. On a new key, allocate one node, register it, and append it; if oversized, unlink head.next and remove its map entry. Clearing removed pointers makes accidental reuse easier to diagnose.

every map entry corresponds to exactly one real list node, every real node appears in the map, adjacent next/previous pointers agree, and the list orders keys from least to most recent. Both structures must change within one operation. Forgetting the map deletion leaves a ghost hit on an evicted node.

Operation List from least to most recent Map keys
put a; put b a, b a, b
get a b, a a, b
put c a, c a, c

Expected map lookup plus constant pointer work gives O(1) time per get/put, subject to ordinary hash-table assumptions. State is O(C) nodes/map entries plus two sentinels. Each operation uses O(1) auxiliary space. The inspection helper items_lru() costs O(C) time/output space and is not part of the constant-time API.

Follow-up 1 — capacity is bytes. Predict what happens when c alone occupies eight bytes in a ten-byte cache containing a=4 and b=4. Evicting one is insufficient; remove least-recent entries until used bytes fit. Define oversized-object rejection and whether an unsuccessful overwrite preserves the old value before coding.

Diagram: Test-case scenarios to settle before coding

Follow-up 2 — concurrent readers. A get is a mutation because it changes recency. One mutex can cover map lookup and list movement as a single critical section. Do not run user loaders while holding it. Per-key load coordination is a separate requirement; a locked cache alone does not prevent duplicate computation.

Senior depth includes pointer-integrity checks after mixed operations and a simple list-based oracle. Lead depth distinguishes cache policy, load ownership, and memory accounting. The supplied cache intentionally makes no thread-safety claim.

Reference: solution.py (download file, source below). Tests cover middle removal, capacity 0/1, overwrite/None/miss behavior, eviction, and every link after seeded operations.

solution.py · solution.py
"""LRU implemented with a dict and explicit doubly linked sentinel list."""


class Node:
    __slots__ = ("key", "value", "prev", "next")

    def __init__(self, key=None, value=None):
        self.key, self.value = key, value
        self.prev = self.next = None


class LRUCache:
    def __init__(self, capacity):
        if not isinstance(capacity, int) or capacity < 0:
            raise ValueError("capacity must be a nonnegative integer")
        self.capacity = capacity
        self.nodes = {}
        self.head, self.tail = Node(), Node()
        self.head.next, self.tail.prev = self.tail, self.head

    @staticmethod
    def _unlink(node):
        node.prev.next = node.next
        node.next.prev = node.prev
        node.prev = node.next = None

    def _append_recent(self, node):
        previous = self.tail.prev
        previous.next = node
        node.prev, node.next = previous, self.tail
        self.tail.prev = node

    def get(self, key):
        node = self.nodes[key]
        self._unlink(node)
        self._append_recent(node)
        return node.value

    def put(self, key, value):
        """Return an evicted (key, value), or None; overwrite refreshes recency."""
        if key in self.nodes:
            node = self.nodes[key]
            node.value = value
            self._unlink(node)
            self._append_recent(node)
            return None
        if self.capacity == 0:
            return key, value
        node = Node(key, value)
        self.nodes[key] = node
        self._append_recent(node)
        if len(self.nodes) > self.capacity:
            victim = self.head.next
            self._unlink(victim)
            del self.nodes[victim.key]
            return victim.key, victim.value
        return None

    def items_lru(self):
        result = []
        node = self.head.next
        while node is not self.tail:
            result.append((node.key, node.value))
            node = node.next
        return result
python -m unittest discover -s curriculum/04-scale-and-evolution/01-data-at-scale/problems/28-manual-lru-cache -p 'test_*.py'