Least Frequently Used is a cache algorithm used to manage memory within a computer. In this method, system keeps track of number of times a block is referenced in memory. The system removes the item with the lowest reference frequency when the cache is full.
Our goal is to design a data structure that follows the constraints of a Least Frequently Used(LFU) cache. Our LFU cache should support the following operations:
The frequency for a key in the cache is incremented when either a get or put operation is called on it. The operations get and put must each run in O(1) average time complexity.
We initialize an array of sizes equal to that of our cache. Each element stores the key and value along with the frequency and the time at which this key is accessed. We use this frequency and the timestamp to determine the least frequently used element.
class Element
{
int key;
int val,
int frequency;
int timeStamp
public Element(int k, int v, int time)
{
key = k;
val = v;
frequency = 1;
timeStamp = time;
}
}
int get(int key): We traverse the array and compare the key of each element in our cache with the given key. If we find the key of an element equal to the key, we increment the frequency of this element and update the timestamp. If we do not find any element, we return -1. Time complexity = O(n).
void put(int key, int value): If the array is not full, we create a new element with frequency 1 and timestamp 0. We increase the timestamp of the existing data in the array by 1. If the array is full, we must delete the least frequently used element. For this, we iterate through the array and find the element with the least frequency. In the case of frequency ties, we consider the least recently used element (oldest timestamp). Now we insert the new element.
First, we will implement the insert and access operations in O(1) time. We need two maps to achieve this, one to store the key-value pairs and the second one to store counts/frequency of access.
public class LFUCache
{
private Map<Integer, Integer> valueMap = new HashMap<>();
private Map<Integer, Integer> frequencyMap = new HashMap<>();
private final int size;
public LFUCache(int capacity)
{
size = capacity;
}
public int get(int key)
{
if(valueMap.containsKey(key) == false)
return -1;
frequencyMap.put(key, frequencyMap.get(key) + 1);
return valueMap.get(key);
}
public void put(int key, int value)
{
if(valueMap.containsKey(key) == false)
{
valueMap.put(key, value);
frequencyMap.put(key, 1);
}
else
{
valueMap.put(key, value);
frequencyMap.put(key, frequencyMap.get(key) + 1);
}
}
}
Now in the above code, we need to implement the code for the eviction strategy. When the map size reaches the max capacity, we need to find the item with the lowest frequency count. In our current implementation, we have to iterate through all the values of the frequencyMap and find the lowest count and remove the corresponding key from both the maps. This takes O(n) time.
Also, if multiple keys have the same frequency count, we cannot find the least recently used key with our current implementation as HashMap does not store the insertion order. To handle this, we add one more data structure, a sorted map with the key as the frequency and value as a list of elements with the same frequency.
Now, new items can be added to the end of the list with frequency 1. Since the map stores the frequencies in sorted order, we can find the list with the lowest frequency in O(1) time. Also, we can delete the list’s first item (of lowest frequency) since that will be the least recently used in O(1) time.
public class LFUCache
{
private Map<Integer, Integer> valueMap = new HashMap<>();
private Map<Integer, Integer> countMap = new HashMap<>();
private TreeMap<Integer, List<Integer>> frequencyMap = new TreeMap<>();
private final int size;
public LFUCache(int capacity)
{
size = capacity;
}
public int get(int key)
{
if(valueMap.containsKey(key) == false || size == 0)
return -1;
//remove element from current frquency list and move it to the next containsKey
// Not O(1)
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(new Integer(key));
//remove frquency from map if list is empty
if(frequencyMap.get(frequency).size() == 0)
frequencyMap.remove(frequency);
frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
countMap.put(key, frequency + 1);
return valueMap.get(key);
}
public void put(int key, int value)
{
if(valueMap.containsKey(key) == false && size > 0)
{
if(valueMap.size() == size)
{
// remove first element from the list
//element with minimum frrquency
lowestCount = frequencyMap.firstKey();
int keyToDelete = frequencyMap.get(lowestCount).remove(0);
//remove frquency from map if list is empty
if(frequencyMap.get(lowestCount).size() == 0)
frequencyMap.remove(lowestCount);
valueMap.remove(keyToDelete) ;
countMap.remove(keyToDelete);
}
valueMap.put(key, value);
countMap.put(key, 1);
frequencyMap.computeIfAbsent(1, k -> new LinkedList<>()).add(key);
}
else if(size > 0)
{
//update value
valueMap.put(key, value);
// update count and frequency map
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(new Integer(key));
//remove frquency from map if list is empty
if(frequencyMap.get(frequency).size() == 0)
frequencyMap.remove(frequency);
frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
countMap.put(key, frequency + 1);
}
}
}
Thus, both insert and delete operations are O(1), i.e. constant-time operations.
While solving the eviction problem, we have increased the access operation time to O(n). All of the item-keys sharing the same frequency are in a list. Now, if one of these items is accessed, we need to move it to the list of the following frequency. We will have to iterate through the list first to find the item, which in worst-case will take O(n) operations.
We somehow need to access that item directly in the list without iteration to solve this problem. If we can do this, we can delete this item in O(1) time from the current frequency list and move it at the end of the list of the following frequency list in O(1) time.
For this, we need a doubly linked list. We will create a node that stores an item’s key, value, and position in the list. We will convert the list to a doubly-linked list.
public class LFUCache {
private Map<Integer, Node> valueMap = new HashMap<>();
private Map<Integer, Integer> countMap = new HashMap<>();
private TreeMap<Integer, DoubleLinkedList> frequencyMap = new TreeMap<>();
private final int size;
public LFUCache(int n) {
size = n;
}
public int get(int key) {
if (!valueMap.containsKey(key) || size == 0) {
return -1;
}
// Remove element from current frequency list and move it to the next one in O(1) time
Node nodeTodelete = valueMap.get(key);
Node node = new Node(key, nodeTodelete.value());
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(nodeTodelete);
removeIfListEmpty(frequency);
valueMap.remove(key);
countMap.remove(key);
valueMap.put(key, node);
countMap.put(key, frequency + 1);
frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(node);
return valueMap.get(key).value;
}
public void put(int key, int value) {
if (!valueMap.containsKey(key) && size > 0){
Node node = new Node(key, value);
if (valueMap.size() == size) {
// remove the first node(LRU) from the list the of smallest frequency(LFU)
int lowestCount = frequencyMap.firstKey();
Node nodeTodelete = frequencyMap.get(lowestCount).head();
frequencyMap.get(lowestCount).remove(nodeTodelete);
removeIfListEmpty(lowestCount);
int keyToDelete = nodeTodelete.key();
valueMap.remove(keyToDelete);
countMap.remove(keyToDelete);
}
frequencyMap.computeIfAbsent(1, k -> new DoubleLinkedList()).add(node);
valueMap.put(key, node);
countMap.put(key, 1);
}
else if(size > 0){
Node node = new Node(key, value);
Node nodeTodelete = valueMap.get(key);
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(nodeTodelete);
removeIfListEmpty(frequency);
valueMap.remove(key);
countMap.remove(key);
valueMap.put(key, node);
countMap.put(key, frequency + 1);
frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(node);
}
}
private void removeIfListEmpty(int frequency) {
// remove from map if list is empty
if (frequencyMap.get(frequency).len() == 0) {
frequencyMap.remove(frequency);
}
}
private class Node {
private int key;
private int value;
Node next;
Node prev;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
public int key() {
return key;
}
public int value() {
return value;
}
}
private class DoubleLinkedList {
private int n;
private Node head;
private Node tail;
public void add(Node node) {
if (head == null) {
head = node;
} else {
tail.next = node;
node.prev = tail;
}
tail = node;
n++;
}
public void remove(Node node) {
if (node.next == null) tail = node.prev;
else node.next.prev = node.prev;
if (node.prev == null) head = node.next;
else node.prev.next = node.next;
n--;
}
public Node head() {
return head;
}
public int len() {
return n;
}
}
}
Consider a caching network proxy application for the HTTP protocol. This proxy typically sits between the internet and the user or a set of users. It ensures that all the users can access the internet and enables sharing of all the shareable resources for optimum network utilisation and improved responsiveness. Such a caching proxy should maximize the amount of data it can cache in the limited amount of storage or memory at its disposal. Typically, many static resources such as images, CSS style sheets, and javascript code can quickly be cached for a reasonably long time before newer versions replace them. These static resources are included in pretty much every page, so it is most beneficial to cache them since pretty much every request will require them. Furthermore, since a network proxy must serve thousands of requests per second, the overhead needed to do so should be kept to a minimum.
It should evict only those resources that are not used very frequently to that effect. Hence, the frequently used resources should be kept at the expense of the not so frequently used ones since the former has proved helpful over a while. Thus, these caching proxies can employ the LFU cache replacement strategy to evict the least frequently used items in its cache when there is a lack of memory. LRU cache might also be an appropriate strategy here, but it would fail when the request pattern is such that all requested items do not fit into the cache and the items are requested in a round-robin fashion. In the case of LRU, items will constantly enter and leave the cache, with no user request ever hitting the cache. Under the same conditions, however, the LFU algorithm will perform much better, with most cached items resulting in a cache hit.
Enjoy learning, Enjoy Algorithms!