questions F-L

This commit is contained in:
2025-05-25 11:47:04 +01:00
parent 360b5fa255
commit ad320dc703
54 changed files with 11235 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
title: LRU Cache
slug: lru-cache
difficulty: medium
leetcode_id: 146
leetcode_url: https://leetcode.com/problems/lru-cache/
categories:
- hash-tables
- linked-lists
patterns:
- linkedlist-reversal
description: |
Design a data structure that follows the constraints of a **Least Recently Used (LRU) cache**.
Implement the `LRUCache` class:
- `LRUCache(int capacity)` Initialise the LRU cache with **positive** size `capacity`.
- `int get(int key)` Return the value of the `key` if the key exists, otherwise return `-1`.
- `void put(int key, int value)` Update the value of the `key` if the `key` exists. Otherwise, add the `key-value` pair to the cache. If the number of keys exceeds the `capacity` from this operation, **evict** the least recently used key.
The functions `get` and `put` must each run in `O(1)` average time complexity.
constraints: |
- `1 <= capacity <= 3000`
- `0 <= key <= 10^4`
- `0 <= value <= 10^5`
- At most `2 * 10^5` calls will be made to `get` and `put`.
examples:
- input: |
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
output: "[null, null, null, 1, null, -1, null, -1, 3, 4]"
explanation: |
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // cache is {1=1}
lRUCache.put(2, 2); // cache is {1=1, 2=2}
lRUCache.get(1); // return 1
lRUCache.put(3, 3); // LRU key was 2, evicts key 2, cache is {1=1, 3=3}
lRUCache.get(2); // returns -1 (not found)
lRUCache.put(4, 4); // LRU key was 1, evicts key 1, cache is {4=4, 3=3}
lRUCache.get(1); // return -1 (not found)
lRUCache.get(3); // return 3
lRUCache.get(4); // return 4
explanation:
intuition: |
Imagine a stack of plates in a restaurant kitchen. When a plate is used, it goes back on top of the stack. When you need a clean plate, you always grab from the top. The plate at the **bottom** of the stack is the one that hasn't been touched in the longest time — it's the "least recently used".
An LRU cache works the same way: we need to track which items were accessed most recently, and when we run out of space, we evict the item that hasn't been touched in the longest time.
The challenge is the **O(1) time requirement** for both `get` and `put`. A simple list would give us O(n) for finding elements. A hash map gives us O(1) lookup but doesn't track order. We need **both** capabilities simultaneously.
The key insight is to combine two data structures:
- A **hash map** for O(1) key lookups
- A **doubly linked list** for O(1) insertion, deletion, and reordering
The hash map points directly to nodes in the linked list, so we can find any element in O(1) time. The doubly linked list maintains the access order — most recently used at the head, least recently used at the tail. When we access an element, we can remove it from its current position and move it to the head in O(1) time because we have direct pointers to adjacent nodes.
approach: |
We solve this using a **Hash Map + Doubly Linked List** combination:
**Step 1: Define the node structure**
- Create a `Node` class with `key`, `value`, `prev`, and `next` pointers
- The key is stored in the node so we can remove entries from the hash map during eviction
&nbsp;
**Step 2: Initialise the data structures**
- `cache`: A hash map mapping keys to their corresponding nodes
- `capacity`: The maximum number of items allowed
- `head` and `tail`: Dummy sentinel nodes that simplify edge case handling
- Connect `head.next = tail` and `tail.prev = head` initially (empty list between sentinels)
&nbsp;
**Step 3: Implement helper methods**
- `_remove(node)`: Remove a node from its current position in the doubly linked list
- `_add_to_head(node)`: Insert a node right after the head sentinel (marks it as most recently used)
&nbsp;
**Step 4: Implement get(key)**
- If key not in cache, return `-1`
- Otherwise, move the node to the head (mark as recently used) and return its value
&nbsp;
**Step 5: Implement put(key, value)**
- If key exists, update its value and move to head
- If key is new:
- Create a new node and add to head
- Add to the hash map
- If over capacity, remove the node before `tail` (the LRU item) and delete from hash map
&nbsp;
Using sentinel nodes eliminates null checks when removing the first/last real node, making the code cleaner and less error-prone.
common_pitfalls:
- title: Using a List for Access Tracking
description: |
A common first instinct is to use a regular list or array to track access order. However, moving an element to the front of a list requires O(n) time to shift elements.
With up to `2 * 10^5` operations, O(n) per operation means up to 4 * 10^10 operations total — this will cause **Time Limit Exceeded (TLE)**.
The doubly linked list with direct node references allows O(1) removal and insertion.
wrong_approach: "Array or singly linked list for order tracking"
correct_approach: "Doubly linked list with hash map for O(1) node access"
- title: Forgetting to Store Key in Node
description: |
When evicting the LRU item, you need to remove it from both the linked list AND the hash map. If the node doesn't store its key, you can't efficiently find which hash map entry to delete.
Always store the key in the node so eviction can update the hash map in O(1) time.
wrong_approach: "Node only stores value"
correct_approach: "Node stores both key and value"
- title: Not Handling the Update Case
description: |
When `put` is called with an existing key, some implementations add a new node without removing the old one. This corrupts the data structure and leads to incorrect eviction behaviour.
Always check if the key exists first. If it does, update the existing node's value and move it to the head instead of creating a new node.
wrong_approach: "Always create new node on put"
correct_approach: "Check existence first, update if present"
- title: Edge Cases with Sentinel Nodes
description: |
Without sentinel (dummy) nodes, removing the first or last real node requires special handling of null pointers. This leads to complex, error-prone code.
Using dummy `head` and `tail` nodes means the first real node is always `head.next` and the last is always `tail.prev`. Removal logic becomes uniform for all nodes.
key_takeaways:
- "**Combine data structures**: When one structure doesn't meet all requirements, combine two. Hash map + linked list gives O(1) lookup AND O(1) reordering."
- "**Sentinel nodes simplify edge cases**: Dummy head/tail nodes eliminate null checks and special cases for first/last elements."
- "**Store redundant data when needed**: Keeping the key in the node seems redundant but enables O(1) eviction from the hash map."
- "**Classic interview pattern**: This exact combination (hash map + doubly linked list) appears in many cache and ordering problems."
time_complexity: "O(1) for both `get` and `put`. Hash map lookup is O(1), and doubly linked list insertion/removal is O(1) with direct node references."
space_complexity: "O(capacity). We store at most `capacity` nodes in the linked list and `capacity` entries in the hash map."
solutions:
- approach_name: Hash Map + Doubly Linked List
is_optimal: true
code: |
class Node:
"""Doubly linked list node storing key-value pair."""
def __init__(self, key: int = 0, value: int = 0):
self.key = key
self.value = value
self.prev: Node | None = None
self.next: Node | None = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache: dict[int, Node] = {} # key -> node
# Sentinel nodes simplify edge cases
self.head = Node() # Most recently used after head
self.tail = Node() # Least recently used before tail
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node: Node) -> None:
"""Remove node from its current position in the list."""
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
def _add_to_head(self, node: Node) -> None:
"""Add node right after head (marks as most recently used)."""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
# Move accessed node to head (most recently used)
node = self.cache[key]
self._remove(node)
self._add_to_head(node)
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
# Update existing node and move to head
node = self.cache[key]
node.value = value
self._remove(node)
self._add_to_head(node)
else:
# Create new node
new_node = Node(key, value)
self.cache[key] = new_node
self._add_to_head(new_node)
# Evict LRU if over capacity
if len(self.cache) > self.capacity:
lru_node = self.tail.prev # Node before tail is LRU
self._remove(lru_node)
del self.cache[lru_node.key] # Key stored in node!
explanation: |
**Time Complexity:** O(1) for both operations — hash map lookup and linked list manipulation are constant time.
**Space Complexity:** O(capacity) — storing up to `capacity` nodes plus the hash map entries.
The hash map provides instant key lookup, while the doubly linked list maintains access order. Sentinel nodes eliminate edge case handling. When evicting, we access the node before `tail` and use its stored key to clean up the hash map.
- approach_name: OrderedDict (Python Built-in)
is_optimal: true
code: |
from collections import OrderedDict
class LRUCache:
"""LRU Cache using Python's OrderedDict which maintains insertion order."""
def __init__(self, capacity: int):
self.capacity = capacity
# OrderedDict remembers insertion order
self.cache: OrderedDict[int, int] = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
# Move to end (most recently used)
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
# Update and move to end
self.cache.move_to_end(key)
self.cache[key] = value
# Evict oldest if over capacity
if len(self.cache) > self.capacity:
# popitem(last=False) removes first (oldest) item
self.cache.popitem(last=False)
explanation: |
**Time Complexity:** O(1) for both operations — `OrderedDict` uses a hash map + doubly linked list internally.
**Space Complexity:** O(capacity) — same as the manual implementation.
Python's `OrderedDict` is essentially the same data structure we built manually. Using `move_to_end()` marks an item as recently used, and `popitem(last=False)` removes the oldest item. This is the pragmatic choice in a real Python codebase, but understanding the manual implementation is valuable for interviews.