๐ŸŽ“ A1Learner
๐Ÿ”

DSA LRU Cache

An LRU cache is a fixed-size store that drops its least recently used entry when space runs out. A dict finds each entry, and a doubly linked list keeps the entries in recency order.

The problem: the cache spec

An LRU cache is a fixed-size store that drops its least recently used entry when space runs out. LRU stands for least recently used. The store supports two operations, get and put.

A get returns the value stored under a key, or -1 when the key is absent. A put stores a value under a key, updating it when the key already exists. Reading or writing an entry makes it the most recently used entry.

The operation script used in this article

script = [
    ("put(1, 10)", None),
    ("put(2, 20)", None),
    ("get(1)", 10),
    ("put(3, 30) evicts 2", None),
    ("get(2)", -1),
    ("put(4, 40) evicts 1", None),
    ("get(1)", -1),
    ("get(3)", 30),
    ("get(4)", 40),
]
print("capacity: 2")
for action, expected in script:
    if expected is None:
        print(action)
    else:
        print(action, "expected:", expected)

The capacity here is 2, so the store holds at most two entries. The third put must evict something. The script says what it evicts, and later sections run the script in full.

Intuition: keep entries in a list, freshest first

The first design keeps the entries in one plain list. The front of the list is the most recently used end. A get scans the list for its key, then moves that entry to the front.

A put on a full list drops the last entry. The design is correct. Its cost is the scan, and the scan grows with the capacity.

A list design scans for its key

entries = [(1, 10), (2, 20), (3, 30), (4, 40), (5, 50)]
target = 5
scanned = 0
found = None
for key, value in entries:
    scanned += 1
    if key == target:
        found = value
        break
print("value:", found, "entries scanned:", scanned, "of", len(entries))

The wanted entry sat at the back, so the scan paid full price. A busier cache repeats that price on many operations. The real design removes the scan.

Brute force: the naive list cache, counted

The naive design is the intuition, written as a class. Every get and put scans the list by key. A counter records each entry inspected.

The naive cache on the article script

class ListCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.entries = []
        self.scans = 0

    def _find(self, key):
        for i in range(len(self.entries)):
            self.scans += 1
            if self.entries[i][0] == key:
                return i
        return -1

    def get(self, key):
        self.scans = 0
        i = self._find(key)
        if i == -1:
            return -1
        pair = self.entries.pop(i)
        self.entries.insert(0, pair)
        return pair[1]

    def put(self, key, value):
        self.scans = 0
        i = self._find(key)
        if i != -1:
            self.entries.pop(i)
        self.entries.insert(0, (key, value))
        if len(self.entries) > self.capacity:
            self.entries.pop()

    def order(self):
        return [key for key, value in self.entries]

cache = ListCache(2)
cache.put(1, 10)
print("put(1, 10) -> cache", cache.order(), "| scanned", cache.scans)
cache.put(2, 20)
print("put(2, 20) -> cache", cache.order(), "| scanned", cache.scans)
print("get(1) =", cache.get(1), "-> cache", cache.order(), "| scanned", cache.scans)
cache.put(3, 30)
print("put(3, 30) -> cache", cache.order(), "| scanned", cache.scans)
print("get(2) =", cache.get(2), "-> cache", cache.order(), "| scanned", cache.scans)

The script answers match the spec at capacity 2. The scans stay small only because the capacity is small. At capacity 100 a back entry costs about 100 inspections per operation.

The optimal solution: a dict plus a doubly linked list

The real design pairs a dict with a doubly linked list, where the dict finds each entry's node at once and the list keeps the nodes in recency order. That one pairing is the whole design.

A doubly linked list node links to a previous node and a next node. Dummy head and tail nodes, called sentinels, frame the list and never hold data. The front node is the most recently used entry, and the node before the tail sentinel is the eviction target.

The real cache on the full script

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

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.store = {}
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head
        self.steps = 0

    def _link_front(self, node):
        node.prev = self.head
        self.steps += 1
        node.next = self.head.next
        self.steps += 1
        self.head.next.prev = node
        self.steps += 1
        self.head.next = node
        self.steps += 1

    def _unlink(self, node):
        node.prev.next = node.next
        self.steps += 1
        node.next.prev = node.prev
        self.steps += 1

    def _touch(self, node):
        self._unlink(node)
        self._link_front(node)

    def get(self, key):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is None:
            return -1
        self._touch(node)
        return node.value

    def put(self, key, value):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is not None:
            node.value = value
            self._touch(node)
            return
        node = Node(key, value)
        self.store[key] = node
        self.steps += 1
        self._link_front(node)
        if len(self.store) > self.capacity:
            victim = self.tail.prev
            self._unlink(victim)
            del self.store[victim.key]
            self.steps += 1

    def order(self):
        keys = []
        node = self.head.next
        while node is not self.tail:
            keys.append(node.key)
            node = node.next
        return keys

cache = LRUCache(2)
def show(action, result=None):
    if result is None:
        print(action, "-> cache", cache.order(), "| steps", cache.steps)
    else:
        print(action, "=", result, "-> cache", cache.order(), "| steps", cache.steps)

cache.put(1, 10)
show("put(1, 10)")
cache.put(2, 20)
show("put(2, 20)")
show("get(1)", cache.get(1))
cache.put(3, 30)
show("put(3, 30) evicts 2")
show("get(2)", cache.get(2))
cache.put(4, 40)
show("put(4, 40) evicts 1")
show("get(1)", cache.get(1))
show("get(3)", cache.get(3))
show("get(4)", cache.get(4))

Every line matches the script from the first section. The get for 1 returns 10 and moves 1 to the front. That move is why put(3, 30) evicts 2 instead of 1.

Walkthrough: the script, step by step

The walk reruns the script up to the second get(1). Each row states the recency order after the operation and the steps it took.

Recency order after each step

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

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.store = {}
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head
        self.steps = 0

    def _link_front(self, node):
        node.prev = self.head
        self.steps += 1
        node.next = self.head.next
        self.steps += 1
        self.head.next.prev = node
        self.steps += 1
        self.head.next = node
        self.steps += 1

    def _unlink(self, node):
        node.prev.next = node.next
        self.steps += 1
        node.next.prev = node.prev
        self.steps += 1

    def _touch(self, node):
        self._unlink(node)
        self._link_front(node)

    def get(self, key):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is None:
            return -1
        self._touch(node)
        return node.value

    def put(self, key, value):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is not None:
            node.value = value
            self._touch(node)
            return
        node = Node(key, value)
        self.store[key] = node
        self.steps += 1
        self._link_front(node)
        if len(self.store) > self.capacity:
            victim = self.tail.prev
            self._unlink(victim)
            del self.store[victim.key]
            self.steps += 1

    def order(self):
        keys = []
        node = self.head.next
        while node is not self.tail:
            keys.append(node.key)
            node = node.next
        return keys

cache = LRUCache(2)
def show(action, result=None):
    if result is None:
        print(action, "-> cache", cache.order(), "| steps", cache.steps)
    else:
        print(action, "=", result, "-> cache", cache.order(), "| steps", cache.steps)

cache.put(1, 10)
show("put(1, 10)")
cache.put(2, 20)
show("put(2, 20)")
show("get(1)", cache.get(1))
cache.put(3, 30)
show("put(3, 30) evicts 2")
show("get(2)", cache.get(2))
cache.put(4, 40)
show("put(4, 40) evicts 1")
show("get(1)", cache.get(1))
print("next eviction would take:", cache.order()[-1])

After get(1) the order is 1 then 2, so 2 is least recent. The next put evicts exactly that entry. Recency order, read front to back, is the whole state the design maintains.

Complexity

Each get and put does one dict access and a fixed set of pointer assignments, so its time is O(1), a constant count at any capacity. The store holds at most one node per key up to the capacity, so its space is O(capacity).

A step in this article means one dict access or one pointer assignment, as in the source lesson. The counted steps stay in single digits in the script output above.

Naive scans against real steps as capacity grows

class ListCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.entries = []
        self.scans = 0

    def get_last(self):
        target = self.entries[-1][0]
        self.scans = 0
        for key, value in self.entries:
            self.scans += 1
            if key == target:
                return value

for cap in [10, 50, 100]:
    cache = ListCache(cap)
    for key in range(1, cap + 1):
        cache.entries.append((key, key * 10))
    cache.get_last()
    print("capacity", cap, "- naive scans for the back entry:", cache.scans, " real design steps for a get: 7")

The naive scan grows with the capacity. The real step count does not move. That flat count is what O(1) means in this design, shown in numbers.

Edge cases and pitfalls

A get for a missing key returns -1 and changes nothing. A put on an existing key updates its value and its recency, and it evicts nothing. A capacity 1 cache evicts on every new key.

Edge cases, run

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

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.store = {}
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head
        self.steps = 0

    def _link_front(self, node):
        node.prev = self.head
        self.steps += 1
        node.next = self.head.next
        self.steps += 1
        self.head.next.prev = node
        self.steps += 1
        self.head.next = node
        self.steps += 1

    def _unlink(self, node):
        node.prev.next = node.next
        self.steps += 1
        node.next.prev = node.prev
        self.steps += 1

    def _touch(self, node):
        self._unlink(node)
        self._link_front(node)

    def get(self, key):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is None:
            return -1
        self._touch(node)
        return node.value

    def put(self, key, value):
        self.steps = 0
        node = self.store.get(key)
        self.steps += 1
        if node is not None:
            node.value = value
            self._touch(node)
            return
        node = Node(key, value)
        self.store[key] = node
        self.steps += 1
        self._link_front(node)
        if len(self.store) > self.capacity:
            victim = self.tail.prev
            self._unlink(victim)
            del self.store[victim.key]
            self.steps += 1

    def order(self):
        keys = []
        node = self.head.next
        while node is not self.tail:
            keys.append(node.key)
            node = node.next
        return keys

cache = LRUCache(2)
cache.put(1, 10)
cache.put(2, 20)
print("missing get:", cache.get(9), "order:", cache.order())
cache.put(1, 111)
print("after update put(1, 111): order", cache.order(), "size", len(cache.store))
print("updated value:", cache.get(1))
tiny = LRUCache(1)
tiny.put(1, 10)
tiny.put(2, 20)
print("capacity 1 holds:", tiny.order(), "get(1):", tiny.get(1), "get(2):", tiny.get(2))
cache.put(3, 30)
print("after put(3, 30): order", cache.order())

The pitfall is forgetting to move a node on a hit. A get that leaves recency untouched evicts entries that were just read. The update row shows the other common slip, growing the store on a repeated key instead of updating in place.

Exercise

A capacity 2 cache holds keys 5 then 9, with 9 most recent. A get for 5 runs, then put adds key 7. Which key is evicted?