Builds on Dummy Node and Hash Map, which you haven’t finished yet.
LRU Cache (HashMap + Doubly Linked List)
LRU Cache (LC 146)(opens leetcode.com)
A cache with a fixed capacity needs to hold key-value pairs and, whenever it's full and a new key arrives, evict whichever key was touched longest ago.
Build an LRUCache class:
LRUCache(int capacity)sets up the cache with a positivecapacity.int get(int key)returns the value stored forkey, or-1ifkeyisn't present.void put(int key, int value)insertskeywithvalue, or overwrites the value ifkeyis already present. If this push takes the cache pastcapacity, evict the least recently used key first.
Both get and put must run in O(1) average time.
LRU Cache
key-value storage ➔ hash map (O(1) lookup) + DLL (O(1) recency)O(1) lookups plus eviction by least recent use (or by frequency).| 1 | from typing import Optional |
| 2 | |
| 3 | class DLinkedNode: |
| 4 | def __init__(self, key: int = 0, val: int = 0) -> None: |
| 5 | self.key = key # Stored so eviction can delete the map entry |
| 6 | self.val = val |
| 7 | self.prev: Optional["DLinkedNode"] = None |
| 8 | self.next: Optional["DLinkedNode"] = None |
| 9 | |
| 10 | class LRUCache: |
| 11 | """LRU Cache: hash map for O(1) lookup + doubly-linked list for O(1) recency order. |
| 12 | Time: O(1) get and put | Space: O(capacity) |
| 13 | """ |
| 14 | def __init__(self, capacity: int) -> None: |
| 15 | self.cap = capacity |
| 16 | self.cache: dict[int, DLinkedNode] = {} |
| 17 | self.head = DLinkedNode() # Sentinel: most recent side |
| 18 | self.tail = DLinkedNode() # Sentinel: least recent side |
| 19 | self.head.next = self.tail |
| 20 | self.tail.prev = self.head |
| 21 | |
| 22 | def _remove(self, node: DLinkedNode) -> None: |
| 23 | node.prev.next = node.next # Unlink: sentinels mean no null checks |
| 24 | node.next.prev = node.prev |
| 25 | |
| 26 | def _add_to_head(self, node: DLinkedNode) -> None: |
| 27 | node.prev = self.head |
| 28 | node.next = self.head.next |
| 29 | self.head.next.prev = node |
| 30 | self.head.next = node |
| 31 | |
| 32 | def get(self, key: int) -> int: |
| 33 | # 1. Return A Miss, Or Promote The Found Node To Most Recently Used: |
| 34 | # A successful read counts as a use just like a write, so the node must move to the front |
| 35 | # before its value is returned; otherwise the node next to tail could stop being the least-recently-used one. |
| 36 | if key not in self.cache: |
| 37 | return -1 |
| 38 | node = self.cache[key] |
| 39 | self._remove(node) # A read counts as a use: |
| 40 | self._add_to_head(node) # move to the most recent position |
| 41 | return node.val |
| 42 | |
| 43 | def put(self, key: int, value: int) -> None: |
| 44 | # 1. Update In Place And Promote, Or Insert A New Node (Evicting If Full): |
| 45 | # An existing key keeps its map entry and only moves to the front; a brand-new key may first |
| 46 | # evict the node next to tail, which is always the least recently used because every use moves its node to the front. |
| 47 | if key in self.cache: |
| 48 | node = self.cache[key] |
| 49 | node.val = value # Update in place, then mark as most recent |
| 50 | self._remove(node) |
| 51 | self._add_to_head(node) |
| 52 | else: |
| 53 | if len(self.cache) >= self.cap: |
| 54 | lru = self.tail.prev # Least recently used sits next to the tail |
| 55 | self._remove(lru) |
| 56 | del self.cache[lru.key] |
| 57 | new_node = DLinkedNode(key, value) |
| 58 | self.cache[key] = new_node |
| 59 | self._add_to_head(new_node) |
head to tail holds exactly the nodes in self.cache, ordered from most to least recently used.The hash map finds any node instantly, and the doubly linked list keeps nodes in order of use: most recent next to head, least recent next to tail. This is correct because every get and every put moves its node to the front, so no node can sit behind one that was used less recently, and the node just before tail is always the least recently used; each node stores its key so evicting it can also del self.cache[lru.key]. The trap is put on a key that is already there: update node.val and move the node to the front, or it keeps its old place and is evicted too early; with capacity 2, put(1, 1), put(2, 2), put(1, 9), put(3, 3) must evict 2, not 1.
See it run
LRU Cache, capacity 2, ops = put(1, 1), put(2, 2), put(1, 9), put(3, 3), get(2), get(1)
call none yet
The row is the list from head to tail. Each box shows key:val. The box next to head is the most recently used key.
usedthe node that this call reads, writes or moves to the frontlruthe least recently used node, next to tail
The cache is empty, so head is linked straight to tail.
Gotcha 1 of 4
- The trap
Gotcha 1: Updating
node.valwithout moving the node to the frontMistakenode.val = valueFixnode.val = value self._remove(node) self._add_to_head(node)What happens:
puton a cached key is a use, likeget. Without the move, the node keeps its old place neartail. With capacity 2,put(1, 1),put(2, 2),put(1, 9)andput(3, 3)then evict key 1. Key 2 was used less recently, so key 2 must be evicted.Example: capacity = 2: put(1, 1), put(2, 2), put(1, 9), put(3, 3), then get(1) and get(2)Mistake: returns (-1, 2)Fix: returns (9, -1)
- Only the last
sizevalues are kept and they leave in arrival order, as in Moving Average from Data Stream (LC 346): a queue drops the oldest value, nothing is looked up by key, and reading a value never makes it stay longer. See Fixed Window
Deep Dive
Why the list stays in order of use
The cache needs two things at once: find a key in O(1), and know which key was used longest ago. The hash map self.cache does the first. The doubly linked list does the second. It keeps the nodes in order from the most recent (next to head) to the least recent (next to tail).
Only two moves change the list. _add_to_head puts a node next to head, and _remove unlinks a node. Every get that finds a key and every put ends with the node next to head. So a node can never sit behind a node that was used less recently.
Trace a cache with cap = 2. After put(1, 1) and put(2, 2), the list is 2:2, 1:1. put(1, 9) updates the value and moves key 1 to the front, so the list is 1:9, 2:2. put(3, 3) finds the cache full, takes lru = tail.prev, which is key 2, and evicts it. The list is 3:3, 1:9.
Then get(2) returns -1, because key 2 is gone. get(1) returns 9 and moves key 1 to the front, so the list is 1:9, 3:3.
This is why the card's invariant says that the list holds exactly the nodes in self.cache, in order of use. Each method changes the map and the list together.
Why a doubly linked list, and why sentinels
To move a node, _remove needs the node before it and the node after it. A singly linked list has no link to the node before, so finding it takes O(N) steps. A doubly linked list stores prev and next in each node. The map gives the node directly, and _remove unlinks it in O(1).
head and tail are sentinel nodes. A sentinel is a fixed node that holds no data. Because they always exist, every real node has a prev and a next. _remove and _add_to_head therefore need no checks for an empty list, for the first node or for the last node.
The least recently used node is always self.tail.prev. That is the node put evicts when the cache is full.
Why each node stores its key
Eviction removes the node from the list and the entry from the map. The list gives lru = self.tail.prev. The map entry is self.cache[lru.key], so the node must know its own key.
Without key in the node, del self.cache[lru.key] is impossible. The map keeps an entry that points at a node no longer in the list. A later get of that key would call _remove on a node that is not linked, and the list breaks.
The order of the steps in the else branch of put matters. Evict first, only if len(self.cache) >= self.cap. Then create the new node. If the new node were added first, tail.prev could be the new node itself. With cap = 1, put would evict the key it just added.
Updating a key that already exists
put on an existing key is a use, the same as get. The branch for key in self.cache must do three things: set node.val, call _remove(node) and call _add_to_head(node). It must not create a second node, and it must not evict.
The card's trap is to set node.val and stop. With cap = 2, the calls put(1, 1), put(2, 2), put(1, 9), put(3, 3) then evict key 1, because key 1 stayed at the back. The correct code evicts key 2, which was used least recently. The correct cache keeps keys 1 and 3.
Each call does a fixed number of dictionary and pointer operations. So get and put are O(1), and the space is O(cap).
60-Second Pitch
"I need O(1) lookup and O(1) eviction of the key used longest ago. So I combine a hash map and a doubly linked list. The map gives the node for a key. The list keeps the nodes in order of use, with head and tail as sentinels, so unlinking a node needs no special cases. Every get and every put moves its node next to head. So the node before tail is always the least recently used, and put evicts it when the cache is full. Each node stores its key, so I can delete the map entry too. Every operation is O(1), and the space is O(capacity)."
Common Questions
- What is the time and space complexity?
getandputare bothO(1)time. The map lookup isO(1)on average, and_removeand_add_to_headchange a fixed number of links. The space is O(capacity), for at mostcapnodes andcapmap entries.- Why not use an array or a plain list for the order?
Moving an item to the front of a Python list takes
O(N), because the other items shift. A doubly linked list moves a node by changing four links. The map gives the node inO(1), so no search is needed.- Can you use
OrderedDictinstead? Yes, in Python.
OrderedDict.move_to_endmoves a key inO(1), andpopitem(last=False)removes the oldest key inO(1). Many interviewers accept it, but some ask for the list version. The list version shows that you know why the moves areO(1).- What changes for a least frequently used cache?
Order of use is no longer enough, because the key with the lowest use count must go. LFU Cache (LC 460) keeps a count for each key and one list of keys for each count. Ties inside one count are broken by recency, which is the LRU rule again.
- How would this change with several threads?
A
getalso writes to the list, so two threads can corrupt the links. The simplest fix is one lock around each method. For heavy traffic, split the cache into several parts, each with its own lock and its own list, and choose the part by the hash of the key.
SWE Reasoning
Spot it
- A design problem asks for
getandputinO(1). - When the cache is full, the entry used longest ago must be removed.
- Both reading and writing count as a use.
- One structure alone fails: a hash map has no order, and a list has no
O(1)lookup. - The statement says "least recently used", or a variant with counts (LFU Cache (LC 460)).
The invariant, step by step
LRUCache(cap)creates an emptyself.cache, and linksheadtotail. The list holds no nodes.putof a new key checkslen(self.cache) >= self.cap. If the cache is full, it takeslru = self.tail.prev, calls_remove(lru)and runsdel self.cache[lru.key].- It then creates a node, stores it in
self.cache[key]and calls_add_to_head. The list and the map each gained one node. getof a key inself.cachecalls_remove(node)and then_add_to_head(node). The node moves from its place to the front, and the map is unchanged.putof an existing key setsnode.valand moves the node the same way.- Every use leaves the used node next to
head, so the node next totailis the one used longest ago. The list and the map hold the same keys after every call.
Traps
- Updating
node.valwithout moving the node. The node keeps its old place and is evicted too early. - Evicting from the list but not the map. If
del self.cache[lru.key]is missing, a latergetfinds a node that is no longer in the list. - A node without
key. The eviction code cannot find the map entry to delete. - Moving only on
put.getis a use too. Without the move inget, a key that is read often can still be evicted. - Checking
>instead of>=. Withlen(self.cache) > self.cap, the cache grows tocap + 1before it evicts. - Forgetting that
getof a missing key returns-1. The function must return before it touches the list.
Practice
Builds on Dummy Node and Hash Map, which you haven’t finished yet.
LRU Cache (HashMap + Doubly Linked List)
LRU Cache (LC 146)(opens leetcode.com)
A cache with a fixed capacity needs to hold key-value pairs and, whenever it's full and a new key arrives, evict whichever key was touched longest ago.
Build an LRUCache class:
LRUCache(int capacity)sets up the cache with a positivecapacity.int get(int key)returns the value stored forkey, or-1ifkeyisn't present.void put(int key, int value)insertskeywithvalue, or overwrites the value ifkeyis already present. If this push takes the cache pastcapacity, evict the least recently used key first.
Both get and put must run in O(1) average time.
LRU Cache
key-value storage ➔ hash map (O(1) lookup) + DLL (O(1) recency)O(1) lookups plus eviction by least recent use (or by frequency).| 1 | from typing import Optional |
| 2 | |
| 3 | class DLinkedNode: |
| 4 | def __init__(self, key: int = 0, val: int = 0) -> None: |
| 5 | self.key = key # Stored so eviction can delete the map entry |
| 6 | self.val = val |
| 7 | self.prev: Optional["DLinkedNode"] = None |
| 8 | self.next: Optional["DLinkedNode"] = None |
| 9 | |
| 10 | class LRUCache: |
| 11 | """LRU Cache: hash map for O(1) lookup + doubly-linked list for O(1) recency order. |
| 12 | Time: O(1) get and put | Space: O(capacity) |
| 13 | """ |
| 14 | def __init__(self, capacity: int) -> None: |
| 15 | self.cap = capacity |
| 16 | self.cache: dict[int, DLinkedNode] = {} |
| 17 | self.head = DLinkedNode() # Sentinel: most recent side |
| 18 | self.tail = DLinkedNode() # Sentinel: least recent side |
| 19 | self.head.next = self.tail |
| 20 | self.tail.prev = self.head |
| 21 | |
| 22 | def _remove(self, node: DLinkedNode) -> None: |
| 23 | node.prev.next = node.next # Unlink: sentinels mean no null checks |
| 24 | node.next.prev = node.prev |
| 25 | |
| 26 | def _add_to_head(self, node: DLinkedNode) -> None: |
| 27 | node.prev = self.head |
| 28 | node.next = self.head.next |
| 29 | self.head.next.prev = node |
| 30 | self.head.next = node |
| 31 | |
| 32 | def get(self, key: int) -> int: |
| 33 | # 1. Return A Miss, Or Promote The Found Node To Most Recently Used: |
| 34 | # A successful read counts as a use just like a write, so the node must move to the front |
| 35 | # before its value is returned; otherwise the node next to tail could stop being the least-recently-used one. |
| 36 | if key not in self.cache: |
| 37 | return -1 |
| 38 | node = self.cache[key] |
| 39 | self._remove(node) # A read counts as a use: |
| 40 | self._add_to_head(node) # move to the most recent position |
| 41 | return node.val |
| 42 | |
| 43 | def put(self, key: int, value: int) -> None: |
| 44 | # 1. Update In Place And Promote, Or Insert A New Node (Evicting If Full): |
| 45 | # An existing key keeps its map entry and only moves to the front; a brand-new key may first |
| 46 | # evict the node next to tail, which is always the least recently used because every use moves its node to the front. |
| 47 | if key in self.cache: |
| 48 | node = self.cache[key] |
| 49 | node.val = value # Update in place, then mark as most recent |
| 50 | self._remove(node) |
| 51 | self._add_to_head(node) |
| 52 | else: |
| 53 | if len(self.cache) >= self.cap: |
| 54 | lru = self.tail.prev # Least recently used sits next to the tail |
| 55 | self._remove(lru) |
| 56 | del self.cache[lru.key] |
| 57 | new_node = DLinkedNode(key, value) |
| 58 | self.cache[key] = new_node |
| 59 | self._add_to_head(new_node) |
head to tail holds exactly the nodes in self.cache, ordered from most to least recently used.The hash map finds any node instantly, and the doubly linked list keeps nodes in order of use: most recent next to head, least recent next to tail. This is correct because every get and every put moves its node to the front, so no node can sit behind one that was used less recently, and the node just before tail is always the least recently used; each node stores its key so evicting it can also del self.cache[lru.key]. The trap is put on a key that is already there: update node.val and move the node to the front, or it keeps its old place and is evicted too early; with capacity 2, put(1, 1), put(2, 2), put(1, 9), put(3, 3) must evict 2, not 1.
See it run
LRU Cache, capacity 2, ops = put(1, 1), put(2, 2), put(1, 9), put(3, 3), get(2), get(1)
call none yet
The row is the list from head to tail. Each box shows key:val. The box next to head is the most recently used key.
usedthe node that this call reads, writes or moves to the frontlruthe least recently used node, next to tail
The cache is empty, so head is linked straight to tail.
Gotcha 1 of 4
- The trap
Gotcha 1: Updating
node.valwithout moving the node to the frontMistakenode.val = valueFixnode.val = value self._remove(node) self._add_to_head(node)What happens:
puton a cached key is a use, likeget. Without the move, the node keeps its old place neartail. With capacity 2,put(1, 1),put(2, 2),put(1, 9)andput(3, 3)then evict key 1. Key 2 was used less recently, so key 2 must be evicted.Example: capacity = 2: put(1, 1), put(2, 2), put(1, 9), put(3, 3), then get(1) and get(2)Mistake: returns (-1, 2)Fix: returns (9, -1)
- Only the last
sizevalues are kept and they leave in arrival order, as in Moving Average from Data Stream (LC 346): a queue drops the oldest value, nothing is looked up by key, and reading a value never makes it stay longer. See Fixed Window
Deep Dive
Why the list stays in order of use
The cache needs two things at once: find a key in O(1), and know which key was used longest ago. The hash map self.cache does the first. The doubly linked list does the second. It keeps the nodes in order from the most recent (next to head) to the least recent (next to tail).
Only two moves change the list. _add_to_head puts a node next to head, and _remove unlinks a node. Every get that finds a key and every put ends with the node next to head. So a node can never sit behind a node that was used less recently.
Trace a cache with cap = 2. After put(1, 1) and put(2, 2), the list is 2:2, 1:1. put(1, 9) updates the value and moves key 1 to the front, so the list is 1:9, 2:2. put(3, 3) finds the cache full, takes lru = tail.prev, which is key 2, and evicts it. The list is 3:3, 1:9.
Then get(2) returns -1, because key 2 is gone. get(1) returns 9 and moves key 1 to the front, so the list is 1:9, 3:3.
This is why the card's invariant says that the list holds exactly the nodes in self.cache, in order of use. Each method changes the map and the list together.
Why a doubly linked list, and why sentinels
To move a node, _remove needs the node before it and the node after it. A singly linked list has no link to the node before, so finding it takes O(N) steps. A doubly linked list stores prev and next in each node. The map gives the node directly, and _remove unlinks it in O(1).
head and tail are sentinel nodes. A sentinel is a fixed node that holds no data. Because they always exist, every real node has a prev and a next. _remove and _add_to_head therefore need no checks for an empty list, for the first node or for the last node.
The least recently used node is always self.tail.prev. That is the node put evicts when the cache is full.
Why each node stores its key
Eviction removes the node from the list and the entry from the map. The list gives lru = self.tail.prev. The map entry is self.cache[lru.key], so the node must know its own key.
Without key in the node, del self.cache[lru.key] is impossible. The map keeps an entry that points at a node no longer in the list. A later get of that key would call _remove on a node that is not linked, and the list breaks.
The order of the steps in the else branch of put matters. Evict first, only if len(self.cache) >= self.cap. Then create the new node. If the new node were added first, tail.prev could be the new node itself. With cap = 1, put would evict the key it just added.
Updating a key that already exists
put on an existing key is a use, the same as get. The branch for key in self.cache must do three things: set node.val, call _remove(node) and call _add_to_head(node). It must not create a second node, and it must not evict.
The card's trap is to set node.val and stop. With cap = 2, the calls put(1, 1), put(2, 2), put(1, 9), put(3, 3) then evict key 1, because key 1 stayed at the back. The correct code evicts key 2, which was used least recently. The correct cache keeps keys 1 and 3.
Each call does a fixed number of dictionary and pointer operations. So get and put are O(1), and the space is O(cap).
60-Second Pitch
"I need O(1) lookup and O(1) eviction of the key used longest ago. So I combine a hash map and a doubly linked list. The map gives the node for a key. The list keeps the nodes in order of use, with head and tail as sentinels, so unlinking a node needs no special cases. Every get and every put moves its node next to head. So the node before tail is always the least recently used, and put evicts it when the cache is full. Each node stores its key, so I can delete the map entry too. Every operation is O(1), and the space is O(capacity)."
Common Questions
- What is the time and space complexity?
getandputare bothO(1)time. The map lookup isO(1)on average, and_removeand_add_to_headchange a fixed number of links. The space is O(capacity), for at mostcapnodes andcapmap entries.- Why not use an array or a plain list for the order?
Moving an item to the front of a Python list takes
O(N), because the other items shift. A doubly linked list moves a node by changing four links. The map gives the node inO(1), so no search is needed.- Can you use
OrderedDictinstead? Yes, in Python.
OrderedDict.move_to_endmoves a key inO(1), andpopitem(last=False)removes the oldest key inO(1). Many interviewers accept it, but some ask for the list version. The list version shows that you know why the moves areO(1).- What changes for a least frequently used cache?
Order of use is no longer enough, because the key with the lowest use count must go. LFU Cache (LC 460) keeps a count for each key and one list of keys for each count. Ties inside one count are broken by recency, which is the LRU rule again.
- How would this change with several threads?
A
getalso writes to the list, so two threads can corrupt the links. The simplest fix is one lock around each method. For heavy traffic, split the cache into several parts, each with its own lock and its own list, and choose the part by the hash of the key.
SWE Reasoning
Spot it
- A design problem asks for
getandputinO(1). - When the cache is full, the entry used longest ago must be removed.
- Both reading and writing count as a use.
- One structure alone fails: a hash map has no order, and a list has no
O(1)lookup. - The statement says "least recently used", or a variant with counts (LFU Cache (LC 460)).
The invariant, step by step
LRUCache(cap)creates an emptyself.cache, and linksheadtotail. The list holds no nodes.putof a new key checkslen(self.cache) >= self.cap. If the cache is full, it takeslru = self.tail.prev, calls_remove(lru)and runsdel self.cache[lru.key].- It then creates a node, stores it in
self.cache[key]and calls_add_to_head. The list and the map each gained one node. getof a key inself.cachecalls_remove(node)and then_add_to_head(node). The node moves from its place to the front, and the map is unchanged.putof an existing key setsnode.valand moves the node the same way.- Every use leaves the used node next to
head, so the node next totailis the one used longest ago. The list and the map hold the same keys after every call.
Traps
- Updating
node.valwithout moving the node. The node keeps its old place and is evicted too early. - Evicting from the list but not the map. If
del self.cache[lru.key]is missing, a latergetfinds a node that is no longer in the list. - A node without
key. The eviction code cannot find the map entry to delete. - Moving only on
put.getis a use too. Without the move inget, a key that is read often can still be evicted. - Checking
>instead of>=. Withlen(self.cache) > self.cap, the cache grows tocap + 1before it evicts. - Forgetting that
getof a missing key returns-1. The function must return before it touches the list.