In this post, we will discuss some of the ways by which we can implement LRU cache. This post assumes that you have the basic knowledge of an LRU cache. So, we would focus on the implementation side. We will navigate from the laziest to the ideal approach to solve the problem in the context of an interview.
Using LinkedHashMap
We know that LRU requires ordering the elements in access order. So, we need a data structure that will enable us to maintain the access order. A cache demands get
and put
operations to be executed in constant time. We know that LinkedHashMap
follows insertion order, but how to achieve access order
?
Fortunately, LinkedHashMap
gives us the option to specify the access order
in place of insertion order
. So, the implementation would look like below:
class LRUCache<K,V>{
int capacity;
LinkedHashMap<K,V> data;
public LRUCacheExamples(int capacity){
this.capacity = capacity;
this.data = new LinkedHashMap<>(capacity,0.8f,true){
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
};
}
}
The third argument in the LinkedHashMap
constructor denotes the access order
.true
indicates that we want to follow access order
. It will follow the insertion order unless we specify it to true
.
Note that, the elements will be ordered from least recently accessed
to most recently accessed
. For example, if we insert some dummy values to LinkedHashMap
as below:
cache.put("abc", "abc");
cache.put("cde", "cde");
cache.put("efg", "efg");
Now, it will be in this order ->
abc -> abc, cde -> cde, efg -> efg
If we do cache.get("cde")
or cache.put("cde", "someOtherValue")
then, the order will be (least recently
to most recently
used)->
abc -> abc, efg -> efg , cde -> someOtherValue
Also, note that we have overridden the method removeEldestEntry
so that the cache automatically removes the eldest element (least recently used
).
As you might guess, this is a pretty quick and laziest way to achieve LRU functionality. Do not mention this solution upfront in an actual interview unless the interviewer explicitly asks for a simple solution. In most cases, the interviewer would want you to design the data structure on your own.
Designing an LRU cache from scratch
Before we jump into the solution, let's re-iterate the requirements once:
-> get()/put()
operations are expected to have constant runtime.
-> Space complexity should not exceed O(n)
where n
is the total number of elements in the cache. A cache has to be fast and space efficient as well.
-> Should be able to remove elements from the cache in constant time.
Both get()
and put()
operations would need to push the data to the front & cache should not evict the least recently used elements when the maximum capacity is exceeded.
We need to perform remove()
/put()
operations to ensure the access order
. So, what is the data structure that can do insertion
/removal
in constant time? Yes, it is linked list
:) Below is a simple class that can act as a data node in the linked list
.
class Node<K,V>{
K key;
V data;
Node<K,V> next;
Node<K,V> prev;
public Node(K key, V data){
this.key = key;
this.data = data;
}
}
Now, there are multiple ways to store the node: for example, using a LinkedList
or Deque
from Java. The advantage is that we can make use of their readymade methods. That's up to the interviewer if he/she wants to do it that way. It is always better to expect the worst and prepare accordingly. So, be prepared to implement it from scratch.
Given the above Node
class, we can go ahead and create the actual implementation for the cache. So, the implementation class would look like below:
public class LRUCache<K,V> {
Node<K,V> head;
Node<K,V> tail;
Map<K,Node<K,V>> dataMap;
int size;
int max;
public LRUCache(int max){
dataMap = new HashMap<>();
this.max = max;
}
}
We use a Map
to store the data and achieve constant runtime for get()
operation. Other member variables are self-explanatory.
We need to add some helper functions first.
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
public boolean canEvict() {
return size == max;
}
private void moveToHead(Node < K, V > node) {
node.prev = null;
node.next = head;
head.prev = node;
head = head.prev;
}
The method moveToHead()
will place the node in the head position (Note: node is not null
).
Let's discuss the implementation of remove()
first. Because we will be re-using it on multiple occasions to maintain access order
. Take a look at the below code for remove()
method:
public V remove(K key){
if(head==null || tail==null){
throw new RuntimeException("Cache is empty!");
}
Node<K,V> node = dataMap.remove(key);
if(node == null){
throw new RuntimeException("No mappings found!");
}
V value = node.data;
if(node.prev != null){
node.prev.next = node.next;
}else{
head = head.next;
}
if(node.next != null){
node.next.prev = node.prev;
}else{
tail = tail.prev;
}
size--;
return value;
}
If we need to remove a node
which is head
, then it means that node.prev
is null
.
So, we update the head
-> head = head.next
.
Similarly, if the node
is tail
, then it means that node.next
is null. So, we update the tail
-> tail = tail.prev
.
Otherwise, we are sure that the node
is an intermediate node. So, we made the adjustments accordingly. In the above implementation of remove()
, we are making pointer adjustments only and thereby constant runtime.
Let's implement the evict
functionality next. It is a requirement before we implement the put()
method. Take a look at the below code for LRU eviction policy:
private void evict() {
if (head == null || tail == null) {
throw new RuntimeException("Cache is empty!");
}
remove(tail.key);
}
evict()
is a private method since it is an internal decision to evict a cache. We just have to re-use the earlier discussed remove functionality. We remove the tail
, because tail
is the least recently used node among others.
Now that we implemented all the required methods, let's discuss the implementation of the put()
method. Take a look at the below code:
public K put(K key, V value) {
Node < K, V > current = dataMap.get(key);
if (current != null) {
current.data = value;
if (current.prev != null) {
current.prev.next = current.next;
moveToHead(current);
}
} else {
if (canEvict()) {
evict();
}
current = new Node < K, V > (key, value);
if (head == null || tail == null) {
head = tail = current;
} else {
moveToHead(current);
}
size++;
}
dataMap.put(key, current);
return key;
}
Let's dissect the code for more understanding. Consider the below block of code:
if (current != null) {
current.data = value;
if (current.prev != null) {
current.prev.next = current.next;
moveToHead(current);
}
}
In the case of an existing key, we just need to move the data to the head position.
If the element is head
itself, then we just have to update the value :)
In any other case, we make the adjustment current.prev.next = current.next;
and move it to the head position.
Now onto the other case, where we add brand new data into the cache:
else {
if (canEvict()) {
evict();
}
current = new Node < K, V > (key, value);
if (head == null || tail == null) {
head = tail = current;
} else {
moveToHead(current);
}
size++;
}
dataMap.put(key,current);
Before we add data to the cache, we need to see if we exceed the maximum cache capacity. If yes, we need to evict first and then proceed. We move the data to the head position as well. Finally, we insert the data and increment size
.
Finally, let's discuss the get()
method implementation from the below code:
public V get(K key) {
Node < K, V > node = dataMap.get(key);
if (node == null) {
throw new RuntimeException("No mappings found!");
} else {
if (node.prev != null) {
node.prev.next = node.next;
if (node.next != null) {
node.next.prev = node.prev;
} else {
tail = tail.prev;
}
moveToHead(node);
}
}
return node.data;
}
The node can be either head
, tail
or an intermediate node. If the key
is corresponding to head
node, then we don't have to adjust the pointers as the data is already at the head position. Note that node.prev
is always null for a head
node.
If the node is tail
node, then we also need to update tail
value. Finally, we move the node to the head position.
Further improvements to explore
- Supporting multi-thread environment: Making it thread-safe and concurrent data structure.
Hope this post is helpful and the full source code can be viewed here.