LRU cache in Java with Generics and O(1) operations

This is a question that comes up a lot in job interviews. The idea is to define a data structure instead of using Java's built in LinkedHashMap.

An LRU cache deletes the least recently used entry to insert a new one. So, given the following scenario:

 A - B - C - D - E

Where A is the least recently used item, if we were to insert F, we need to remove A.

This can be easily implemented if we keep a HashMap with the cache entries by (key,value) and a separate list that contains the elements' key and time of use. However, we would need to query the list to find the least recently used item, with a potential O(n) time complexity.

How can this structure be implemented in Java for Generic objects and O(1) operations?

This is different from the possible duplicate in that it focuses on efficiency (O(1) ops) and implementing the data structure itself, not extending Java's.


Solution 1:

From the question itself, we can see that the problem of O(n) operations arises when querying the linked list. Therefore, we need an alternative data structure. We need to be able to update the items' last access time from the HashMap without searching.

We can keep two separate data structures. A HashMap with (Key,Pointer) pairs and a doubly linked list which will work as the priority queue for deletion and store the Values. From the HashMap, we can point to an element in the doubly linked list and update its' retrieval time. Because we go directly from the HashMap to the item in the list, our time complexity remains at O(1)

For example, our doubly linked list can look like:

least_recently_used  -> A <-> B <-> C <-> D <-> E <- most_recently_used

We need to keep a pointer to the LRU and MRU items. The entries' values will be stored in the list and when we query the HashMap, we will get a pointer to the list. On get(), we need to put the item at the right-most side of the list. On put(key,value), if the cache is full, we need to remove the item at the left-most side of the list from both, the list and the HashMap.

The following is an example implementation in Java:

public class LRUCache<K, V>{

    // Define Node with pointers to the previous and next items and a key, value pair
    class Node<T, U> {
        Node<T, U> previous;
        Node<T, U> next;
        T key;
        U value;

        public Node(Node<T, U> previous, Node<T, U> next, T key, U value){
            this.previous = previous;
            this.next = next;
            this.key = key;
            this.value = value;
        }
    }

    private HashMap<K, Node<K, V>> cache;
    private Node<K, V> leastRecentlyUsed;
    private Node<K, V> mostRecentlyUsed;
    private int maxSize;
    private int currentSize;

    public LRUCache(int maxSize){
        this.maxSize = maxSize;
        this.currentSize = 0;
        leastRecentlyUsed = new Node<K, V>(null, null, null, null);
        mostRecentlyUsed = leastRecentlyUsed;
        cache = new HashMap<K, Node<K, V>>();
    }

    public V get(K key){
        Node<K, V> tempNode = cache.get(key);
        if (tempNode == null){
            return null;
        }
        // If MRU leave the list as it is
        else if (tempNode.key == mostRecentlyUsed.key){
            return mostRecentlyUsed.value;
        }

        // Get the next and previous nodes
        Node<K, V> nextNode = tempNode.next;
        Node<K, V> previousNode = tempNode.previous;

        // If at the left-most, we update LRU 
        if (tempNode.key == leastRecentlyUsed.key){
            nextNode.previous = null;
            leastRecentlyUsed = nextNode;
        }

        // If we are in the middle, we need to update the items before and after our item
        else if (tempNode.key != mostRecentlyUsed.key){
            previousNode.next = nextNode;
            nextNode.previous = previousNode;
        }

        // Finally move our item to the MRU
        tempNode.previous = mostRecentlyUsed;
        mostRecentlyUsed.next = tempNode;
        mostRecentlyUsed = tempNode;
        mostRecentlyUsed.next = null;

        return tempNode.value;

    }

    public void put(K key, V value){
        if (cache.containsKey(key)){
            return;
        }

        // Put the new node at the right-most end of the linked-list
        Node<K, V> myNode = new Node<K, V>(mostRecentlyUsed, null, key, value);
        mostRecentlyUsed.next = myNode;
        cache.put(key, myNode);
        mostRecentlyUsed = myNode;

        // Delete the left-most entry and update the LRU pointer
        if (currentSize == maxSize){
            cache.remove(leastRecentlyUsed.key);
            leastRecentlyUsed = leastRecentlyUsed.next;
            leastRecentlyUsed.previous = null;
        }

        // Update cache size, for the first added entry update the LRU pointer
        else if (currentSize < maxSize){
            if (currentSize == 0){
                leastRecentlyUsed = myNode;
            }
            currentSize++;
        }
    }
}

Solution 2:

Implementation that passes the tests of the leetcode question with simple unit tests

I have made a pull request with this at: https://github.com/haoel/leetcode/pull/90/files The code was later improved here in the answer with accessOrder = true and I didn't bother to make another pull request though, so the code here is slightly better for now.

LRUCache.java

import java.util.Iterator;
import java.util.LinkedHashMap;

public class LRUCache {

    private int capacity;
    private LinkedHashMap<Integer,Integer> map;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new LinkedHashMap<>(16, 0.75f, true);
    }

    public int get(int key) {
        Integer value = this.map.get(key);
        if (value == null) {
            value = -1;
        }
        return value;
    }

    public void put(int key, int value) {
        if (
            !this.map.containsKey(key) &&
            this.map.size() == this.capacity
        ) {
            Iterator<Integer> it = this.map.keySet().iterator();
            it.next();
            it.remove();
        }
        this.map.put(key, value);
    }
}

LRUCacheTest.java

public class LRUCacheTest {
    public static void main(String[] args) {
        LRUCache c;

        // Starts empty.
        c = new LRUCache(2);
        assert c.get(1) == -1;

        // Below capcity.
        c = new LRUCache(2);
        c.put(1, 1);
        assert c.get(1) == 1;
        assert c.get(2) == -1;
        c.put(2, 4);
        assert c.get(1) == 1;
        assert c.get(2) == 4;

        // Above capacity, oldest is removed.
        c = new LRUCache(2);
        c.put(1, 1);
        c.put(2, 4);
        c.put(3, 9);
        assert c.get(1) == -1;
        assert c.get(2) == 4;
        assert c.get(3) == 9;

        // get renews entry
        c = new LRUCache(2);
        c.put(1, 1);
        c.put(2, 4);
        assert c.get(1) == 1;
        c.put(3, 9);
        assert c.get(1) == 1;
        assert c.get(2) == -1;
        assert c.get(3) == 9;

        // Double put does not remove due to capacity.
        c = new LRUCache(2);
        assert c.get(2) == -1;
        c.put(2, 6);
        assert c.get(1) == -1;
        c.put(1, 5);
        c.put(1, 2);
        assert c.get(1) == 2;
        assert c.get(2) == 6;
    }
}

removeEldestEntry() alternative implementation

Not sure it is worth it as it takes the same number of lines, but here goes for completeness:

import java.util.LinkedHashMap;
import java.util.Iterator;
import java.util.Map;

import java.io.*;

class LinkedhashMapWithCapacity<K,V> extends LinkedHashMap<K,V> {
    private int capacity;

    public LinkedhashMapWithCapacity(int capacity) {
        super(16, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return this.size() > this.capacity;
    }
}

public class LRUCache {

    private LinkedhashMapWithCapacity<Integer,Integer> map;

    public LRUCache(int capacity) {
        this.map = new LinkedhashMapWithCapacity<>(capacity);
    }

    public int get(int key) {
        Integer value = this.map.get(key);
        if (value == null) {
            value = -1;
        }
        return value;
    }

    public void put(int key, int value) {
        this.map.put(key, value);
    }
}

Tested on Ubuntu 20.10, OpenJDK 11.0.10.