← Back to DevBytes

LRU Cache: Implementation and Time Complexity Analysis

What is an LRU Cache?

An LRU Cache (Least Recently Used Cache) is a fixed-size data structure that evicts the least recently accessed item when it reaches capacity and a new item needs to be inserted. It maintains items in order of recency, automatically discarding items that haven't been accessed for the longest period. This eviction policy is based on the observation that data accessed recently is likely to be accessed again soon, while data that hasn't been touched for a while is less valuable to keep around.

At its core, an LRU Cache must support two primary operations efficiently:

Both operations should ideally run in O(1) time complexity, which makes the LRU Cache an interesting algorithmic challenge — a simple array or linked list alone won't give us constant-time lookups and constant-time eviction simultaneously.

Why the LRU Cache Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The LRU eviction strategy appears throughout systems design and performance engineering. Here are some practical scenarios where it shines:

In all these cases, the cache has a fixed memory budget, and LRU provides a simple, high-performance heuristic that performs well under typical access patterns exhibiting temporal locality.

Core Data Structures Behind O(1) Operations

To achieve O(1) for both get and put, we combine two data structures:

Here's why a singly linked list or an array won't work alone:

With a doubly linked list, each node has both prev and next pointers. Given a direct reference to a node (which the hash map provides), we can unlink it in O(1) by updating its neighbors' pointers.

Node Structure

class DoublyLinkedNode:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

The node stores the key alongside the value. This is critical: when we evict the tail node, we need its key to remove the corresponding entry from the hash map. Without storing the key in the node, we'd have no way to clean up the dictionary.

Full Implementation in Python

Below is a complete, production-style implementation of an LRU Cache. It includes thorough handling of edge cases: accessing an existing key updates its position, inserting a new key at capacity triggers eviction, and updating an existing key overwrites the value while refreshing recency.

class LRUCache:
    """
    A fixed-capacity cache that evicts the least recently used item
    when space is needed for a new insertion.
    
    All operations run in O(1) time.
    """
    
    def __init__(self, capacity: int):
        if capacity <= 0:
            raise ValueError("Capacity must be a positive integer")
        
        self.capacity = capacity
        self.size = 0
        
        # Hash map: key -> DoublyLinkedNode
        self.cache = {}
        
        # Sentinel nodes for the doubly linked list.
        # Head points to the most recently used item.
        # Tail points to the least recently used item.
        self.head = DoublyLinkedNode(None, None)  # dummy head
        self.tail = DoublyLinkedNode(None, None)  # dummy tail
        
        self.head.next = self.tail
        self.tail.prev = self.head
    
    # --------------------------------------------------
    # Private linked list helpers
    # --------------------------------------------------
    
    def _add_to_front(self, node):
        """Insert node immediately after the dummy head (MRU position)."""
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
    
    def _remove_node(self, node):
        """Unlink node from the list without deleting it."""
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node
    
    def _move_to_front(self, node):
        """Promote an existing node to the MRU position."""
        self._remove_node(node)
        self._add_to_front(node)
    
    def _evict_lru(self):
        """Remove the least recently used node (the one just before dummy tail)."""
        lru_node = self.tail.prev
        self._remove_node(lru_node)
        del self.cache[lru_node.key]
        self.size -= 1
    
    # --------------------------------------------------
    # Public API
    # --------------------------------------------------
    
    def get(self, key):
        """
        Retrieve the value for 'key' if it exists.
        Returns -1 if the key is not present.
        On a cache hit, the item is marked as most recently used.
        
        Time Complexity: O(1)
        """
        if key not in self.cache:
            return -1
        
        node = self.cache[key]
        self._move_to_front(node)
        return node.value
    
    def put(self, key, value):
        """
        Insert or update a key-value pair.
        If the key already exists, update its value and move it to the front.
        If the key is new and the cache is at capacity, evict the LRU item first.
        
        Time Complexity: O(1)
        """
        if key in self.cache:
            # Update existing key
            node = self.cache[key]
            node.value = value
            self._move_to_front(node)
            return
        
        # Evict if at capacity before inserting the new item
        if self.size == self.capacity:
            self._evict_lru()
        
        # Create and insert the new node
        new_node = DoublyLinkedNode(key, value)
        self.cache[key] = new_node
        self._add_to_front(new_node)
        self.size += 1
    
    # --------------------------------------------------
    # Debugging helpers
    # --------------------------------------------------
    
    def __str__(self):
        """Return a string showing items from MRU to LRU."""
        items = []
        current = self.head.next
        while current != self.tail:
            items.append(f"{current.key}:{current.value}")
            current = current.next
        return "{" + " -> ".join(items) + "}"
    
    def __len__(self):
        return self.size


class DoublyLinkedNode:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

Step-by-Step Walkthrough

Let's trace through a sequence of operations to see the internal mechanics in action:

# Create a cache with capacity 3
cache = LRUCache(3)

cache.put("a", 1)   # Cache: {a:1}
cache.put("b", 2)   # Cache: {b:2 -> a:1}
cache.put("c", 3)   # Cache: {c:3 -> b:2 -> a:1}

# Accessing "a" moves it to the front
cache.get("a")      # Returns 1, Cache: {a:1 -> c:3 -> b:2}

# Inserting "d" at capacity evicts the LRU item ("b")
cache.put("d", 4)   # Evicts b:2, Cache: {d:4 -> a:1 -> c:3}

# Verify eviction
print(cache.get("b"))  # Returns -1 (not found)
print(cache.get("a"))  # Returns 1
print(cache.get("c"))  # Returns 3
print(cache.get("d"))  # Returns 4

Time Complexity Analysis

Let's analyze every method in detail to confirm the O(1) guarantee:

get(key) — O(1)

The worst case for the hash lookup is O(n) if there are many hash collisions, but Python's dictionary implementation resizes dynamically to keep the load factor low, making this exceedingly rare in practice. We can treat it as O(1) amortized.

put(key, value) — O(1)

There's no loop, no traversal, and no allocation that scales with cache size. Every operation completes in a bounded number of steps regardless of how many items are in the cache.

Space Complexity

The space complexity is O(n) where n is the cache capacity. We store one dictionary entry and one doubly linked list node per cached item. The sentinel head and tail nodes consume constant extra space.

Comparison with Alternative Approaches

Here's how the combined hash-map-plus-doubly-linked-list approach compares to naive implementations:

Approach get() Time put() Time Notes
Array + linear scan O(n) O(n) Finding an item requires scanning; eviction requires shifting elements.
Singly linked list + hash map O(n) O(n) Removing an arbitrary node requires traversing from the head to find its predecessor.
OrderedDict (Python) O(1) O(1) Built-in solution (see next section), but relies on internal doubly linked list implementation.
Doubly linked list + hash map O(1) O(1) Optimal. Every operation is constant time.

Using Python's Built-in OrderedDict

Python's collections.OrderedDict (and since Python 3.7, the standard dict which maintains insertion order) can be leveraged to build an LRU Cache with remarkably little code. The OrderedDict internally uses a doubly linked list to preserve order, and its move_to_end method gives us the promotion behavior we need.

from collections import OrderedDict

class LRUCacheBuiltin:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()
    
    def get(self, key):
        if key not in self.cache:
            return -1
        # Move the accessed key to the end (most recent position)
        self.cache.move_to_end(key)
        return self.cache[key]
    
    def put(self, key, value):
        if key in self.cache:
            # Update and move to most recent
            self.cache.move_to_end(key)
            self.cache[key] = value
            return
        
        # Evict the least recently used (first item) if at capacity
        if len(self.cache) >= self.capacity:
            self.cache.popitem(last=False)  # last=False pops the first inserted item
        
        self.cache[key] = value
        # New items are automatically placed at the end (most recent)

This implementation is concise and leverages Python's optimized C-level data structures. The popitem(last=False) call removes the oldest entry in O(1), and move_to_end is also O(1). For production Python code, this is often the preferred approach unless you need explicit control over the linked list (for example, to add custom eviction callbacks or statistics).

Best Practices and Common Pitfalls

1. Always Store the Key Inside the Node

A common mistake is storing only the value in the linked list node. When the LRU node is evicted from the tail, you need its key to remove the corresponding entry from the hash map. Without the key, the dictionary would accumulate stale entries, causing a memory leak and incorrect behavior on subsequent get calls.

2. Use Sentinel Nodes

Sentinel (dummy) head and tail nodes eliminate edge-case null checks. Without them, every insertion and removal would need to handle cases where the list is empty or where we're operating on the first or last real node. Sentinels make the code cleaner and less error-prone.

3. Thread Safety Considerations

The basic LRU Cache implementation is not thread-safe. If multiple threads access the cache concurrently, you must add synchronization. A coarse-grained lock (like threading.Lock in Python) around get and put works but creates contention. For high-concurrency scenarios, consider:

4. Handle Capacity Zero or Negative Gracefully

Always validate the capacity parameter in the constructor. A zero-capacity cache should reject all put operations (or raise an error at construction time). Our implementation raises a ValueError for non-positive capacities, which is clear and immediate.

5. Consider Eviction Callbacks

In real-world systems, you often need to perform cleanup when an item is evicted — closing file handles, decrementing reference counts, or notifying downstream systems. Add an optional callback parameter:

class LRUCacheWithCallback:
    def __init__(self, capacity, on_evict=None):
        self.capacity = capacity
        self.on_evict = on_evict  # callable(key, value)
        # ... rest of initialization
    
    def _evict_lru(self):
        lru_node = self.tail.prev
        self._remove_node(lru_node)
        del self.cache[lru_node.key]
        self.size -= 1
        if self.on_evict:
            self.on_evict(lru_node.key, lru_node.value)

6. Monitor Cache Metrics

Track hit rate, miss rate, and eviction count. These metrics help you tune the cache capacity and diagnose performance issues. A simple instrumentation layer might look like this:

class InstrumentedLRUCache(LRUCache):
    def __init__(self, capacity):
        super().__init__(capacity)
        self.hits = 0
        self.misses = 0
        self.evictions = 0
    
    def get(self, key):
        if key in self.cache:
            self.hits += 1
            return super().get(key)
        self.misses += 1
        return -1
    
    def _evict_lru(self):
        self.evictions += 1
        super()._evict_lru()
    
    @property
    def hit_ratio(self):
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0

7. Beware of Memory Overhead

Each cache entry consumes memory for the dictionary slot, the node object, and its two pointers. In Python, this overhead is roughly 200–300 bytes per entry. For caches holding millions of small items, consider using a more memory-efficient representation: array-backed storage with integer indices instead of Python objects, or a language with value types like C++ or Rust. Alternatively, use an off-the-shelf library like cachetools or lru-dict which are implemented in C for better memory density.

8. Choose the Right Eviction Policy Variant

Standard LRU assumes that recency perfectly predicts future access, but this isn't always true. Consider these variants when appropriate:

LRU Cache in a Web Application Context

Imagine a high-traffic API endpoint that fetches user profiles from a database. Without caching, every request hits the database. With an LRU cache in front, we can dramatically reduce database load. Here's a sketch of how that might look in a Flask-like application:

# Global LRU cache shared across requests
user_profile_cache = LRUCache(capacity=5000)

def get_user_profile(user_id: int):
    # Try cache first
    cached = user_profile_cache.get(user_id)
    if cached != -1:
        return cached
    
    # Cache miss — fetch from database
    profile = db.fetch_user_profile(user_id)
    if profile:
        user_profile_cache.put(user_id, profile)
    return profile

The LRU eviction policy ensures that the cache automatically adapts to changing traffic patterns: if a subset of users becomes suddenly popular, their profiles will naturally displace less frequently accessed ones, keeping the cache fresh without manual invalidation.

Testing the LRU Cache

A thorough test suite validates correctness under various scenarios. Here's a minimal pytest-compatible test plan:

def test_basic_operations():
    cache = LRUCache(3)
    
    cache.put("a", 1)
    cache.put("b", 2)
    cache.put("c", 3)
    assert cache.get("a") == 1
    assert cache.get("b") == 2
    assert cache.get("c") == 3
    
    # Test eviction
    cache.put("d", 4)  # Should evict the LRU: "a" was accessed, so "b" or "c"? 
    # Actually after get("a") -> get("b") -> get("c"), "c" is MRU, "a" is LRU
    # Wait — let's trace carefully:
    # After puts: MRU=c, then b, LRU=a
    # get("a") moves a to front: MRU=a, then c, LRU=b
    # get("b") moves b to front: MRU=b, then a, LRU=c
    # get("c") moves c to front: MRU=c, then b, LRU=a
    # put("d") evicts "a"
    assert cache.get("a") == -1
    assert cache.get("b") == 2
    assert cache.get("c") == 3
    assert cache.get("d") == 4

def test_update_existing_key():
    cache = LRUCache(2)
    cache.put("x", 100)
    cache.put("x", 200)  # Update
    assert cache.get("x") == 200
    assert len(cache) == 1

def test_capacity_one():
    cache = LRUCache(1)
    cache.put("k1", "v1")
    assert cache.get("k1") == "v1"
    cache.put("k2", "v2")
    assert cache.get("k1") == -1
    assert cache.get("k2") == "v2"

def test_zero_capacity_raises():
    import pytest
    with pytest.raises(ValueError):
        LRUCache(0)
    with pytest.raises(ValueError):
        LRUCache(-5)

Conclusion

The LRU Cache is a foundational building block in systems design, balancing memory constraints with access patterns that exhibit temporal locality. By pairing a hash map with a doubly linked list — or leveraging Python's OrderedDict — you achieve true O(1) operations for both reads and writes. The implementation requires careful attention to details like storing keys in nodes, using sentinel pointers, and handling the eviction path correctly, but once in place, it delivers predictable, high-performance caching that adapts automatically to workload shifts.

Beyond the basic implementation, production-grade LRU caches benefit from instrumentation, eviction callbacks, thread safety, and consideration of variant policies like LRU-K or CLOCK. Whether you're building a database buffer pool, a web application cache layer, or a mobile image loader, understanding LRU internals gives you both a practical tool and deeper insight into the trade-offs that govern memory-bound system performance.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles