On This Page

What Is an LRU Cache?

A cache stores frequently accessed data in fast storage (usually RAM) to avoid expensive operations โ€” database queries, network calls, disk reads, or heavy computations. Your browser caches web pages so it doesn't re-download them every visit. Your CPU caches memory regions so it doesn't hit RAM every clock cycle. Caches are everywhere.

But caches have limited space. When the cache is full and you need to store something new, you have to evict (remove) something old to make room. The question is: what do you kick out? Different eviction strategies optimize for different access patterns.

Eviction Policies

LRU (Least Recently Used) evicts the item that hasn't been accessed for the longest time. The assumption: if you haven't touched it recently, you probably won't need it soon. This works well for most real-world access patterns, which tend to exhibit temporal locality โ€” recently accessed items are likely to be accessed again soon.

LFU (Least Frequently Used) evicts the item with the fewest total accesses. The assumption: rarely-accessed items are less valuable than frequently-accessed ones. This works well for stable popularity distributions (a few items are always popular), but it has a weakness: an item accessed 1,000 times last week but never again will linger in the cache forever while newer, more relevant items get evicted. LFU has a "stale popularity" problem.

Other policies you should know about:

A Brief History

The LRU concept dates back to the 1960s, when it was studied in the context of virtual memory page replacement โ€” the operating system needs to decide which memory pages to swap to disk when physical RAM is full. Lรกszlรณ Bรฉlรกdy proved in 1966 that the optimal strategy (minimizing page faults) is to evict the page that won't be used for the longest time in the future. Since we can't predict the future, LRU approximates this by looking at the recent past. It remains the most widely used cache eviction policy over 60 years later.

Where Are They Used?

How It Works

The core challenge: you need O(1) for both get(key) and put(key, value). No data structure does both on its own. A hash map (a data structure that maps keys to values using a hash function, giving O(1) average lookup and insertion) gives O(1) lookup by key, but can't track ordering. A doubly linked list (a linked list where each node has pointers to both the next and previous nodes, enabling O(1) insertion and deletion at any position) gives O(1) move-to-front and remove-from-tail, but can't do O(1) lookup by key. Combine them.

The Data Structure

Every key is in both structures simultaneously. The hash map lets you find any node instantly; the linked list lets you move nodes and identify the LRU element instantly.

get(key)

  1. Look up the key in the hash map โ†’ O(1).
  2. If not found, return -1.
  3. If found, get the linked list node.
  4. Move that node to the head of the list (it's now the most recently used) โ†’ O(1) with doubly linked list.
  5. Return its value.

put(key, value)

  1. If the key already exists: update its value, move the node to the head โ†’ O(1).
  2. If the key is new:
    1. Create a new node, insert it at the head of the list โ†’ O(1).
    2. Add the key โ†’ node mapping to the hash map โ†’ O(1).
    3. If the cache now exceeds capacity: remove the tail node from the list โ†’ O(1). Delete that node's key from the hash map โ†’ O(1).

The Sentinel Node Trick

Use sentinel (dummy) head and tail nodes โ€” nodes that hold no real data but simplify the code enormously. The real data lives between these two sentinels. This eliminates null checks everywhere โ€” you never have to handle "is this the first node?" or "is this the last node?" as special cases.

Without sentinels, every list operation needs 3-4 conditionals. With sentinels, every operation is the same 4 lines of pointer manipulation. This trick cuts your code in half and eliminates an entire class of bugs.

How LFU Differs

LFU is trickier because you need to track access frequency per item, and among items with the same frequency, evict the least recently used one (LRU as tiebreaker). The standard O(1) approach:

Interactive Visualization

Watch the doubly linked list and hash map work together. When you access an item, it moves to the front. When the cache overflows, the tail gets evicted.

LRU Cache (capacity: 4)

Operations & Complexity

OperationLRU CacheLFU CacheNotes
get(key) O(1) O(1) Hash map lookup + linked list move
put(key, val) O(1) O(1) Hash map insert + linked list ops
Eviction O(1) O(1) Remove tail (LRU) or min-freq tail (LFU)
Space O(capacity) O(capacity) Hash map + doubly linked list nodes

Everything is O(1). That's the whole point. If any operation is worse than O(1), you've designed it wrong. The hash map handles key lookup; the linked list handles ordering. Neither structure alone achieves O(1) for both tasks.

Implementation

LRU Cache (Python) โ€” Interview-Ready

Python
class Node:
    """Doubly linked list node holding a cache entry.
    Stores key (needed for hash map deletion on eviction) and value."""
    __slots__ = ('key', 'val', 'prev', 'next')  # Memory optimization

    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    """Least Recently Used cache with O(1) get and put.
    
    Data structure: HashMap + Doubly Linked List
    - HashMap: key โ†’ Node (for O(1) lookup)
    - DLL: ordered by recency (head = most recent, tail = least recent)
    - Sentinel head/tail eliminate null-check edge cases
    """

    def __init__(self, capacity: int):
        self.cap = capacity
        self.cache = {}  # key โ†’ Node

        # Sentinel (dummy) head and tail nodes
        # Real data lives between these two sentinels
        self.head = Node()  # Dummy head โ€” most recent end
        self.tail = Node()  # Dummy tail โ€” least recent end
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node):
        """Unlink a node from wherever it is in the list. O(1).
        Works for any position โ€” no special cases needed thanks to sentinels."""
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_to_front(self, node):
        """Insert node right after the dummy head (most recent position). O(1)."""
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def get(self, key: int) -> int:
        """Retrieve value by key. Moves accessed item to front (most recent).
        Returns -1 if key not found."""
        if key not in self.cache:
            return -1
        node = self.cache[key]
        # This key was just accessed โ€” make it the most recently used
        self._remove(node)
        self._add_to_front(node)
        return node.val

    def put(self, key: int, value: int) -> None:
        """Insert or update a key-value pair.
        If cache is at capacity, evicts the least recently used item."""
        if key in self.cache:
            # Key exists โ€” update value and move to front
            self._remove(self.cache[key])

        # Create new node (or replacement node if key existed)
        node = Node(key, value)
        self.cache[key] = node
        self._add_to_front(node)

        # Check capacity โ€” evict LRU if over limit
        if len(self.cache) > self.cap:
            lru = self.tail.prev  # The node just before dummy tail = least recent
            self._remove(lru)
            del self.cache[lru.key]  # This is why nodes store the key!


# Usage โ€” matches LeetCode 146 exactly
cache = LRUCache(2)
cache.put(1, 1)        # cache: {1=1}
cache.put(2, 2)        # cache: {1=1, 2=2}
print(cache.get(1))    # 1 โ€” moves key 1 to front
cache.put(3, 3)        # evicts key 2 (LRU), cache: {1=1, 3=3}
print(cache.get(2))    # -1 (evicted)
cache.put(4, 4)        # evicts key 1 (LRU, since 3 was accessed more recently via put)
print(cache.get(1))    # -1 (evicted)
print(cache.get(3))    # 3 (still in cache)
print(cache.get(4))    # 4 (still in cache)

LRU Cache โ€” Python OrderedDict Shortcut

Python
from collections import OrderedDict

class LRUCacheSimple(OrderedDict):
    """LRU Cache using Python's OrderedDict.
    
    OrderedDict maintains insertion order AND supports move_to_end().
    This is a legitimate 10-line implementation. Some interviewers
    accept it; others want the from-scratch version. Know both.
    
    In production, this is actually what you'd use โ€” no need to
    reinvent doubly linked lists when the stdlib has you covered.
    """

    def __init__(self, capacity: int):
        super().__init__()
        self.cap = capacity

    def get(self, key: int) -> int:
        if key not in self:
            return -1
        self.move_to_end(key)  # Mark as most recently used
        return self[key]

    def put(self, key: int, value: int) -> None:
        if key in self:
            self.move_to_end(key)
        self[key] = value
        if len(self) > self.cap:
            self.popitem(last=False)  # Remove oldest (leftmost) item

LRU Cache (JavaScript)

JavaScript
class LRUCache {
  constructor(capacity) {
    this.cap = capacity;
    this.cache = new Map();

    // Sentinel nodes โ€” the dummy trick
    this.head = { key: null, val: null, prev: null, next: null };
    this.tail = { key: null, val: null, prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  _remove(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _addToFront(node) {
    node.next = this.head.next;
    node.prev = this.head;
    this.head.next.prev = node;
    this.head.next = node;
  }

  get(key) {
    if (!this.cache.has(key)) return -1;
    const node = this.cache.get(key);
    this._remove(node);
    this._addToFront(node);
    return node.val;
  }

  put(key, value) {
    if (this.cache.has(key)) this._remove(this.cache.get(key));

    const node = { key, val: value, prev: null, next: null };
    this.cache.set(key, node);
    this._addToFront(node);

    if (this.cache.size > this.cap) {
      const lru = this.tail.prev;
      this._remove(lru);
      this.cache.delete(lru.key);
    }
  }
}

// Note: JavaScript's Map preserves insertion order, so you could
// also implement LRU by deleting and re-inserting on access.
// But the explicit DLL approach matches what interviewers expect.

LFU Cache (Python) โ€” O(1) Everything

Python
from collections import defaultdict, OrderedDict

class LFUCache:
    """Least Frequently Used cache with O(1) get and put (LC 460).
    
    Three data structures working together:
    1. key_to_val: key โ†’ value (for O(1) value lookup)
    2. key_to_freq: key โ†’ frequency (for O(1) frequency lookup)
    3. freq_to_keys: frequency โ†’ OrderedDict of keys (for O(1) LRU within each frequency)
    
    Plus min_freq to track which frequency bucket to evict from.
    """

    def __init__(self, capacity: int):
        self.cap = capacity
        self.key_to_val = {}
        self.key_to_freq = {}
        self.freq_to_keys = defaultdict(OrderedDict)  # freq โ†’ {key: None, ...}
        self.min_freq = 0

    def _update_freq(self, key):
        """Increment a key's frequency and move it to the right bucket."""
        freq = self.key_to_freq[key]
        self.key_to_freq[key] = freq + 1

        # Remove from current frequency bucket
        del self.freq_to_keys[freq][key]
        if not self.freq_to_keys[freq]:
            del self.freq_to_keys[freq]
            if self.min_freq == freq:
                self.min_freq += 1  # This bucket is empty โ€” min moves up

        # Add to new frequency bucket (at the end = most recent)
        self.freq_to_keys[freq + 1][key] = None

    def get(self, key: int) -> int:
        if key not in self.key_to_val:
            return -1
        self._update_freq(key)
        return self.key_to_val[key]

    def put(self, key: int, value: int) -> None:
        if self.cap <= 0:
            return

        if key in self.key_to_val:
            self.key_to_val[key] = value
            self._update_freq(key)
            return

        # Evict if at capacity
        if len(self.key_to_val) >= self.cap:
            # Remove the LRU item from the min-frequency bucket
            evict_key, _ = self.freq_to_keys[self.min_freq].popitem(last=False)
            del self.key_to_val[evict_key]
            del self.key_to_freq[evict_key]

        # Insert new item with frequency 1
        self.key_to_val[key] = value
        self.key_to_freq[key] = 1
        self.freq_to_keys[1][key] = None
        self.min_freq = 1  # New item always has frequency 1

Common Mistakes

โš ๏ธ Beginner Pitfalls with LRU Cache
  • Not storing the key in the linked list node: When you evict the tail node, you need to delete it from the hash map too. But you only have the node โ€” how do you know which key to delete? By storing the key inside the node. Forgetting this means you can't clean up the hash map on eviction, leading to a memory leak.
  • Forgetting to update the node when key already exists in put(): If put(1, 10) is called and key 1 already exists with value 5, you must update the value AND move the node to front. A common bug is creating a new node without removing the old one โ€” now you have two nodes for the same key, and the cache size tracking is wrong.
  • Not using sentinel nodes: Without dummy head/tail, every linked list operation needs 3-5 null checks. The code balloons from 20 lines to 40+, and each conditional is a potential bug. Sentinels are not optional โ€” they're essential for clean, correct code.
  • Using a singly linked list instead of doubly linked: Removing a node from a singly linked list requires finding its predecessor, which is O(n). A doubly linked list lets you unlink any node in O(1) because you have both prev and next pointers. The "doubly" part is non-negotiable.
  • Confusing LRU order on put() without get(): When put(key, val) is called for an existing key, that counts as an access โ€” the key moves to the front. Many implementations forget this and only move-to-front on get(). This causes wrong eviction order.

Real-World Applications

1. API Response Cache with TTL

Production caches usually combine LRU eviction with TTL (Time-To-Live โ€” an expiration time after which entries are considered stale and must be refreshed). Redis, Memcached, and CDN edge caches all support both mechanisms.

Python
import time

class TTLLRUCache:
    """LRU Cache with Time-To-Live expiration.
    
    Expiration is handled lazily: expired entries are only removed
    when accessed. Simple and sufficient for most use cases.
    """
    def __init__(self, capacity: int, default_ttl: float = 60.0):
        self.cap = capacity
        self.default_ttl = default_ttl
        self.cache = {}
        self.head = {'key': None, 'val': None, 'expires': 0, 'prev': None, 'next': None}
        self.tail = {'key': None, 'val': None, 'expires': 0, 'prev': None, 'next': None}
        self.head['next'] = self.tail
        self.tail['prev'] = self.head
        self.hits = 0
        self.misses = 0

    def _remove(self, node):
        node['prev']['next'] = node['next']
        node['next']['prev'] = node['prev']

    def _add_to_front(self, node):
        node['next'] = self.head['next']
        node['prev'] = self.head
        self.head['next']['prev'] = node
        self.head['next'] = node

    def get(self, key):
        if key not in self.cache:
            self.misses += 1
            return None
        node = self.cache[key]
        if time.time() > node['expires']:  # Lazy expiration
            self._remove(node)
            del self.cache[key]
            self.misses += 1
            return None
        self._remove(node)
        self._add_to_front(node)
        self.hits += 1
        return node['val']

    def put(self, key, value, ttl=None):
        if ttl is None: ttl = self.default_ttl
        if key in self.cache: self._remove(self.cache[key])
        node = {'key': key, 'val': value, 'expires': time.time() + ttl,
                'prev': None, 'next': None}
        self.cache[key] = node
        self._add_to_front(node)
        while len(self.cache) > self.cap:
            lru = self.tail['prev']
            self._remove(lru)
            del self.cache[lru['key']]

    @property
    def hit_rate(self):
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0


api_cache = TTLLRUCache(capacity=100, default_ttl=30.0)
api_cache.put("user:123", {"name": "Alice", "role": "admin"})
api_cache.put("product:456", {"name": "Widget", "price": 29.99}, ttl=300)
print(api_cache.get("user:123"))  # Cache hit
print(f"Hit rate: {api_cache.hit_rate:.1%}")

2. Python's functools.lru_cache (Memoization)

Python ships with an LRU cache built into the standard library. The @lru_cache decorator caches function return values based on arguments. It's used for memoizing expensive computations โ€” recursive Fibonacci, database lookups, API calls, anything where the same inputs produce the same outputs. One decorator, massive speedup.

Python
from functools import lru_cache
import time

# Without caching: exponential time O(2^n)
def fib_slow(n):
    if n < 2: return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# With caching: O(n) time, O(n) space
@lru_cache(maxsize=128)  # Cache up to 128 unique inputs
def fib_fast(n):
    if n < 2: return n
    return fib_fast(n - 1) + fib_fast(n - 2)

start = time.time()
print(fib_fast(100))  # 354224848179261915075 โ€” instant
print(f"Time: {time.time() - start:.6f}s")

# Check cache statistics
print(fib_fast.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
# 98 cache hits = 98 recursive calls skipped!

# Real-world: memoize expensive database queries
@lru_cache(maxsize=1000)
def get_user_permissions(user_id):
    """Cache user permissions to avoid repeated DB queries.
    First call: hits the database. Subsequent calls: instant."""
    # Imagine this queries a database (100ms round trip)
    return {"user_id": user_id, "roles": ["admin", "editor"]}

3. DNS Resolution Cache

Every time you visit a website, your computer translates the domain name (like "google.com") to an IP address. This DNS lookup takes 10-100ms. Your operating system, browser, and ISP all maintain LRU caches of recent DNS lookups. Without DNS caching, every page load would be measurably slower โ€” especially pages that load resources from multiple domains (CDNs, analytics, ads).

Python
import time

class DNSCache:
    """Simplified DNS resolver cache with LRU eviction and TTL.
    
    Real DNS caches (in your OS, browser, and ISP) work similarly:
    - Cache DNS responses with their TTL (time-to-live)
    - Evict least recently used entries when full
    - A cache hit saves 10-100ms per lookup
    
    Typical sizes: browser cache ~1000 entries, OS cache ~10000
    """
    def __init__(self, capacity=1000):
        self.cache = {}  # domain โ†’ (ip, expiry, prev, next)
        self.cap = capacity
        self.hits = 0
        self.misses = 0
        # Using OrderedDict for simplicity here
        from collections import OrderedDict
        self.lru = OrderedDict()

    def resolve(self, domain):
        """Resolve a domain name to an IP address."""
        now = time.time()

        if domain in self.lru:
            ip, expiry = self.lru[domain]
            if now < expiry:
                self.lru.move_to_end(domain)
                self.hits += 1
                return ip  # Cache hit! Saved ~50ms
            else:
                del self.lru[domain]  # Expired

        # Cache miss โ€” do actual DNS lookup (simulated)
        self.misses += 1
        ip, ttl = self._dns_lookup(domain)

        self.lru[domain] = (ip, now + ttl)
        self.lru.move_to_end(domain)

        # Evict if over capacity
        while len(self.lru) > self.cap:
            self.lru.popitem(last=False)

        return ip

    def _dns_lookup(self, domain):
        """Simulate a real DNS lookup (would take 10-100ms)."""
        # Returns (ip_address, ttl_seconds)
        return (f"93.184.216.{hash(domain) % 256}", 300)

    @property
    def hit_rate(self):
        total = self.hits + self.misses
        return self.hits / total if total else 0


dns = DNSCache(capacity=100)
# First visit โ€” cache miss
dns.resolve("google.com")    # Miss: ~50ms lookup
# Second visit โ€” cache hit  
dns.resolve("google.com")    # Hit: <0.001ms (instant)
print(f"DNS cache hit rate: {dns.hit_rate:.0%}")  # 50%

4. Redis Cache Configuration

Redis is the most widely-used in-memory cache in production. When configured with a memory limit, it uses LRU (or LFU) eviction to decide what to drop. Here's how you'd configure and interact with Redis as an LRU cache โ€” this is production code that runs at companies like Twitter, GitHub, and Airbnb.

Python
# Redis LRU configuration (redis.conf):
# maxmemory 256mb
# maxmemory-policy allkeys-lru
#
# Policies available:
#   allkeys-lru    โ†’ LRU across ALL keys (most common)
#   volatile-lru   โ†’ LRU only on keys with TTL set
#   allkeys-lfu    โ†’ LFU across all keys (Redis 4.0+)
#   allkeys-random โ†’ Random eviction (simple, sometimes sufficient)
#   noeviction     โ†’ Return error when memory is full

import redis

# Connect to Redis configured as LRU cache
r = redis.Redis(host='localhost', port=6379)

# Cache an API response with 5-minute TTL
r.setex("api:user:123", 300, '{"name": "Alice", "role": "admin"}')

# Read from cache โ€” O(1)
cached = r.get("api:user:123")
if cached:
    print(f"Cache hit: {cached.decode()}")
else:
    print("Cache miss โ€” fetch from database")
    # ... fetch from DB, then cache the result

# Check memory usage
info = r.info("memory")
print(f"Used memory: {info['used_memory_human']}")
print(f"Evicted keys: {info.get('evicted_keys', 0)}")

Interview Patterns

๐Ÿ’ก The #1 Most Asked Design Question

LRU Cache (LeetCode 146) has been asked at Google, Amazon, Meta, Microsoft, Apple, Netflix, and basically every company that conducts technical interviews. If you learn one data structure implementation by heart, make it this one. You should be able to write it in 15 minutes on a whiteboard with zero bugs. Practice until the sentinel node setup is muscle memory.

๐Ÿ’ก Pattern: Hash Map + Doubly Linked List

This is THE pattern for LRU. The hash map gives O(1) key lookup. The doubly linked list gives O(1) reordering. Together they achieve O(1) for everything. The sentinel head/tail trick is non-negotiable โ€” learn it, internalize it, use it every time. Write exactly two helper methods: _remove(node) and _add_to_front(node). Every operation becomes a combination of these two.

๐Ÿ’ก Pattern: OrderedDict Shortcut (Python)

Python's collections.OrderedDict already combines a dict with insertion order. You can call move_to_end(key) to bump items and popitem(last=False) to remove the oldest. Some interviewers accept this; others want the from-scratch implementation. Know both โ€” use OrderedDict in production, write the DLL version in interviews. The from-scratch version shows you understand the mechanics; the OrderedDict version shows you know the tools.

๐Ÿ’ก Clean Implementation Tips

Keep the Node class minimal โ€” just key, val, prev, next. Use __slots__ for memory optimization if the interviewer cares about production code. The code should read almost like pseudocode: "remove the node, add it to front." If your get() or put() method is longer than 8 lines, you're probably not using helpers.

๐Ÿ’ก Pattern: LFU = LRU + Frequency Buckets

LFU Cache (LC 460) is a harder variant. The trick: maintain a hash map of frequency โ†’ LRU list. Each list is ordered by recency within that frequency. Track the minimum frequency globally. When evicting, remove the tail of the min-frequency list. When incrementing a key's frequency, move it to the next bucket. The min_freq update logic is the tricky part โ€” it only increments when the current min-freq bucket becomes empty.

๐Ÿ’ก Pattern: TTL Cache Extension

A common follow-up: "add expiration to your cache." Two approaches: lazy deletion (check TTL on every get(), remove if expired โ€” simple but doesn't proactively free memory) or active expiration (background thread periodically scans for expired entries โ€” more complex but frees memory faster). Redis uses a hybrid: lazy deletion on every read, plus a probabilistic active sweep that runs 10 times per second and randomly samples 20 keys with TTL, deleting expired ones.

๐Ÿ’ก Pattern: Thread-Safe LRU Cache

Follow-up question: "make it thread-safe." The simplest approach: wrap get() and put() with a mutex lock. More sophisticated: use a read-write lock (multiple readers, single writer). Even more advanced: use a concurrent hash map + lock-free linked list, or shard the cache by key hash (each shard has its own lock). Java's ConcurrentLinkedHashMap and Google's Caffeine cache use lock striping (16-64 shards). Know at least the mutex approach for interviews.

๐Ÿ’ก Pattern: Cache Metrics

Production caches track: hit rate (% of gets that return data), eviction count, average latency, and memory usage. A low hit rate means your cache is too small or your access pattern is pathological (e.g., sequential scan through data larger than the cache). Mentioning these metrics in a system design interview shows you think about production operations, not just code. A good target: 90%+ hit rate for read-heavy workloads.

Practice Problems

ProblemDifficultyKey Idea
146. LRU Cache Medium The classic โ€” HashMap + Doubly Linked List
460. LFU Cache Hard Frequency buckets, each bucket is an LRU list
707. Design Linked List Medium Practice doubly linked list ops โ€” prerequisite for LRU
432. All O'one Data Structure Hard Similar pattern โ€” HashMap + Doubly Linked List of frequency buckets
1472. Design Browser History Medium Doubly linked list navigation โ€” back/forward with truncation
706. Design HashMap Easy Build the hash map component from scratch
1381. Design a Stack With Increment Operation Medium Practice custom data structure design with O(1) operations
981. Time Based Key-Value Store Medium Key-value store with timestamp โ€” cache concepts + binary search