Welcome back to our series on solving LeetCode problems with TypeScript, tailored for Silicon Valley job interviews! Today, we’re diving into a fascinating and challenging problem: designing and implementing a Least Recently Used (LRU) Cache. This problem is a staple in technical interviews, especially at top-tier companies like Amazon and Google, as it tests your ability to combine data structures like hash maps and doubly linked lists into an efficient design. Whether you’re preparing for a coding round or a system design-adjacent question, mastering the LRU Cache will give you a significant edge. Let’s break it down step by step, explore the problem with an easy-to-understand analogy, and implement a solution in TypeScript.
The LRU Cache problem (LeetCode #146) is classified as Medium to Hard due to its requirement for a deep understanding of data structure design. The task is to create a cache with a fixed capacity that stores key-value pairs. When the cache reaches its capacity, it should evict the least recently used item to make room for a new one. Additionally, both getting and putting items in the cache should be performed in time complexity, which means we need an efficient way to track usage order and access elements quickly.
This problem is highly relevant for interviews because it mimics real-world scenarios like caching in web browsers or databases, where quick access and efficient memory management are critical. It tests your ability to:
In Silicon Valley interviews, especially at companies like Amazon and Google, you might encounter this as part of a coding round or even as a precursor to a broader system design discussion. Let’s simplify the concept before diving into the technical details.
Imagine you have a small toy box that can only hold 3 toys at a time. Every time you play with a toy, you put it on top of the pile in the box because it’s the “most recently used.” If the box is full and you want to add a new toy, you have to take out the toy at the bottom of the pile—the one you haven’t played with in the longest time (the “least recently used”). To find a toy quickly, you also have a little notebook where you write down which toy is in which spot in the box.
In this analogy:
The challenge is to make sure that finding a toy, playing with it (moving it to the top), or replacing the bottom toy with a new one all happen super fast, without rearranging the whole box every time. That’s what we’ll solve with our data structures!
To achieve time complexity for both get
and put
operations, we need
two main data structures:
Here’s the strategy:
get
a key, we find it in the hash map, move its node to the head of
the linked list (marking it as most recently used), and return its value.put
a key-value pair, if the key exists, we update its value and
move it to the head. If it doesn’t exist and the cache is full, we remove the
tail node (least recently used), update the hash map, and add the new node to
the head. If there’s space, we simply add the new node to the head.Let’s implement this in TypeScript, leveraging type safety to make our code robust and clear.
We’ll start by defining the structure of our doubly linked list node and then build the LRU Cache class with all required functionality. Below is the complete solution with detailed comments explaining each part.
// Define the structure of a node in the doubly linked list
interface Node {
key: number;
value: number;
prev: Node | null;
next: Node | null;
}
class LRUCache {
private capacity: number; // Maximum number of items the cache can hold
private cache: Map<number, Node>; // Hash map for O(1) lookup of nodes by key
private dummyHead: Node; // Dummy head node for the doubly linked list
private dummyTail: Node; // Dummy tail node for the doubly linked list
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map<number, Node>();
// Initialize dummy head and tail to simplify list operations
this.dummyHead = { key: 0, value: 0, prev: null, next: null };
this.dummyTail = { key: 0, value: 0, prev: null, next: null };
this.dummyHead.next = this.dummyTail;
this.dummyTail.prev = this.dummyHead;
}
// Helper method to add a node right after the head
private addNode(node: Node): void {
node.prev = this.dummyHead;
node.next = this.dummyHead.next;
this.dummyHead.next!.prev = node;
this.dummyHead.next = node;
}
// Helper method to remove an existing node from the linked list
private removeNode(node: Node): void {
const prev = node.prev;
const next = node.next;
prev!.next = next;
next!.prev = prev;
}
// Helper method to move a node to the head (most recently used)
private moveToHead(node: Node): void {
this.removeNode(node);
this.addNode(node);
}
// Get the value associated with a key if it exists in the cache
get(key: number): number {
const node = this.cache.get(key);
if (!node) {
return -1; // Key not found
}
// Move to head (mark as most recently used)
this.moveToHead(node);
return node.value;
}
// Put a key-value pair into the cache
put(key: number, value: number): void {
const node = this.cache.get(key);
if (node) {
// If key exists, update its value and move to head
node.value = value;
this.moveToHead(node);
} else {
// Create a new node
const newNode: Node = { key, value, prev: null, next: null };
this.cache.set(key, newNode);
this.addNode(newNode);
// If over capacity, remove the least recently used item (tail)
if (this.cache.size > this.capacity) {
// Remove from hash map and linked list
const tail = this.dummyTail.prev!;
this.cache.delete(tail.key);
this.removeNode(tail);
}
}
}
}
// Example usage and testing
function testLRUCache() {
const cache = new LRUCache(2);
cache.put(1, 1); // Cache is {1=1}
cache.put(2, 2); // Cache is {1=1, 2=2}
console.log(cache.get(1)); // Returns 1
cache.put(3, 3); // Evicts key 2, cache is {1=1, 3=3}
console.log(cache.get(2)); // Returns -1 (not found)
cache.put(4, 4); // Evicts key 1, cache is {4=4, 3=3}
console.log(cache.get(1)); // Returns -1 (not found)
console.log(cache.get(3)); // Returns 3
console.log(cache.get(4)); // Returns 4
}
testLRUCache();
key
, value
, and pointers to prev
and next
nodes.
TypeScript’s type safety ensures we don’t mix up properties.Map
for lookups
and a doubly linked list for maintaining order.addNode
, removeNode
, and moveToHead
manage the
linked list operations efficiently.get
and put
operations are because hash
map lookups and doubly linked list node movements (thanks to direct pointers)
are constant time.TypeScript’s Map
and interface definitions make the code more readable and
less error-prone. For instance, explicitly typing the cache
as
Map<number, Node>
ensures that we only store nodes associated with numeric
keys, catching potential bugs during development. This is particularly useful in
a collaborative environment or when maintaining code over time, which aligns
with practices at Silicon Valley companies.
In this blog post, we’ve tackled the LRU Cache problem, a medium-to-hard LeetCode challenge that’s a favorite in Silicon Valley interviews at companies like Amazon and Google. Here’s what we learned:
get
and put
were implemented with helper methods to
manage the doubly linked list efficiently.Mastering the LRU Cache not only prepares you for coding interviews but also deepens your understanding of data structure design—a critical skill for real-world applications. In the next post, we’ll explore another challenging problem to further build your algorithmic toolkit. Until then, keep practicing, and happy coding!