LRU cache in O(1)
Get and put in constant time with a hash map pointing into a doubly linked list.
- source
- LeetCode 146 · classic interview
- tags
- hash map · linked list · caching
- O(1)time
- O(capacity)space
The problem
Design a cache with a fixed capacity. get(key) returns the value or -1. put(key, value) inserts or updates. When the cache is full, evict the item that was least recently used. Both operations must run in O(1).
Questions I would ask first
- Does
getcount as a use? Yes: reading refreshes the item. - Does updating an existing key count as a use? Also yes.
- Capacity of 0? Treat as always empty.
- Single thread? For the interview, yes. See “in the real world” for the rest.
Brute force
Keep an array ordered by recency. get scans it (O(n)), moves the hit to the front (O(n)). put does the same and pops the tail when full. Correct, and too slow: every operation touches the whole array.
Where it hurts
Two things need to be fast at once: finding a key and moving an item to the front. An array finds slowly and moves slowly. A hash map finds fast but has no order. A linked list moves fast but finds slowly.
The key idea
Use both, and let each do the one thing it is good at.
- 01Hash mapkey → node, O(1) lookup
- 02Doubly linked listmost recent at the head, least at the tail
- 03Sentinelsfake head and tail so no edge cases on insert or remove
A get looks the node up in the map, unlinks it and relinks it at the head. A put on a new key links a node at the head and, if over capacity, drops the tail node and deletes its key from the map.
Solution
class Node {
constructor(
public key: number,
public value: number,
public prev: Node | null = null,
public next: Node | null = null,
) {}
}
class LRUCache {
private map = new Map<number, Node>()
private head = new Node(0, 0) // sentinel: most recent side
private tail = new Node(0, 0) // sentinel: least recent side
constructor(private capacity: number) {
this.head.next = this.tail
this.tail.prev = this.head
}
get(key: number): number {
const node = this.map.get(key)
if (!node) return -1
this.moveToFront(node)
return node.value
}
put(key: number, value: number): void {
const existing = this.map.get(key)
if (existing) {
existing.value = value
this.moveToFront(existing)
return
}
const node = new Node(key, value)
this.map.set(key, node)
this.linkAtFront(node)
if (this.map.size > this.capacity) {
const victim = this.tail.prev!
this.unlink(victim)
this.map.delete(victim.key)
}
}
private unlink(node: Node) {
node.prev!.next = node.next
node.next!.prev = node.prev
}
private linkAtFront(node: Node) {
node.next = this.head.next
node.prev = this.head
this.head.next!.prev = node
this.head.next = node
}
private moveToFront(node: Node) {
this.unlink(node)
this.linkAtFront(node)
}
}
Complexity
get and put are a map lookup plus a constant number of pointer changes: O(1). Memory is one node per entry plus the map: O(capacity).
Variants worth knowing
- LFU (least frequently used): a map of frequency → its own LRU list, plus a
minFreqcounter. Still O(1), twice the bookkeeping. - TTL: store an expiry per node and treat expired hits as misses; evict lazily on access.
- JavaScript shortcut:
Mapkeeps insertion order, sodelete+setmoves a key to the end. Shorter code, same complexity, and worth mentioning before writing the list by hand.
In the real world
Redis implements approximate LRU by sampling a few keys instead of keeping an exact list: exactness is not worth the memory per key. HTTP caches and CPU caches do the same trade. The interview answer is the exact version; production usually is not.