Implementing LRU Cache in Python

Implementing LRU Cache in Python


Python LRU Cache Data Structures

Least Recently Used (LRU) cache is a popular technique used in computer programming to manage a cache of fixed size. It discards the least recently used items first to make space for new ones. In this blog post, we’ll explore how to implement an LRU cache in Python, taking reference from the provided code.

Click to expand the code
# Node class to represent each item in the cache
class Node:
  def __init__(self, k, v, next = None, prev = None):
    self.value = v  # Value of the node
    self.key = k  # Key of the node
    self.next = next  # Pointer to the next node
    self.prev = prev  # Pointer to the previous node

# LRU class to represent the Least Recently Used cache
class LRU:
  # Node class is defined inside the LRU class for better encapsulation
  class Node:
    def __init__(self, k, v, next = None, prev = None):
      self.value = v  # Value of the node
      self.key = k  # Key of the node
      self.next = next  # Pointer to the next node
      self.prev = prev  # Pointer to the previous node

  # Initialization method for the LRU cache
  def __init__(self, space):
    self.head = Node(0,0)  # Dummy head node
    self.tail = Node(0,0)  # Dummy tail node
    self.head.next = self.tail  # Connect head and tail nodes
    self.tail.prev = self.head
    self.space = space  # Maximum size of the cache
    self.store = {}  # Dictionary to store the nodes by their keys

  # Method to insert a node at the head of the list
  def insert(self, node):
    node.next = self.head.next
    node.prev = self.head
    node.next.prev = node
    self.head.next = node
    self.store[node.key] = node  # Add the node to the dictionary

  # Method to remove a node from the list
  def remove(self, node):
    del self.store[node.key]  # Remove the node from the dictionary
    node.prev.next = node.next
    node.next.prev = node.prev

  # Method to add a key-value pair to the cache
  def add(self,k,v):
    if k in self.store:  # If the key already exists, remove the node
      self.remove(self.store[k])
    else:
      if len(self.store) == self.space:  # If the cache is full, remove the least recently used node
        self.remove(self.tail.prev)
    self.insert(Node(k,v))  # Insert the new node at the head of the list
    self.print()  # Print the current state of the cache

  # Method to get the value associated with a key
  def get(self, k):
    if k not in self.store:  # If the key does not exist, return -1
      self.print()
      return -1
    node = self.store[k]  # Get the node associated with the key
    self.remove(node)  # Remove the node from its current position
    self.insert(node)  # Insert the node at the head of the list
    self.print()  # Print the current state of the cache
    return node.value  # Return the value associated with the key

  # Method to print the current state of the cache
  def print(self):
    tmp = self.head.next
    c = 1
    while tmp != self.tail:
      print(f"At pos {c} | {tmp.key} -> {tmp.value}")  # Print the key-value pair at the current position
      tmp = tmp.next  # Move to the next node
      c+=1
    print('------------------------')

# Create an instance of the LRU cache with a maximum size of 3
cache = LRU(space=3)

# Add key-value pairs to the cache
cache.add(1,'A')
cache.add(2,'B')
cache.add(3,'C')

# Get the value associated with a key
cache.get(2)
cache.get(4)

# Add more key-value pairs to the cache
cache.add(4,'D')
cache.add(3,'E')

# Get the value associated with a key
cache.get(4)

# Add another key-value pair to the cache
cache.add(1,'A')
Click to expand the output (Try in a code editor) ``` ---------- | 1 -> A | ---------- | V ---------- | 2 -> B | | 1 -> A | ---------- | V ---------- | 3 -> C | | 2 -> B | | 1 -> A | ---------- | V ---------- | 2 -> B | | 3 -> C | | 1 -> A | ---------- | V ---------- | 2 -> B | | 3 -> C | | 1 -> A | ---------- | V ---------- | 4 -> D | | 2 -> B | | 3 -> C | ---------- | V ---------- | 3 -> E | | 4 -> D | | 2 -> B | ---------- | V ---------- | 4 -> D | | 3 -> E | | 2 -> B | ---------- | V ---------- | 1 -> A | | 4 -> D | | 3 -> E | ---------- | V ```

The provided code is a well-structured implementation of an LRU cache. It consists of two classes: Node and LRU. The Node class represents a single node in the doubly linked list, while the LRU class manages the cache.

Let’s start by understanding the Node class. This class has four attributes: key, value, next, and prev. The next and prev attributes are pointers to the next and previous nodes in the doubly linked list, respectively.

class Node:
  def __init__(self, k, v, next = None, prev = None):
    self.value = v
    self.key = k
    self.next = None
    self.prev = None

Now, let’s move on to the LRU class. This class has five methods: __init__, insert, remove, add, and get.

The __init__ method initializes the cache with a fixed size, space. It also creates a dummy head and tail node to simplify the insertion and removal of nodes.

def __init__(self, space):
  self.head = Node(0,0)
  self.tail = Node(0,0)
  self.head.next = self.tail
  self.space = space
  self.store = {}

The insert method adds a node to the head of the list.

def insert(self, node):
  node.next = self.head.next
  node.prev = self.head
  node.next.prev = node
  self.head.next = node
  self.store[node.key] = node

The remove method removes a node from the list.

def remove(self, node):
  del self.store[node.key]
  node.prev.next = node.next
  node.next.prev = node.prev

The add method adds a key-value pair to the cache. If the key already exists, it updates the value. If the cache is full, it removes the least recently used item before adding the new item.

def add(self,k,v):
  if k in self.store:
    self.remove(self.store[k])
  else:
    if len(self.store) == self.space:
      self.remove(self.tail.prev)
  self.insert(Node(k,v))
  self.print()

The get method retrieves the value associated with a key. If the key is not found, it returns -1.

def get(self, k):
  if k not in self.store:
    self.print()
    return -1
  node = self.store[k]
  self.remove(node)
  self.insert(node)
  self.print()
  return node.value

Now, let’s walk through the example usage of the LRU class:

cache = LRU(space=3)
cache.add(1,'A')
cache.add(2,'B')
cache.add(3,'C')

In this example, we create an LRU cache with a size of 3. We then add three key-value pairs to the cache. The cache is currently full, so the least recently used item will be discarded when a new item is added.

cache.get(2)

Here, we retrieve the value associated with key 2. Since we just accessed this item, it becomes the most recently used item in the cache.

cache.get(4)

In this case, we try to retrieve the value associated with key 4, which is not in the cache. The get method returns -1 to indicate that the key was not found.

cache.add(4,'D')

Since the cache is full, adding a new item will cause the least recently used item to be discarded. In this case, the item with key 1 will be discarded, as it was the least recently used item.

cache.add(3,'E')

Here, we add a new item with key 3 and value ‘E’. Since this item was already in the cache, its value is updated, and it becomes the most recently used item.

cache.get(4)
cache.add(1,'A')

Finally, we retrieve the value associated with key 4 and add a new item with key 1 and value ‘A’. Since the cache is full, adding a new item will cause the least recently used item to be discarded. In this case, the item with key 2 will be discarded, as it was the least recently used item after retrieving the value associated with key 4.

Implementing an LRU cache in Python can be a valuable skill to add to your toolkit.By following the explanations and code snippets provided in this blog post, you should be able to understand and implement your own LRU cache in Python with ease.