diff --git a/Sprint-2/implement_lru_cache/CHANGE_MADE.md b/Sprint-2/implement_lru_cache/CHANGE_MADE.md new file mode 100644 index 0000000..eabf467 --- /dev/null +++ b/Sprint-2/implement_lru_cache/CHANGE_MADE.md @@ -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. \ No newline at end of file diff --git a/Sprint-2/implement_lru_cache/lru_cache.py b/Sprint-2/implement_lru_cache/lru_cache.py index e69de29..61f1d49 100644 --- a/Sprint-2/implement_lru_cache/lru_cache.py +++ b/Sprint-2/implement_lru_cache/lru_cache.py @@ -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 + + +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 \ No newline at end of file diff --git a/Sprint-2/implement_lru_cache/lru_cache_test.py b/Sprint-2/implement_lru_cache/lru_cache_test.py index d37df01..3b8cd1d 100644 --- a/Sprint-2/implement_lru_cache/lru_cache_test.py +++ b/Sprint-2/implement_lru_cache/lru_cache_test.py @@ -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()