task
Design a Least Recently Used cache with a fixed capacity:
Get(key)— return the value if present (and mark it as just-used), else-1.Put(key, value)— insert or update (marking it as just-used); if this pushes the cache overcapacity, evict the least recently used entry first.
Both operations must run in O(1) average time.
LRUCache(2); Put(1,1); Put(2,2); Get(1) → 1
Put(3,3) → evicts key 2 (it's the least recently used)
Get(2) → -1
how to think
O(1) lookup by key means a dictionary — that part’s not in question. The hard part is O(1)
“promote this entry to most-recently-used” and O(1) “evict whatever’s least recently used”,
together. An array can’t do either without shifting; a plain singly linked list can’t remove an
arbitrary node in O(1) because you’d need its predecessor, which you don’t have. A doubly
linked list can, because every node already stores its own predecessor — removing it is a
four-pointer patch, no search required. So: a dictionary maps key -> node, and a doubly linked
list keeps the nodes ordered most-recently-used to least. This is exactly why “LRU Cache” always
pairs a hashmap with the doubly linked list this topic builds.
template instance
Dummy head — twice over. A dummy node sits at each end (head and tail) so every real node always has a real predecessor and successor to patch, with no “is this the first/last node?” branch. Invariant: the dictionary always points straight at a key’s node, and list order left-to-right is most- to least-recently-used.
solution
Two implementations, because interviewers ask for both: build the mechanism by hand once to prove you understand why it’s O(1), then know that .NET already ships the doubly linked list part.
hand-rolled: dictionary + doubly linked list
public class LRUCache
{
private class Node(int key, int value)
{
public int Key = key;
public int Value = value;
public Node? Prev;
public Node? Next;
}
private readonly int _capacity;
private readonly Dictionary<int, Node> _map = new();
private readonly Node _head = new(0, 0); // dummy — MRU sits right after this
private readonly Node _tail = new(0, 0); // dummy — LRU sits right before this
public LRUCache(int capacity)
{
_capacity = capacity;
_head.Next = _tail;
_tail.Prev = _head;
}
public int Get(int key)
{
if (!_map.TryGetValue(key, out var node)) return -1;
Remove(node);
InsertAtHead(node); // touched → most recently used
return node.Value;
}
public void Put(int key, int value)
{
if (_map.TryGetValue(key, out var existing))
{
existing.Value = value;
Remove(existing);
InsertAtHead(existing);
return;
}
if (_map.Count == _capacity)
{
var lru = _tail.Prev!; // node right before the dummy tail
Remove(lru);
_map.Remove(lru.Key);
}
var node = new Node(key, value);
_map[key] = node;
InsertAtHead(node);
}
private void Remove(Node node)
{
node.Prev!.Next = node.Next;
node.Next!.Prev = node.Prev;
}
private void InsertAtHead(Node node)
{
node.Next = _head.Next;
node.Prev = _head;
_head.Next!.Prev = node;
_head.Next = node;
}
}
idiomatic: LinkedList<T> and LinkedListNode<T>
System.Collections.Generic.LinkedList<T> already is a doubly linked list with O(1)
AddFirst, Remove, and RemoveLast — there’s no reason to hand-roll the node plumbing above
outside of an interview that specifically asks for it.
public class LRUCacheBuiltin
{
private readonly int _capacity;
private readonly LinkedList<(int Key, int Value)> _order = new();
private readonly Dictionary<int, LinkedListNode<(int Key, int Value)>> _map = new();
public LRUCacheBuiltin(int capacity) => _capacity = capacity;
public int Get(int key)
{
if (!_map.TryGetValue(key, out var node)) return -1;
_order.Remove(node);
_order.AddFirst(node); // relinks the existing node — no allocation
return node.Value.Value;
}
public void Put(int key, int value)
{
if (_map.TryGetValue(key, out var existing))
{
_order.Remove(existing);
}
else if (_map.Count == _capacity)
{
var lru = _order.Last!;
_map.Remove(lru.Value.Key);
_order.RemoveLast();
}
_map[key] = _order.AddFirst((key, value));
}
}
trace
LRUCache(2), then the classic operation sequence — order shown most-recently-used first:
| operation | result | order (MRU → LRU) |
|---|---|---|
Put(1, 1) |
— | 1:1 |
Put(2, 2) |
— | 2:2, 1:1 |
Get(1) |
1 |
1:1, 2:2 |
Put(3, 3) |
— | 3:3, 1:1 (key 2 evicted — it was LRU) |
Get(2) |
-1 |
3:3, 1:1 (unchanged — nothing to promote) |
Put(4, 4) |
— | 4:4, 3:3 (key 1 evicted — it was LRU) |
Get(1) |
-1 |
4:4, 3:3 |
Get(3) |
3 |
3:3, 4:4 |
Get(4) |
4 |
4:4, 3:3 |
Watch Get(1) at the top: it doesn’t just read the value, it moves key 1 to the front — that
single promotion is why key 2, not key 1, is the one evicted by the very next Put.
why it works
Every touch — Get on a hit, or Put — removes the node from wherever it sits and reinserts it
at the head, so “most recently used” always means “closest to the head” by construction; no
scanning is ever needed to find it. Eviction always takes the node just before the dummy tail,
which by the same invariant is the one that has gone longest untouched. Remove and
InsertAtHead only ever touch a fixed number of pointers — four, regardless of list size —
because a doubly linked node already knows its own neighbors.
common bugs
- Updating
Getwithout moving the node to the front — the whole point of LRU tracking is lost; a “recently used” read that doesn’t promote makes eviction order wrong. - Forgetting the sentinel dummies at both ends — without a dummy tail, evicting when the list
has exactly one real node means patching a
nullpredecessor. - Evicting
_head.Next(the MRU end) instead of_tail.Prev(the LRU end) — a one-line typo that inverts the entire cache’s eviction policy. - With
LinkedListNode<T>:node.Value.Value = xwon’t compile (CS1612 — the tuple comes back as a copy); assign a whole new tuple withnode.Value = (key, value), or remove-and-re-add as the code above does since you need to promote the node anyway.
variants you can now solve
- Insert Delete GetRandom O(1) (LC 380) — the sibling design question: dictionary + list again, but swap-remove replaces doubly-linked splicing because order doesn’t matter here.
- LFU Cache (LC 460) — same shape, one dimension harder: evict by frequency first, recency second, which needs a dictionary of doubly linked lists (one per frequency) instead of one.
- Merge K Sorted Lists (LC 23) — a different structure entirely, but the same lesson: know when a specialized structure (here, a heap) beats hand-rolling one from linked-list parts.