1. Introduction
What is an LRU Cache?
LRU stands for Least Recently Used. An LRU Cache is a fixed-size data structure that stores a limited number of key-value pairs. When the cache is full and a new item needs to be inserted, it evicts (removes) the item that was used least recently β i.e., the item that hasn't been accessed for the longest time.
In simple words:
- The cache has a capacity (maximum number of items it can hold).
- Every time you access or insert an item, that item becomes the "most recently used."
- When the cache is full and a new item comes in, the "least recently used" item is thrown out to make space.
π Where is LRU Cache Used in Real-World Systems?
LRU is not just a textbook exercise β it powers real systems you use every day:
| System |
How LRU Helps |
| Operating Systems |
Uses LRU-based page replacement algorithms to decide which memory page should be swapped out when RAM becomes full. |
| Databases |
Manages buffer pools and page caches (e.g., MySQL InnoDB Buffer Pool) by evicting the least recently accessed pages. |
| Content Delivery Networks (CDNs) |
Removes the least recently requested web content to make room for newer, frequently accessed files. |
| Web Browsers |
Caches recently visited web pages, images, and other resources to improve loading speed and reduce network requests. |
| Redis / Memcached |
Provides built-in LRU eviction policies to automatically remove old cache entries when memory limits are reached. |
| Mobile Applications |
Image loading libraries like Glide and Picasso use LRU caches to store recently viewed images for faster rendering. |
| CPU Caches |
Many processors use approximate LRU replacement policies to retain frequently accessed data in high-speed cache memory. |
π Note: This problem is popular precisely because it tests multiple skills at once β hash maps, linked lists, pointer manipulation, and time-complexity reasoning β all in one question.
2. Problem Statement
Design a data structure that follows the constraints of a Least Recently Used (LRU) Cache.
Implement the LRUCache class:
LRUCache(int capacity) β Initialize the LRU cache with positive size capacity.int get(int key) β Return the value of the key if it exists, otherwise return -1.void put(int key, int value) β Update the value of the key if it exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds the capacity from this operation, evict the least recently used key.
The functions get and put must each run in O(1) average time complexity.
Explaining the Cache Capacity
The capacity is the maximum number of key-value pairs the cache can hold at any time. Once this limit is reached, inserting a new key requires evicting an old one β specifically, the one that was least recently accessed.
Explaining get(key)
- If
key exists in the cache β return its value and mark it as recently used. - If
key does not exist β return -1 (cache miss).
Explaining put(key, value)
- If
key already exists β update its value and mark it as recently used. - If
key doesn't exist:- If the cache is not full, simply insert the new pair.
- If the cache is full, evict the least recently used item first, then insert the new pair.
Eviction Policy
π§ Key Rule: "Recently used" means recently accessed via get() OR recently inserted/updated via put(). Any touch to a key refreshes its recency.
The eviction always removes the item that has gone the longest without being touched.
3. Examples
Example 1
Input:
LRUCache lru = new LRUCache(2);
lru.put(1, 1);
lru.put(2, 2);
lru.get(1);
lru.put(3, 3);
lru.get(2);
lru.put(4, 4);
lru.get(1);
lru.get(3);
lru.get(4);
Explanation with cache state after every operation (Most Recently Used β Least Recently Used, left to right):
| Step |
Operation |
Cache State (MRU β LRU) |
Returned Value |
| 1 |
put(1, 1) |
{1 = 1} |
β |
| 2 |
put(2, 2) |
{2 = 2, 1 = 1} |
β |
| 3 |
get(1) |
{1 = 1, 2 = 2} |
1 |
| 4 |
put(3, 3) |
{3 = 3, 1 = 1} (2 evicted) |
β |
| 5 |
get(2) |
{3 = 3, 1 = 1} |
-1 |
| 6 |
put(4, 4) |
{4 = 4, 3 = 3} (1 evicted) |
β |
| 7 |
get(1) |
{4 = 4, 3 = 3} |
-1 |
| 8 |
get(3) |
{3 = 3, 4 = 4} |
3 |
| 9 |
get(4) |
{4 = 4, 3 = 3} |
4 |
Example 2 (Capacity = 1)
LRUCache lru = new LRUCache(1);
lru.put(5, 100);
lru.get(5);
lru.put(6, 200);
lru.get(5);
lru.get(6);
With capacity 1, every new key evicts the previous one, since only one item can exist at a time.
4. Constraints
Typical constraints (as seen on LeetCode #146):
1 <= capacity <= 30000 <= key <= 10^40 <= value <= 10^5- At most
2 * 10^5 calls will be made to get and put. - Both
get and put must run in O(1) average time complexity.
β οΈ Important: The O(1) requirement is the whole point of this problem. Any solution that uses O(n) search or O(n) deletion will technically produce correct output but will fail on time limits for large inputs (up to 2Γ10β΅ operations).
5. Intuition
Before writing any code, let's build the right mental model.
π« Why Normal Arrays Are Inefficient
If we store (key, value) pairs in a plain array or vector:
- To find a key, we must scan the array β O(n) search.
- To track recency, we'd need to move accessed elements to the front or back β O(n) shifting.
- To delete the least recently used element (which could be anywhere), we'd need to shift all subsequent elements β O(n) deletion.
This clearly violates the O(1) requirement.
π Why Searching Becomes Expensive
Without an index (like a hash map), locating a key means checking every element one by one. As the cache grows toward its capacity limit (up to 3000, and total operations up to 2Γ10β΅), linear search becomes a bottleneck.
βοΈ Why Deletion From the Middle Is Costly
Even if we knew where an element was, removing it from the middle of an array requires shifting all elements after it to fill the gap β again O(n).
This is why arrays (and even singly linked lists, as we'll see) struggle here: deletion from an arbitrary position is expensive unless we use a structure that supports O(1) removal given a pointer to the node.
β‘ Why We Need Constant Time Operations
Since the problem explicitly demands O(1) average time for both get and put, we need:
- O(1) lookup β solved by a HashMap.
- O(1) insertion/deletion at arbitrary positions β solved by a Doubly Linked List.
Neither structure alone is sufficient β we need both, working together.
πΊοΈ How HashMap Helps
A HashMap<key, Node*> lets us jump directly to the node holding a given key in O(1) average time, instead of searching for it. This solves the "search" problem instantly.
But a HashMap alone can't tell us which key was least recently used β hash maps have no inherent ordering. That's where the linked list comes in.
π Why a Doubly Linked List Is Required
We need a structure that maintains usage order: most recently used at one end, least recently used at the other. Whenever a key is accessed, we move its node to the "most recently used" end.
A Doubly Linked List (DLL) allows:
- O(1) removal of a node if we already have a pointer to it (because we can directly access both
prev and next pointers). - O(1) insertion at the front (most recently used position).
- O(1) access to the tail's neighbor (least recently used) for eviction.
β Why a Singly Linked List Is Insufficient
In a Singly Linked List, each node only knows its next node β not its previous one.
To remove a node from a singly linked list, you need the previous node's reference to skip over it (prev->next = node->next). Since we don't have a prev pointer, we'd have to traverse from the head to find it β O(n).
This defeats the purpose. Only a Doubly Linked List gives us O(1) deletion given just a pointer to the node itself, which is exactly what our HashMap provides us.
β Singly Linked List
Head
β
A
β
B
β
C
β
NULL
Deleting Node B
To delete B, we first need to find its previous node (A) by traversing
from Head.
β±οΈ Time Complexity: O(n)
β
Doubly Linked List
Head
β
A
β
B
β
C
β
Tail
Deleting Node B
Every node stores both prev and next.
We simply reconnect:
A β C
β±οΈ Time Complexity: O(1)
6. Brute Force Approach
Idea
Use a simple vector (or array) of (key, value) pairs. To track recency, whenever a key is accessed, remove it from its current position and push it to the front (or back) of the vector. Eviction removes the last element.
Step-by-Step Algorithm
- Maintain a
vector<pair<int,int>> cache and an integer capacity. get(key):- Linearly scan the vector for
key. - If found, remember its value, erase it from its current position, and re-insert it at the front.
- If not found, return
-1.
put(key, value):- Linearly scan for
key. - If found, erase the old entry.
- If the vector size equals
capacity (and we're inserting a brand-new key), remove the last element (least recently used). - Insert
(key, value) at the front.
Dry Run
| Step |
Operation |
Vector State (Front = MRU) |
| 1 |
put(1, 1) |
[(1,1)] |
| 2 |
put(2, 2) |
[(2,2), (1,1)] |
| 3 |
get(1) β returns 1 |
[(1,1), (2,2)] |
| 4 |
put(3, 3) β evicts (2,2) |
[(3,3), (1,1)] |
C++ Implementation (Brute Force)
#include <bits/stdc++.h>
using namespace std;
class LRUCacheBrute {
private:
int capacity;
vector<pair<int,int>> cache;
public:
LRUCacheBrute(int cap) : capacity(cap) {}
int get(int key) {
for (int i = 0; i < (int)cache.size(); i++) {
if (cache[i].first == key) {
pair<int,int> entry = cache[i];
cache.erase(cache.begin() + i);
cache.insert(cache.begin(), entry);
return entry.second;
}
}
return -1;
}
void put(int key, int value) {
for (int i = 0; i < (int)cache.size(); i++) {
if (cache[i].first == key) {
cache.erase(cache.begin() + i);
break;
}
}
if ((int)cache.size() == capacity) {
cache.pop_back();
}
cache.insert(cache.begin(), {key, value});
}
};
Time Complexity
get() β O(n) (linear search + shifting)put() β O(n) (linear search + shifting + possible eviction)
Space Complexity
- O(capacity) for storing the pairs.
Drawbacks
- β Violates the O(1) requirement completely.
- β Every operation involves shifting elements β very slow for large inputs (up to 2Γ10β΅ operations).
- β Would Time Limit Exceed (TLE) on platforms like LeetCode for large test cases.
7. Better Approach
Idea
Improve lookup speed using a HashMap for O(1) key lookup, but still use a singly linked list (or a list with only "next" pointers) to track order. This partially optimizes search but deletion is still slow because we can't jump backward.
Data Structures Used
unordered_map<int, value> for value lookup.- A singly linked list (or
list<int> used naively) to track usage order, where we still need to search for a node's position to remove it.
Algorithm
- Maintain
unordered_map<int,int> valueMap for key -> value. - Maintain a singly linked list (or
std::list<int>) storing keys in order of use (front = most recent). get(key):- If key not in
valueMap, return -1. - Otherwise, traverse the list to find the key's node, remove it, and reinsert at the front. (This traversal is the bottleneck.)
- Return
valueMap[key].
put(key, value):- If the key exists, update the value and move it to front (again requires traversal in a singly linked structure to find the
prev node). - If the key is new and cache is full, remove the tail key from both the list and hashmap.
- Insert new key at front, update hashmap.
Dry Run
capacity = 2
| Step |
Operation |
HashMap |
List (Front = MRU) |
| 1 |
put(1, 1) |
{1 : 1} |
[1] |
| 2 |
put(2, 2) |
{1 : 1, 2 : 2} |
[2, 1] |
| 3 |
get(1) |
{1 : 1, 2 : 2} |
[1, 2]
Traversed the list to find and move 1 to the front.
|
| 4 |
put(3, 3) |
{1 : 1, 3 : 3} |
[3, 1]
Capacity exceeded β evicted 2 from the tail.
|
Note: We are not providing a full implementation of the Better Approach because it is not commonly implemented in interviews. Its purpose is to illustrate why using a HashMap + Linked List alone is still insufficient. Since the HashMap stores only the values and not the node locations, we must traverse the linked list to locate a key before moving it to the front, resulting in O(n) time for get() and updating an existing key in put(). Understanding this limitation is more important than memorizing the code, as interviewers typically expect the optimal O(1) solution using a HashMap that stores node pointers along with a Doubly Linked List.
int get(int key) {
if (valueMap.find(key) == valueMap.end())
return -1;
removeKeyFromOrder(key);
order.insert(order.begin(), key);
return valueMap[key];
}
Complexity
get() β O(n) in the worst case (due to findInList scan), though hashmap lookup itself is O(1).put() β O(n) in the worst case for the same reason.- Space β O(capacity).
Pros and Cons
Pros:
- β
Value lookup (
valueMap.find) is O(1) β a genuine improvement over brute force. - β
Simpler mental model than full DLL + hashmap.
Cons:
- β We still don't have O(1) positional access β we know the value instantly, but not where the node lives in the ordering list, so reordering still costs O(n).
- β Still fails the strict O(1)
get/put requirement.
π‘ The missing piece: we need the hashmap to store not just the value, but a direct pointer to the node in the list β so we can splice it out in O(1) without any search. That's exactly what the optimal approach does.
8. Optimal Approach
Core Intuition
The fix to the "Better Approach" is simple but powerful: instead of storing key β value in the hashmap, store key β node pointer. That way, given any key, we can jump directly to its node in the linked list in O(1) β no traversal needed β and since it's a doubly linked list, we can unlink and relink it in O(1) too.
Why HashMap + Doubly Linked List Is Optimal
| Requirement |
Data Structure That Solves It |
Why? |
| O(1) lookup of a key's node |
unordered_map<int, Node*> |
Maps each key directly to its corresponding node, eliminating the need to search the linked list. |
| O(1) removal of a node from anywhere |
Doubly Linked List |
Each node stores both prev and next, allowing it to be removed by updating just two pointers. |
| O(1) insertion at the Most Recently Used (MRU) position |
Doubly Linked List (Insert at Head) |
A newly accessed or inserted node is placed immediately after the head in constant time. |
| O(1) access to the Least Recently Used (LRU) node for eviction |
Doubly Linked List (Tail's Previous Node) |
The least recently used node is always located just before the tail, making eviction an O(1) operation. |
Together, these two structures give us true O(1) average time for every operation.
Structure of Each Node
Each node in the doubly linked list stores:
class Node {
public:
int key;
int value;
Node* prev;
Node* next;
};
π Why store key inside the node? When we evict the least recently used node, we only have a Node* pointer (from the tail side) β we don't automatically know its key. Storing the key inside the node lets us do cache.erase(node->key) in O(1) without any extra lookup.
Head and Tail Dummy Nodes
We maintain two dummy (sentinel) nodes:
head β a placeholder marking the most recently used side.tail β a placeholder marking the least recently used side.
Real nodes always live between head and tail.
- The node right after
head is always the most recently used. - The node right before
tail is always the least recently used (eviction target).
Why Dummy Nodes Simplify Implementation
Without dummy nodes, we'd need to constantly check "is this the first node?" or "is this the last node?" with if (head == nullptr) style edge-case handling scattered everywhere.
With dummy head and tail nodes:
- Insertion at the front is always
insertAfterHead(node) β no special case for an empty list. - Eviction is always
tail->prev β no special case for checking if the list is empty (as long as capacity >= 1 and we only evict when full). - Removal never needs to check
if node->prev == nullptr β because a real node's prev is never nullptr; it's at worst the head dummy.
β
Callout: Dummy nodes trade a tiny bit of extra memory (2 nodes) for eliminating an entire category of null-pointer edge cases. This is a widely used technique in linked-list based interview problems.
Every Operation, Explained Visually

Helper Functions Explained
removeNode(Node* node)
Unlinks a node from wherever it currently sits in the list by rewiring its neighbors to point to each other.
void removeNode(Node* node) {
Node* prevNode = node->prev;
Node* nextNode = node->next;
prevNode->next = nextNode;
nextNode->prev = prevNode;
}
This works regardless of where the node is (front, middle, or just before tail) β that's the power of a doubly linked list. Runs in O(1).
insertAfterHead(Node* node)
Inserts a node right after the dummy head, making it the new "most recently used" node.
void insertAfterHead(Node* node) {
Node* nextNode = head->next;
head->next = node;
node->prev = head;
node->next = nextNode;
nextNode->prev = node;
}
Runs in O(1) β always a fixed 4-pointer rewire.
moveToFront(Node* node)
Simply combines the two helpers above: remove the node from its current spot, then reinsert it right after head.
void moveToFront(Node* node) {
removeNode(node);
insertAfterHead(node);
}
This is called whenever a key is accessed via get() or updated via put(), since both actions make that key the most recently used.
9. Complete Algorithm
get(key) β Step by Step
- Check if
key exists in the hashmap. - Cache miss: If not found, return
-1. - Cache hit: If found:
- Retrieve the
Node* from the hashmap. - Call
moveToFront(node) to mark it as most recently used. - Return
node->value.
put(key, value) β Step by Step
- Key already exists:
- Retrieve the
Node* from the hashmap. - Update
node->value = value. - Call
moveToFront(node).
- New key:
- Check capacity: If
cache.size() == capacity (cache is full):- Identify the least recently used node:
tail->prev. - Remove it from the linked list using
removeNode(). - Erase its key from the hashmap.
- Delete the node (free memory).
- Create a new
Node(key, value). - Insert it into the hashmap:
cache[key] = newNode. - Call
insertAfterHead(newNode) to place it as most recently used.
π Note: The order of "evict first, then insert" vs "insert first, then evict" doesn't matter functionally as long as you correctly check capacity before adding a brand-new key β never evict when simply updating an existing key.
10. Dry Run
Let's trace through the exact example requested, with Capacity = 2.
Operations: put(1,1) β put(2,2) β get(1) β put(3,3) β get(2) β put(4,4) β get(1) β get(3) β get(4)
| Step |
Operation |
HashMap |
Linked List (Head β Tail, MRU β LRU) |
Cache Contents |
Returned Value |
| 1 |
put(1, 1) |
{1 β Node(1,1)} |
Head β (1,1) β Tail |
{1 = 1} |
β |
| 2 |
put(2, 2) |
{1 β Node(1,1), 2 β Node(2,2)} |
Head β (2,2) β (1,1) β Tail |
{2 = 2, 1 = 1} |
β |
| 3 |
get(1) |
Same |
Head β (1,1) β (2,2) β Tail |
{1 = 1, 2 = 2} |
1 |
| 4 |
put(3, 3) |
Evict 2 β {1 β Node(1,1), 3 β Node(3,3)} |
Head β (3,3) β (1,1) β Tail |
{3 = 3, 1 = 1} |
β |
| 5 |
get(2) |
2 not present |
Unchanged: Head β (3,3) β (1,1) β Tail |
{3 = 3, 1 = 1} |
-1 |
| 6 |
put(4, 4) |
Evict 1 β {3 β Node(3,3), 4 β Node(4,4)} |
Head β (4,4) β (3,3) β Tail |
{4 = 4, 3 = 3} |
β |
| 7 |
get(1) |
1 not present |
Unchanged: Head β (4,4) β (3,3) β Tail |
{4 = 4, 3 = 3} |
-1 |
| 8 |
get(3) |
Same |
Head β (3,3) β (4,4) β Tail |
{3 = 3, 4 = 4} |
3 |
| 9 |
get(4) |
Same |
Head β (4,4) β (3,3) β Tail |
{4 = 4, 3 = 3} |
4 |
Step-by-step reasoning for the tricky evictions:
- Step 4: Before inserting key 3, cache size is already 2 (
= capacity). The LRU node (tail->prev) is key 2 (since key 1 was refreshed in step 3, making key 2 the least recently used). So key 2 is evicted. - Step 6: Before inserting key 4, cache size is 2 again. The LRU node is key 1 (it hasn't been touched since step 3, while key 3 was touched in step 4). So key 1 is evicted.
C++ Code (Interview-Quality, Complete Implementation)
#include <unordered_map>
using namespace std;
class Node {
public:
int key;
int value;
Node* prev;
Node* next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
class LRUCache {
private:
int capacity;
unordered_map<int, Node*> cache;
Node* head;
Node* tail;
void removeNode(Node* node) {
Node* prevNode = node->prev;
Node* nextNode = node->next;
prevNode->next = nextNode;
nextNode->prev = prevNode;
}
void insertAfterHead(Node* node) {
Node* nextNode = head->next;
head->next = node;
node->prev = head;
node->next = nextNode;
nextNode->prev = node;
}
void moveToFront(Node* node) {
removeNode(node);
insertAfterHead(node);
}
public:
LRUCache(int cap) {
capacity = cap;
head = new Node(-1, -1);
tail = new Node(-1, -1);
head->next = tail;
tail->prev = head;
}
int get(int key) {
auto it = cache.find(key);
if (it == cache.end()) {
return -1;
}
Node* node = it->second;
moveToFront(node);
return node->value;
}
void put(int key, int value) {
auto it = cache.find(key);
if (it != cache.end()) {
Node* node = it->second;
node->value = value;
moveToFront(node);
return;
}
if ((int)cache.size() == capacity) {
Node* lru = tail->prev;
removeNode(lru);
cache.erase(lru->key);
delete lru;
}
Node* newNode = new Node(key, value);
cache[key] = newNode;
insertAfterHead(newNode);
}
~LRUCache() {
Node* curr = head;
while (curr != nullptr) {
Node* next = curr->next;
delete curr;
curr = next;
}
}
};
11. Correctness Proof
We need to prove two things:
Claim 1: The node immediately after head is always the most recently used key, and the node immediately before tail is always the least recently used key.
Proof by invariant: We maintain the invariant that every time a key is touched (via get returning a hit, or put inserting/updating), we immediately call either insertAfterHead() (for new nodes) or moveToFront() (for existing nodes) β both of which place that node directly after head. Since this happens immediately after every touch, and no other operation reorders the list, the node after head is, by induction, always the most recently touched key at any point in time. Since the list is finite and ordered, the node before tail must be the one that has gone the longest without being touched β i.e., the least recently used.
Claim 2: Eviction always removes the correct (least recently used) key.
Proof: Eviction only happens when inserting a brand-new key while cache.size() == capacity. At that moment, by Claim 1, tail->prev is guaranteed to be the least recently used node among all currently cached keys. Removing it and erasing it from the hashmap keeps the hashmap and linked list perfectly synchronized β every key in the hashmap has exactly one corresponding node in the list, and vice versa.
Claim 3: get() and put() always reflect the true current value.
Proof: The hashmap always stores the up-to-date Node* for a key (nodes are updated in place via node->value = value, never replaced), so get() always reads the latest value. Since keys are never duplicated (we check cache.find(key) before creating a new node), each key maps to exactly one node at all times.
Together, these three claims guarantee the algorithm always returns the correct value for get() and always evicts the correct key during put().
12. Complexity Analysis
| Approach |
Time Complexity (get / put) |
Space Complexity |
| Brute Force (Vector) |
O(n) / O(n) |
O(capacity) |
| Better (HashMap + List with Linear Search) |
O(n) (worst case) / O(n) (worst case) |
O(capacity) |
| Optimal (HashMap + Doubly Linked List) |
O(1) / O(1) |
O(capacity) |
Why Every Operation Becomes O(1) in the Optimal Approach
- HashMap lookup (
cache.find(key)) β O(1) average, since hashing gives direct bucket access. removeNode() β O(1), because we directly access node->prev and node->next β no traversal needed.insertAfterHead() β O(1), because head is a fixed reference point β always a constant number of pointer updates.moveToFront() β O(1), since it's just removeNode() + insertAfterHead(), both O(1).- Eviction β O(1), since
tail->prev gives direct access to the LRU node without any search.
π The key insight: O(1) is achievable only because the hashmap gives us the node pointer directly β eliminating the need to search for a node's position before we can remove or reorder it.
15. Interview Tips
Here are common follow-up questions interviewers ask after you present the optimal solution:
- "Can you make this thread-safe?" Discuss using a
mutex to lock around get/put, and mention trade-offs (a global lock hurts concurrency; finer-grained locking is complex due to shared list pointers). - "How would you implement an LFU (Least Frequently Used) Cache instead?" Explain that LFU requires tracking frequency counts, typically using a hashmap of frequency β doubly linked lists (an extra layer of complexity).
- "What if capacity is 0?" Clarify with the interviewer β typically constraints guarantee
capacity >= 1, but you should handle it gracefully (e.g., every put immediately evicts, or you simply never insert). - "Can you use
std::list from STL instead of writing your own doubly linked list?" Yes β std::list<pair<int,int>> plus a hashmap of key -> list iterator achieves the same O(1) behavior, since std::list supports O(1) splicing given an iterator. Be ready to discuss why storing an iterator in the map (not just a value) is essential here. - "How would you scale this to a distributed cache?" Mention consistent hashing to distribute keys across multiple cache nodes, and that each node can run its own local LRU instance.
- "What's the space overhead of your solution?" Each node stores extra
prev/next pointers plus a hashmap entry, so it's roughly O(capacity) with a higher constant factor than a plain array β but this is the necessary trade-off for O(1) time.
π‘ Tip: Always mention why simpler approaches fail (arrays, singly linked lists) before jumping to the optimal solution β interviewers want to see your reasoning process, not just memorized code.
16. Frequently Asked Questions
Q1. What is the time complexity of LRU Cache operations? Both get() and put() run in O(1) average time complexity using the HashMap + Doubly Linked List approach.
Q2. Why can't we use only a HashMap for LRU Cache? A HashMap gives O(1) lookup but has no concept of ordering, so it cannot tell us which key was least recently used without additional bookkeeping.
Q3. Why can't we use only a Doubly Linked List without a HashMap? Without a hashmap, finding whether a key exists (and where its node is) would require traversing the list β O(n) β defeating the purpose.
Q4. Why is a Doubly Linked List used instead of a Singly Linked List? Because deletion requires access to a node's previous node too. A singly linked list only knows the next node, forcing an O(n) traversal to find the previous node before deletion.
Q5. What is the purpose of dummy head and tail nodes? They eliminate special-case handling for empty lists or single-node lists, simplifying insertAfterHead() and eviction logic significantly.
Q6. Can I implement LRU Cache using Java's LinkedHashMap? Yes β Java's LinkedHashMap has a built-in "access order" mode and can override removeEldestEntry() to implement LRU in just a few lines, since it internally maintains a doubly linked list already.
Q7. What happens if put() is called with a key that already exists? The value is updated, and the key is marked as most recently used β no eviction occurs since no new key is being added.
Q8. Is LRU Cache related to Operating Systems concepts? Yes β it's directly analogous to the page replacement algorithm used in virtual memory management, where the OS evicts the least recently used memory page when RAM is full.
Q9. What is the space complexity of the optimal LRU Cache solution? O(capacity), since we store exactly one hashmap entry and one linked list node per cached key (plus 2 constant-size dummy nodes).
Q10. How is LRU different from LFU (Least Frequently Used)? LRU evicts based on recency of access (when it was last used), while LFU evicts based on frequency of access (how many times it was used) β different eviction philosophies for different workloads.
Q11. Does the order of checking capacity vs. inserting matter in put()? No, as long as you only evict for brand-new keys and check capacity before the cache would exceed its limit β both "evict-then-insert" and equivalent logic produce identical results.
Q12. Can this problem be solved without a linked list, using an ordered map? In some languages, an ordered structure with efficient reordering (like Python's OrderedDict) can substitute for the manual doubly linked list, since it provides similar O(1) move-to-end/front semantics internally.
17. Key Takeaways
- π LRU Cache evicts the least recently used item when capacity is exceeded.
- π A HashMap alone is insufficient because it lacks ordering information.
- π A Doubly Linked List alone is insufficient because lookups would require O(n) traversal.
- π The optimal solution combines both: HashMap for O(1) lookup, Doubly Linked List for O(1) reordering and eviction.
- π Dummy head/tail nodes remove edge-case complexity from insertion and deletion logic.
- π Every key touch β whether via
get() or put() β must move that node to the "most recently used" position. - π Eviction always targets the node just before
tail, guaranteed by the maintained invariant. - π This problem is a favorite in interviews because it tests system design thinking at a small scale β recognizing which data structure combination satisfies strict time-complexity constraints.
π― Final tip: When solving this in an interview, always narrate your reasoning β explain why brute force fails, why HashMap alone fails, and how combining it with a Doubly Linked List solves every bottleneck. This is what separates a "correct" answer from a great answer.
video reference:
code link:
https://leetcode.com/problems/lru-cache/description/