Data Structures & Algorithms
This page covers everything a coding interview tests: how to measure cost with Big-O, the data structures you pick from, the dozen patterns that solve most problems, sorting, and dynamic programming. Every idea comes with a plain-English analogy, a Python template and the questions interviewers actually ask.
- Pick the data structure that makes your most frequent operation O(1) or O(log n); the hash map is the default workhorse.
- Most problems map to a small set of patterns: two pointers, sliding window, binary search, prefix sums, monotonic stack, BFS/DFS, backtracking, heap top-k, greedy and DP.
- Input size hints at the target complexity: n up to 10^5 or 10^6 usually means O(n) or O(n log n).
- DP = recursion + memory: define the state, the transition and the base case, then choose top-down or bottom-up.
- In the interview, clarify, give a brute force, optimise, state time and space, code cleanly, then test with edge cases out loud.
How to approach a coding interview
A coding round grades four things: problem solving (did you find a good algorithm), coding (is it correct and readable), communication (could the interviewer follow your thinking) and verification (did you test it yourself). A strong candidate with a clear process beats a fast candidate who codes silently and ships a bug.
Think of a doctor seeing a patient. A good doctor asks questions first, rules out the obvious, proposes a treatment, explains the risks, and then checks that the treatment worked. In the interview: the questions are clarifying the input and constraints, the obvious diagnosis is the brute force, the treatment is the optimised algorithm, the risks are the complexity trade-offs, and the check-up is walking through test cases.
The seven-step framework
- Clarify (2-3 min) Restate the problem. Ask about input size, value ranges, negatives, duplicates, empty input, sorted or not, and what to return when there is no answer. Write one small example and its expected output.
- Brute force (1-2 min) Say the simplest correct idea and its complexity, even if it is O(n^2) or O(2^n). This proves you understand the problem and gives a baseline.
- Optimise (5-10 min) Look for the bottleneck. Ask: can a hash map remove the inner loop? Is the input sorted (two pointers, binary search)? Is it a contiguous range (sliding window, prefix sums)? Are there repeated subproblems (DP)? Is it "shortest steps" (BFS)?
- Agree before coding State the algorithm in two or three sentences plus its time and space. Get a nod from the interviewer.
- Code (15-20 min) Use clear names, small helper functions, and narrate what each block does. Do not over-optimise micro details.
- Test Trace your own example line by line, then the edge cases: empty, single element, all equal, negatives, maximum size, cycles or disconnected graphs.
- Discuss Restate final complexity, mention alternatives and follow-ups (streaming input, memory limits, concurrency).
Constraints tell you the target complexity
| Input size n | Acceptable complexity | Typical technique |
|---|---|---|
| n ≤ 10 | O(n!) | Permutations, brute force |
| n ≤ 20 | O(2^n) | Subsets, bitmask DP, backtracking |
| n ≤ 500 | O(n^3) | Interval DP, Floyd-Warshall |
| n ≤ 5,000 | O(n^2) | 2-D DP, nested loops |
| n ≤ 10^6 | O(n log n) | Sorting, heaps, binary search |
| n > 10^7 | O(n) or O(log n) | Single pass, hashing, math |
The rule of thumb is that a typical judge runs roughly 10^8 simple operations per second, so pick the complexity that keeps you below that.
Pattern picker: trigger words to technique
| If the problem says or implies... | Reach for |
|---|---|
| Sorted array, pair or triplet with a target sum, palindrome | Two pointers |
| Contiguous subarray or substring with a constraint | Sliding window |
| Sorted data, or "minimum X such that condition holds" | Binary search (on index or on the answer) |
| Sum of a range, many range queries, subarray sum equals k | Prefix sums + hash map |
| Next greater / smaller element, histogram, stock span | Monotonic stack |
| Overlapping meetings, merge ranges | Sort by start, sweep intervals |
| All combinations, permutations, subsets, puzzles | Backtracking |
| Shortest path, fewest steps, unweighted graph or grid | BFS |
| Shortest path with non-negative weights | Dijkstra |
| Dependencies, ordering, prerequisites | Topological sort |
| Connected groups, "are these linked", redundant edge | Union-Find |
| Top k, kth largest, merge k sorted, running median | Heap |
| Count ways, min/max cost, "can we reach", overlapping choices | Dynamic programming |
| Linked list cycle, middle of list | Fast and slow pointers |
| Prefix matching, autocomplete, word search | Trie |
| "Appears once while others appear twice", subsets as masks | Bit manipulation |
Big-O and complexity analysis
Big-O describes how the running time (or memory) of an algorithm grows as the input size n grows, ignoring constant factors and lower-order terms. O(2n + 5) is O(n), and O(n^2 + n) is O(n^2). It lets you compare algorithms independently of hardware or language.
Finding a name in a printed phone book. Flipping page by page is O(n): twice the pages, twice the time. Opening in the middle and discarding half each time is O(log n): doubling the book adds just one more step. Comparing every name with every other name to find duplicates is O(n^2): doubling the book quadruples the work. Big-O is simply the answer to "how much worse does it get when the input doubles?"
The complexity ladder (best to worst)
| Class | Name | Example | n = 1,000,000 (approx. operations) |
|---|---|---|---|
| O(1) | Constant | Array index, hash lookup, stack push | 1 |
| O(log n) | Logarithmic | Binary search, balanced BST operation, heap push | 20 |
| O(sqrt n) | Square root | Trial-division primality test | 1,000 |
| O(n) | Linear | Scan an array, BFS/DFS over the graph | 10^6 |
| O(n log n) | Linearithmic | Merge sort, heap sort, sort-then-scan | 2 x 10^7 |
| O(n^2) | Quadratic | Nested loops over all pairs, bubble sort | 10^12 (too slow) |
| O(2^n) | Exponential | All subsets, naive recursive Fibonacci | Only for n up to ~25 |
| O(n!) | Factorial | All permutations, brute-force travelling salesman | Only for n up to ~10 |
How to calculate it
- Sequential steps add: O(n) followed by O(n log n) is O(n log n) (keep the dominant term).
- Nested loops multiply: a loop of n inside a loop of m is O(n x m).
- Halving loops are logarithmic:
while n > 1: n //= 2runs log2(n) times. - Recursion: cost = (number of calls) x (work per call). A tree with branching factor b and depth d has about b^d calls. Fibonacci without memoisation is O(2^n); with memoisation it is O(n).
- Divide and conquer (Master theorem): T(n) = aT(n/b) + O(n^d). Merge sort (a=2, b=2, d=1) is O(n log n); binary search (a=1, b=2, d=0) is O(log n).
- Multiple inputs: use separate variables. Graph traversal is O(V + E), not O(n). Comparing two strings of length m and n is O(m x n).
Big-O, Big-Omega, Big-Theta
| Notation | Meaning | Informal use |
|---|---|---|
| O(f(n)) | Upper bound: grows no faster than f | What interviewers mean by "the complexity" |
| Omega(f(n)) | Lower bound: grows at least as fast as f | "Any comparison sort needs Omega(n log n)" |
| Theta(f(n)) | Tight bound: both upper and lower | Merge sort is Theta(n log n) |
Best, average, worst and amortized
- Worst case is the guarantee. Quicksort is O(n^2) worst case (bad pivots on sorted input).
- Average case assumes typical random input. Quicksort averages O(n log n); hash map lookups average O(1) but degrade to O(n) when every key collides.
- Amortized is the average cost per operation over a long sequence. Appending to a dynamic array is O(1) amortized: occasionally the array doubles (O(n) copy), but because capacity doubles, total copying over n appends is at most about 2n, so each append costs O(1) on average.
Dynamic array doubling: cost of n appends capacity: 1 -> 2 -> 4 -> 8 -> 16 ... copies: 1 + 2 + 4 + 8 + ... + n/2 < n total work = n writes + < n copies = O(n) => O(1) amortized per append
Space complexity
Space complexity counts extra memory beyond the input (sometimes called auxiliary space). Remember the hidden costs:
- Recursion stack: DFS on a tree of height h uses O(h) stack; on a skewed tree that is O(n). Python's default recursion limit is about 1000, so deep recursion may need
sys.setrecursionlimitor an explicit stack. - Copies and slices:
s[1:]in Python copies, which can silently turn an O(n) algorithm into O(n^2). - Output space is usually not counted, but say so explicitly (for example, "O(1) extra space apart from the result").
s += c) can be O(n^2) because strings are immutable. Build a list and "".join() it at the end.Arrays, strings and hashing
An array stores elements in one contiguous block of memory, so element i is at base + i x size: O(1) random access and excellent CPU cache behaviour. The price is that inserting or deleting in the middle shifts everything after it (O(n)). A dynamic array (Python list, Java ArrayList, C++ vector) grows by doubling, giving O(1) amortized append.
Operation cheat sheet for every structure on this page
| Structure | Access | Search | Insert | Delete | Space | Use when |
|---|---|---|---|---|---|---|
| Array / dynamic array | O(1) | O(n) | O(n), append O(1)* | O(n), pop end O(1) | O(n) | Index access, cache-friendly scans |
| Sorted array | O(1) | O(log n) | O(n) | O(n) | O(n) | Static data with many lookups |
| Linked list | O(n) | O(n) | O(1) at known node | O(1) if you hold prev (singly) or the node (doubly) | O(n) | Frequent splicing, LRU lists |
| Stack / queue / deque | ends only | O(n) | O(1) | O(1) | O(n) | LIFO (undo, DFS) / FIFO (BFS) |
| Hash map / set | n/a | O(1) avg, O(n) worst† | O(1) avg | O(1) avg | O(n) | Lookup by key, counting, dedupe |
| Balanced BST (TreeMap) | O(log n) | O(log n) | O(log n) | O(log n) | O(n) | Sorted order, range, floor/ceiling |
| Binary heap | peek O(1) | O(n) | O(log n) | pop O(log n) | O(n) | Top-k, scheduling, Dijkstra |
| Trie | n/a | O(L) | O(L) | O(L) | O(total chars x alphabet) | Prefix search, autocomplete |
| Graph (adjacency list) | n/a | O(V + E) traversal | edge O(1) | edge O(degree) | O(V + E) | Relationships, paths, networks |
| Union-Find | n/a | find ~O(1)* | union ~O(1)* | n/a | O(n) | Connectivity, cycle detection, Kruskal |
* amortized. L = key length. V and E = number of vertices and edges. "~O(1)" for Union-Find means O(alpha(n)), the inverse Ackermann function, which is at most 4 for any realistic n. † Java 8+ HashMap treeifies a long chain into a red-black tree once the table is large enough, so that bucket is O(log n) instead of O(n).
Strings
- Strings are arrays of characters. In Python and Java they are immutable: every modification creates a new string.
- For a fixed alphabet (for example 26 lowercase letters), a count array of size 26 is faster and smaller than a hash map, and counts as O(1) space.
- Anagram check: compare character counts (O(n)) rather than sorting (O(n log n)).
- Common string patterns: sliding window (longest substring), two pointers (palindrome), hashing (group anagrams), trie (prefixes), DP (edit distance, LCS).
Hash maps and hash sets
A hash map stores key-value pairs. A hash function turns the key into an integer, which is reduced to a bucket index (for example hash(key) % capacity). Lookup, insert and delete are O(1) on average.
A hash map is a coat check. You hand over your coat (the value) and get a ticket number (the hash of the key). To get the coat back you go straight to hook number 42 instead of searching every hook. If two coats are given the same number (a collision), the attendant hangs both on that hook and checks the name tag (the key equality check). When the room gets too crowded (high load factor), the venue moves to a bigger room and re-hangs every coat (resizing and rehashing).
key "cat" --hash()--> 9812734 --% 8--> bucket 6
bucket: 0 1 2 3 4 5 6 7
- - [dog] - - - [cat]->[owl] -
^ collision handled by chaining
- Chaining: each bucket holds a list of entries. Java 8+
HashMaptreeifies a bin into a red-black tree only when both are true: the bin has at leastTREEIFY_THRESHOLD(8) entries and the table capacity is at leastMIN_TREEIFY_CAPACITY(64). If the chain is long but the table is still smaller than 64, the map resizes instead of treeifying (a small table with one long chain is almost always a bad hash, and growing the table usually spreads the keys). After treeify, that bucket is O(log n). A tree bin untreeifies back to a list when it shrinks belowUNTREEIFY_THRESHOLD(6). - Open addressing: on collision, probe for another free slot (linear probing, quadratic probing, double hashing). Python's
dictuses open addressing. Deletion needs "tombstone" markers. - Load factor = entries / buckets. When it passes a threshold (0.75 in Java), the table doubles and every key is rehashed. That occasional O(n) step is why inserts are O(1) amortized.
- Keys must be immutable (or at least their hash must not change). Python lists cannot be dict keys; use tuples. In Java, if you override
equals()you must overridehashCode(). - Worst case is O(n) if an attacker crafts colliding keys (hash-flooding). Randomised hash seeds defend against this.
# Python hash map idioms
from collections import Counter, defaultdict
freq = Counter(words) # word -> count, O(n)
top3 = freq.most_common(3)
groups = defaultdict(list) # group anagrams
for w in words:
groups["".join(sorted(w))].append(w)
seen = set() # dedupe / membership in O(1)
for x in nums:
if x in seen:
print("duplicate", x)
seen.add(x)
The source material also shows the Java equivalent for counting, which is worth recognising:
Map<String, Integer> freq = new HashMap<>();
for (String w : words)
freq.merge(w, 1, Integer::sum); // O(1) average per word
Hash map template: Two Sum in one pass
def two_sum(nums, target):
index_of = {} # value -> index seen so far
for i, x in enumerate(nums):
if target - x in index_of:
return [index_of[target - x], i]
index_of[x] = i
return []
# O(n) time, O(n) space; brute force is O(n^2)
HashMap does not).Linked lists
A linked list is a chain of nodes where each node holds a value and a pointer to the next node (singly linked) or to both neighbours (doubly linked). Inserting after a node you already hold is O(1). Deleting is O(1) on a doubly linked list (you have the previous pointer) or on a singly linked list if you already hold the previous node; given only the node itself on a singly linked list you must walk from the head (or use the "copy the next node and skip it" trick, which cannot delete the tail). Reaching the i-th node is O(n), and nodes are scattered in memory, so scans are slower than arrays in practice.
A treasure hunt where each clue tells you where the next clue is hidden. To reach clue 50 you must follow clues 1 to 49 (O(n) access). But adding a new clue between 10 and 11 is easy: rewrite clue 10 to point to the new one, and the new one to point to 11 (O(1) insert). An array, by contrast, is a row of numbered lockers: you can walk straight to locker 50, but inserting a locker in the middle means shifting everyone down.
Singly: head -> [3|*] -> [7|*] -> [9|*] -> None Doubly: None <- [3] <-> [7] <-> [9] -> None Dummy head trick: dummy -> head ... (removes "is this the first node?" special cases)
Core techniques
- Dummy (sentinel) node: start with
dummy = ListNode(0, head)so that deleting or inserting at the head needs no special case; returndummy.next. - Reversal: the three-pointer dance (
prev,curr,nxt). - Fast and slow pointers: find the middle, detect a cycle, find the k-th node from the end (move fast k steps first).
- Merge: walk two sorted lists with a tail pointer.
- Hash map + doubly linked list: the classic O(1) LRU cache.
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
def reverse(head):
prev, curr = None, head
while curr:
nxt = curr.next # save the rest
curr.next = prev # flip the pointer
prev, curr = curr, nxt
return prev # O(n) time, O(1) space
def middle(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
return slow # second middle for even length
def has_cycle(head): # Floyd's tortoise and hare
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast:
return True
return False
Why Floyd's algorithm finds the cycle start
Let a be the distance from head to the cycle entry, c the cycle length, and suppose they meet b steps into the cycle. The slow pointer walked a + b; the fast pointer walked twice that, 2(a + b), which is also a + b + k*c for some whole number of extra laps k. So a + b = k*c, meaning a = k*c - b. Therefore a pointer starting at the head and a pointer starting at the meeting point, both moving one step at a time, meet exactly at the entry.
curr.next before saving it, and forgetting to check fast.next before fast.next.next. Draw the pointers on paper for a 3-node list before coding.Stacks, queues and deques
A stack is last-in-first-out (LIFO): push and pop at the same end. A queue is first-in-first-out (FIFO): add at the back, remove from the front. A deque (double-ended queue) supports O(1) add and remove at both ends and can act as either.
A stack is a pile of plates: you add to the top and take from the top, so the last plate washed is the first one used. A queue is the line at a ticket counter: first to arrive is first served. A deque is a train platform with doors at both ends. The mapping: plates are function calls or undo steps (stack), people in line are BFS nodes or jobs waiting (queue), and the two-door platform is the sliding-window maximum, where elements leave from either end (deque).
| Structure | Python | Java | Typical uses |
|---|---|---|---|
| Stack | list with append/pop | ArrayDeque (push/pop) | Call stack, undo, DFS, parentheses, expression evaluation, monotonic stack |
| Queue | collections.deque with append/popleft | ArrayDeque (offer/poll) | BFS, task buffers, producer-consumer, level-order traversal |
| Deque | collections.deque | ArrayDeque | Sliding window max/min, 0-1 BFS, palindromes |
list.pop(0) in Python as a queue: it is O(n) because every element shifts. Use collections.deque. In Java, prefer ArrayDeque over the legacy synchronised Stack class.Template: valid parentheses
def is_valid(s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
else:
stack.append(ch)
return not stack # O(n) time, O(n) space
Queue built from two stacks (amortized O(1))
class MyQueue:
def __init__(self):
self.inbox, self.outbox = [], []
def push(self, x):
self.inbox.append(x)
def pop(self):
self.peek()
return self.outbox.pop()
def peek(self):
if not self.outbox: # move only when outbox is empty
while self.inbox:
self.outbox.append(self.inbox.pop())
return self.outbox[-1]
# each element is moved at most once => O(1) amortized per operation
Min stack: O(1) getMin
Store pairs (value, min_so_far) on the stack. The minimum is always the top pair's second field, and popping restores the previous minimum automatically.
Heaps and priority queues
A binary heap is a complete binary tree stored in an array where every parent is smaller than its children (min-heap) or larger (max-heap). The root is always the minimum (or maximum). Peek is O(1); push and pop are O(log n) because an element "bubbles" up or down at most the height of the tree. Building a heap from n items with heapify is O(n), not O(n log n).
A hospital emergency-room triage desk. The most urgent patient is always seen next (the root). When a new patient arrives, the nurse compares them with those ahead and moves them up only as far as their urgency justifies (sift up, O(log n)). Nobody bothers to fully sort the waiting room, which is why a heap is cheaper than keeping a sorted list: it only guarantees who is first, not the complete order.
Min-heap as tree as array (index i: children 2i+1, 2i+2; parent (i-1)//2)
1
/ \ [1, 3, 2, 7, 4, 5]
3 2 0 1 2 3 4 5
/ \ /
7 4 5
Python heapq recipes
import heapq
h = []
heapq.heappush(h, 5) # min-heap by default
smallest = heapq.heappop(h)
heapq.heapify(nums) # O(n) in place
# max-heap: push negated values
heapq.heappush(h, -x); largest = -heapq.heappop(h)
# tuples sort by first field: (priority, tie_breaker, item)
heapq.heappush(h, (dist, node))
def kth_largest(nums, k): # keep a min-heap of size k
h = []
for x in nums:
heapq.heappush(h, x)
if len(h) > k:
heapq.heappop(h) # drop the smallest
return h[0] # O(n log k) time, O(k) space
Heap patterns
- Top-k: keep a heap of size k of the opposite type (min-heap for k largest). O(n log k).
- K-way merge: put the head of each of k sorted lists in a min-heap; pop the smallest and push its successor. O(N log k).
- Two heaps (running median): a max-heap for the lower half and a min-heap for the upper half, kept balanced in size; the median is at one or both tops. O(log n) add, O(1) median.
- Scheduling: meeting rooms (min-heap of end times), task scheduler, CPU job queues.
- Graph algorithms: Dijkstra and Prim use a min-heap of (distance, node).
Heap
- O(1) peek at min or max
- O(log n) push and pop
- O(n) build
- No ordered iteration, O(n) search
- Array-backed, cache friendly
Balanced BST (TreeMap, SortedList)
- O(log n) min, max, insert, delete
- Ordered iteration, floor, ceiling, range queries
- Can delete arbitrary elements in O(log n)
- More memory, pointer-heavy
heapq has no decrease-key, so Dijkstra implementations push duplicates and skip stale entries.Trees, binary search trees and tries
A tree is a connected graph with no cycles: one root, and every other node has exactly one parent. A binary tree allows at most two children. The height h decides the cost of most operations: a balanced tree has h = O(log n); a degenerate (skewed) tree has h = O(n).
A company org chart. The CEO is the root, managers are internal nodes, and individual contributors are leaves. Asking "who is the lowest manager above both Alice and Bob?" is the lowest common ancestor problem. A binary search tree is like a well-run library where every shelf says "earlier titles to the left, later titles to the right", so you find a book by making one left-or-right decision per level instead of scanning every shelf.
Traversals
| Traversal | Order | Typical use |
|---|---|---|
| Pre-order | node, left, right | Copy or serialise a tree |
| In-order | left, node, right | Visits a BST in sorted order; validate BST, k-th smallest |
| Post-order | left, right, node | Compute from children up: height, diameter, delete a tree |
| Level-order (BFS) | level by level | Right side view, minimum depth, zigzag, connect next pointers |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
def max_depth(root): # post-order recursion
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))
from collections import deque
def level_order(root): # BFS by level
if not root:
return []
out, q = [], deque([root])
while q:
level = []
for _ in range(len(q)): # freeze the level size
node = q.popleft()
level.append(node.val)
if node.left: q.append(node.left)
if node.right: q.append(node.right)
out.append(level)
return out
def inorder_iterative(root): # explicit stack, no recursion
out, stack, node = [], [], root
while stack or node:
while node:
stack.append(node); node = node.left
node = stack.pop()
out.append(node.val)
node = node.right
return out
Binary search trees
In a BST, every value in the left subtree is smaller than the node and every value in the right subtree is larger. Search, insert and delete walk one path: O(h). Self-balancing variants (AVL, red-black) keep h = O(log n); Java's TreeMap and C++ std::map are red-black trees. Python has no built-in balanced BST; use bisect on a sorted list for small data or the third-party sortedcontainers.SortedList.
def is_valid_bst(node, lo=float('-inf'), hi=float('inf')):
if not node:
return True
if not (lo < node.val < hi):
return False
return (is_valid_bst(node.left, lo, node.val) and
is_valid_bst(node.right, node.val, hi))
def lowest_common_ancestor(root, p, q): # general binary tree
if not root or root is p or root is q:
return root
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
return root if left and right else left or right
long bounds to avoid overflow at Integer.MAX_VALUE.Tries (prefix trees)
A trie stores strings character by character: each edge is a character, and a path from the root spells a prefix. Insert and search cost O(L) where L is the word length, independent of how many words are stored, and "all words starting with a prefix" is natural.
Words: cat, car, dog
(root)
/ \
c d
| |
a o
/ \ |
t* r* g* (* = end of 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 _walk(self, s):
node = self.root
for ch in s:
if ch not in node:
return None
node = node[ch]
return node
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
The array-of-26-children version (Node[] kids = new Node[26] in Java) is faster for lowercase-only input but wastes memory for sparse alphabets. Uses: autocomplete, spell check, word search on a grid, IP longest-prefix routing, XOR maximisation with a bitwise trie.
Other trees worth naming
Segment tree
Range queries (sum, min, max) and point or range updates in O(log n). Use when both queries and updates are frequent.
Fenwick tree (BIT)
Prefix sums with point updates in O(log n), in a tiny array. Simpler than a segment tree for sums.
B-tree / B+ tree
Wide, shallow trees used by databases and filesystems so each node fits a disk page and lookups need few disk reads.
AVL and red-black
Self-balancing BSTs that rotate after inserts and deletes to keep height O(log n).
Graphs: BFS, DFS, topological sort, Dijkstra and Union-Find
A graph is a set of vertices (V) connected by edges (E). Edges can be directed or undirected, weighted or unweighted. Trees, grids, social networks, dependency lists and road maps are all graphs. Store them as an adjacency list (a map from node to neighbours, O(V + E) space, best for sparse graphs) or an adjacency matrix (V x V, O(1) edge check, best for dense graphs).
A map of cities (vertices) and roads (edges). BFS is dropping a stone in a pond: ripples reach every city one ring of roads at a time, so the first time a ripple touches a city is the fewest-roads route. DFS is a hiker who follows one road as far as possible, then backtracks to the last junction. Dijkstra is a delivery driver who always extends the currently cheapest known route. Union-Find is a set of clubs that merge: to know if two people are connected, ask whether their club presidents are the same person.
from collections import defaultdict, deque
def build_graph(n, edges, directed=False):
g = defaultdict(list)
for u, v in edges:
g[u].append(v)
if not directed:
g[v].append(u)
return g
BFS: shortest path in an unweighted graph
def bfs_shortest(g, src):
dist = {src: 0}
q = deque([src])
while q:
u = q.popleft()
for v in g[u]:
if v not in dist: # mark when enqueued, not when popped
dist[v] = dist[u] + 1
q.append(v)
return dist # O(V + E) time and space
DFS: components, paths, cycles
def count_components(n, g):
seen = set()
def dfs(u):
seen.add(u)
for v in g[u]:
if v not in seen:
dfs(v)
count = 0
for u in range(n):
if u not in seen:
dfs(u); count += 1
return count
# Grid DFS: number of islands
def num_islands(grid):
R, C = len(grid), len(grid[0])
def sink(r, c):
if 0 <= r < R and 0 <= c < C and grid[r][c] == '1':
grid[r][c] = '0'
for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
sink(r + dr, c + dc)
islands = 0
for r in range(R):
for c in range(C):
if grid[r][c] == '1':
sink(r, c); islands += 1
return islands # O(R x C)
BFS
- Queue, explores level by level
- Shortest path in unweighted graphs
- Memory: the widest frontier (can be large)
- Level-order, "minimum steps", multi-source spread (rotting oranges)
DFS
- Stack or recursion, goes deep first
- Path existence, connected components, cycle detection, topological order, backtracking
- Memory: the depth of the path
- Deep graphs can overflow the recursion stack
Cycle detection
- Undirected: DFS and a neighbour is visited but is not your parent, or Union-Find reports two endpoints already in the same set.
- Directed: three colours (white = unvisited, grey = on the current path, black = done). An edge to a grey node is a back edge, meaning a cycle. Alternatively, Kahn's algorithm fails to output all nodes.
Topological sort (Kahn's algorithm)
Orders the nodes of a directed acyclic graph (DAG) so every edge goes from earlier to later: build order, course prerequisites, task scheduling.
def topo_sort(n, edges): # edge (a, b) means a before b
g = defaultdict(list); indeg = [0] * n
for a, b in edges:
g[a].append(b); indeg[b] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
order = []
while q:
u = q.popleft(); order.append(u)
for v in g[u]:
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
return order if len(order) == n else [] # [] means a cycle exists
Dijkstra: shortest path with non-negative weights
import heapq
def dijkstra(g, src): # g[u] = [(v, w), ...]
dist = {src: 0}
pq = [(0, src)]
while pq:
d, u = heapq.heappop(pq)
if d > dist.get(u, float('inf')):
continue # stale entry, skip
for v, w in g[u]:
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(pq, (nd, v))
return dist # O((V + E) log V)
| Algorithm | Graph type | Complexity | Notes |
|---|---|---|---|
| BFS | Unweighted | O(V + E) | Fewest edges |
| 0-1 BFS | Weights 0 or 1 | O(V + E) | Deque: push 0-weight edges to the front |
| Dijkstra | Non-negative weights | O((V + E) log V) | Greedy; fails with negative edges |
| Bellman-Ford | Negative weights allowed | O(V x E) | Relax all edges V-1 times; detects negative cycles |
| Floyd-Warshall | All pairs | O(V^3) | DP over intermediate nodes; small V |
| A* | Weighted with a heuristic | Depends on heuristic | Dijkstra guided by an admissible estimate (maps, games) |
| Kruskal (MST) | Undirected weighted | O(E log E) | Sort edges, add if Union-Find says no cycle |
| Prim (MST) | Undirected weighted | O(E log V) | Grow the tree with a min-heap |
Union-Find (disjoint set union)
Tracks which elements belong to the same group. With path compression (point nodes straight at the root while finding) and union by rank or size (attach the smaller tree under the larger), both operations are amortized O(alpha(n)), effectively constant.
class DSU:
def __init__(self, n):
self.parent = list(range(n)); self.size = [1] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected: adding edge makes a cycle
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra; self.size[ra] += self.size[rb]
return True
Core patterns I: pointers, windows, binary search and ranges
Most array and string problems are solved by one of a handful of scanning patterns. Each one turns a quadratic brute force into linear or logarithmic time by never re-examining work that has already been done.
Reading a long book for a report. Two pointers is two readers starting at opposite ends and meeting in the middle. A sliding window is a magnifying glass you slide along the text, widening or narrowing it but never jumping back. Binary search is opening the book in the middle to find a chapter. Prefix sums are the page numbers in the table of contents: the length of any chapter is just end page minus start page, no counting required. A monotonic stack is a stack of sticky notes where you throw away every note that can never matter again.
1. Two pointers
Use when: sorted array, pairs or triplets with a target, palindromes, in-place partitioning or removing duplicates. Pointers either move toward each other (opposite ends) or in the same direction (read and write pointers).
def pair_with_sum(a, target): # a is sorted
l, r = 0, len(a) - 1
while l < r:
s = a[l] + a[r]
if s == target: return (l, r)
if s < target: l += 1
else: r -= 1
return None # O(n) time, O(1) space
def remove_duplicates(a): # sorted, in place; returns new length
w = 0
for r in range(len(a)):
if r == 0 or a[r] != a[r - 1]:
a[w] = a[r]; w += 1
return w
Classics: 3Sum (sort, fix one element, two-pointer the rest, skip duplicates; O(n^2)), container with most water (move the shorter wall inward), trapping rain water, sort colours (Dutch national flag, three pointers).
2. Sliding window
Use when: the answer is a contiguous subarray or substring and the validity of the window changes predictably as it grows or shrinks. Each element enters and leaves the window at most once, so the total is O(n).
def longest_unique_substring(s): # variable-size window
last = {} # char -> last index
left = best = 0
for right, ch in enumerate(s):
if ch in last and last[ch] >= left:
left = last[ch] + 1 # jump past the repeat
last[ch] = right
best = max(best, right - left + 1)
return best
def max_sum_k(a, k): # fixed-size window
cur = best = sum(a[:k])
for i in range(k, len(a)):
cur += a[i] - a[i - k]
best = max(best, cur)
return best
Generic shape: expand right; while the window is invalid, shrink left; record the answer. Classics: minimum window substring (need/have counts), longest repeating character replacement (window length minus max frequency must be at most k), permutation in string, sliding window maximum (monotonic deque).
3. Fast and slow pointers
Use when: cycle detection (linked list, or a function like "happy number" or "find the duplicate number" where values point to indices), middle of a list, k-th from the end. See the linked-list section for templates.
4. Binary search (on an index and on the answer)
Use when: data is sorted, or you can ask a yes/no question ok(x) that is monotonic (false, false, ..., true, true). Each step halves the range: O(log n).
def lower_bound(a, target): # first index with a[i] >= target
lo, hi = 0, len(a) # search space [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < target: lo = mid + 1
else: hi = mid
return lo # same as bisect.bisect_left
# Binary search on the answer: minimum ship capacity to deliver in D days
def ship_within_days(weights, D):
def ok(cap):
days, load = 1, 0
for w in weights:
if load + w > cap:
days += 1; load = 0
load += w
return days <= D
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = (lo + hi) // 2
if ok(mid): hi = mid # feasible: try smaller
else: lo = mid + 1
return lo # O(n log(sum))
Classics: search in rotated sorted array (one half is always sorted; decide which), find minimum in rotated array, first and last position, Koko eating bananas, split array largest sum, median of two sorted arrays (binary search the partition of the smaller array, O(log min(m, n))).
[lo, hi) "find the first true" template above avoids most off-by-one bugs. In languages with fixed-size integers, compute mid = lo + (hi - lo) / 2 to avoid overflow.5. Prefix sums
Use when: many range-sum queries, or "count subarrays whose sum equals k". prefix[i] is the sum of the first i elements, so the sum of a[l..r] is prefix[r+1] - prefix[l] in O(1).
def subarray_sum_equals_k(nums, k):
count, running = 0, 0
seen = {0: 1} # prefix sum -> how many times
for x in nums:
running += x
count += seen.get(running - k, 0)
seen[running] = seen.get(running, 0) + 1
return count # O(n), works with negatives
Variations: 2-D prefix sums for rectangle queries, product of array except self (prefix and suffix products), difference arrays for many range updates, prefix XOR.
6. Monotonic stack
Use when: "next greater element", "previous smaller element", daily temperatures, largest rectangle in a histogram, stock span. Keep the stack sorted; pop every element that the new element makes irrelevant. Each element is pushed and popped once: O(n).
def next_greater(nums):
res = [-1] * len(nums)
stack = [] # indices with decreasing values
for i, x in enumerate(nums):
while stack and nums[stack[-1]] < x:
res[stack.pop()] = x # x is the next greater for that index
stack.append(i)
return res
def largest_rectangle(heights):
stack, best = [], 0 # increasing heights (indices)
for i, h in enumerate(heights + [0]): # sentinel flushes the stack
while stack and heights[stack[-1]] >= h:
height = heights[stack.pop()]
width = i if not stack else i - stack[-1] - 1
best = max(best, height * width)
stack.append(i)
return best
The monotonic deque variant solves sliding window maximum in O(n): keep indices with decreasing values; drop the front when it leaves the window.
7. Intervals
Use when: meetings, bookings, ranges. Almost always start by sorting by start time (or by end time for greedy selection).
def merge_intervals(intervals):
intervals.sort(key=lambda x: x[0])
merged = []
for s, e in intervals:
if merged and s <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged # O(n log n)
import heapq
def min_meeting_rooms(intervals):
intervals.sort()
ends = [] # min-heap of end times in use
for s, e in intervals:
if ends and ends[0] <= s:
heapq.heappop(ends) # reuse the room that freed earliest
heapq.heappush(ends, e)
return len(ends)
Related: insert interval, non-overlapping intervals (greedy by earliest end; answer = n minus the maximum compatible set), interval list intersections (two pointers), sweep line with +1/-1 events.
Core patterns II: backtracking, greedy, heap top-k and bits
These patterns deal with choices: exploring all of them (backtracking), committing to the locally best one (greedy), keeping only the best k (heap), or packing many yes/no choices into the bits of one integer (bit manipulation).
Planning a road trip. Backtracking is trying every route on the map, turning back at every dead end, and remembering the good ones. Greedy is always taking the exit that looks fastest right now, which works on some road networks and fails badly on others. A top-k heap is a leaderboard that only keeps the ten best scores and throws out anyone who drops off. Bitmasks are a row of light switches, one per city, where "visited" means the switch is on and the whole trip state fits in one number.
8. Backtracking (DFS over choices)
Use when: generate all subsets, permutations or combinations; constraint puzzles (N-Queens, Sudoku); word search on a grid. The template is choose, explore, un-choose. Prune early when a partial choice cannot lead to a valid answer.
def subsets(nums):
res, path = [], []
def bt(start):
res.append(path[:]) # record a copy
for i in range(start, len(nums)):
path.append(nums[i]) # choose
bt(i + 1) # explore
path.pop() # un-choose
bt(0)
return res # O(n x 2^n)
def permutations(nums):
res, path, used = [], [], [False] * len(nums)
def bt():
if len(path) == len(nums):
res.append(path[:]); return
for i, x in enumerate(nums):
if used[i]: continue
used[i] = True; path.append(x)
bt()
path.pop(); used[i] = False
bt()
return res # O(n x n!)
def combination_sum(cands, target): # reuse allowed
res, path = [], []
cands.sort()
def bt(start, remain):
if remain == 0:
res.append(path[:]); return
for i in range(start, len(cands)):
if cands[i] > remain: break # prune: sorted, rest are bigger
path.append(cands[i])
bt(i, remain - cands[i])
path.pop()
bt(0, target)
return res
Duplicates: sort first and skip nums[i] == nums[i-1] when i > start. N-Queens: track used columns and both diagonals (r - c and r + c) in sets.
9. Greedy
Use when: a locally optimal choice can be proved to lead to a global optimum, usually with an exchange argument ("any optimal solution can be changed to include my greedy choice without getting worse").
- Interval scheduling: pick the meeting that ends earliest, repeat. Maximises the number of non-overlapping meetings.
- Jump game: track the furthest reachable index.
- Gas station: if the total gas is at least the total cost, start after the point where the running tank last went negative.
- Huffman coding: repeatedly merge the two least frequent symbols.
- Kadane's maximum subarray: extend the current run or restart at this element (greedy and DP at once).
def can_jump(nums):
reach = 0
for i, step in enumerate(nums):
if i > reach:
return False
reach = max(reach, i + step)
return True
def max_subarray(nums): # Kadane
best = cur = nums[0]
for x in nums[1:]:
cur = max(x, cur + x)
best = max(best, cur)
return best
10. Heap top-k
Use when: "k largest", "k most frequent", "k closest points", "k-th smallest in a sorted matrix". Keep a heap of size k; the total is O(n log k), which beats sorting when k is much smaller than n and works on a stream.
import heapq
from collections import Counter
def top_k_frequent(nums, k):
counts = Counter(nums)
return heapq.nlargest(k, counts.keys(), key=counts.get) # O(n log k)
def top_k_frequent_bucket(nums, k): # O(n) bucket sort alternative
counts = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)]
for x, c in counts.items():
buckets[c].append(x)
out = []
for c in range(len(buckets) - 1, 0, -1):
for x in buckets[c]:
out.append(x)
if len(out) == k:
return out
return out
11. Bit manipulation
| Trick | Expression | Why it works |
|---|---|---|
| Check bit i | (x >> i) & 1 | Shift bit i to position 0 |
| Set / clear / toggle bit i | x | (1 << i), x & ~(1 << i), x ^ (1 << i) | Mask with a single 1 bit |
| Clear lowest set bit | x & (x - 1) | Subtracting 1 flips the lowest 1 and the zeros below it |
| Isolate lowest set bit | x & -x | Two's complement; used by Fenwick trees |
| Power of two | x > 0 and x & (x - 1) == 0 | Exactly one bit set |
| Count set bits | loop x &= x - 1; or bin(x).count('1') | Each step removes one bit (Kernighan) |
| Single number | XOR all values | a ^ a = 0 and a ^ 0 = a, so pairs cancel |
| Enumerate subsets | for mask in range(1 << n) | Bit i of mask means "element i is in" |
def single_number(nums):
res = 0
for x in nums:
res ^= x
return res
def missing_number(nums): # 0..n with one missing
res = len(nums)
for i, x in enumerate(nums):
res ^= i ^ x
return res
x & (x - 1) clears the lowest set bit" is a favourite.Sorting algorithms
Sorting is rarely asked to be implemented from scratch, but you must know the trade-offs, and "sort first" is the opening move for many problems (intervals, two pointers, grouping, greedy). Any sort that only compares elements needs at least Omega(n log n) comparisons in the worst case; non-comparison sorts (counting, radix, bucket) beat this by exploiting the structure of keys.
Sorting a deck of cards. Insertion sort is how you sort a poker hand: pick up one card at a time and slide it into place. Merge sort is splitting the deck into single cards and zipping piles back together in order. Quicksort is picking a pivot card and throwing smaller cards to the left and bigger ones to the right, then repeating on each side. Counting sort is having 13 labelled boxes and dropping each card in the box for its rank. Stability means two sevens keep the order in which they were dealt.
Comparison table
| Algorithm | Best | Average | Worst | Extra space | Stable? | Notes |
|---|---|---|---|---|---|---|
| Bubble | O(n) | O(n^2) | O(n^2) | O(1) | Yes | Teaching only; best case needs an early-exit flag |
| Selection | O(n^2) | O(n^2) | O(n^2) | O(1) | No | At most n-1 swaps; useful when writes are expensive |
| Insertion | O(n) | O(n^2) | O(n^2) | O(1) | Yes | Great for small or nearly sorted input; online |
| Merge | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes | Predictable; linked lists; external sorting of huge files |
| Quick | O(n log n) | O(n log n) | O(n^2) | O(log n) stack | No | Fastest in practice, in place; random pivot avoids worst case |
| Heap | O(n log n) | O(n log n) | O(n log n) | O(1) | No | Guaranteed bound, in place, poor cache locality |
| Counting | O(n + k) | O(n + k) | O(n + k) | O(n + k) | Yes | Integers in a small known range k |
| Radix | O(d(n + b)) | O(d(n + b)) | O(d(n + b)) | O(n + b) | Yes | d digits in base b; uses a stable sort per digit |
| Bucket | O(n + k) | O(n + k) | O(n^2) | O(n + k) | Yes* | Uniformly distributed floats; *if buckets are sorted stably |
| TimSort | O(n) | O(n log n) | O(n log n) | O(n) | Yes | Merge + insertion on natural runs; Python sorted, Java objects |
What the libraries use
- Python
list.sort()andsorted(): TimSort, stable, O(n log n) worst case, O(n) on already-sorted data. - Java
Arrays.sort: dual-pivot quicksort for primitive arrays (not stable, which is fine because equal primitives are indistinguishable) and TimSort for object arrays (stable). - C++
std::sort: introsort (quicksort that switches to heapsort if recursion gets too deep, plus insertion sort for tiny ranges).std::stable_sortis merge-based.
Stability, and why it matters
A stable sort keeps equal elements in their original relative order. This matters when you sort by multiple keys in passes: sort by first name, then stable-sort by last name, and people with the same last name stay ordered by first name. Radix sort depends on this property for each digit pass.
Templates: merge sort and quicksort
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= keeps it stable
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
out.extend(left[i:]); out.extend(right[j:])
return out
import random
def quicksort(a, lo=0, hi=None):
if hi is None: hi = len(a) - 1
if lo >= hi: return
p = random.randint(lo, hi) # random pivot
a[p], a[hi] = a[hi], a[p]
pivot, i = a[hi], lo
for j in range(lo, hi): # Lomuto partition
if a[j] < pivot:
a[i], a[j] = a[j], a[i]; i += 1
a[i], a[hi] = a[hi], a[i]
quicksort(a, lo, i - 1); quicksort(a, i + 1, hi)
def quickselect(a, k): # k-th smallest, 0-based, O(n) average
pivot = random.choice(a)
lows = [x for x in a if x < pivot]
highs = [x for x in a if x > pivot]
pivots = len(a) - len(lows) - len(highs)
if k < len(lows): return quickselect(lows, k)
if k < len(lows) + pivots: return pivot
return quickselect(highs, k - len(lows) - pivots)
How the elementary sorts move data
Input: [5, 2, 4, 1] Insertion: [5 | 2 4 1] -> [2 5 | 4 1] -> [2 4 5 | 1] -> [1 2 4 5] (grow a sorted prefix) Selection: pick min 1 -> [1 | 2 4 5] ... (swap the minimum of the rest to the front) Bubble: adjacent swaps push the largest to the end each pass Merge: [5 2][4 1] -> [5][2][4][1] -> [2 5][1 4] -> [1 2 4 5] Quick: pivot 4 -> [2 1] 4 [5] -> recurse on each side
people.sort(key=lambda p: (p.age, -p.score)) sorts by age ascending, then score descending. Use functools.cmp_to_key only when you need a true comparator (for example "largest number" formed by concatenation).Dynamic programming
Dynamic programming (DP) is recursion plus memory. It applies when a problem has optimal substructure (the best answer is built from best answers to smaller subproblems) and overlapping subproblems (the same smaller subproblems come up again and again). If the subproblems do not overlap, it is plain divide and conquer (like merge sort), not DP.
Climbing a staircase and writing on each step how many ways there are to reach it. To fill in step 10 you do not re-walk the whole staircase; you just add the numbers already written on steps 9 and 8. Naive recursion is a forgetful climber who recounts from the bottom every time. The numbers written on the steps are the DP table, the "add the two below" rule is the transition, and the first two steps (which you know directly) are the base cases.
The framework: state, transition, base case
- State Decide what
dp[i](ordp[i][j]) means in words, for example "the minimum coins to make amount i" or "the length of the LCS of the first i characters of A and first j of B". The state must capture everything needed to make the remaining decisions. - Transition (recurrence) Express a state in terms of smaller states by considering the last choice: "take item i or skip it", "the last step was 1 or 2 stairs".
- Base cases The smallest states you know directly:
dp[0] = 0, empty string, zero capacity. - Order Fill so that every state's dependencies are computed first (bottom-up), or recurse with a cache (top-down).
- Answer Say which state holds the final result (
dp[n],dp[m][n], or the max over all states). - Optimise space If each row only depends on the previous one, keep one or two rows.
Top-down vs bottom-up
Top-down (memoisation)
- Write the natural recursion, add a cache
- Only computes states that are actually needed
- Easiest to derive in an interview
- Recursion overhead and depth limits
Bottom-up (tabulation)
- Loops fill a table in dependency order
- No recursion, usually faster
- Enables rolling-array space optimisation
- Must work out the fill order explicitly
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n): # top-down: O(n) time, O(n) stack
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)
def fib(n): # bottom-up, O(1) space
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
DP families to recognise
| Family | Examples | State shape |
|---|---|---|
| 1-D sequence | Climbing stairs, house robber, decode ways, Kadane, word break | dp[i] |
| 0/1 knapsack | Subset sum, partition equal subset, target sum | dp[i][capacity] or 1-D reversed |
| Unbounded knapsack | Coin change (min coins, number of ways), rod cutting | dp[amount] forward loop |
| 2-D grid | Unique paths, minimum path sum, maximal square, dungeon game | dp[r][c] |
| Two strings | LCS, edit distance, distinct subsequences, regex and wildcard matching | dp[i][j] |
| Subsequences | Longest increasing subsequence, Russian doll envelopes | dp[i] or patience sorting |
| Intervals | Longest palindromic subsequence, matrix chain, burst balloons | dp[l][r], fill by length |
| State machine | Stock buy and sell with cooldown or fee | dp[i][holding] |
| Trees and DAGs | House robber III, longest path in a DAG, tree diameter | Per-node return values |
| Bitmask | Travelling salesman, assign n workers to n jobs (n up to ~20) | dp[mask][i] |
1-D: house robber and climbing stairs
def rob(nums): # cannot rob adjacent houses
take, skip = 0, 0 # best if we rob / don't rob house i
for x in nums:
take, skip = skip + x, max(take, skip)
return max(take, skip)
# dp[i] = max(dp[i-1], dp[i-2] + nums[i])
def climb_stairs(n): # 1 or 2 steps at a time
a, b = 1, 1
for _ in range(n - 1):
a, b = b, a + b
return b
Coin change (unbounded knapsack)
def coin_change(coins, amount): # minimum number of coins
INF = float('inf')
dp = [0] + [INF] * amount # dp[a] = min coins for amount a
for a in range(1, amount + 1):
for c in coins:
if c <= a and dp[a - c] + 1 < dp[a]:
dp[a] = dp[a - c] + 1
return dp[amount] if dp[amount] != INF else -1 # O(amount x coins)
def coin_ways(coins, amount): # number of combinations
dp = [1] + [0] * amount
for c in coins: # coins outer => combinations, not permutations
for a in range(c, amount + 1):
dp[a] += dp[a - c]
return dp[amount]
0/1 knapsack
def knapsack(weights, values, W):
dp = [0] * (W + 1) # dp[w] = best value with capacity w
for wt, val in zip(weights, values):
for w in range(W, wt - 1, -1): # go DOWN so each item is used once
dp[w] = max(dp[w], dp[w - wt] + val)
return dp[W] # O(n x W) time, O(W) space
def can_partition(nums): # equal subset sum
total = sum(nums)
if total % 2: return False
target = total // 2
dp = [True] + [False] * target
for x in nums:
for s in range(target, x - 1, -1):
dp[s] = dp[s] or dp[s - x]
return dp[target]
Note that O(n x W) is pseudo-polynomial: it is polynomial in the numeric value W, not in the number of bits needed to write W. Knapsack is NP-hard in general.
Longest increasing subsequence
def lis_quadratic(nums): # dp[i] = LIS ending at i, O(n^2)
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)
import bisect
def lis(nums): # patience sorting, O(n log n)
tails = [] # tails[k] = smallest tail of an IS of length k+1
for x in nums:
i = bisect.bisect_left(tails, x)
if i == len(tails): tails.append(x)
else: tails[i] = x
return len(tails)
Longest common subsequence and edit distance
def lcs(a, b):
m, n = len(a), len(b)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n] # O(m x n)
def edit_distance(a, b): # insert, delete, replace cost 1
m, n = len(a), len(b)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1): dp[i][0] = i # delete all
for j in range(n + 1): dp[0][j] = j # insert all
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], # delete
dp[i][j-1], # insert
dp[i-1][j-1]) # replace
return dp[m][n]
Edit distance "horse" -> "ros"
"" r o s
"" 0 1 2 3
h 1 1 2 3
o 2 2 1 2
r 3 2 2 2
s 4 3 3 2
e 5 4 4 3 <- answer 3 (replace h->r, delete r, delete e)
2-D grid: unique paths
def unique_paths(m, n): # only right or down moves
row = [1] * n
for _ in range(1, m):
for c in range(1, n):
row[c] += row[c - 1] # from above (old row[c]) + from left
return row[-1] # O(m x n) time, O(n) space
Word break
def word_break(s, words):
words = set(words)
dp = [True] + [False] * len(s) # dp[i]: s[:i] can be segmented
for i in range(1, len(s) + 1):
for j in range(i):
if dp[j] and s[j:i] in words:
dp[i] = True; break
return dp[len(s)] # O(n^2) checks (x substring cost)
Range queries: prefix sums, Fenwick trees and segment trees
Prefix sums answer static range sums in O(1) after O(n) setup, but a later point update forces you to rebuild. When the interview mixes point updates with range queries (or range updates with point queries), reach for a Fenwick tree or a segment tree: both are O(log n) per operation.
A company org chart of running totals. A prefix-sum array is a printed year-end report: instant to read, useless the moment one office changes a number. A Fenwick tree is a chain of managers where each person stores the sum of a power-of-two block of reports under them; changing one office walks the chain of managers whose blocks contain that office. A segment tree is a full binary tree of departments: the leaves are offices, each parent stores the combined answer for its two children, and a query walks O(log n) departments that exactly cover the requested range.
When to pick which
| Need | Structure | Time |
|---|---|---|
| Many range sums, no updates | Prefix sums | O(n) build, O(1) query |
| Point update + prefix / range sum | Fenwick tree (BIT) | O(log n) each; tiny code |
| Point or range update + range min / max / sum / gcd | Segment tree (optionally lazy) | O(log n) each; more code |
| 2-D prefix sums (submatrix sum) | 2-D prefix table | O(1) query after O(R x C) build |
class Fenwick:
def __init__(self, n):
self.t = [0] * (n + 1) # 1-indexed
def add(self, i, delta): # point update
i += 1
while i < len(self.t):
self.t[i] += delta
i += i & -i # next responsible index
def prefix(self, i): # sum of [0 .. i]
i += 1; s = 0
while i > 0:
s += self.t[i]
i -= i & -i
return s
def range_sum(self, l, r):
return self.prefix(r) - (self.prefix(l - 1) if l else 0)
i & -i isolates the lowest set bit, which is the size of the block this index is responsible for. A segment tree stores 4n nodes (safe upper bound) and can be extended with lazy propagation for range updates. Both are O(n) memory.
Quick revision
- Big-O ignores constants and keeps the dominant term; always state both time and space, and name every input variable.
- Amortized O(1): dynamic-array append and hash-map insert are occasionally O(n) but average O(1) because capacity doubles.
- Rough budget: about 10^8 simple operations per second; n = 10^5 to 10^6 means O(n) or O(n log n).
- Recursion depth counts as space: O(h) for trees, O(n) for skewed trees and deep DFS.
- Hash maps give O(1) average lookup via hashing and buckets; worst case O(n) when keys collide. Java 8+
HashMaptreeifies a bin only if it has 8+ entries and the table capacity is at least 64 (otherwise it resizes); a treeified bin is O(log n). - Replacing an inner search loop with a hash-map lookup is the most common optimisation.
- Linked lists: use a dummy head, save
nextbefore rewiring, checkfast and fast.next. - Floyd's cycle detection: after the pointers meet, reset one to head and step both by one to reach the entry.
- Use
collections.dequefor queues in Python;list.pop(0)is O(n). - Heaps: O(1) peek, O(log n) push and pop, O(n) heapify; a size-k min-heap keeps the k largest.
- Two heaps (max-heap low half, min-heap high half) give a running median.
- A BST in-order traversal is sorted; validate with min/max bounds, not just children.
- Diameter of a tree is the max of left-height + right-height over every node, not only the path through the root.
- Fenwick tree: point update and prefix sum in O(log n) via
i & -i; segment tree if you need min/max or range updates. equalsandhashCodemust agree; mutable keys in a hash map get lost.- Tries give O(L) insert and search and natural prefix queries at the cost of memory.
- BFS finds shortest paths in unweighted graphs; mark nodes visited when enqueuing.
- DFS finds components, paths and cycles; directed cycles are detected with grey (on-stack) nodes.
- Topological sort (Kahn): repeatedly remove in-degree-zero nodes; fewer than n output means a cycle.
- Dijkstra needs non-negative weights, O((V + E) log V); use Bellman-Ford for negative edges.
- Union-Find with path compression and union by size is effectively O(1) per operation.
- Two pointers need a monotonic property (usually sortedness) to know which pointer to move.
- Sliding window: expand right, shrink left while invalid; fails with negative numbers for sum constraints.
- Binary search on the answer works whenever feasibility is monotonic; use a half-open "first true" template.
- Prefix sums plus a hash map count subarrays with sum k in O(n), even with negatives.
- Monotonic stack solves next-greater and histogram problems in O(n) because each index is pushed and popped once.
- Interval problems start by sorting; meeting rooms uses a min-heap of end times.
- Backtracking is choose, explore, un-choose; copy the path when recording and prune early.
- Greedy needs a proof (exchange argument); coin change with {1, 3, 4} breaks greedy.
- Comparison sorts are Omega(n log n); counting and radix sort beat it for bounded integer keys.
- Stable sorts (merge, insertion, TimSort, counting) keep equal keys in original order.
- Quicksort is fastest in practice but O(n^2) worst case; random pivots or introsort fix it.
- DP needs optimal substructure and overlapping subproblems; define state, transition, base case, order, answer.
- DP complexity = number of states x work per state.
- 0/1 knapsack iterates capacity downward; unbounded iterates upward.
- LIS is O(n^2) with DP or O(n log n) with patience sorting and binary search.
- Interview flow: clarify, brute force, optimise, agree, code, test edge cases, discuss complexity.
Glossary
- Adjacency list
- Graph storage mapping each vertex to the list of its neighbours; O(V + E) space.
- Amortized cost
- Average cost per operation over a worst-case sequence of operations, even if single operations are occasionally expensive.
- Backtracking
- Depth-first search over choices that undoes each choice after exploring it, pruning branches that cannot succeed.
- Balanced BST
- A binary search tree (AVL, red-black) that rebalances itself to keep height O(log n).
- BFS
- Breadth-first search; explores a graph level by level using a queue.
- Big-O
- Asymptotic upper bound on how cost grows with input size, ignoring constants.
- Binary heap
- Complete binary tree in an array where each parent is no larger (min-heap) or no smaller (max-heap) than its children.
- Binary search on the answer
- Binary search over possible answer values using a monotonic feasibility check.
- Collision
- Two different keys hashing to the same bucket; resolved by chaining or open addressing.
- DAG
- Directed acyclic graph; a directed graph with no cycles, which always has a topological order.
- Deque
- Double-ended queue with O(1) insertion and removal at both ends.
- DFS
- Depth-first search; follows one path as deep as possible before backtracking, using recursion or a stack.
- Dijkstra's algorithm
- Greedy single-source shortest-path algorithm for non-negative edge weights using a min-heap.
- Divide and conquer
- Split a problem into independent subproblems, solve each, and combine the results (merge sort).
- Dynamic programming
- Solving problems with overlapping subproblems by storing and reusing sub-answers.
- Exchange argument
- Proof technique for greedy algorithms showing any optimal solution can be changed to include the greedy choice.
- Fenwick tree
- Binary indexed tree supporting prefix sums and point updates in O(log n).
- Segment tree
- Binary tree over array ranges that answers range queries and applies point or range updates in O(log n).
- Treeify
- Java 8+
HashMapconversion of a long collision chain into a red-black tree, only when the bin has 8+ entries and the table capacity is at least 64. - Hash function
- Function mapping a key to an integer, used to pick a bucket in a hash table.
- In-place
- An algorithm that uses O(1) (or very little) extra memory beyond the input.
- Load factor
- Number of entries divided by number of buckets; triggers resizing when too high.
- Memoisation
- Caching the results of a recursive function so each input is computed once (top-down DP).
- Monotonic stack
- A stack kept in increasing or decreasing order, used for next-greater or next-smaller queries.
- Open addressing
- Collision strategy that stores all entries in the table itself and probes for another free slot.
- Optimal substructure
- Property that an optimal solution is composed of optimal solutions to subproblems.
- Overlapping subproblems
- Property that the same subproblems recur many times in a naive recursion.
- Path compression
- Union-Find optimisation that points nodes directly at their root during find.
- Prefix sum
- Running total array where the sum of any range is the difference of two entries.
- Pseudo-polynomial
- Running time polynomial in a numeric input value (like capacity W) but exponential in its bit length.
- Quickselect
- Partition-based selection of the k-th smallest element in O(n) average time.
- Sliding window
- Technique that maintains a contiguous range with two indices that only move forward.
- Stable sort
- A sort that preserves the relative order of elements with equal keys.
- Tabulation
- Bottom-up DP that fills a table iteratively in dependency order.
- TimSort
- Hybrid stable sort combining merge sort and insertion sort on natural runs; used by Python and Java for objects.
- Topological sort
- Linear ordering of a DAG's vertices so every edge points forward.
- Trie
- Prefix tree storing strings character by character along root-to-node paths.
- Two pointers
- Technique using two indices that move through data, often from both ends, to avoid nested loops.
- Union-Find
- Disjoint set union structure supporting near-constant-time find and union of groups.
Interview questions
Fundamentals
What is Big-O notation, and why do we drop constants and lower-order terms?
Big-O is an asymptotic upper bound on how an algorithm's cost grows as the input size n grows. Constants and smaller terms are dropped because for large n they do not change which algorithm wins: O(3n + 100) and O(n) both double when n doubles, while O(n^2) quadruples. It lets you compare algorithms independent of hardware and language. In practice constants still matter for small inputs, so mention them when relevant (for example, insertion sort beats merge sort on tiny arrays).
What is the difference between time complexity and space complexity?
Time complexity counts how the number of basic operations grows with n; space complexity counts how extra memory grows with n (auxiliary space, excluding the input and usually the output). Include hidden costs in space: the recursion stack (O(h) for tree DFS), copies from slicing, and hash maps or visited sets. Many problems trade one for the other, for example Two Sum goes from O(n^2) time and O(1) space to O(n) time and O(n) space with a hash map.
What does amortized O(1) mean? Use dynamic-array append as the example.
Amortized cost is the average cost per operation over a worst-case sequence. A dynamic array doubles its capacity when full, copying all elements (O(n)). But copies happen at sizes 1, 2, 4, ..., n/2, which sum to less than n. So n appends do at most about 3n work in total, which is O(1) per append on average. It is a guarantee over the sequence, not a probability, unlike "average case".
Give examples where average-case and worst-case complexity differ.
- Quicksort: O(n log n) average, O(n^2) worst when pivots are always the smallest or largest (for example, a naive first-element pivot on sorted input).
- Hash map lookup: O(1) average, O(n) worst if every key lands in one bucket. Java 8+
HashMapconverts a bin of 8+ entries into a red-black tree only when table capacity is at least 64 (otherwise it resizes), so that bucket becomes O(log n). - Quickselect: O(n) average, O(n^2) worst.
- Unbalanced BST: O(log n) average for random inserts, O(n) for sorted inserts.
Always say which case you are quoting; interviewers probe the worst case.
Array vs linked list: when would you use each?
Array: O(1) random access, contiguous memory (cache friendly, fast scans), O(1) amortized append, but O(n) insert or delete in the middle. Linked list: O(1) insert after a known node; delete is O(1) on a doubly linked list or if you hold the previous node on a singly linked list, otherwise O(n). No resizing, but O(n) access and poor cache locality, plus pointer overhead per node. Default to arrays; choose linked lists when you splice at known positions often, as in an LRU cache's recency list or an OS free list.
How does a hash map work internally, and how are collisions handled?
A hash function turns the key into an integer, which is reduced modulo the table size to pick a bucket. On lookup the map hashes the key, goes to the bucket and compares keys with equality. Collisions are handled by:
- Chaining: each bucket holds a list of entries. Java 8+
HashMaptreeifies a bin into a red-black tree when it has 8+ entries and the table capacity is at least 64; a smaller table resizes instead. - Open addressing: probe for another slot (linear, quadratic, double hashing), as Python's
dictdoes; deletions leave tombstones.
When the load factor (entries / buckets) passes a threshold (0.75 in Java), the table doubles and all keys are rehashed, which keeps operations O(1) amortized.
When does Java HashMap convert a collision chain into a tree?
Java 8+ HashMap treeifies a bin into a red-black tree only when both conditions hold: the bin has at least TREEIFY_THRESHOLD (8) entries, and the table capacity is at least MIN_TREEIFY_CAPACITY (64). If the chain is long but the table is still smaller than 64, treeifyBin resizes the table instead of building a tree, because a tiny table with one long chain almost always means a poor hash and growing the table usually spreads the keys. After treeify, lookups in that bin are O(log n). The bin converts back to a list when it shrinks below UNTREEIFY_THRESHOLD (6), typically during a resize. Interviewers who only hear "8 entries" are looking for this capacity-64 nuance.
What is the equals and hashCode contract?
If you override equals() you must override hashCode() so that equal objects have the same hash. The contract: reflexive, symmetric, transitive, consistent; x.equals(y) implies x.hashCode() == y.hashCode(); unequal objects may share a hash (that is a collision). Breaking it puts equal keys in different buckets, so a map can store two "equal" keys or fail to find one you just inserted. Keys must be immutable while they sit in a map: changing a field that participates in the hash loses the entry. Python's rule is the same: objects used as dict keys must be hashable and their hash must not change.
Stack vs queue: give real uses of each.
A stack is LIFO: function call stack, undo history, browser back button, DFS, expression evaluation, matching parentheses, monotonic-stack problems. A queue is FIFO: BFS, job and print queues, producer-consumer buffers, request handling in order, level-order tree traversal. In Python use a list for a stack and collections.deque for a queue.
When would you use a heap instead of a balanced BST such as TreeMap?
Use a heap when you only ever need the minimum or maximum: top-k, scheduling, Dijkstra, merging k lists. It gives O(1) peek, O(log n) push and pop, O(n) build, and is a compact array. Use a balanced BST when you need sorted iteration, floor or ceiling, range queries, or deletion of arbitrary elements, all in O(log n).
What is a trie, and when is it better than a hash set of words?
A trie is a prefix tree where each edge is a character; words that share a prefix share a path. Insert and search are O(L) for a word of length L, independent of the dictionary size. It beats a hash set when you need prefix operations: autocomplete, "does any word start with ...", word search on a grid with pruning, longest common prefix. A hash set can only answer exact membership. The cost is memory: many nodes, each with a child map or array.
BFS vs DFS: when do you pick which?
BFS uses a queue and explores level by level, so it finds the shortest path in an unweighted graph and suits level-order problems and multi-source spreading. DFS uses recursion or a stack and goes deep, which suits path existence, connected components, cycle detection, topological sort and backtracking. BFS memory is the widest frontier; DFS memory is the maximum depth. Both are O(V + E).
What are the four ways to traverse a binary tree?
Pre-order (node, left, right) for copying or serialising; in-order (left, node, right), which visits a BST in sorted order; post-order (left, right, node) for computing values from children such as height or diameter; and level-order (BFS with a queue) for per-level views. The first three are DFS variants and can be written recursively or with an explicit stack.
Adjacency list vs adjacency matrix?
An adjacency list stores each vertex's neighbours: O(V + E) space, iterating neighbours costs O(degree), ideal for sparse graphs (most real graphs). An adjacency matrix is V x V: O(V^2) space, O(1) "is there an edge u-v?" check, better for dense graphs or algorithms like Floyd-Warshall. Interviews almost always expect an adjacency list built with a dict of lists.
Why is binary search O(log n), and what does it require?
Each comparison discards half the remaining range, so after k steps n/2^k elements remain; that reaches 1 when k = log2(n). A million elements need about 20 comparisons. It requires random access and a monotonic property: sorted data, or a yes/no predicate that flips from false to true exactly once. It does not work efficiently on linked lists because finding the middle is O(n).
What is a stable sort, and name some stable and unstable ones.
A stable sort keeps elements with equal keys in their original relative order. Stable: merge sort, insertion sort, bubble sort, counting sort, radix sort, TimSort (Python's sorted, Java's object sort). Unstable: quicksort, heap sort, selection sort. Stability matters for multi-key sorting done in passes and is required inside radix sort.
What are the costs of common Python operations?
| Operation | Cost |
|---|---|
list[i], append, pop() | O(1) (append amortized) |
list.insert(0, x), pop(0), x in list | O(n) |
list[a:b] | O(b - a) copy |
dict / set get, set, in, delete | O(1) average |
deque append / popleft on both ends | O(1) |
heapq.heappush / heappop | O(log n) |
sorted() | O(n log n) |
"".join(parts) | O(total length) |
What is recursion, and what can go wrong with it?
A recursive function solves a problem by calling itself on smaller inputs until it reaches a base case. Each call uses a stack frame, so space is O(depth). Pitfalls: a missing or wrong base case (infinite recursion), exponential blow-up from recomputing the same subproblems (fix with memoisation), and stack overflow on deep inputs (Python's default limit is about 1000 frames). Convert to an explicit stack or iteration when depth can be large.
What two properties make a problem suitable for dynamic programming?
Optimal substructure: the optimal answer can be built from optimal answers to subproblems. Overlapping subproblems: the same subproblems are solved repeatedly by naive recursion. If subproblems are independent (merge sort's halves), it is divide and conquer, and caching does not help.
Memoisation vs tabulation?
Memoisation is top-down: write the recursion and cache results by argument; it computes only the states you need and is easiest to derive. Tabulation is bottom-up: loops fill a table in dependency order; no recursion overhead or depth limit, and it enables rolling-array space savings. Both have the same time complexity, which is the number of states times the work per state.
Coding: Two Sum. Return indices of two numbers adding to a target.
One pass with a hash map from value to index. For each x, check whether target - x has been seen. O(n) time, O(n) space (brute force is O(n^2)). If the array is sorted and you need values, two pointers give O(1) space.
def two_sum(nums, target):
seen = {}
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = iCoding: Contains duplicate and valid anagram.
Duplicate: add to a set and return True on the first repeat; O(n) time and space (sorting gives O(n log n) time with O(1) extra). Anagram: equal lengths and equal character counts; O(n) with a 26-slot array or Counter.
def contains_duplicate(nums):
return len(set(nums)) != len(nums)
def is_anagram(s, t):
from collections import Counter
return Counter(s) == Counter(t)Coding: Valid parentheses.
Push opening brackets on a stack; on a closing bracket, the stack top must be its matching opener. The stack must be empty at the end. O(n) time, O(n) space. Edge cases: empty string (valid), starting with a closer, leftover openers.
def is_valid(s):
match = {')': '(', ']': '[', '}': '{'}
st = []
for c in s:
if c in match:
if not st or st.pop() != match[c]:
return False
else:
st.append(c)
return not stCoding: Reverse a linked list, iteratively and recursively.
Iterative: three pointers; save next, flip curr.next to prev, advance. O(n) time, O(1) space. Recursive: reverse the rest, then make the next node point back at the current one; O(n) stack space.
def reverse(head):
prev = None
while head:
head.next, prev, head = prev, head, head.next
return prev
def reverse_rec(head):
if not head or not head.next:
return head
new_head = reverse_rec(head.next)
head.next.next = head
head.next = None
return new_headCoding: Merge two sorted linked lists.
Use a dummy node and a tail pointer; repeatedly attach the smaller head; attach whatever remains. O(m + n) time, O(1) extra space.
def merge(a, b):
dummy = tail = ListNode()
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.nextCoding: Maximum depth of a binary tree.
Post-order recursion: depth is 1 plus the larger child depth; null has depth 0. O(n) time, O(h) space. BFS counting levels also works and avoids deep recursion.
def max_depth(root):
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))Coding: Best time to buy and sell a stock (one transaction).
Track the minimum price so far; the best profit is the maximum of price - min_so_far. O(n) time, O(1) space.
def max_profit(prices):
lo, best = float('inf'), 0
for p in prices:
lo = min(lo, p)
best = max(best, p - lo)
return bestCoding: Invert a binary tree.
Swap the left and right children of every node, recursively or with BFS. O(n) time, O(h) space.
def invert(root):
if root:
root.left, root.right = invert(root.right), invert(root.left)
return rootGoing deeper
HashMap vs LinkedHashMap vs ConcurrentHashMap vs Hashtable?
- HashMap: unsynchronized, no insertion-order guarantee, null key and values allowed. Default map in a single thread.
- LinkedHashMap: HashMap plus a doubly linked list of entries; iteration is insertion order (or access order, which is how Java implements LRU with
removeEldestEntry). - Hashtable: legacy, fully synchronized on every call, no nulls. Do not use it in new code.
- ConcurrentHashMap: thread-safe without locking the whole table (bin-level / CAS); no nulls. Use it for shared maps across threads. Iteration is weakly consistent (may miss or see concurrent updates) rather than fail-fast.
Python's dict is insertion-ordered and not thread-safe; use a lock or a concurrent structure if several threads mutate it.
How do you implement a queue with two stacks?
Use an in stack for enqueue and an out stack for dequeue. Enqueue: push onto in, O(1). Dequeue: if out is empty, pop every element from in onto out (this reverses the order so the oldest item is on top), then pop out. Each element is moved at most twice, so dequeue is O(1) amortized. This is how some libraries hide a queue behind stack-only primitives, and it is a favourite interview question.
What is the diameter of a binary tree?
The number of edges (or nodes, clarify) on the longest path between any two nodes. It may not pass through the root. Recurse: for each node, compute the height of both children; the longest path through that node is left_height + right_height; the answer is the max of that over every node. Return height upward and thread the diameter through an outer variable or a pair. O(n) time, O(h) space. A common bug is returning only the path through the root.
How does a Fenwick tree (binary indexed tree) work?
It stores prefix sums in an array where index i (1-based) is responsible for a block of i & -i elements ending at i. A point update adds delta to i and then walks i += i & -i to update every block that contains i. A prefix query walks i -= i & -i and sums those blocks. Both are O(log n). Range sum is prefix(r) - prefix(l-1). Use it when you need many point updates mixed with prefix or range sums. A segment tree is more general (min, max, range updates with lazy propagation) but longer to write.
How does "binary search on the answer" work? Give an example.
When you cannot binary search the input directly but the answer space is monotonic (if capacity x works, any larger capacity also works), binary search the answer value and test feasibility with a helper. Total cost is O(log(range) x cost of check). Example: Koko eating bananas. The minimum speed k such that all piles finish in h hours: ok(k) is sum(ceil(p / k)) <= h; search k in [1, max(piles)].
def min_eating_speed(piles, h):
lo, hi = 1, max(piles)
while lo < hi:
k = (lo + hi) // 2
if sum((p + k - 1) // k for p in piles) <= h:
hi = k
else:
lo = k + 1
return loExplain Dijkstra's algorithm and its limitation.
Keep a min-heap of (distance, node). Pop the closest unsettled node; its distance is now final. Relax each outgoing edge: if dist[u] + w < dist[v], update and push. O((V + E) log V) with a binary heap. It relies on the fact that once a node is popped, no later path can be shorter, which is only true with non-negative weights. With negative edges use Bellman-Ford (O(V x E), also detects negative cycles). For unweighted graphs plain BFS is enough; for 0/1 weights use 0-1 BFS with a deque.
Coding: Course schedule. Can all courses be finished given prerequisites?
Model as a directed graph and check for a cycle with Kahn's topological sort: compute in-degrees, enqueue zero in-degree nodes, pop and decrement neighbours. If every course gets processed, there is no cycle. O(V + E).
def can_finish(n, prereqs):
g = [[] for _ in range(n)]; indeg = [0] * n
for course, pre in prereqs:
g[pre].append(course); indeg[course] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
done = 0
while q:
u = q.popleft(); done += 1
for v in g[u]:
indeg[v] -= 1
if indeg[v] == 0: q.append(v)
return done == nCourse Schedule II returns the order itself (the popped sequence).
What is Union-Find, and why is it nearly O(1)?
Union-Find (disjoint set union) keeps a parent pointer per element; the root identifies the group. find follows parents to the root; union links two roots. Two optimisations make it effectively constant time: path compression (flatten the path during find) and union by rank or size (attach the smaller tree under the larger). Together they give O(alpha(n)) amortized, where alpha is the inverse Ackermann function (at most 4 in practice). Uses: dynamic connectivity, cycle detection in undirected graphs, Kruskal's MST, number of provinces, accounts merge.
When is greedy correct, and when do you need DP?
Greedy is correct when a locally optimal choice is provably part of some globally optimal solution, usually shown with an exchange argument (for example, choosing the meeting that ends earliest never hurts). If choices interact so that a good choice now can block a better combination later, you need DP to consider alternatives. A quick test: look for a small counterexample. Coin change with {1, 3, 4} and amount 6 breaks greedy (4+1+1) while DP finds 3+3.
Why is building a heap O(n) and not O(n log n)?
Bottom-up heapify sifts down each internal node, starting from the last parent. Sift-down cost is proportional to a node's height, and most nodes are near the bottom: about n/2 nodes have height 0, n/4 height 1, n/8 height 2, and so on. The total is n x sum(h / 2^(h+1)), which converges to O(n). Inserting n items one by one is O(n log n).
Quicksort vs merge sort: which and why?
Quicksort is in place (O(log n) stack), cache friendly, and usually fastest in practice, but O(n^2) worst case and not stable; random or median-of-three pivots and introsort fix the worst case. Merge sort is always O(n log n), stable, suits linked lists and external sorting, but needs O(n) extra memory for arrays. Libraries use hybrids: TimSort (merge + insertion) and introsort (quick + heap + insertion).
How do you detect a cycle in a directed graph vs an undirected graph?
Directed: DFS with three states: unvisited, visiting (on the current recursion path), done. Reaching a "visiting" node means a back edge, which is a cycle. Or run Kahn's algorithm: if not all nodes are output, a cycle exists. Undirected: DFS where a visited neighbour that is not your parent means a cycle, or Union-Find where an edge joins two nodes already in the same set. A simple visited set is not enough for directed graphs, because two paths reaching the same node (a diamond) is not a cycle.
Coding: Longest substring without repeating characters.
Sliding window with a last-seen index map. When the current character was seen inside the window, move left just past its previous occurrence. O(n) time, O(alphabet) space.
def length_of_longest(s):
last, left, best = {}, 0, 0
for r, c in enumerate(s):
if last.get(c, -1) >= left:
left = last[c] + 1
last[c] = r
best = max(best, r - left + 1)
return bestCoding: 3Sum. Find all unique triplets that sum to zero.
Sort, fix index i, then two-pointer the rest for -nums[i]. Skip duplicate values for i and after each found triplet. O(n^2) time, O(1) extra apart from output and sorting.
def three_sum(nums):
nums.sort(); res = []
for i in range(len(nums) - 2):
if i and nums[i] == nums[i - 1]: continue
l, r = i + 1, len(nums) - 1
while l < r:
s = nums[i] + nums[l] + nums[r]
if s < 0: l += 1
elif s > 0: r -= 1
else:
res.append([nums[i], nums[l], nums[r]])
l += 1
while l < r and nums[l] == nums[l - 1]: l += 1
r -= 1
return resCoding: Container with most water.
Two pointers at both ends. Area = min(h[l], h[r]) x (r - l). Move the shorter wall inward, because moving the taller one can never increase the area (width shrinks and the height is still capped by the shorter wall). O(n) time, O(1) space.
def max_area(h):
l, r, best = 0, len(h) - 1, 0
while l < r:
best = max(best, min(h[l], h[r]) * (r - l))
if h[l] < h[r]: l += 1
else: r -= 1
return bestCoding: Product of array except self, without division.
The answer at i is (product of everything left of i) x (product of everything right of i). Fill left products in a forward pass, then multiply by a running right product in a backward pass. O(n) time, O(1) extra besides the output.
def product_except_self(nums):
n = len(nums); out = [1] * n
for i in range(1, n):
out[i] = out[i - 1] * nums[i - 1]
right = 1
for i in range(n - 1, -1, -1):
out[i] *= right
right *= nums[i]
return outCoding: Group anagrams.
Map each word to a canonical key: its sorted letters (O(k log k)) or a tuple of 26 counts (O(k)). Group with a dict of lists. Total O(n x k log k) or O(n x k).
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
key = [0] * 26
for c in w: key[ord(c) - 97] += 1
groups[tuple(key)].append(w)
return list(groups.values())Coding: Top K frequent elements.
Count with a hash map, then either keep a min-heap of size k keyed by count (O(n log k)) or bucket-sort by frequency (O(n), because frequency is at most n). Quickselect on counts is O(n) average.
def top_k(nums, k):
c = Counter(nums)
return heapq.nlargest(k, c, key=c.get)Coding: Kth largest element in an array.
Min-heap of size k: push each element and pop when the size exceeds k; the heap top is the answer. O(n log k) time, O(k) space, works on streams. Quickselect gives O(n) average and O(1) extra space but O(n^2) worst case; randomise the pivot. Sorting is O(n log n) and acceptable as a baseline.
Coding: Merge overlapping intervals.
Sort by start. Walk through; if the current interval starts at or before the end of the last merged one, extend that end; otherwise start a new merged interval. O(n log n) time.
def merge(intervals):
out = []
for s, e in sorted(intervals):
if out and s <= out[-1][1]:
out[-1][1] = max(out[-1][1], e)
else:
out.append([s, e])
return outCoding: Number of islands.
Scan every cell; on unvisited land, flood-fill its whole island (DFS or BFS) marking cells visited, and count one island. O(R x C) time; recursion depth can reach R x C, so use BFS or an explicit stack for big grids. Union-Find is an alternative, useful when land is added dynamically (Number of Islands II).
Coding: Validate a binary search tree.
Recurse with an allowed (low, high) range: the root has (-inf, +inf); a left child inherits (low, node.val), a right child (node.val, high). Alternatively an in-order traversal must be strictly increasing. O(n) time, O(h) space. Clarify how duplicates are treated.
def is_bst(node, lo=float('-inf'), hi=float('inf')):
if not node: return True
if not lo < node.val < hi: return False
return is_bst(node.left, lo, node.val) and is_bst(node.right, node.val, hi)Coding: Lowest common ancestor of two nodes.
General binary tree: if the root is null, p or q, return it; recurse left and right; if both sides return non-null, the root is the LCA; otherwise return the non-null side. O(n) time, O(h) space. For a BST, walk from the root: if both values are smaller go left, if both larger go right, otherwise the current node is the LCA, O(h).
Coding: Search in a rotated sorted array.
Modified binary search. At each mid, one half is sorted. If the left half is sorted and the target lies inside it, search left; otherwise search right. Mirror for the right half. O(log n). With duplicates, the worst case degrades to O(n) because you may not be able to tell which half is sorted.
def search(a, t):
lo, hi = 0, len(a) - 1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == t: return mid
if a[lo] <= a[mid]: # left half sorted
if a[lo] <= t < a[mid]: hi = mid - 1
else: lo = mid + 1
else: # right half sorted
if a[mid] < t <= a[hi]: lo = mid + 1
else: hi = mid - 1
return -1Coding: Coin change (fewest coins to make an amount).
State dp[a] = fewest coins for amount a. Transition dp[a] = 1 + min(dp[a - c]) over coins c no larger than a. Base dp[0] = 0; unreachable amounts stay infinity, return -1. O(amount x coins) time, O(amount) space. BFS over amounts is an equivalent view (fewest steps). Greedy is wrong for arbitrary coin systems.
Coding: House robber.
dp[i] = max(dp[i-1], dp[i-2] + nums[i]): either skip house i or rob it plus the best up to i-2. Only two previous values are needed, so O(n) time and O(1) space. House Robber II (circular) runs this twice, excluding the first house and then the last.
Coding: Longest increasing subsequence.
O(n^2) DP: dp[i] = LIS ending at i = 1 + max dp[j] over j < i with smaller value. O(n log n): maintain tails, where tails[k] is the smallest possible tail of an increasing subsequence of length k+1; for each x, binary search the first tail at least x and replace it (or append). The length of tails is the answer; tails itself is not necessarily a valid subsequence.
Coding: Subarray sum equals k (numbers may be negative).
Prefix sums with a hash map of how many times each prefix sum has occurred. At each position, the number of subarrays ending here with sum k equals the count of earlier prefixes equal to running - k. Seed the map with {0: 1}. O(n) time and space. Sliding window does not work because negatives break monotonicity.
Coding: Daily temperatures (days until a warmer day).
Monotonic decreasing stack of indices. For each day, pop all colder days from the stack; for each popped index the answer is the current index minus it. Push the current index. Each index is pushed and popped once: O(n).
def daily_temperatures(t):
res, st = [0] * len(t), []
for i, x in enumerate(t):
while st and t[st[-1]] < x:
j = st.pop(); res[j] = i - j
st.append(i)
return resCoding: Generate all subsets and all permutations.
Backtracking. Subsets: at each start index record the current path, then try adding each later element; there are 2^n subsets, so O(n x 2^n). Permutations: try every unused element at each position; n! results, O(n x n!). With duplicates, sort first and skip an element equal to its predecessor at the same depth. An iterative alternative for subsets: for each number, add it to every existing subset. Bitmask enumeration also works for n up to about 20.
Coding: Remove the n-th node from the end of a linked list.
Use a dummy head and two pointers. Move fast n+1 steps ahead from the dummy, then move both until fast is null; slow.next is the node to delete. One pass, O(1) space. The dummy handles deleting the head.
def remove_nth_from_end(head, n):
dummy = slow = fast = ListNode(0, head)
for _ in range(n + 1): fast = fast.next
while fast:
slow, fast = slow.next, fast.next
slow.next = slow.next.next
return dummy.nextCoding: Detect a cycle in a linked list and return its starting node.
Floyd's algorithm: slow moves 1, fast moves 2. If they meet, reset one pointer to head and move both one step at a time; they meet at the cycle entry (because head-to-entry distance equals meeting-point-to-entry distance modulo the cycle length). O(n) time, O(1) space. A visited set also works in O(n) space.
Coding: Rotting oranges (minimum minutes until all rot).
Multi-source BFS: enqueue all rotten oranges at time 0, spread to fresh neighbours level by level, counting minutes. If fresh oranges remain at the end, return -1. O(R x C). The key insight is that starting BFS from all sources at once gives each cell its distance to the nearest source.
Coding: Clone a graph.
DFS or BFS with a hash map from original node to its copy. When visiting a node, create its clone if absent, then clone and link each neighbour. The map doubles as the visited set, which handles cycles. O(V + E).
def clone_graph(node):
copies = {}
def dfs(n):
if n in copies: return copies[n]
c = Node(n.val); copies[n] = c
c.neighbors = [dfs(x) for x in n.neighbors]
return c
return dfs(node) if node else NoneCoding: Min stack with O(1) push, pop, top and getMin.
Store pairs (value, minimum so far). getMin reads the second field of the top pair. O(1) for everything, O(n) space. A space-saving variant keeps a second stack that only pushes when a new value is less than or equal to the current minimum.
class MinStack:
def __init__(self): self.s = []
def push(self, x):
self.s.append((x, min(x, self.s[-1][1]) if self.s else x))
def pop(self): self.s.pop()
def top(self): return self.s[-1][0]
def getMin(self): return self.s[-1][1]Coding: Word break.
dp[i] is true if the prefix s[:i] can be split into dictionary words: true if some j < i has dp[j] true and s[j:i] in the word set. O(n^2) substring checks (each O(L) to hash); limit j to the maximum word length to speed up. A trie or BFS over indices are alternatives. Word Break II (all sentences) adds memoised backtracking and can be exponential in output size.
Coding: Maximum subarray sum.
Kadane's algorithm: the best sum ending at i is either nums[i] alone or the best ending at i-1 plus nums[i]. Track the global maximum. O(n) time, O(1) space. Handles all-negative arrays if you initialise with the first element, not zero. A divide-and-conquer O(n log n) solution exists but is rarely preferred.
Advanced
How do you check whether a binary tree is height-balanced?
A tree is balanced if at every node the heights of the two children differ by at most 1 (AVL-style). Do a post-order walk that returns height, or -1 if that subtree is already unbalanced. If either child returns -1, or |lh - rh| > 1, return -1; otherwise return 1 + max(lh, rh). The root is balanced iff the walk does not return -1. O(n) time, O(h) space. Computing height separately at every node is the slow O(n^2) version; interviewers notice.
Segment tree vs Fenwick tree: when do you write each?
Both support O(log n) point updates and range queries after O(n) build. Write a Fenwick tree when the query is a prefix or range sum (or any invertible group operation): it is about 15 lines, one array, and uses i & -i. Write a segment tree when you need range min/max, gcd, or range updates (lazy propagation: store a pending update on a node and push it to children only when you recurse). Segment trees use about 4n nodes and more code. For static range sums with no updates, neither: use a prefix-sum array.
Coding: Design an LRU cache with O(1) get and put.
Combine a hash map (key to node) with a doubly linked list ordered by recency (most recent at the front). get: look up the node, move it to the front. put: update or insert at the front; if over capacity, remove the node at the back and delete its key from the map. Both are O(1). In Python, OrderedDict with move_to_end and popitem(last=False) does this; in Java, LinkedHashMap with access order and removeEldestEntry.
from collections import OrderedDict
class LRUCache:
def __init__(self, cap):
self.cap, self.d = cap, OrderedDict()
def get(self, k):
if k not in self.d: return -1
self.d.move_to_end(k)
return self.d[k]
def put(self, k, v):
self.d[k] = v
self.d.move_to_end(k)
if len(self.d) > self.cap:
self.d.popitem(last=False) # evict least recently usedInterviewers often want the manual version: sentinel head and tail nodes plus _remove(node) and _add_front(node) helpers.
Coding: Find the median of a data stream.
Two heaps: a max-heap low (stored negated in Python) for the smaller half and a min-heap high for the larger half. Keep len(low) equal to or one more than len(high), and every element of low no larger than every element of high. Add: push to low, move low's max to high, and if high grows larger, move its min back. Median: top of low, or the average of both tops. O(log n) add, O(1) median.
class MedianFinder:
def __init__(self): self.low, self.high = [], []
def addNum(self, x):
heapq.heappush(self.low, -x)
heapq.heappush(self.high, -heapq.heappop(self.low))
if len(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
def findMedian(self):
if len(self.low) > len(self.high): return -self.low[0]
return (-self.low[0] + self.high[0]) / 2Coding: Merge k sorted lists.
Min-heap of the current head of each list, keyed by value (add an index as a tie breaker so nodes are never compared). Pop the smallest, append it, push its successor. O(N log k) time for N total nodes, O(k) heap space. Alternative: pairwise divide-and-conquer merging, also O(N log k). Merging one list at a time is O(N x k).
def merge_k(lists):
h = [(n.val, i, n) for i, n in enumerate(lists) if n]
heapq.heapify(h)
dummy = tail = ListNode()
while h:
_, i, n = heapq.heappop(h)
tail.next = tail = n
if n.next: heapq.heappush(h, (n.next.val, i, n.next))
return dummy.nextCoding: Trapping rain water.
Water above index i is min(max_left, max_right) - height[i]. Two pointers avoid the prefix arrays: move the side with the smaller maximum inward, because that side's water level is fully determined by its own maximum. O(n) time, O(1) space. A monotonic stack solution also runs in O(n).
def trap(h):
l, r = 0, len(h) - 1
lmax = rmax = water = 0
while l < r:
if h[l] < h[r]:
lmax = max(lmax, h[l]); water += lmax - h[l]; l += 1
else:
rmax = max(rmax, h[r]); water += rmax - h[r]; r -= 1
return waterCoding: Minimum window substring.
Sliding window with counts. need holds required counts of t; missing counts characters still needed. Expand right, decrementing need; when missing reaches 0 the window is valid, so shrink left as far as possible while still valid, recording the smallest window. O(|s| + |t|) time.
def min_window(s, t):
need = Counter(t); missing = len(t)
left = start = 0; best = float('inf')
for right, c in enumerate(s):
if need[c] > 0: missing -= 1
need[c] -= 1
while missing == 0:
if right - left + 1 < best:
best, start = right - left + 1, left
need[s[left]] += 1
if need[s[left]] > 0: missing += 1
left += 1
return "" if best == float('inf') else s[start:start + best]Coding: Median of two sorted arrays in O(log(min(m, n))).
Binary search a cut in the smaller array A at i; the cut in B is j = (m + n + 1) // 2 - i, so the left side holds half the elements. The cut is correct when A[i-1] <= B[j] and B[j-1] <= A[i] (use -inf / +inf beyond the ends). If A[i-1] > B[j] move i left, else move it right. The median is the max of the left side (odd total) or the average of the max-left and min-right (even total).
Coding: Edit distance between two words.
dp[i][j] = edits to turn the first i characters of a into the first j of b. If the characters match, dp[i][j] = dp[i-1][j-1]; otherwise 1 + min(delete dp[i-1][j], insert dp[i][j-1], replace dp[i-1][j-1]). Base: dp[i][0] = i, dp[0][j] = j. O(m x n) time; O(min(m, n)) space with two rows. Used in spell checkers and DNA alignment.
Coding: Serialise and deserialise a binary tree.
Pre-order traversal writing a sentinel (for example #) for null children, joined by commas. Deserialise by reading tokens from an iterator and rebuilding recursively in the same order. O(n) both ways. Level-order with nulls (LeetCode's format) also works. Without null markers, you need two traversals (pre-order plus in-order) and unique values.
def serialize(root):
out = []
def go(n):
if not n: out.append('#'); return
out.append(str(n.val)); go(n.left); go(n.right)
go(root); return ','.join(out)
def deserialize(data):
it = iter(data.split(','))
def build():
v = next(it)
if v == '#': return None
n = TreeNode(int(v)); n.left = build(); n.right = build()
return n
return build()Coding: Word ladder (fewest single-letter changes from begin to end).
BFS over words, where neighbours differ by one letter. Generating neighbours by trying 26 letters at each position is O(L x 26) per word; alternatively precompute wildcard buckets like h*t mapping to words. Total O(N x L^2) including string building. Bidirectional BFS from both ends dramatically cuts the explored frontier. Remove words from the set when enqueued to avoid revisits.
Coding: Sliding window maximum.
Monotonic deque of indices whose values are decreasing. For each new index: pop from the back while the back's value is no larger than the new value (it can never be a maximum again), append, pop the front if it has left the window, and once the window is full, the front is the maximum. O(n) total, O(k) space. A heap gives O(n log n).
def max_sliding_window(a, k):
dq, out = deque(), []
for i, x in enumerate(a):
while dq and a[dq[-1]] <= x: dq.pop()
dq.append(i)
if dq[0] <= i - k: dq.popleft()
if i >= k - 1: out.append(a[dq[0]])
return outCoding: Largest rectangle in a histogram.
Monotonic increasing stack of indices. When a bar lower than the stack top arrives, pop: the popped bar's height extends from just after the new stack top to just before the current index. Append a 0-height sentinel to flush the stack at the end. O(n). Maximal rectangle in a binary matrix applies this row by row to column heights, O(R x C).
Coding: Longest palindromic substring.
Expand around centre: for each of the 2n-1 centres (each character and each gap), expand while characters match, tracking the longest. O(n^2) time, O(1) space, and simpler than the O(n^2)-space DP (dp[i][j] true if s[i] == s[j] and dp[i+1][j-1]). Manacher's algorithm achieves O(n) but is rarely expected.
def longest_palindrome(s):
best = ""
for c in range(len(s)):
for l, r in ((c, c), (c, c + 1)):
while l >= 0 and r < len(s) and s[l] == s[r]:
l -= 1; r += 1
if r - l - 1 > len(best): best = s[l + 1:r]
return bestCoding: N-Queens.
Backtrack row by row. Keep three sets: used columns, used "r - c" diagonals and used "r + c" anti-diagonals. For each column in the current row, skip if any set conflicts; otherwise place, recurse to the next row, and remove. Record a board when all rows are placed. Roughly O(n!) with heavy pruning; bitmasks make the sets faster.
Coding: Cheapest flights within K stops.
Plain Dijkstra fails because a cheaper path with more stops can block a pricier path with fewer stops. Use Bellman-Ford limited to K+1 rounds: in each round relax all edges using a copy of the previous round's distances, so each round adds at most one edge. O(K x E). Alternatively, Dijkstra or BFS over (node, stops) states.
def find_cheapest(n, flights, src, dst, k):
dist = [float('inf')] * n; dist[src] = 0
for _ in range(k + 1):
nxt = dist[:]
for u, v, w in flights:
if dist[u] + w < nxt[v]: nxt[v] = dist[u] + w
dist = nxt
return -1 if dist[dst] == float('inf') else dist[dst]Coding: Alien dictionary (derive letter order from sorted words).
Compare each adjacent pair of words; the first differing character gives an edge (a before b). If a word is followed by its own proper prefix (for example "abc" then "ab"), the input is invalid. Then topologically sort the letters with Kahn's algorithm; if a cycle prevents outputting every letter, return "". O(total characters).
Coding: Word search II (find all dictionary words in a grid).
Build a trie of the words, then DFS from every cell following trie edges; this searches all words simultaneously instead of once per word. Mark cells visited during a path and restore them on backtrack. Prune: remove a word from the trie once found, and delete leaf nodes that have no remaining words. Worst case roughly O(R x C x 4 x 3^(L-1)).
Coding: Find the duplicate number in 1..n (n+1 values) with O(1) space, without modifying the array.
Treat the array as a linked list where index i points to nums[i]. A duplicate value means two indices point to the same node, which creates a cycle; the cycle entry is the duplicate. Run Floyd's tortoise and hare. O(n) time, O(1) space. Binary search on the value range with counting is an O(n log n) alternative.
Why can no comparison-based sort beat O(n log n) in the worst case?
A comparison sort can be modelled as a binary decision tree where each internal node is one comparison and each leaf is one output ordering. It must distinguish all n! permutations, so it needs at least n! leaves, and a binary tree with n! leaves has height at least log2(n!), which is Theta(n log n) by Stirling's approximation. Counting, radix and bucket sorts avoid the bound because they do not rely only on comparisons.
Explain Kruskal's and Prim's minimum spanning tree algorithms.
Both are greedy and correct by the cut property (the lightest edge crossing any cut belongs to some MST). Kruskal: sort edges by weight; add each edge if Union-Find says its endpoints are in different components; O(E log E). Best for sparse graphs or edge lists. Prim: grow one tree from a start vertex, repeatedly adding the cheapest edge leaving the tree via a min-heap; O(E log V). Best for dense graphs with adjacency lists.
Coding: Implement a data structure with insert, delete and getRandom all in O(1).
Keep an array of values plus a hash map from value to its index. Insert: append and record the index. Delete: swap the element with the last one, update the moved element's index in the map, pop the end and delete the key. getRandom: pick a random index. All O(1) average.
import random
class RandomizedSet:
def __init__(self): self.a, self.pos = [], {}
def insert(self, x):
if x in self.pos: return False
self.pos[x] = len(self.a); self.a.append(x); return True
def remove(self, x):
if x not in self.pos: return False
i, last = self.pos[x], self.a[-1]
self.a[i], self.pos[last] = last, i
self.a.pop(); del self.pos[x]; return True
def getRandom(self): return random.choice(self.a)Coding: Longest consecutive sequence in O(n).
Put all numbers in a set. For each number that has no predecessor (x - 1 not in the set), count upward while x + 1 is present. Each number is visited at most twice, so O(n) even though there is a nested loop. Sorting gives O(n log n).
def longest_consecutive(nums):
s, best = set(nums), 0
for x in s:
if x - 1 not in s:
y = x
while y + 1 in s: y += 1
best = max(best, y - x + 1)
return bestHow would you reconstruct the actual solution from a DP table, not just the optimal value?
Either store the choice made at each state (a parent pointer array) or walk backwards through the filled table, re-checking which transition produced each value. For LCS: start at dp[m][n]; if the characters match, emit it and go diagonal; otherwise move to whichever neighbour holds the larger value. For knapsack: if dp[i][w] != dp[i-1][w], item i was taken. Note that rolling-array space optimisation usually discards the information needed for reconstruction, so keep the full table (or use Hirschberg's divide-and-conquer trick for LCS).
Scenario & debugging
Your solution is correct but times out on n = 10^5. What do you do?
First estimate: at 10^5, O(n^2) is 10^10 operations, far too slow; you need O(n log n) or O(n). Find the nested loop or repeated work: can a hash map replace the inner search, can sorting enable two pointers or binary search, can prefix sums answer range queries in O(1), is there a monotonic structure (stack or deque), or are there repeated subproblems to memoise? Also check hidden costs: x in list, list.pop(0), string concatenation in a loop, slicing inside recursion, and recursion without memoisation.
You need to sort a 10 GB file with only 1 GB of RAM. How?
External merge sort. Pass 1: read about 1 GB at a time, sort it in memory, and write each sorted run to disk (about 10 runs). Pass 2: k-way merge all runs using a min-heap holding the current smallest line of each run, with buffered reads and writes. Total I/O is about two reads and two writes of the data; time is O(N log N). If there are too many runs for buffers, merge in multiple passes. If keys are small integers, a counting approach may need only one pass.
Find the top 10 most frequent search queries from billions of log lines.
If the distinct queries fit in memory: hash map counts, then a size-10 min-heap, O(N + D log 10). If not: hash-partition the logs by query into many files so each query lands in exactly one file, count and take the top 10 per partition, then merge the candidates (this is what MapReduce does). For a live stream with limited memory, use approximate structures: Count-Min Sketch for counts plus a small heap of heavy hitters, or the Space-Saving / Misra-Gries algorithms, accepting bounded error.
Your recursive DFS crashes with a stack overflow on a deep tree in production. What do you do?
The input is deeper than the call stack allows (for example a skewed tree or long linked chain). Options: rewrite the DFS iteratively with an explicit stack stored on the heap; switch to BFS if order does not matter; in Python, raising sys.setrecursionlimit is a stopgap that can still crash the interpreter; in C or Java, run on a thread with a larger stack. Add a test with a degenerate input of maximum size so it cannot regress.
You are stuck in the middle of a coding interview. What should you do?
Keep talking. Restate what you know and what is blocking you. Work a small example by hand and look for a pattern. Walk the pattern checklist: sorted, contiguous, shortest, dependencies, overlapping subproblems. Offer the brute force and code it if time is short, since a working suboptimal solution beats an unfinished optimal one. Accept hints gracefully and build on them; interviewers score how you use hints.
How would you remove duplicates from 1 billion URLs on one machine?
A billion URLs of about 100 bytes is roughly 100 GB, too large for a hash set in RAM. Options: hash-partition the URLs into, say, 1,000 files by hash(url) % 1000 (duplicates always land in the same file), then dedupe each file with an in-memory set; or external sort then remove adjacent duplicates. If a small false-positive rate is acceptable, a Bloom filter (about 1.2 GB for a 1 percent error rate at 10^9 items) answers "probably seen" in O(1) with no false negatives. Storing a 64-bit hash instead of the full URL reduces memory at the cost of rare collisions.
Your binary search sometimes loops forever or misses the answer. How do you debug it?
Write down the invariant: what does lo mean, what does hi mean, and is the interval closed or half-open? Infinite loops usually come from lo = mid when mid rounds down and hi = lo + 1; use lo = mid + 1 or round mid up. Test tiny cases exhaustively: empty array, one element, two elements, target smaller than all, larger than all, and duplicates. Compare against a linear-scan reference on random inputs.
Design a data structure for a game leaderboard: update a player's score, get the top k, and get a player's rank.
Keep a hash map from player to score plus an ordered structure keyed by (-score, player): a balanced BST or skip list (Redis sorted sets use a skip list plus hash). Update: remove the old key, insert the new, O(log n). Top k: iterate the first k, O(k + log n). Rank: an order-statistic tree or skip list with span counts gives O(log n). A heap alone cannot answer rank or update arbitrary players efficiently. If scores are bounded integers, a Fenwick tree over score values gives O(log S) rank queries.
Count hits in the last 5 minutes for a high-traffic endpoint.
Exact but small memory: a circular buffer of 300 one-second buckets, each storing (timestamp, count). On a hit, if the bucket's timestamp is stale, reset it; increment. To count, sum buckets whose timestamps are within 300 seconds: O(300) per query, O(1) per hit, constant memory. A deque of timestamps is simpler but grows with traffic. For multiple servers, aggregate per-server buckets or use a shared store with per-second keys and expiry.
Autocomplete must return the top 5 suggestions for any prefix within a few milliseconds. What structure?
A trie where each node caches the top 5 completions (by popularity) for its prefix, so a query is O(prefix length) plus reading 5 cached entries. Updating popularity requires refreshing cached lists up the path, so rebuild or update in batches offline from query logs. Compress chains of single-child nodes (radix tree) to save memory. For very large vocabularies, shard by first characters and cache hot prefixes.
A hash map in production is suddenly slow and CPU-bound. What could be happening?
Likely many keys landing in the same bucket: a poor custom hashCode (for example returning a constant or ignoring most fields), keys that are mutable and changed after insertion (entries become unreachable, causing leaks and misses), or deliberate hash-flooding with crafted keys. Check the hash distribution, fix hashCode/equals consistency, use immutable keys, and rely on randomised hashing. Also check for resize storms if the map is repeatedly created at a small capacity; presize it when the size is known.
The interviewer asks you to reduce your 2-D DP from O(m x n) space. How do you approach it?
Look at which cells each state reads. If dp[i][j] only needs row i-1 and the current row, keep two rows, or one row if you order the loop so that values you still need are not overwritten (for the diagonal dependency, save dp[i-1][j-1] in a temporary before overwriting). Choose the shorter dimension as the row length. Mention that this loses the ability to reconstruct the path unless you store choices separately.
You must process a stream of numbers and at any time report whether any two seen so far sum to a target. How do you choose the structure?
It depends on the ratio of adds to queries. If adds dominate, store counts in a hash map (O(1) add) and answer a query by scanning distinct values for their complement (O(D) query). If queries dominate and the target is fixed, maintain a set of achievable pair sums on each add (O(D) add, O(1) query). Stating this trade-off and asking about the workload is exactly what interviewers want.
Your graph BFS runs out of memory on a large social network. What are your options?
The frontier grows exponentially with depth (average degree to the power d). Options: bidirectional BFS from source and target, which explores about two frontiers of size b^(d/2) instead of b^d; limit the depth (degrees of separation rarely need more than 6); store visited nodes compactly (bitsets or integer IDs instead of objects); or switch to iterative deepening DFS, which trades time for O(depth) memory. For truly huge graphs, partition across machines and run level-synchronous BFS.