Data Structure Design Problems

advanced design LRU-cache min-stack advanced

Design problems are a special breed of interview questions. Instead of solving a puzzle with existing data structures, we’re asked to build a data structure that supports specific operations with specific time complexities. They test whether we truly understand how data structures work under the hood.

In simple language, the interviewer says “build me a thing that does X in O(1)” and we figure out which building blocks to combine.

Why Interviewers Love These

Design problems test multiple skills at once: understanding of data structures, ability to combine them creatively, edge case handling, and clean API design. They’re also closer to real engineering work — we often need to design custom data structures to meet performance requirements.

Min Stack

We covered this briefly in the stacks topic. Let’s go deeper. The challenge: design a stack that supports push, pop, top, and getMin — all in O(1) time.

The trick: maintain a second stack that tracks the minimum at each level. Every time we push to the main stack, we also track what the minimum is at that point.

A cleaner approach: store pairs of (value, current_minimum) in a single stack.

// Min Stack -- all operations O(1)
class MinStack {
  constructor() {
    this.stack = []; // each entry: [value, minAtThisLevel]
  }

  push(val) {
    const currentMin = this.stack.length
      ? Math.min(val, this.getMin())
      : val;
    this.stack.push([val, currentMin]);
  }

  pop() {
    this.stack.pop();
  }

  top() {
    return this.stack[this.stack.length - 1][0];
  }

  getMin() {
    return this.stack[this.stack.length - 1][1];
  }
}
# Min Stack -- all operations O(1)
class MinStack:
    def __init__(self):
        self.stack = []  # each entry: (value, min_at_this_level)

    def push(self, val):
        current_min = min(val, self.get_min()) if self.stack else val
        self.stack.append((val, 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]
// Min Stack -- all operations O(1)
class MinStack {
    // each entry: [value, minAtThisLevel]
    Deque<int[]> stack = new ArrayDeque<>();

    void push(int val) {
        int currentMin = stack.isEmpty() ? val : Math.min(val, getMin());
        stack.push(new int[]{val, currentMin});
    }

    void pop() { stack.pop(); }

    int top() { return stack.peek()[0]; }

    int getMin() { return stack.peek()[1]; }
}

The space trade-off is O(n) extra for the minimums. That’s acceptable — we traded space for O(1) getMin.

Queue Using Two Stacks

Design a queue (FIFO) using only two stacks. This sounds weird, but it’s a common interview question that tests our understanding of both structures.

The idea: use one stack for pushing (inStack) and one for popping (outStack). When we need to dequeue and outStack is empty, we pour everything from inStack into outStack. This reverses the order — and reversing LIFO gives us FIFO.

Each element gets moved at most twice (pushed to inStack, moved to outStack), so amortized cost per operation is O(1).

// Queue using two stacks -- amortized O(1) per operation
class MyQueue {
  constructor() {
    this.inStack = [];   // for push
    this.outStack = [];  // for pop/peek
  }

  push(val) {
    this.inStack.push(val);
  }

  pop() {
    this._transfer();
    return this.outStack.pop();
  }

  peek() {
    this._transfer();
    return this.outStack[this.outStack.length - 1];
  }

  empty() {
    return !this.inStack.length && !this.outStack.length;
  }

  _transfer() {
    // only transfer when outStack is empty
    if (!this.outStack.length) {
      while (this.inStack.length) {
        this.outStack.push(this.inStack.pop());
      }
    }
  }
}
# Queue using two stacks -- amortized O(1) per operation
class MyQueue:
    def __init__(self):
        self.in_stack = []   # for push
        self.out_stack = []  # for pop/peek

    def push(self, val):
        self.in_stack.append(val)

    def pop(self):
        self._transfer()
        return self.out_stack.pop()

    def peek(self):
        self._transfer()
        return self.out_stack[-1]

    def empty(self):
        return not self.in_stack and not self.out_stack

    def _transfer(self):
        # only transfer when out_stack is empty
        if not self.out_stack:
            while self.in_stack:
                self.out_stack.append(self.in_stack.pop())
// Queue using two stacks -- amortized O(1) per operation
class MyQueue {
    Deque<Integer> inStack = new ArrayDeque<>();
    Deque<Integer> outStack = new ArrayDeque<>();

    void push(int val) { inStack.push(val); }

    int pop() { transfer(); return outStack.pop(); }

    int peek() { transfer(); return outStack.peek(); }

    boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); }

    private void transfer() {
        if (outStack.isEmpty()) {
            while (!inStack.isEmpty()) {
                outStack.push(inStack.pop());
            }
        }
    }
}

LRU Cache — The Big One

Design a cache with a fixed capacity that evicts the Least Recently Used item when full. It needs:

  • get(key) — return the value in O(1), mark as recently used
  • put(key, value) — insert/update in O(1), evict LRU if over capacity

This is probably the most commonly asked design problem. Let’s build it.

The Insight

We need two things working together:

  1. Hash map for O(1) key lookup
  2. Doubly linked list for O(1) insertion/removal and tracking recency order

The most recently used item goes to the front of the list. The least recently used is at the back. When we access or insert an item, we move it to the front. When we need to evict, we remove from the back.

LRU Cache: Hash Map + Doubly Linked List
Hash Map:
A -> node1 B -> node2 C -> node3
DLL: HEAD <--> C (MRU) <--> B <--> A (LRU) <--> TAIL
get(A) -> move A to front | put(D) when full -> evict A (closest to TAIL)

We use sentinel head and tail nodes to avoid null checks. Every real node sits between them.

Full Implementation

// LRU Cache -- O(1) get and put
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.map = new Map();
    // sentinel nodes -- avoid null checks
    this.head = { prev: null, next: null };
    this.tail = { prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

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

  put(key, value) {
    if (this.map.has(key)) {
      this._remove(this.map.get(key));
    }
    const node = { key, val: value, prev: null, next: null };
    this._addToFront(node);
    this.map.set(key, node);

    if (this.map.size > this.capacity) {
      const lru = this.tail.prev; // least recently used
      this._remove(lru);
      this.map.delete(lru.key);
    }
  }

  _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;
  }
}
# LRU Cache -- O(1) get and put
class DLLNode:
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        # sentinel nodes -- avoid null checks
        self.head = DLLNode()
        self.tail = DLLNode()
        self.head.next = self.tail
        self.tail.prev = self.head

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node)
        self._add_to_front(node)
        return node.val

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

        if len(self.map) > self.capacity:
            lru = self.tail.prev  # least recently used
            self._remove(lru)
            del self.map[lru.key]

    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
// LRU Cache -- O(1) get and put
class LRUCache {
    class DLLNode {
        int key, val;
        DLLNode prev, next;
        DLLNode(int k, int v) { key = k; val = v; }
    }

    int capacity;
    Map<Integer, DLLNode> map = new HashMap<>();
    DLLNode head = new DLLNode(0, 0); // sentinel
    DLLNode tail = new DLLNode(0, 0); // sentinel

    LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    int get(int key) {
        if (!map.containsKey(key)) return -1;
        DLLNode node = map.get(key);
        remove(node);
        addToFront(node);
        return node.val;
    }

    void put(int key, int value) {
        if (map.containsKey(key)) remove(map.get(key));
        DLLNode node = new DLLNode(key, value);
        addToFront(node);
        map.put(key, node);

        if (map.size() > capacity) {
            DLLNode lru = tail.prev;
            remove(lru);
            map.remove(lru.key);
        }
    }

    void remove(DLLNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    void addToFront(DLLNode node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
}

Python Shortcut

Python’s OrderedDict maintains insertion order and supports move_to_end. This makes LRU cache a 10-line solution:

# LRU Cache using OrderedDict -- Python shortcut
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.cache = OrderedDict()
        self.capacity = capacity

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)  # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)  # remove oldest (LRU)

Know both approaches. The OrderedDict version is great for speed in interviews, but the interviewer might ask us to implement it from scratch.

Java Shortcut

Java’s LinkedHashMap maintains insertion order too:

// LRU Cache using LinkedHashMap -- Java shortcut
class LRUCache extends LinkedHashMap<Integer, Integer> {
    int capacity;

    LRUCache(int capacity) {
        super(capacity, 0.75f, true); // true = access-order
        this.capacity = capacity;
    }

    int get(int key) {
        return super.getOrDefault(key, -1);
    }

    void put(int key, int value) {
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity; // auto-evict when over capacity
    }
}

Tips for Design Problems in Interviews

  1. Clarify requirements first. What operations? What time complexities? What constraints?
  2. Start with the brute force. “We could use a list and scan it each time, but that’s O(n)…”
  3. Identify the bottleneck. Which operation is too slow? What data structure speeds it up?
  4. Combine data structures. The magic is usually in combining two simple structures (hash map + linked list, two stacks, etc.).
  5. Handle edge cases. Empty state, single element, at capacity. The interviewer will ask about these.

Design problems feel intimidating, but they all follow the same pattern: figure out what’s slow, pick the right data structure to fix it, and combine structures when one isn’t enough.