Skip to content
DSA Patterns

Learn

Design Data Structure

How to design a data structure in an interview: pick the combination that makes every operation O(1). Covers LRU and LFU caches, tries, Min Stack and O(1) random removal.

3 min readUpdated 2 Sept 2026

#Design#Hash Map#Linked List#Trie

These questions give you an API and a complexity target — usually O(1) — and ask you to build to it. The recurring answer is that **no single structure gets you there; a combination does.** A hash map has O(1) lookup but no order. A linked list has O(1) removal but no lookup. Put them together and you have both, and that composition is the entire pattern.

Min Stack

The warm-up. push, pop, top and getMin, all O(1). Scanning for the minimum is O(n), so store it — but the minimum changes as things pop, which means one variable is not enough. Store a minimum per element:

class MinStack:
    def __init__(self):
        self.stack = []          # (value, minimum at or below this point)

    def push(self, x):
        current_min = min(x, self.stack[-1][1]) if self.stack else x
        self.stack.append((x, current_min))

    def pop(self):
        self.stack.pop()

    def top(self):
        return self.stack[-1][0]

    def get_min(self):
        return self.stack[-1][1]

The idea generalises: when a query's answer changes as the structure shrinks, keep the answer alongside each element rather than in a single field.

LRU Cache

The most-asked design question there is. get and put in O(1), evicting the least recently used entry when full.

  • A hash map gives O(1) lookup by key.
  • A doubly linked list gives O(1) move-to-front and O(1) removal from the tail.
  • The map stores nodes of that list, so any key can be unlinked without traversing.

Doubly linked is required — a singly linked list cannot unlink a node in O(1) because it cannot reach the predecessor.

hash mapkey → nodeCABDhead — most recenttail — evict thisprev and next pointers are what make unlinking any node O(1)
The map gives O(1) lookup; the doubly linked list gives O(1) move-to-front and O(1) eviction from the tail. Neither structure alone can do both.
class Node:
    __slots__ = ("key", "value", "prev", "next")
    def __init__(self, key=0, value=0):
        self.key, self.value = key, value
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        # Sentinel head and tail: no null checks anywhere in the link code.
        self.head, self.tail = Node(), Node()
        self.head.next, self.tail.prev = self.tail, self.head

    def _remove(self, node):
        node.prev.next, node.next.prev = node.next, node.prev

    def _add_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.map:
            return -1
        node = self.map[key]
        self._remove(node)
        self._add_front(node)          # touching an entry makes it most recent
        return node.value

    def put(self, key, value):
        if key in self.map:
            self._remove(self.map[key])
        node = Node(key, value)
        self.map[key] = node
        self._add_front(node)

        if len(self.map) > self.capacity:
            lru = self.tail.prev
            self._remove(lru)
            del self.map[lru.key]      # store the key IN the node

Two details that decide whether this works: sentinel head and tail nodes, which remove every null check from the link manipulation, and storing the key inside the node, without which eviction cannot find the map entry to delete.

LFU Cache is the harder sibling: a map from key to node, a map from frequency to a list

of nodes at that frequency, and a running minimum frequency. Eviction takes the least recently used node from the minFreq bucket — the same composition, one level deeper.

Trie

The structure behind prefix search. Each node holds children by character and a flag marking the end of a word.

class Trie:
    def __init__(self):
        self.root = {}

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
        node["$"] = True               # end-of-word marker

    def search(self, word):
        node = self._walk(word)
        return node is not None and "$" in node

    def starts_with(self, prefix):
        return self._walk(prefix) is not None

    def _walk(self, s):
        node = self.root
        for ch in s:
            if ch not in node:
                return None
            node = node[ch]
        return node

Operations are O(length of the word), independent of how many words are stored — that independence is the reason to use a trie over a hash set, along with prefix queries, which a hash set cannot answer at all.

Design Add and Search Words adds a . wildcard, which turns search into a

DFS: at a ., recurse into every child.

Insert Delete GetRandom in O(1)

The one that teaches a genuinely reusable trick. Random access needs an array; O(1) delete needs a hash map; arrays cannot delete from the middle in O(1). The resolution: **swap the doomed element with the last one, then pop.**

import random

class RandomizedSet:
    def __init__(self):
        self.values = []
        self.index = {}                # value -> its position in self.values

    def insert(self, val):
        if val in self.index:
            return False
        self.index[val] = len(self.values)
        self.values.append(val)
        return True

    def remove(self, val):
        if val not in self.index:
            return False
        position = self.index[val]
        last = self.values[-1]
        self.values[position] = last          # move the last element into the hole
        self.index[last] = position
        self.values.pop()
        del self.index[val]
        return True

    def get_random(self):
        return random.choice(self.values)

Order does not matter, so filling the hole with the last element is free — recognising when order is not required is what unlocks the O(1).

How to answer these in an interview

  1. Restate the API and the target complexity. They are the constraints.
  2. Name what each operation needs. Lookup by key, ordering, min, random access.
  3. Pick a structure per requirement, then say how they stay in sync.
  4. Walk one eviction or deletion out loud — that is where these designs break.

Mistakes that cost the round

  • Singly linked list in an LRU cache. O(1) unlink needs both pointers.
  • Not storing the key in the node, leaving eviction unable to clean up the map.
  • Skipping sentinels, then drowning in null checks under time pressure.
  • Letting the two structures drift. Every mutation must update both.
  • Scanning for the minimum or the random element — that is the O(n) the question

forbids.

What to drill

  1. Min Stack — carry the answer per element.
  2. Implement Trie — the prefix structure.
  3. Design Add and Search Words — trie plus wildcard DFS.
  4. LRU Cache — hash map plus doubly linked list.
  5. Insert Delete GetRandom O(1) — swap-with-last.
  6. LFU Cache — the same composition, one level deeper.

All on the 22 DSA Patterns sheet.

Frequently asked

How does an LRU cache get O(1) for both get and put?

By combining two structures. A hash map maps keys to nodes for O(1) lookup, and a doubly linked list keeps entries in recency order so the most recent can be moved to the front and the least recent evicted from the tail, both in O(1). The map holding node references is what lets any entry be unlinked without traversing the list.

Why must the linked list be doubly linked?

Removing a node in O(1) requires rewiring its predecessor, and a singly linked list can only reach the predecessor by walking from the head — O(n). The prev pointer is what makes eviction and move-to-front constant time.

How do you delete from an array in O(1)?

Only when order does not matter: swap the target with the last element, update the index map for the moved element, then pop the end. That is the trick behind Insert Delete GetRandom — random access needs contiguous storage, and swap-with-last is what makes deletion from the middle constant time.

Related