2. LRU Cache Intermediate

A. Realistic Interview Prompt

"Design an in-memory cache with a fixed capacity that supports get and put in O(1) time. When the cache is full, evict the least-recently-used entry."

B. Difficulty

Intermediate as a pure data-structure exercise (many candidates have memorized DLL+HashMap). It becomes a genuine LLD problem — not just a DSA problem — the moment the interviewer asks for thread-safety, pluggable eviction policies, or scale, which is why it's included here despite its simple core.

C. What This Problem Tests

D. Requirements

Functional

  1. Fixed capacity, set at construction
  2. get(key) returns the value or a miss signal; counts as a "use" (updates recency)
  3. put(key, value) inserts or updates; counts as a "use"
  4. When inserting past capacity, evict the least-recently-used entry first

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1What should get return on a miss — null, a sentinel, an exception?Defines the API contract callers rely onMethod signature / return type
2Is capacity fixed forever, or can it be resized at runtime?Resizing down means potentially evicting multiple entries at onceWhether a resize() method exists
3Does calling put on an existing key count as a "use" (update recency)?Determines whether put always calls moveToFrontput implementation
4Is this accessed by a single thread or concurrently?The single biggest design fork for this problemLocking strategy, or none
5Should the eviction policy be pluggable (LRU today, LFU tomorrow)?Determines whether to design a Cache interface nowInterface extraction vs. concrete class
6Is there a TTL/expiration requirement in addition to size-based eviction?TTL needs either lazy checks or a background sweepNode gains an expiry field; extra component
7Do we need a callback/listener when an entry is evicted?E.g. write-back to a database on evictionObserver/listener hook
8Is this a single in-process cache, or does it need to be shared across machines?Distributed caching (Redis-like) is a fundamentally different problemScoping — see Y
9Is capacity counted by number of entries, or by total memory/weight?Weight-based capacity changes the eviction-until-conditionCapacity accounting logic

F. Assumptions

G. Actors

ActorResponsibility
Client code / calling thread(s)Calls get/put; in the concurrent version, multiple threads call simultaneously
Cache (System)Maintains capacity invariant and recency order autonomously

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
Node<K,V>Intrusive doubly-linked-list element holding one entrykey, value, prev, next—Any policy logic — it's a pure data holder
LRUCache<K,V>Facade: owns the map + list, enforces capacity and recency ordercapacity, Map<K,Node>, head/tail sentinelsget(key), put(key,value)Persistence, distributed coordination
Cache<K,V> (interface, introduced only if pluggability is requested)Common contract so callers don't depend on a concrete eviction policy—get, putHow eviction actually works internally

I. Class Relationships

J. Class Diagram

head/tail + map values

LRUCache<K,V>

-capacity

-map

-head

-tail

+get(K) : V

+put(K,V)

Node<K,V>

key

value

prev

next

«interface»

Cache<K,V>

+get(K)

+put(K,V)

View Mermaid source
classDiagram
  class LRUCache~K,V~ {
    -capacity
    -map
    -head
    -tail
    +get(K) V
    +put(K,V)
  }
  class Node~K,V~ {
    key
    value
    prev
    next
  }
  class Cache~K,V~ {
    <<interface>>
    +get(K)
    +put(K,V)
  }
  LRUCache o-- Node : head/tail + map values
  Cache <|.. LRUCache

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
NodeHolds one key/value + pointersAny ordering decision
LRUCacheCapacity enforcement, recency ordering, lookupPersistence, cross-process sharing

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPNode is a pure data holder; all policy logic lives in LRUCacheNode with its own evictSelf() logic that reaches into siblings
OCPAdding a peek() or listener hook doesn't require changing existing move/evict logicEvery new requirement (TTL, listener) bolted on by editing get/put directly with more if/else
LSPIf Cache<K,V> exists, any implementation must honor O(1)-ish semantics callers expectAn "LFU-lite" implementation that's secretly O(n) but still claims the interface
ISPTwo-method interface — nothing forced on clients they don't needA bloated interface with resize(), persist(), subscribe() all mandatory
DIPClient code can depend on Cache<K,V> rather than LRUCache directly, if swappability was requestedClient code calling ((LRUCache)cache).evictOldest() directly, breaking the abstraction

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
Facade / EncapsulationCallers shouldn't manipulate the map or list directlyLRUCache.get/put hides both structuresN/A — always appropriate here
Strategy (cache-level, not field-level)Swapping LRU for LFU entirelyA Cache<K,V> interface with LRUCache/LFUCache as siblingsDo not try to inject a fine-grained "EvictionPolicy" object into one fixed DLL+Map structure — LFU needs frequency buckets, not a recency list. The two policies need different backing data structures, so the correct seam is the whole class, not a plugged-in comparator. This is a case where Strategy looks tempting but doesn't cleanly fit — flag it explicitly rather than forcing it.
Decorator (optional)Add logging/metrics around get/put without touching core logicMetricsCache wrapping a Cache<K,V>Skip if metrics were never asked for
Observer (optional)Notify on eviction (e.g. write-back)An EvictionListener called from inside put's eviction branchSkip unless a "notify on evict" requirement exists

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Eviction algorithmLRULFU, ARC, CLOCK/approximate-LRUCache<K,V> interface (whole-class swap, not a plugged field)
Concurrency modelSingle-threadedSingle lock → sharded locksLocking is internal to LRUCache; public API doesn't change
Capacity accountingEntry countByte/weight-basedIsolate capacity check into one method (isOverCapacity()) so the rule can change in one place
PersistenceNoneWrite-through/write-back to a storeFuture CacheWriter/CacheLoader hook, not built prematurely

O. Design Evolution

What stays stable: the two-method public API (get/put). Internal locking granularity and eviction bookkeeping evolve underneath it without callers noticing.

P. API / Interface Design

interface Cache<K, V> {
  Optional<V> get(K key);
  void        put(K key, V value);
}

class LRUCache<K, V> implements Cache<K, V> {
  LRUCache(int capacity);            // throws IllegalArgumentException if capacity <= 0
  Optional<V> get(K key);           // O(1); updates recency
  void        put(K key, V value);   // O(1); may trigger eviction
}

Ownership/validation notes: returning Optional<V> (or a language-appropriate nullable/sentinel) avoids ambiguity between "missing key" and "value is actually null." Capacity is validated once, at construction, not on every operation.

Q. Pseudocode — Critical Logic

get(key):
  node = map.get(key)
  if node is null: return MISS
  moveToHead(node)
  return node.value

put(key, value):
  node = map.get(key)
  if node exists:
    node.value = value
    moveToHead(node)
    return
  newNode = Node(key, value)
  map[key] = newNode
  addToHead(newNode)
  if map.size() > capacity:
    lru = tail.prev
    removeNode(lru)
    map.remove(lru.key)

R. End-to-End Use Case

capacity = 2
put(1,"a")  → list: [1]                map:{1}
put(2,"b")  → list: [2,1]              map:{1,2}
get(1)      → moves 1 to head → [1,2]  returns "a"
put(3,"c")  → over capacity → evict tail (2) → [3,1]   map:{1,3}
get(2)      → MISS (was evicted)

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
HashMap<K, Node>O(1) key lookupDirect pointer to the node, no scanningTreeMap by last-access time — O(log n), unnecessary here
Doubly linked list w/ head+tail sentinelsO(1) reordering and O(1) tail evictionSentinels avoid null-checks at the boundariesSingly linked list — can't remove an arbitrary node in O(1) without a prev pointer

T. Complexity Analysis

OperationTimeSpace
getO(1)O(1) extra
putO(1)O(1) extra
Overall storage—O(capacity)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Make it thread-safe."Concurrency reasoningGuard get/put with one exclusive lockLRUCacheMust realize get mutates (moveToHead) — a read-write lock optimization is a bug here, not an improvement
"Now support LFU, switchable at runtime."Recognizing pattern limitsExtract Cache<K,V>; write a separate LFUCache with frequency bucketsNew class, not a plugged fieldStrategy at the class level, not the field level (see M)
"Needs to hold a billion entries — one process won't fit."LLD → HLD boundaryRecognize this is now a distributed cache problemNone in-process — infra/service changeConsistent hashing, replication — out of LLD scope
"Add TTL to entries."State/lifecycle extensionNode gains an expiry timestamp; check lazily on access or sweep periodicallyNode, LRUCache.getLazy-check is simpler; background sweep is more proactive but adds a thread to reason about
"Reduce lock contention under heavy concurrent load."Scalability under concurrencyShard into N sub-caches, each with its own lock, keyed by hash(key) % NLRUCache wrapped by a ShardedCacheGlobal LRU order becomes approximate — trade precision for throughput
"What if two threads both try to insert the same brand-new key at once?"Atomicity of check-then-actThe existence-check + insert must happen inside the same locked sectionLRUCache.putClassic check-then-act race if split across two lock acquisitions
"Add an eviction callback for write-back to a database."Extensibility via hooksAn EvictionListener invoked when a node is removedLRUCache gains an optional listener fieldCall the listener outside the lock to avoid holding it during I/O
"I want to inspect the LRU value without affecting its recency."API design / CQS disciplineAdd a non-mutating peek(key)LRUCacheKeeps get's contract ("get counts as use") intact and explicit
"Persist cache contents across restarts."Persistence trade-offsSerialize order + entries on shutdown, or treat cache purely as derived/rebuildable stateNew (de)serialization pathOften simpler to just accept a cold cache after restart than to persist a cache correctly

W. Concurrency

The central insight: get is not a pure read — it mutates the linked list (moveToHead). Treating it as read-only and reaching for a read-write lock (letting concurrent "readers" proceed together) is a classic, subtle bug: two concurrent "reads" can corrupt the list pointers.

X. Persistence

Caches are usually intentionally volatile — the source of truth lives elsewhere (DB), and the cache is just an optimization. If persistence is explicitly requested: introduce a CacheLoader (read-through on miss) and/or CacheWriter (write-through/write-back on put), and be explicit that persisted "cache" state is a performance optimization, not a durability guarantee — losing it on crash should be an acceptable, recoverable event, not a data-loss incident.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDThis class: map+list, eviction, in-process locking
HLDA caching service/tier (Redis-like), client libraries, cache invalidation strategy
Distributed SystemsConsistent hashing across cache nodes, replication, handling node failure without losing all cached data at once

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
Ordering structureDoubly linked listSorted map by timestampDLL is O(1) for reorder/evict; sorted map is O(log n) but avoids pointer bookkeeping
Concurrency controlSingle global lockSharded locksGlobal lock is simple and exactly correct; sharding trades exact global LRU order for throughput
Eviction policy extensibilityWhole-class Strategy (Cache<K,V>)Field-level policy injection into one structureWhole-class swap is honest about differing internal structures; field injection looks elegant but breaks down for LFU

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design an LRU cache with O(1) get and put.
Candidate: I'll use a hash map for O(1) lookup and a doubly linked list for O(1) reordering — the map's values and the list's nodes will be the same objects.
Interviewer: Walk me through put when the cache is full.
Candidate: Insert the new node at the head, then if size exceeds capacity, remove the tail's previous node — that's the least recently used — and delete it from the map too.
Interviewer: Now make it thread-safe.
Candidate: I'd guard both get and put with a single exclusive lock. It's worth calling out explicitly: get isn't read-only here, it moves the node to the head, so a read-write lock would actually be a bug, not an optimization.
Interviewer: Good catch. Now, high contention — thousands of threads hitting this every millisecond.
Candidate: I'd shard into N independent LRU caches by hashing the key, each with its own lock — similar to how ConcurrentHashMap works internally. The trade-off is the LRU order becomes approximate across the whole cache rather than globally exact.
Interviewer: Now I want LFU as an alternative, switchable at runtime.
Candidate: I wouldn't try to inject an eviction-policy object into this same map+list structure — LFU needs frequency buckets, a genuinely different backing structure. I'd extract a Cache<K,V> interface and write LFUCache as a sibling implementation instead.
Interviewer: Last one — a billion entries, way past one machine's memory.
Candidate: That's outside LLD at that point — it becomes a distributed cache with consistent hashing and replication across nodes. The class design here stays the same per-node; the system around it changes.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
ashishps1/awesome-low-level-design (GitHub)HashMap + DLL, synchronized methods for thread safety, helper methods (addToHead, removeTail)Clean, minimal, multi-language reference implementationsDoesn't explore sharding or LFU extensibilityThe helper-method decomposition (addToHead/removeNode/moveToHead) keeps the core logic readable
Medium — Thread-Safe LRU Cache in JavaExplicit discussion of the "get mutates, so RW-lock is wrong" trap; proposes sharded buckets each with its own lockBest concurrency-specific reasoning of the sources foundLighter on the base data-structure walkthroughThe explicit "get is a write" framing — central to this document's W section
AlgoMonster / AlgoMaster (cross-referenced, not deep-fetched)Standard DLL+map walkthroughs with interview framingGood for a first-pass mental modelMentions Strategy/Singleton patterns somewhat by rote rather than derived from a requirementUseful as a sanity check against the synthesized design here

AF. Interview Cheat Sheet

ProblemLRU Cache
DifficultyIntermediate (Advanced+Concurrency once thread-safety is asked)
Core requirementsFixed capacity, O(1) get/put, evict LRU on overflow
Key clarificationsThread-safety needed? Pluggable eviction policy? TTL? Weight-based capacity?
Core entitiesNode, LRUCache (map + DLL), optional Cache<K,V> interface
Key relationshipsMap values and DLL nodes are the same shared objects
Key patternsFacade; whole-class Strategy for alternate policies (not field-level)
Most important decisionRecognizing get mutates state — drives the entire concurrency story
Likely-changing behaviorEviction algorithm, concurrency model, capacity accounting
Concurrency concernget-is-a-write; RW-lock is a bug, not an optimization; sharding trades exactness for throughput
Top follow-upsThread-safety, LFU swap, TTL, eviction callback, billion-entry scale (HLD boundary)
Common mistakesArrayList shifting, RW-lock misuse, forcing a generic eviction-policy field
What NOT to over-engineerPersistence, distributed coordination — unless explicitly asked

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// LRU Cache - full LLD implementation (single-file demo).
// Difficulty badge is plain "Intermediate" (no Concurrency tag), so this stays
// single-threaded and simple - no locks.
import java.util.*;

public class p2 {

    // ---------- Cache (interface) ----------
    // Exists so callers can depend on a contract rather than the concrete LRU
    // implementation - e.g. an LFU variant could implement this same interface later.
    interface Cache<K, V> {
        Optional<V> get(K key);
        void put(K key, V value);
    }

    // ---------- Node ----------
    // Pure intrusive doubly-linked-list element; holds no policy logic of its own.
    static class Node<K, V> {
        K key;
        V value;
        Node<K, V> prev;
        Node<K, V> next;
        Node(K key, V value) { this.key = key; this.value = value; }
    }

    // ---------- LRUCache ----------
    static class LRUCache<K, V> implements Cache<K, V> {
        private final int capacity;
        // Map gives O(1) lookup; the list (via the same Node objects) gives O(1) reordering.
        // This dual-structure trick is the core insight of the whole problem.
        private final Map<K, Node<K, V>> map;
        private final Node<K, V> head; // sentinel; head.next = most-recently-used
        private final Node<K, V> tail; // sentinel; tail.prev = least-recently-used

        LRUCache(int capacity) {
            if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0");
            this.capacity = capacity;
            this.map = new HashMap<>();
            this.head = new Node<>(null, null);
            this.tail = new Node<>(null, null);
            head.next = tail;
            tail.prev = head;
        }

        public Optional<V> get(K key) {
            Node<K, V> node = map.get(key);
            if (node == null) return Optional.empty();
            moveToHead(node);
            return Optional.of(node.value);
        }

        public void put(K key, V value) {
            Node<K, V> existing = map.get(key);
            if (existing != null) {
                existing.value = value;
                moveToHead(existing);
                return;
            }
            Node<K, V> newNode = new Node<>(key, value);
            map.put(key, newNode);
            addToHead(newNode);
            if (map.size() > capacity) {
                Node<K, V> lru = tail.prev;
                removeNode(lru);
                map.remove(lru.key);
            }
        }

        // ---- internal list helpers ----
        private void addToHead(Node<K, V> node) {
            node.prev = head;
            node.next = head.next;
            head.next.prev = node;
            head.next = node;
        }

        private void removeNode(Node<K, V> node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }

        private void moveToHead(Node<K, V> node) {
            removeNode(node);
            addToHead(node);
        }

        int size() { return map.size(); }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        LRUCache<Integer, String> cache = new LRUCache<>(2);
        cache.put(1, "a");
        cache.put(2, "b");
        System.out.println("get(1) = " + cache.get(1)); // Optional[a], 1 is now most recent

        cache.put(3, "c"); // capacity exceeded -> evicts key 2 (least recently used)
        System.out.println("get(2) = " + cache.get(2)); // Optional.empty (evicted)
        System.out.println("get(3) = " + cache.get(3)); // Optional[c]

        cache.put(1, "a-updated"); // update existing key, also refreshes recency
        System.out.println("get(1) = " + cache.get(1)); // Optional[a-updated]

        System.out.println("final size = " + cache.size());
    }
}