Loading content...
In computer science, we often face a fundamental trade-off: time vs space. Caching and memoization represent one of the most powerful applications of this trade-off—using extra memory to avoid redundant computation.
The core idea is elegant:
If you've computed something before, don't compute it again—just look it up.
This pattern appears everywhere:
At the heart of all these techniques is the hash table—providing O(1) lookup for cached values.
By the end of this page, you will understand memoization as a fundamental optimization technique: how to implement it manually and with utilities, when to apply it, what its limitations are, and how it connects to dynamic programming. You'll also learn about cache eviction policies and practical caching considerations.
Memoization is a specific form of caching where we store the results of function calls. When the function is called again with the same arguments, we return the cached result instead of recomputing.
Requirements for Memoization:
Pure Function: The function must be pure—given the same inputs, it always produces the same output (referential transparency). Side effects break memoization.
Hashable Arguments: Arguments must be usable as cache keys. For complex objects, this requires serialization.
Worth Caching: The computation must be expensive enough to justify the memory overhead.
The Canonical Example: Fibonacci
The naive recursive Fibonacci implementation has exponential time complexity due to redundant computation:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
/** * Naive Fibonacci: Exponential Time O(2^n) * * Problem: fib(40) makes over 300 million recursive calls * Each call to fib(n) computes fib(n-1) and fib(n-2) from scratch */function fibNaive(n: number): number { if (n <= 1) return n; return fibNaive(n - 1) + fibNaive(n - 2);} /** * Memoized Fibonacci: Linear Time O(n) * * Key insight: fib(k) is computed at most once for each k from 0 to n */function fibMemoized(n: number, cache = new Map<number, number>()): number { // Check cache first if (cache.has(n)) { return cache.get(n)!; } // Base cases if (n <= 1) return n; // Compute and cache const result = fibMemoized(n - 1, cache) + fibMemoized(n - 2, cache); cache.set(n, result); return result;} // Performance comparisonconsole.time('naive');// fibNaive(40); // ~1-2 secondsconsole.timeEnd('naive'); console.time('memoized');fibMemoized(40); // ~0.1 millisecondsconsole.timeEnd('memoized'); // Call count visualization for n=5:// Naive: fib(5) → fib(4)×1 → fib(3)×2 → fib(2)×3 → fib(1)×5 → fib(0)×3// Total: 15 calls// Memoized: fib(5) → fib(4)×1 → fib(3)×1 → fib(2)×1 → fib(1)×1 → fib(0)×1// Total: 6 calls (one per unique value)For Fibonacci, memoization reduces complexity from O(2ⁿ) to O(n)—an exponential-to-linear improvement. At n=40, that's from ~1 billion operations to ~40 operations. This pattern applies to ANY function with overlapping subproblems.
Rather than adding caching logic to every function, we can create a higher-order function that wraps any function with memoization. This is the decorator pattern applied to caching.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
/** * Generic memoization utility for single-argument functions */function memoize<T, R>(fn: (arg: T) => R): (arg: T) => R { const cache = new Map<T, R>(); return (arg: T): R => { if (cache.has(arg)) { return cache.get(arg)!; } const result = fn(arg); cache.set(arg, result); return result; };} // Example usageconst expensiveComputation = (n: number): number => { console.log(`Computing for ${n}...`); let result = 0; for (let i = 0; i < n * 1000000; i++) { result += i; } return result;}; const memoizedComputation = memoize(expensiveComputation); console.log(memoizedComputation(5)); // "Computing for 5..." (computed)console.log(memoizedComputation(5)); // (cached, no log)console.log(memoizedComputation(10)); // "Computing for 10..." (computed)console.log(memoizedComputation(5)); // (cached, no log) /** * Memoization for multi-argument functions * Key challenge: How to create cache keys from multiple arguments */function memoizeMulti<T extends any[], R>( fn: (...args: T) => R, keyFn: (...args: T) => string = (...args) => JSON.stringify(args)): (...args: T) => R { const cache = new Map<string, R>(); return (...args: T): R => { const key = keyFn(...args); if (cache.has(key)) { return cache.get(key)!; } const result = fn(...args); cache.set(key, result); return result; };} // Example: Memoizing a function with multiple argumentsconst gridTraversal = (rows: number, cols: number): number => { if (rows === 1 || cols === 1) return 1; return gridTraversal(rows - 1, cols) + gridTraversal(rows, cols - 1);}; const memoizedGrid = memoizeMulti(gridTraversal);console.log(memoizedGrid(10, 10)); // Fast with memoizationNon-hashable keys: Lists, dicts, and custom objects need serialization Memory growth: Unbounded caches can consume all memory Stale data: If underlying data changes, cached results become invalid Side effects: Memoizing impure functions causes bugs
Dynamic Programming (DP) is memoization applied systematically. The two approaches are equivalent but differ in direction:
Top-Down (Memoization):
Bottom-Up (Tabulation):
Both achieve the same asymptotic complexity, but each has advantages:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
/** * Coin Change Problem: Minimum coins to make amount * Classic DP problem demonstrating both approaches */ // Top-Down (Memoization)function coinChangeTopDown(coins: number[], amount: number): number { const cache = new Map<number, number>(); function dp(remaining: number): number { // Base cases if (remaining === 0) return 0; if (remaining < 0) return Infinity; // Check cache if (cache.has(remaining)) { return cache.get(remaining)!; } // Try each coin, take minimum let minCoins = Infinity; for (const coin of coins) { const result = dp(remaining - coin); minCoins = Math.min(minCoins, result + 1); } cache.set(remaining, minCoins); return minCoins; } const result = dp(amount); return result === Infinity ? -1 : result;} // Bottom-Up (Tabulation)function coinChangeBottomUp(coins: number[], amount: number): number { // dp[i] = minimum coins to make amount i const dp = new Array(amount + 1).fill(Infinity); dp[0] = 0; // Base case: 0 coins for amount 0 // Fill table from smallest to largest for (let i = 1; i <= amount; i++) { for (const coin of coins) { if (coin <= i) { dp[i] = Math.min(dp[i], dp[i - coin] + 1); } } } return dp[amount] === Infinity ? -1 : dp[amount];} // Both produce same resultsconsole.log(coinChangeTopDown([1, 2, 5], 11)); // 3 (5+5+1)console.log(coinChangeBottomUp([1, 2, 5], 11)); // 3Real-world caches can't grow indefinitely. When the cache is full, we need a cache eviction policy to decide what to remove.
Common Eviction Policies:
LRU Cache Implementation:
LRU is the most common policy. It requires O(1) access AND O(1) eviction, which is achieved using a hash map + doubly linked list:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
/** * LRU Cache Implementation * * Data Structures: * - Map: key → Node (O(1) lookup) * - Doubly Linked List: maintains access order (O(1) move/remove) * * Most recently used at tail, least recently used at head */class LRUNode<K, V> { key: K; value: V; prev: LRUNode<K, V> | null = null; next: LRUNode<K, V> | null = null; constructor(key: K, value: V) { this.key = key; this.value = value; }} class LRUCache<K, V> { private capacity: number; private cache: Map<K, LRUNode<K, V>>; private head: LRUNode<K, V>; // Dummy head (oldest) private tail: LRUNode<K, V>; // Dummy tail (newest) constructor(capacity: number) { this.capacity = capacity; this.cache = new Map(); // Initialize dummy nodes this.head = new LRUNode<K, V>(null as any, null as any); this.tail = new LRUNode<K, V>(null as any, null as any); this.head.next = this.tail; this.tail.prev = this.head; } private addToTail(node: LRUNode<K, V>): void { node.prev = this.tail.prev; node.next = this.tail; this.tail.prev!.next = node; this.tail.prev = node; } private removeNode(node: LRUNode<K, V>): void { node.prev!.next = node.next; node.next!.prev = node.prev; } private moveToTail(node: LRUNode<K, V>): void { this.removeNode(node); this.addToTail(node); } get(key: K): V | undefined { const node = this.cache.get(key); if (!node) return undefined; // Mark as recently used this.moveToTail(node); return node.value; } put(key: K, value: V): void { const existingNode = this.cache.get(key); if (existingNode) { // Update existing existingNode.value = value; this.moveToTail(existingNode); } else { // Add new const newNode = new LRUNode(key, value); this.cache.set(key, newNode); this.addToTail(newNode); // Evict if over capacity if (this.cache.size > this.capacity) { const lru = this.head.next!; this.removeNode(lru); this.cache.delete(lru.key); } } }} // Example usageconst cache = new LRUCache<number, string>(3);cache.put(1, "one");cache.put(2, "two");cache.put(3, "three");console.log(cache.get(1)); // "one" (1 becomes most recent)cache.put(4, "four"); // Evicts 2 (least recent)console.log(cache.get(2)); // undefined (evicted)console.log(cache.get(3)); // "three"Beyond algorithm optimization, caching appears throughout software systems. Understanding these patterns helps you apply hashing concepts to real-world problems.
Application 1: API Response Caching
Cache expensive API calls to reduce latency and server load:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
/** * API Response Cache with TTL (Time To Live) */interface CacheEntry<T> { value: T; expiresAt: number;} class TTLCache<T> { private cache = new Map<string, CacheEntry<T>>(); private defaultTTL: number; // milliseconds constructor(defaultTTL: number = 60000) { // Default 1 minute this.defaultTTL = defaultTTL; } get(key: string): T | undefined { const entry = this.cache.get(key); if (!entry) return undefined; if (Date.now() > entry.expiresAt) { this.cache.delete(key); return undefined; } return entry.value; } set(key: string, value: T, ttl?: number): void { this.cache.set(key, { value, expiresAt: Date.now() + (ttl ?? this.defaultTTL) }); } // Periodic cleanup of expired entries cleanup(): void { const now = Date.now(); for (const [key, entry] of this.cache) { if (now > entry.expiresAt) { this.cache.delete(key); } } }} // Example: Caching API responsesconst apiCache = new TTLCache<any>(30000); // 30 second TTL async function fetchWithCache(url: string): Promise<any> { // Check cache first const cached = apiCache.get(url); if (cached) { console.log('Cache hit:', url); return cached; } // Fetch and cache console.log('Cache miss, fetching:', url); const response = await fetch(url); const data = await response.json(); apiCache.set(url, data); return data;}Application 2: Computed Property Caching
Cache expensive property computations in objects:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
from functools import cached_propertyfrom typing import Listimport hashlib class DatasetAnalyzer: """ Class demonstrating computed property caching. Expensive computations are cached after first access. """ def __init__(self, data: List[float]): self._data = data @cached_property def mean(self) -> float: """Computed once, then cached.""" print("Computing mean...") return sum(self._data) / len(self._data) @cached_property def variance(self) -> float: """Uses cached mean.""" print("Computing variance...") mean = self.mean # Uses cached value return sum((x - mean) ** 2 for x in self._data) / len(self._data) @cached_property def checksum(self) -> str: """Expensive hash computation.""" print("Computing checksum...") data_str = ','.join(map(str, self._data)) return hashlib.sha256(data_str.encode()).hexdigest() # Usageanalyzer = DatasetAnalyzer([1.0, 2.0, 3.0, 4.0, 5.0]) # First access computesprint(analyzer.mean) # "Computing mean..." → 3.0print(analyzer.variance) # "Computing variance..." (mean already cached) → 2.0 # Subsequent access returns cachedprint(analyzer.mean) # 3.0 (no computation)print(analyzer.variance) # 2.0 (no computation) # Manual cache invalidation patternclass MutableDataset: """When data can change, need manual cache management.""" def __init__(self, data: List[float]): self._data = data self._cache = {} @property def mean(self) -> float: if 'mean' not in self._cache: self._cache['mean'] = sum(self._data) / len(self._data) return self._cache['mean'] def add_point(self, value: float) -> None: self._data.append(value) self._cache.clear() # Invalidate cacheApplication 3: Request Deduplication
Prevent duplicate in-flight requests:
123456789101112131415161718192021222324252627282930313233343536373839
/** * Request deduplication: If same request is in-flight, return that promise * instead of making a duplicate request. */class RequestDeduplicator { private inFlight = new Map<string, Promise<any>>(); async fetch(url: string): Promise<any> { // If request is already in-flight, return that promise if (this.inFlight.has(url)) { console.log(`Deduplicating request to ${url}`); return this.inFlight.get(url); } // Start new request const promise = fetch(url) .then(r => r.json()) .finally(() => { // Remove from in-flight when done this.inFlight.delete(url); }); this.inFlight.set(url, promise); return promise; }} // Usage: Multiple simultaneous calls get same responseconst dedup = new RequestDeduplicator(); // These all share ONE network requestPromise.all([ dedup.fetch('/api/data'), dedup.fetch('/api/data'), dedup.fetch('/api/data'),]).then(results => { // All three get same data from single request console.log(results);});Memoization isn't free. Understanding when it hurts more than it helps is crucial.
12345678910111213141516171819202122232425262728293031323334
// ❌ ANTI-PATTERN: Memoizing cheap operationsconst addBad = memoize((a: number, b: number) => a + b);// Cache lookup is comparable cost to addition! // ❌ ANTI-PATTERN: Memoizing with unique inputsconst formatDateBad = memoize((timestamp: number) => new Date(timestamp).toISOString());// If every timestamp is unique, cache never hits // ❌ ANTI-PATTERN: Memoizing impure functionslet counter = 0;const badMemo = memoize((n: number) => { counter++; // Side effect! return n * 2;});badMemo(5); // counter = 1badMemo(5); // counter = 1 (cached)badMemo(5); // counter = 1 (cached)// Caller might expect counter to be 3! // ❌ ANTI-PATTERN: Unbounded cache with many unique keysconst processingCache = new Map<string, any>();async function processData(data: HugeObject) { const key = JSON.stringify(data); // Expensive serialization! if (processingCache.has(key)) { return processingCache.get(key); } const result = expensiveProcess(data); processingCache.set(key, result); // Memory leak: cache grows indefinitely! return result;} // ✅ CORRECT: Bounded cache with evictionconst boundedCache = new LRUCache<string, any>(1000);Before memoizing, ask:
If any answer is 'no', reconsider memoization.
Caching and memoization are fundamental techniques for trading space for time. Understanding when and how to apply them is essential for building efficient systems.
| Pattern | Use Case | Key Consideration |
|---|---|---|
| Simple Memoization | Recursive functions | Pure functions only |
| LRU Cache | Bounded memory | Capacity tuning |
| TTL Cache | Time-sensitive data | Staleness tolerance |
| Request Deduplication | Concurrent requests | Promise handling |
| Computed Properties | Expensive derivations | Invalidation on change |
Congratulations! You have now mastered the common hashing patterns: frequency counting, two-sum/pair problems, duplicate detection, grouping by characteristic, and caching/memoization. These patterns form the foundation for solving countless problems efficiently. With hash tables in your toolkit, you can transform O(n²) algorithms into O(n) solutions and build systems that scale gracefully.