/** * Node in the doubly-linked list used for LRU tracking. * Each node represents a cache entry with bidirectional pointers. */ class LRUNode { public readonly key: string public data: T public size: number public prev: LRUNode | SentinelNode | null = null public next: LRUNode | SentinelNode | null = null constructor(key: string, data: T, size: number) { this.key = key this.data = data this.size = size } } /** * Sentinel node used for head/tail boundaries. * These nodes don't contain actual cache data but simplify list operations. */ class SentinelNode { public prev: LRUNode | SentinelNode | null = null public next: LRUNode | SentinelNode | null = null } /** * LRU (Least Recently Used) Cache implementation using a doubly-linked list * and hash map for O(1) operations. * * Algorithm: * - Uses a doubly-linked list to maintain access order (most recent at head) * - Hash map provides O(1) key-to-node lookup * - Sentinel head/tail nodes simplify edge case handling * - Size-based eviction supports custom size calculation functions * * Data Structure Layout: * HEAD <-> [most recent] <-> ... <-> [least recent] <-> TAIL * * Operations: * - get(): Move accessed node to head (mark as most recent) * - set(): Add new node at head, evict from tail if over capacity * - Eviction: Remove least recent node (tail.prev) when size exceeds limit */ export class LRUCache { private readonly cache: Map> = new Map() private readonly head: SentinelNode private readonly tail: SentinelNode private totalSize: number = 0 private readonly maxSize: number private readonly calculateSize: ((value: T) => number) | undefined constructor(maxSize: number, calculateSize?: (value: T) => number) { this.maxSize = maxSize this.calculateSize = calculateSize // Create sentinel nodes to simplify doubly-linked list operations // HEAD <-> TAIL (empty list) this.head = new SentinelNode() this.tail = new SentinelNode() this.head.next = this.tail this.tail.prev = this.head } /** * Adds a node immediately after the head (marks as most recently used). * Used when inserting new items or when an item is accessed. * PRECONDITION: node must be disconnected (prev/next should be null) */ private addToHead(node: LRUNode): void { node.prev = this.head node.next = this.head.next // head.next is always non-null (points to tail or another node) this.head.next!.prev = node this.head.next = node } /** * Removes a node from its current position in the doubly-linked list. * Updates the prev/next pointers of adjacent nodes to maintain list integrity. * PRECONDITION: node must be connected (prev/next are non-null) */ private removeNode(node: LRUNode): void { // Connected nodes always have non-null prev/next node.prev!.next = node.next node.next!.prev = node.prev } /** * Moves an existing node to the head position (marks as most recently used). * This is the core LRU operation - accessed items become most recent. */ private moveToHead(node: LRUNode): void { this.removeNode(node) this.addToHead(node) } /** * Removes and returns the least recently used node (the one before tail). * This is called during eviction when the cache exceeds capacity. * PRECONDITION: cache is not empty (ensured by caller) */ private removeTail(): LRUNode { const lastNode = this.tail.prev as LRUNode // tail.prev is always non-null and always LRUNode when cache is not empty this.removeNode(lastNode) return lastNode } /** * Sets a key-value pair in the cache. * If the key exists, updates the value and moves to head. * If new, adds at head and evicts from tail if necessary. * * Time Complexity: * - O(1) for uniform item sizes * - O(k) where k is the number of items evicted (can be O(N) for variable sizes) */ public set(key: string, value: T): void { const size = this.calculateSize?.(value) ?? 1 if (size > this.maxSize) { console.warn('Single item size exceeds maxSize') return } const existing = this.cache.get(key) if (existing) { // Update existing node: adjust size and move to head (most recent) existing.data = value this.totalSize = this.totalSize - existing.size + size existing.size = size this.moveToHead(existing) } else { // Add new node at head (most recent position) const newNode = new LRUNode(key, value, size) this.cache.set(key, newNode) this.addToHead(newNode) this.totalSize += size } // Evict least recently used items until under capacity while (this.totalSize > this.maxSize && this.cache.size > 0) { const tail = this.removeTail() this.cache.delete(tail.key) this.totalSize -= tail.size } } /** * Checks if a key exists in the cache. * This is a pure query operation - does NOT update LRU order. * * Time Complexity: O(1) */ public has(key: string): boolean { return this.cache.has(key) } /** * Retrieves a value by key and marks it as most recently used. * Moving to head maintains the LRU property for future evictions. * * Time Complexity: O(1) */ public get(key: string): T | undefined { const node = this.cache.get(key) if (!node) return undefined // Mark as most recently used by moving to head this.moveToHead(node) return node.data } /** * Returns an iterator over the cache entries. The order is outputted in the * order of most recently used to least recently used. */ public *[Symbol.iterator](): IterableIterator<[string, T]> { let current = this.head.next while (current && current !== this.tail) { // Between head and tail, current is always LRUNode const node = current as LRUNode yield [node.key, node.data] current = current.next } } /** * Removes a specific key from the cache. * Updates both the hash map and doubly-linked list. * * Time Complexity: O(1) */ public remove(key: string): void { const node = this.cache.get(key) if (!node) return this.removeNode(node) this.cache.delete(key) this.totalSize -= node.size } /** * Returns the number of items in the cache. */ public get size(): number { return this.cache.size } /** * Returns the current total size of all cached items. * This uses the custom size calculation if provided. */ public get currentSize(): number { return this.totalSize } }