LRU Cache

Medium System Design

Problem

Design a data structure that supports the operations get and put for a Least Recently Used (LRU) cache with a fixed capacity, where get returns the value of the key if it exists, otherwise -1, and put inserts or updates the value of the key, evicting the least recently used item if capacity is exceeded.

  • 1 ≤ capacity ≤ 3000
  • 0 ≤ key ≤ 10⁴
  • 0 ≤ value ≤ 10⁵
  • At most 2 * 10⁵ calls will be made to get and put.

Example

Input: capacity = 2 put(1, 1) put(2, 2) get(1) put(3, 3) get(2) put(4, 4) get(1) get(3) get(4)
Output: [null, null, null, 1, null, -1, null, 3, 4]

Initially, the cache is empty with capacity 2. After put(1,1) and put(2,2), the cache holds keys 1 and 2. get(1) returns 1 and marks key 1 as recently used. put(3,3) evicts key 2 (least recently used) and inserts key 3. get(2) returns -1 since key 2 was evicted. put(4,4) evicts key 1 and inserts key 4. get(1) returns -1 (evicted), get(3) returns 3, and get(4) returns 4.

Approach

Straightforward Solution

A naive approach might use a list or queue to track usage order and a hash map for values, but removing an arbitrary element from a list is O(n), making put and get operations inefficient for large caches.

Core Observation

The LRU cache requires O(1) time for both get and put operations, which demands a data structure that supports fast lookup, insertion, deletion, and ordering by recent use. A hash map provides O(1) key lookup, but does not maintain order. A doubly linked list supports O(1) insertion and removal of nodes and maintains the order of usage, enabling quick eviction of the least recently used item.

Path to Optimal

The key insight is to combine a hash map and a doubly linked list. The hash map stores keys mapped to nodes in the doubly linked list, which maintains the usage order with the most recently used at the tail and least recently used at the head. On get or put, the accessed node is moved to the tail to mark it as recently used. When capacity is exceeded, the head node is removed and its key deleted from the hash map. This design ensures all operations run in O(1).

Optimal Approach

Implement the cache with a hash map for O(1) key-to-node lookup and a doubly linked list to track usage order. Define helper methods to remove a node and insert a node at the tail. On get, if the key exists, remove the node from its current position and re-insert it at the tail, then return its value. On put, if the key exists, remove the old node, then insert the new node at the tail. If capacity is exceeded, remove the head node and delete its key from the hash map. This approach guarantees O(1) time complexity for both operations.

Time

O(1)

All operations (get, put, remove, insert) involve constant-time hash map lookups and pointer updates in the doubly linked list, ensuring O(1) time per operation.

Space

O(capacity)

The hash map and doubly linked list store at most capacity number of nodes, so space scales linearly with the cache capacity.

Pattern Spotlight

Design & Composition (Hash Map + Doubly Linked List)

Combine a hash map for fast key lookup with a doubly linked list to maintain usage order, enabling O(1) access, insertion, and eviction by moving nodes to the tail on access and removing from the head on capacity overflow.

Solution

1class Node:
2 def __init__(self, key, val):
3 self.key, self.val = key, val
4 self.prev = self.next = None
5
6class LRUCache:
7 def __init__(self, capacity: int):
8 self.cap = capacity
9 self.cache = {}
10 self.left, self.right = Node(0, 0), Node(0, 0)
11 self.left.next, self.right.prev = self.right, self.left
12
13 def remove(self, node):
14 prev, nxt = node.prev, node.next
15 prev.next, nxt.prev = nxt, prev
16
17 def insert(self, node):
18 prev, nxt = self.right.prev, self.right
19 prev.next = nxt.prev = node
20 node.next, node.prev = nxt, prev
21
22 def get(self, key: int) -> int:
23 if key in self.cache:
24 self.remove(self.cache[key])
25 self.insert(self.cache[key])
26 return self.cache[key].val
27 return -1
28
29 def put(self, key: int, value: int) -> None:
30 if key in self.cache:
31 self.remove(self.cache[key])
32 self.cache[key] = Node(key, value)
33 self.insert(self.cache[key])
34
35 if len(self.cache) > self.cap:
36 lru = self.left.next
37 self.remove(lru)
38 del self.cache[lru.key]

Step-by-Step Solution

1

Define Node Structure for Doubly Linked List Entries

3self.key, self.val = key, val
4self.prev = self.next = None

Objective

To represent each cache entry as a doubly linked list node storing its key, value, and neighbor pointers.

Key Insight

The cache map points to nodes, not raw values. Each node must therefore carry its key for O(1) eviction cleanup and its prev/next links for O(1) removal and insertion.

Interview Quick-Check

Core Logic

Each cache entry needs both payload fields and doubly linked list pointers so it can move in O(1).

Common Pitfalls & Bugs

If the node does not store its key, eviction cannot remove the matching map entry in O(1).

2

Initialize Cache State with Dummy Head and Tail Nodes

8self.cap = capacity
9self.cache = {}
10self.left, self.right = Node(0, 0), Node(0, 0)
11self.left.next, self.right.prev = self.right, self.left

Objective

To set up the cache capacity, hash map, and doubly linked list with sentinel nodes to simplify insertion and removal operations.

Key Insight

Using dummy head and tail nodes avoids edge cases when inserting or removing nodes at the boundaries of the doubly linked list. The hash map stores key-node pairs for O(1) lookup. This initialization creates a clean, consistent structure that supports efficient updates.

Interview Quick-Check

Core Logic

Dummy head and tail nodes simplify linked list operations by eliminating null checks and special cases for empty or single-element lists.

State & Boundaries

The cache capacity is fixed at initialization and the hash map starts empty.

Common Pitfalls & Bugs

Forgetting to link dummy head and tail nodes correctly can cause insertion/removal bugs.

3

Remove a Node from the Doubly Linked List

14prev, nxt = node.prev, node.next
15prev.next, nxt.prev = nxt, prev

Objective

To unlink a node from its current position in the doubly linked list in O(1) time.

Key Insight

Removing a node requires updating the previous node's next pointer and the next node's previous pointer to bypass the node. This operation is O(1) because the node has direct references to its neighbors, enabling quick removal without traversal.

Interview Quick-Check

Core Logic

Directly updating the pointers of adjacent nodes removes the target node from the list in constant time.

Common Pitfalls & Bugs

Failing to update both prev.next and next.prev pointers leads to corrupted list structure.

4

Insert a Node at the Tail to Mark as Most Recently Used

18prev, nxt = self.right.prev, self.right
19prev.next = nxt.prev = node
20node.next, node.prev = nxt, prev

Objective

To insert a node right before the dummy tail node, marking it as the most recently used item.

Key Insight

Inserting at the tail maintains the order of usage with the most recently used nodes at the end. This involves updating the previous tail's next pointer and the tail's previous pointer to include the new node, all in O(1) time.

Interview Quick-Check

Core Logic

Inserting before the dummy tail node ensures the node is the most recently used, preserving LRU order.

Common Pitfalls & Bugs

Incorrect pointer assignments can break the list or cause cycles.

5

Retrieve Value and Update Usage on Get Operation

23if key in self.cache:
24 self.remove(self.cache[key])
25 self.insert(self.cache[key])
26 return self.cache[key].val
27return -1

Objective

To return the value associated with a key if it exists and update its position to the most recently used.

Key Insight

Accessing a key requires moving its node to the tail to mark it as recently used. This involves removing the node from its current position and reinserting it at the tail. If the key is not found, return -1. This maintains the correct LRU order after every access.

Interview Quick-Check

Core Logic

Moving the accessed node to the tail updates its usage status, ensuring the cache correctly tracks recency.

State & Boundaries

Return -1 immediately if the key is not present in the cache.

Common Pitfalls & Bugs

Failing to move the node on get leads to incorrect eviction order.

6

Insert or Update Key-Value Pair and Evict if Necessary on Put

30if key in self.cache:
31 self.remove(self.cache[key])
32self.cache[key] = Node(key, value)
33self.insert(self.cache[key])
35if len(self.cache) > self.cap:
36 lru = self.left.next
37 self.remove(lru)
38 del self.cache[lru.key]

Objective

To insert a new key-value pair or update an existing one, move it to the most recently used position, and evict the least recently used item if capacity is exceeded.

Key Insight

If the key exists, remove its old node before inserting the updated node at the tail. If the cache size exceeds capacity, remove the head's next node (the least recently used) and delete its key from the hash map. This maintains the cache size and usage order efficiently.

Interview Quick-Check

Core Logic

Evicting the least recently used node from the head and deleting its key maintains the cache size within capacity.

State & Boundaries

Check cache size after insertion to decide if eviction is needed.

Common Pitfalls & Bugs

Forgetting to remove the old node when updating a key causes duplicate nodes and corrupts usage order.

Line Analysis

This solution has 6 Critical lines interviewers watch for.

Line 15 Critical
prev.next, nxt.prev = nxt, prev

Update pointers to bypass the target node, removing it from the list.

This pointer update is the critical operation that removes a node in constant time, enabling efficient cache updates without list traversal.

Line 9 Critical
self.cache = {}

Initialize the hash map to store key-node mappings.

The hash map enables O(1) lookup of nodes by key, which is essential for efficient get and put operations.

Line 19 Critical
prev.next = nxt.prev = node

Link the previous node and dummy tail to the new node.

This dual pointer assignment is essential to correctly insert the node at the tail, ensuring the list remains well-formed and the node is recognized as most recently used.

Line 24 Critical
self.remove(self.cache[key])

Remove the node associated with the key from its current position.

This removal is critical to avoid duplicate nodes and to update the node's position to most recently used, preserving the LRU semantics.

Line 31 Critical
self.remove(self.cache[key])

Remove the existing node for the key if present.

This removal is essential to maintain correct usage order and avoid stale nodes when updating values.

Line 37 Critical
self.remove(lru)

Remove the least recently used node from the doubly linked list.

This removal is the key eviction step that ensures the cache does not exceed capacity while preserving LRU order.

Line 4 Core
self.prev = self.next = None

Initialize the node's previous and next pointers.

Initializing prev and next to None creates a detached node that can later be inserted safely into the doubly linked list.

Line 10 Core
self.left, self.right = Node(0, 0), Node(0, 0)

Create dummy head and tail nodes for the doubly linked list.

Dummy nodes simplify insertion and removal by eliminating edge cases at the boundaries of the list.

Line 20 Core
node.next, node.prev = nxt, prev

Set the new node's next and previous pointers to link it into the list.

Completing the bidirectional links integrates the new node fully into the doubly linked list.

Line 25 Core
self.insert(self.cache[key])

Insert the node at the tail to mark it as most recently used.

Reinserting at the tail updates the usage order to reflect recent access.

Line 32 Core
self.cache[key] = Node(key, value)

Create a new node with the key and value and store it in the hash map.

Storing the new node in the hash map enables O(1) lookup and associates the key with its node.

Line 33 Core
self.insert(self.cache[key])

Insert the new node at the tail to mark it as most recently used.

Inserting at the tail updates the usage order to reflect the most recent insertion or update.

Line 36 Core
lru = self.left.next

Identify the least recently used node (the node after the dummy head).

The node immediately after the dummy head is the least recently used and candidate for eviction.

Line 38 Core
del self.cache[lru.key]

Delete the evicted node's key from the hash map.

Removing the key from the hash map completes the eviction, preventing stale references and enabling correct future lookups.

Line 3 Core
self.key, self.val = key, val

Store the entry key and value inside the node.

Each cache entry must store its key and value together so the list node can serve as the payload referenced by the hash map and used during eviction.

Line 11 Core
self.left.next, self.right.prev = self.right, self.left

Link dummy head and tail nodes to each other.

Establishing the initial empty list structure ensures that insertions and removals can be performed uniformly.

Line 14 Core
prev, nxt = node.prev, node.next

Retrieve the previous and next nodes of the target node.

Accessing adjacent nodes is necessary to unlink the target node from the list in O(1) time.

Line 18 Core
prev, nxt = self.right.prev, self.right

Identify the node currently before the dummy tail and the dummy tail itself.

These pointers define the insertion point for the new node to mark it as most recently used.

Line 23 Core
if key in self.cache:

Check if the key exists in the cache.

Determining presence allows the algorithm to update usage order and value correctly.

Line 30 Core
if key in self.cache:

Check if the key already exists in the cache.

This check determines whether to update an existing node or insert a new one.

Line 35 Core
if len(self.cache) > self.cap:

Check if the cache size exceeds its capacity.

This condition triggers eviction of the least recently used item to maintain capacity constraints.

Line 8 Standard
self.cap = capacity

Store the cache capacity.

The capacity defines the maximum number of items the cache can hold, which is critical for eviction logic.

Line 26 Standard
return self.cache[key].val

Return the value associated with the key.

Returning the stored value fulfills the get operation's contract.

Line 27 Standard
return -1

Return -1 if the key is not found in the cache.

Indicating absence with -1 is required by the problem specification.

Test Your Understanding

Why does combining a hash map with a doubly linked list enable O(1) time complexity for both get and put operations in an LRU cache?

The hash map provides O(1) access to nodes by key, while the doubly linked list maintains the order of usage and supports O(1) insertion and removal of nodes. Together, they allow quick updates to usage order and efficient eviction of the least recently used item.

Don't just read it. Drill it.

Reconstruct LRU Cache from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the LRU Cache drill

or drill a free problem