Skip to content
Open
45 changes: 45 additions & 0 deletions Sprint-2/implement_lru_cache/CHANGE_MADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Changes Made: LRU Cache Implementation

## 1. Architectural Overview
To achieve the requirement of **O(1) time complexity** for both `get` and `set` operations, I implemented a hybrid data structure combining a **Python Dictionary** and a **Doubly Linked List**.

- **The Dictionary (`self.lookup`)**: Provides constant time O(1) access to any node using its key.
- **The Doubly Linked List (`self.order`)**: Maintains the "recency" of items. The **Head** represents the Most Recently Used (MRU) item, and the **Tail** represents the Least Recently Used (LRU) item.

## 2. Component Breakdown

### Node Class
- **Key-Value Storage**: Unlike a standard linked list node, these nodes store both the `key` and the `value`.
- **The "Why"**: Storing the `key` is essential during eviction. When we remove the tail node from the list, we must also delete its corresponding entry from the dictionary. The node must "know" its key so we can perform this reverse lookup.

### LinkedList Class
- **Pointer Management**: Manages `head` and `tail` pointers.
- **Methods**:
- `push_head(node)`: Places a node at the front (MRU position).
- `pop_tail()`: Removes the oldest node (LRU position) and returns the node object so the Cache can identify which key to delete.
- `remove(node)`: Disconnects a node from its current position. This is used when an existing item is accessed or updated and needs to be moved to the front.

### LruCache Class
- **`get(key)`**:
1. Checks the dictionary for the key.
2. If found, it uses `remove()` and `push_head()` to move the node to the front of the list, marking it as recently used.
- **`set(key, value)`**:
1. **If key exists**: Updates the value and moves the node to the front.
2. **If key is new**:
- Checks if the cache is at its `limit`.
- If full, it calls `pop_tail()` and deletes that node's key from the dictionary.
- Adds the new node to both the dictionary and the front of the list.

## 3. Complexity & Trade-offs
- **Time Complexity**: Every operation (`get` and `set`) is **O(1)** because dictionary lookups and linked list pointer updates do not depend on the size of the cache.
- **Space Complexity**: I traded **Space for Time**. I used extra memory to store:
1. A dictionary entry for every item.
2. Two pointers (`next` and `previous`) for every node.
- **Trade-off Choice**: This extra memory usage is worth the benefit of having a cache that never slows down, even as the limit increases to thousands of items.

## 4. Testing Suite
I expanded the test suite to include several critical edge cases:
- **Update Existing Key**: Verified that re-setting a key updates the value and refreshes its "recency" status.
- **Limit of One**: Confirmed that a cache with a limit of 1 correctly evicts the old item every time a new one is added.
- **Non-existent Keys**: Ensured that the cache gracefully returns `None` for missing keys.
- **Complex Values**: Verified that the cache can store non-primitive types like lists, proving it stores data by reference.
129 changes: 129 additions & 0 deletions Sprint-2/implement_lru_cache/lru_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
class Node:
"""
Step 1: Updated Node class.
Each node stores both key and value so we can find the
dictionary entry when a node is evicted from the tail.
"""
def __init__(self, key, value):
self.key = key
self.value = value
self.next = None
self.previous = None
Comment on lines +7 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To practice code reuse, you could import the linked list you implemented in the other exercise and store the key-value pairs as a tuple in each node.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion.

By storing the key and value together as a tuple inside the node, I could use the Linked List I already built without having to write a new one for this file. Thanks for the suggestion.



class LinkedList:
"""
A Doubly Linked List to track the order of usage.
Head is Most Recently Used (MRU).
Tail is Least Recently Used (LRU).
"""
def __init__(self):
self.head = None
self.tail = None

def push_head(self, node):
"""Adds an existing node to the front of the list."""
node.next = self.head
node.previous = None

if self.head is not None:
self.head.previous = node

self.head = node

# If list was empty, this node is also the tail
if self.tail is None:
self.tail = node

def pop_tail(self):
"""Removes the last node (the oldest item) and returns it."""
if self.tail is None:
return None

old_tail = self.tail

if self.head == self.tail:
self.head = None
self.tail = None
else:
self.tail = self.tail.previous
self.tail.next = None

return old_tail

def remove(self, node):
"""Unplucks a node from its current position in the list."""
if node.previous is not None:
node.previous.next = node.next
else:
self.head = node.next

if node.next is not None:
node.next.previous = node.previous
else:
self.tail = node.previous

# Clean up pointers of the removed node
node.next = None
node.previous = None


class LruCache:
"""
The LRU Cache: Dictionary + Linked List.
Provides O(1) time complexity for both get and set operations.
"""
def __init__(self, limit):
# 1. Validation
if limit < 1:
raise ValueError("Cache limit must be at least 1.")

# 2. Store the limit
self.limit = limit

# 3. Dictionary for O(1) key lookups (Key -> Node)
self.lookup = {}

# 4. Doubly Linked List for O(1) ordering
self.order = LinkedList()

def get(self, key):
"""
Logic for get(key):
Find the item, move it to the front, return value.
"""
if key not in self.lookup:
return None

node = self.lookup[key]

# Move to front (Most Recently Used)
self.order.remove(node)
self.order.push_head(node)

return node.value

def set(self, key, value):
"""
Logic for set(key, value):
If exists: update and move to front.
If new: check limit, evict tail if full, then add to front.
"""
if key in self.lookup:
# Update existing
node = self.lookup[key]
node.value = value
self.order.remove(node)
self.order.push_head(node)
else:
# Check limit
if len(self.lookup) >= self.limit:
# Evict the oldest (tail)
evicted_node = self.order.pop_tail()
if evicted_node:
del self.lookup[evicted_node.key]

# Create and add new node
new_node = Node(key, value)
self.order.push_head(new_node)
self.lookup[key] = new_node
34 changes: 34 additions & 0 deletions Sprint-2/implement_lru_cache/lru_cache_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,40 @@ def test_eviction_order_after_gets(self):
self.assertEqual(cache.get("a"), 1)
self.assertEqual(cache.get("c"), 3)

def test_update_existing_key(self):
cache = LruCache(limit=2)
cache.set("a", 1)
cache.set("b", 2)

# Update "a" - it should stay in the cache but change value
cache.set("a", 100)
self.assertEqual(cache.get("a"), 100)

# Adding "c" should now evict "b", because "a" was recently updated
cache.set("c", 3)
self.assertIsNone(cache.get("b"))
self.assertEqual(cache.get("a"), 100)

def test_limit_of_one(self):
cache = LruCache(limit=1)
cache.set("a", 1)
self.assertEqual(cache.get("a"), 1)

cache.set("b", 2) # This should immediately kick out "a"
self.assertIsNone(cache.get("a"))
self.assertEqual(cache.get("b"), 2)

def test_get_non_existent_key(self):
cache = LruCache(limit=5)
# Testing "None" return for keys never added
self.assertIsNone(cache.get("missing_key"))

def test_complex_values(self):
cache = LruCache(limit=2)
# Testing that we can store lists or dictionaries, not just strings
cache.set("list", [1, 2, 3])
self.assertEqual(cache.get("list"), [1, 2, 3])


if __name__ == "__main__":
unittest.main()
Loading