Design-Focused DSA
Master class-based data structure design problems that combine multiple data structures for O(1) operations. Learn the hash map + doubly linked list pattern for LRU/LFU caches, multi-map coordination, and the art of trading space for time in interview design problems.
Learn & Reference
Understanding the Pattern
Design-Focused DSA Pattern
Design-focused DSA problems ask you to build a class (or set of classes) that supports multiple operations, each with strict time complexity requirements. Unlike standard algorithm problems where you write a single function, these problems require you to choose and combine data structures so that every public method meets its O(1) or O(log n) contract.
Core Concept
The central idea is combining two or more data structures so that each one compensates for the other's weakness. A hash map gives O(1) lookup by key but has no concept of ordering. A doubly linked list maintains insertion order and supports O(1) removal (given a node reference) but has no fast lookup. Combine them and you get O(1) lookup AND O(1) order-aware removal — the foundation of LRU Cache.
Hash Map + Doubly Linked List = O(1) ordered cache
HashMap: key -> Node reference (O(1) lookup)
DLL: head <-> [A] <-> [B] <-> [C] <-> tail (O(1) add/remove)
get(key):
1. HashMap lookup -> Node O(1)
2. Remove Node from DLL O(1) (have the reference)
3. Move Node to head of DLL O(1)
put(key, val):
1. If exists: update + move to head O(1)
2. If new: create Node, add to head O(1)
3. If over capacity: remove tail node O(1)
Also remove from HashMap O(1)
Key Techniques
1. Hash Map + Doubly Linked List (LRU Cache)
This is the most common design pattern in interviews. The hash map stores key -> node mappings for O(1) access, and the doubly linked list maintains recency order. When an item is accessed, move it to the front. When capacity is exceeded, evict from the tail.
The doubly linked list uses sentinel nodes (dummy head and dummy tail) to eliminate null-check edge cases. Every insertion and removal is between two existing nodes, so the code is uniform.
Sentinel DLL structure:
dummyHead <-> [MRU] <-> ... <-> [LRU] <-> dummyTail
Remove node X:
X.prev.next = X.next
X.next.prev = X.prev
Insert node X after dummyHead:
X.next = dummyHead.next
X.prev = dummyHead
dummyHead.next.prev = X
dummyHead.next = X
2. Multi-Map Coordination (LFU Cache)
LFU (Least Frequently Used) cache extends the pattern with three coordinated maps:
keyToNode: maps key to its node (value, frequency)freqToList: maps each frequency to a doubly linked list of nodes at that frequencyminFreq: tracks the current minimum frequency for O(1) eviction
On access, a node's frequency increases by 1: remove it from the old frequency list, add it to the new frequency list. If the old frequency list becomes empty and it was minFreq, increment minFreq. On eviction, remove the tail of freqToList[minFreq].
3. Class Design Skeleton
Every design problem follows the same structure:
- Constructor: initialize your data structures and any capacity/configuration
- Core methods: implement each operation using your chosen data structures
- Helper methods: extract repeated pointer operations (like
_remove,_addToHead) into private helpers
The key insight: design your helpers first, then the public methods become 3-5 line functions that compose the helpers.
4. Time-Space Tradeoffs
Design problems explicitly trade space for time. Maintaining a second (or third) data structure costs O(n) extra space, but it buys you O(1) operations that would otherwise be O(n). Common tradeoffs:
- Extra hash map for reverse lookup: O(n) space, O(1) reverse access
- Doubly linked list alongside hash map: O(n) space, O(1) ordered removal
- Frequency buckets: O(n) space, O(1) frequency-based eviction
5. Lazy Deletion
Some design problems benefit from lazy deletion — instead of immediately removing stale entries, mark them as invalid and skip them when encountered during future operations. This is useful when:
- Direct deletion is expensive (e.g., removing from a heap is O(n))
- The data structure doesn't support efficient targeted removal
- Entries naturally expire and are cleaned up during normal access
Recognizing Design Problems
Design problems always provide:
- A class name and constructor signature
- Multiple methods with specified return types
- Explicit time complexity requirements (usually O(1) for all operations)
- A capacity constraint or size limit
The challenge is never about a clever algorithm — it is about choosing the right combination of data structures and wiring them together correctly.
Common Mistakes
- Not using sentinel nodes in doubly linked lists: Without dummy head and tail nodes, every insertion and removal needs special cases for empty list, single element, head removal, and tail removal. Sentinels eliminate all of these — always use them
- Forgetting to update ALL data structures on every operation: If you have a hash map and a linked list, every put/get/delete must update both. Forgetting to remove a key from the hash map when evicting from the linked list causes stale references and wrong capacity counts
- Getting DLL pointer operations out of order: When inserting or removing a node, the order of pointer assignments matters. Drawing the before/after state and numbering the steps prevents bugs. A common error is overwriting a
nextpointer before saving the reference you still need - Not handling the "update existing key" case in put(): Many candidates handle only the "insert new key" path. If
put(key, newValue)is called with an existing key, you must update the value AND move the node to the front (LRU) or update its frequency (LFU) — not insert a duplicate - Tracking minFreq incorrectly in LFU: When a node is accessed and its frequency increases from
ftof+1, if the frequency-flist becomes empty ANDminFreq == f, you must incrementminFreq. Forgetting this causes eviction from the wrong frequency bucket - Making helpers that allocate unnecessarily: The point of O(1) operations is to avoid allocation in hot paths. Your
_removeand_addToHeadhelpers should rewire pointers in place, not create new nodes
When to Use
Template
1# --- Design-Focused DSA Template: Cache with DLL + HashMap ---23class DLLNode:4"""Doubly linked list node storing key-value pair."""5def __init__(self, key=0, val=0):6self.key = key7self.val = val8self.prev = None9self.next = None1011class Cache:12def __init__(self, capacity: int):13self.capacity = capacity14self.cache = {} # key -> DLLNode15# Sentinel nodes eliminate edge cases16self.head = DLLNode() # dummy head (MRU side)17self.tail = DLLNode() # dummy tail (LRU side)18self.head.next = self.tail19self.tail.prev = self.head2021def _remove(self, node: DLLNode) -> None:22"""Remove a node from anywhere in the DLL. O(1)."""23node.prev.next = node.next24node.next.prev = node.prev2526def _add_to_head(self, node: DLLNode) -> None:27"""Insert a node right after the dummy head (MRU position). O(1)."""28node.next = self.head.next29node.prev = self.head30self.head.next.prev = node31self.head.next = node3233def get(self, key: int) -> int:34if key not in self.cache:35return -136node = self.cache[key]37self._remove(node)38self._add_to_head(node) # mark as recently used39return node.val4041def put(self, key: int, value: int) -> None:42if key in self.cache:43node = self.cache[key]44node.val = value45self._remove(node)46self._add_to_head(node)47else:48node = DLLNode(key, value)49self.cache[key] = node50self._add_to_head(node)51if len(self.cache) > self.capacity:52lru = self.tail.prev # least recently used53self._remove(lru)54del self.cache[lru.key]
Syntax Reference
1# --- Python: OrderedDict (built-in LRU building block) ---2from collections import OrderedDict34od = OrderedDict()56# Insert / update (appends to end)7od[key] = value89# Access (does NOT move to end — use move_to_end)10val = od[key] # KeyError if missing11val = od.get(key, default) # returns default if missing1213# Move to end (most recently used)14od.move_to_end(key) # move to end (MRU)15od.move_to_end(key, last=False) # move to front (LRU end)1617# Pop oldest (least recently used)18oldest_key, oldest_val = od.popitem(last=False) # FIFO pop1920# Pop newest (most recently used)21newest_key, newest_val = od.popitem(last=True) # LIFO pop2223# Check existence / size24key in od # O(1)25len(od) # O(1)2627# Delete specific key28del od[key] # O(1)2930# --- Doubly Linked List Node (manual approach) ---31class DLLNode:32def __init__(self, key=0, val=0):33self.key = key34self.val = val35self.prev = None36self.next = None
Common Recipes
1# --- LRU Cache (OrderedDict approach) ---2from collections import OrderedDict34class LRUCache:5def __init__(self, capacity: int):6self.capacity = capacity7self.cache = OrderedDict()89def get(self, key: int) -> int:10if key not in self.cache:11return -112self.cache.move_to_end(key) # mark as recently used13return self.cache[key]1415def put(self, key: int, value: int) -> None:16if key in self.cache:17self.cache.move_to_end(key)18self.cache[key] = value19if len(self.cache) > self.capacity:20self.cache.popitem(last=False) # evict oldest2122# --- LRU Cache (Manual DLL + HashMap) ---23class DLLNode:24def __init__(self, key=0, val=0):25self.key = key26self.val = val27self.prev = None28self.next = None2930class LRUCacheManual:31def __init__(self, capacity: int):32self.capacity = capacity33self.cache = {} # key -> DLLNode34self.head = DLLNode() # sentinel35self.tail = DLLNode() # sentinel36self.head.next = self.tail37self.tail.prev = self.head3839def _remove(self, node):40node.prev.next = node.next41node.next.prev = node.prev4243def _add_to_head(self, node):44node.next = self.head.next45node.prev = self.head46self.head.next.prev = node47self.head.next = node4849def get(self, key):50if key not in self.cache:51return -152node = self.cache[key]53self._remove(node)54self._add_to_head(node)55return node.val5657def put(self, key, value):58if key in self.cache:59node = self.cache[key]60node.val = value61self._remove(node)62self._add_to_head(node)63else:64node = DLLNode(key, value)65self.cache[key] = node66self._add_to_head(node)67if len(self.cache) > self.capacity:68lru = self.tail.prev69self._remove(lru)70del self.cache[lru.key]7172# --- Time-Based Key-Value Store Pattern ---73import bisect7475class TimeMap:76def __init__(self):77self.store = {} # key -> [(timestamp, value)]7879def set(self, key, value, timestamp):80if key not in self.store:81self.store[key] = []82self.store[key].append((timestamp, value))8384def get(self, key, timestamp):85if key not in self.store:86return ""87pairs = self.store[key]88i = bisect.bisect_right(pairs, (timestamp, chr(127))) - 189return pairs[i][1] if i >= 0 else ""
Complexity
| Operation | Time | Space |
|---|---|---|
| LRU Cache get() | O(1) | O(capacity) |
| LRU Cache put() | O(1) | O(capacity) |
| LFU Cache get() | O(1) | O(capacity) |
| LFU Cache put() | O(1) | O(capacity) |
| DLL insert (given position) | O(1) | O(1) |
| DLL remove (given node ref) | O(1) | O(1) |
| HashMap lookup | O(1) avg | O(n) |
| Total space (cache of capacity c) | - | O(c) |
Watch Out
- ✗Not using sentinel nodes in doubly linked lists: Without dummy head and tail nodes, every insertion and removal needs special cases for empty list, single element, head removal, and tail removal. Sentinels eliminate all of these — always use them
- ✗Forgetting to update ALL data structures on every operation: If you have a hash map and a linked list, every put/get/delete must update both. Forgetting to remove a key from the hash map when evicting from the linked list causes stale references and wrong capacity counts
- ✗Getting DLL pointer operations out of order: When inserting or removing a node, the order of pointer assignments matters. Drawing the before/after state and numbering the steps prevents bugs. A common error is overwriting a `next` pointer before saving the reference you still need
- ✗Not handling the "update existing key" case in put(): Many candidates handle only the "insert new key" path. If `put(key, newValue)` is called with an existing key, you must update the value AND move the node to the front (LRU) or update its frequency (LFU) — not insert a duplicate
- ✗Tracking minFreq incorrectly in LFU: When a node is accessed and its frequency increases from `f` to `f+1`, if the frequency-`f` list becomes empty AND `minFreq == f`, you must increment `minFreq`. Forgetting this causes eviction from the wrong frequency bucket
- ✗Making helpers that allocate unnecessarily: The point of O(1) operations is to avoid allocation in hot paths. Your `_remove` and `_addToHead` helpers should rewire pointers in place, not create new nodes