This page is for anyone facing a data structures and algorithms round, from a first job to a senior engineering role. Most rounds check whether you can state a brute force, spot the repeated work and reach a faster solution, then give its time and space cost. The questions run from Big-O and arrays through hashing, linked lists, stacks, trees, heaps and graphs to recursion and dynamic programming, and finish with real-work stories and judgement calls under time pressure. Every coding answer shows the brute force first, then a short Python solution with its cost. Say the answers out loud, then solve each problem yourself without looking.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Count the work: how often the innermost step runs as n grows; drop constants and smaller terms.
Nested loops: two loops over n that each run fully give n times n, so O(n^2).
Halving: a loop that halves n each pass runs about log n times, so O(log n).
Space too: count the extra memory, including the recursion stack.
"I ask how many times the innermost line runs as the input grows, then drop constants and lower-order terms, because Big-O is about the growth rate, not exact counts. If I have a loop over n items and inside it another loop over n items, the inner line runs n times n, so it's O(n squared). If the inner loop only goes from i to n, it runs about half as often, but that's still O(n squared). A loop where n is halved each pass, like binary search, runs about log n times before it reaches one, so that's O(log n). Two loops one after the other are O(n) plus O(n), which is just O(n). I also state space separately: what extra memory I allocate and, for recursion, how deep the call stack gets."
def pairs(a): # O(n^2) time, O(1) extra space
count = 0
for i in range(len(a)):
for j in range(i + 1, len(a)):
count += 1
return count
def halvings(n): # O(log n) time
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
Counting loops instead of work, for example calling any code with two loops O(n^2) even when they run one after the other.
Growth: when full, the array allocates a bigger block, a constant factor larger, and copies everything.
Averaging: with doubling, the total copy work over n appends is under 2n, so each append is O(1) on average.
Caveat: one append can still take O(n); amortized is not a per-call guarantee.
"A dynamic array, like a Python list, keeps spare capacity. Most appends just write into the next free slot, which is constant time. When it's full, it allocates a bigger block and copies everything across, which is O(n) for that one call. The key is that it grows by a multiple, not by a fixed amount. If it doubles, then over n appends the copies add up to roughly n plus n over two plus n over four and so on, which is less than 2n. Spread over n appends, that's a constant per append, so we call it amortized O(1). If it grew by a fixed ten slots each time instead, the copying would add up to O(n squared) overall. I'd also say that amortized isn't a guarantee for each call, so in latency-sensitive code one slow append can still matter."
Saying append is always O(1) with no mention of resizing, or calling it O(n) because of the occasional copy.
Estimate: a million squared is a trillion steps, far too slow; n log n is about twenty million.
Target: n near a million needs O(n) or O(n log n); a few thousand allows O(n^2).
Map to tools: n log n suggests sorting, a heap or binary search; O(n) suggests a hash map, two pointers or one sweep.
Say it: state the brute force and its cost, then the target, then the idea that reaches it.
"I start from the limits, because they tell me the target. A million squared is a trillion basic steps, which would take far too long in any language. A million times log of a million is about twenty million, which is comfortable. Simple operations run somewhere in the tens to hundreds of millions per second, slower in Python, so with n around a million I need O(n) or O(n log n). If n were only a few thousand, O(n squared) would be fine and simpler to write. So out loud I'd say: the brute force is O(n squared), it won't handle a million items, and I'm aiming for n log n, which points to sorting, a heap or binary search, or O(n), which points to a hash map, two pointers or a single sweep. That stops me spending twenty minutes on a solution that was never going to pass."
Coding the O(n^2) solution first and only then asking how big the input can be.
Brute force: check every pair, O(n^2) time, O(1) space.
Insight: for each number the partner you need is target minus it, and a dict answers that in O(1).
One pass: check the dict first, then store the value and its index; O(n) time, O(n) space.
Edge cases: duplicates, using the same element twice, no answer.
"The brute force is two loops checking every pair, which is O(n squared) time and constant space. The wasted work is that for each number I scan the whole list for one specific partner, target minus that number. A hash map answers that in constant time on average. So I walk the list once, and for each number I compute the complement and check whether I've already seen it. If yes, I return the stored index and the current one. If not, I store the current number with its index and move on. Checking before storing means I never pair an element with itself, and duplicates like three plus three work because the first three is already in the map when I reach the second. That's O(n) time and O(n) space."
def two_sum_brute(nums, target): # O(n^2) time, O(1) space
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return []
def two_sum(nums, target): # O(n) time, O(n) space
seen = {} # value -> index
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
return []
Jumping to the hash map without stating the brute force, or storing before checking and pairing an element with itself.
Hashing: a hash function turns the key into an index into an array of slots.
Collisions: handled by chaining (a list per slot) or open addressing (probe for another slot).
Load factor: when the table gets too full it grows and rehashes, so each slot stays short.
Worst case: a poor hash or keys chosen to collide pile into one place, so lookups become O(n).
"A hash table is an array of slots. To store a key, I run it through a hash function and map the result to a slot index. A lookup does the same calculation, so it jumps straight to the right slot instead of searching. Two keys can land on the same slot, which is a collision. One fix is chaining, where each slot holds a small list. Another is open addressing, where you probe for another free slot, which is what Python's dict does. The table tracks how full it is, and past a threshold it grows and rehashes everything, so on average only a few keys compete for any slot. That's why lookups are average O(1). The worst case is O(n): with a poor hash function, or keys someone picked to collide, everything piles up in one place and a lookup becomes a linear scan."
Saying lookups are always O(1), or not knowing what happens when two keys land on the same slot.
Brute force: from every start, extend with a set until a repeat; O(n^2).
Window: keep a left and a right edge; the right edge moves forward every step.
Jump left: remember each character's last index; on a repeat inside the window, move left just past it.
Cost: O(n) time, O(k) space for k distinct characters.
"Brute force, I'd start at every index and extend to the right with a set until I hit a repeat, which is O(n squared). The waste is that after a repeat I restart from the next index and re-scan characters I already know are fine. So I use a sliding window. I keep a left edge and move the right edge forward one character at a time, and I store the last index where I saw each character. If the current character was last seen inside my window, I move the left edge to just past that position, because any window holding both copies is invalid. Now the window has no repeats, and I track the biggest length. Each character is handled once, so it's O(n) time, and space is O(k), where k is the number of distinct characters."
def longest_unique(s): # O(n) time, O(k) space
last = {} # char -> last index seen
left = best = 0
for right, ch in enumerate(s):
if ch in last and last[ch] >= left:
left = last[ch] + 1
last[ch] = right
best = max(best, right - left + 1)
return best
Moving the left edge backwards because of an old repeat that's already outside the window, which silently gives wrong lengths.
Brute force: three nested loops plus a set to remove repeats, O(n^3).
Sort, then fix one: for each i, find a pair in the rest that sums to minus nums[i].
Pointers move by sum: too small, move left up; too big, move right down.
Skip duplicates: at i and after each match, so each triplet appears once; O(n^2) time.
"Brute force is three loops, O(n cubed), plus a set of sorted tuples to remove repeated triplets. Sorting first changes things. I fix the first number at index i, and now I need two numbers after it that sum to minus that value. Because the array is sorted, I can use two pointers, one just after i and one at the end. If the sum is too small, I move the left pointer right to make it bigger; if it's too big, I move the right pointer left. That pair search is O(n), and I do it for each i, so O(n squared) overall, which outweighs the n log n sort. Duplicates are the tricky part: I skip i when it equals the previous value, and after a match I move the left pointer past equal values. Extra space is small apart from the output and the sort."
def three_sum(nums): # O(n^2) time
nums.sort()
out = []
for i in range(len(nums) - 2):
if i > 0 and nums[i] == nums[i - 1]:
continue
lo, hi = i + 1, len(nums) - 1
while lo < hi:
s = nums[i] + nums[lo] + nums[hi]
if s < 0:
lo += 1
elif s > 0:
hi -= 1
else:
out.append([nums[i], nums[lo], nums[hi]])
lo += 1
hi -= 1
while lo < hi and nums[lo] == nums[lo - 1]:
lo += 1
return out
Finding the O(n^2) idea but returning duplicate triplets, or hiding the problem with a set of tuples you can't explain.
Access: array O(1) by index; linked list O(n), walking from the head.
Insert/delete: array O(n) in the middle because items shift; linked list O(1) once you hold the node.
Memory: arrays are contiguous and cache-friendly; nodes carry pointers and scatter in memory.
Pick: arrays by default; linked lists when you splice a lot at nodes you already hold.
"An array stores items side by side, so reading index i is O(1). Inserting or deleting in the middle is O(n) because everything after it has to shift, while appending to a dynamic array is amortized O(1). A linked list stores nodes that point to the next one. Reaching the i-th item is O(n) because I walk from the head, but once I'm holding a node, inserting or removing next to it is O(1), just a few pointer changes. The catch people miss is that finding the node is usually O(n) anyway, and arrays are much friendlier to the CPU cache because the data is contiguous. So in practice I default to an array. I reach for a linked list when I keep inserting and removing at nodes I already have a reference to, like the recency order in an LRU cache."
Saying linked lists are faster for insertion without mentioning that you first have to find the position.
Brute force: copy values to an array and write them back reversed; O(n) extra space.
Three pointers: prev, current and a saved next; point current back at prev, then advance.
Cost: iterative O(n) time, O(1) space; recursive O(n) space for the call stack.
"A simple brute force copies the values into an array and writes them back in reverse, which is O(n) time but O(n) extra space. I can do it in place with three pointers. I keep prev, starting at None, and current, starting at the head. At each step I first save current's next, because I'm about to overwrite it. Then I point current's next back at prev, move prev up to current, and move current to the saved next. When current is None, prev is the new head. That's O(n) time and O(1) extra space. The recursive version reverses the rest of the list first, then makes the next node point back at the current one. It's neat, but every node adds a stack frame, so it's O(n) space, and on a very long list Python would hit its recursion limit."
class Node:
def __init__(self, val, next=None):
self.val, self.next = val, next
def reverse(head): # O(n) time, O(1) space
prev, cur = None, head
while cur:
nxt = cur.next
cur.next = prev
prev, cur = cur, nxt
return prev
Overwriting next before saving it, which cuts off the rest of the list.
Brute force: a set of visited nodes; O(n) time, O(n) space.
Floyd: slow moves one step, fast moves two; they meet only if there's a cycle.
Find the start: reset one pointer to the head, move both one step at a time; they meet at the cycle start.
"The easy version keeps a set of nodes I've visited. If I reach a node twice, there's a cycle, and that node is where it starts. That's O(n) time and O(n) space. To get O(1) space I use two pointers: slow moves one step, fast moves two. If there's no cycle, fast runs off the end. If there is one, fast eventually catches slow inside the loop, because the gap between them closes by one each step, so they must meet. To find where the cycle starts, I put one pointer back at the head and leave the other at the meeting point, then move both one step at a time. They meet exactly at the start of the cycle, because the distance from the head to the start matches the distance from the meeting point forward to the start, give or take whole laps of the cycle."
def cycle_start(head): # O(n) time, O(1) space
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast:
slow = head
while slow is not fast:
slow, fast = slow.next, fast.next
return slow
return None # no cycle
Comparing node values instead of node identity, which breaks as soon as two nodes hold the same value.
Why a stack: the last bracket opened must be the first one closed.
Scan: push openers; on a closer, the top must be its matching opener, or fail.
End check: the stack must be empty; O(n) time, O(n) space.
"A tempting brute force is to keep deleting adjacent pairs like '()' until nothing changes, which works but can be O(n squared). The better fit is a stack, because brackets nest: the most recently opened one has to close first, which is last in, first out. I scan left to right. When I see an opener, I push it. When I see a closer, the stack must not be empty and its top must be the matching opener; if so I pop, and if not the string is unbalanced. At the end the stack must be empty, otherwise something was opened and never closed. A simple counter isn't enough once there are different bracket types, because '([)]' has the right counts in the wrong order. It's O(n) time and O(n) space in the worst case, like a string of only openers."
def balanced(s): # O(n) time, O(n) space
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch in '([{':
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack
Only counting openers and closers, which wrongly accepts '([)]'.
Brute force: on every dequeue, pour everything into the second stack, pop, and pour it back; O(n) per dequeue.
Two roles: an in stack takes every push; an out stack serves every pop.
Refill only when empty: move everything from in to out just when out runs dry, which reverses the order once.
Cost: each item is moved at most once, so push and pop are amortized O(1); O(n) space.
"A stack gives back the newest item and a queue the oldest, so I need to flip the order. The brute force pours the whole stack into the second one on every dequeue, pops the bottom item, and pours everything back, which is O(n) per dequeue. The better version gives the stacks fixed roles. Every enqueue pushes onto the in stack. Every dequeue pops from the out stack, and only when the out stack is empty do I move everything across from the in stack. That single move reverses the order, so the oldest item ends up on top. Each item is pushed and popped at most twice in its life, so over any run of operations the cost averages to O(1) each, even though one dequeue can take O(n). In real Python code I'd just use collections.deque, but this shows I understand both structures."
class TwoStackQueue: # amortized O(1) per operation
def __init__(self):
self.inbox, self.outbox = [], []
def push(self, x):
self.inbox.append(x)
def pop(self):
if not self.outbox:
while self.inbox: # reverse the order once
self.outbox.append(self.inbox.pop())
return self.outbox.pop() # raises IndexError if empty
Moving every item back and forth on each dequeue and calling it O(1), or not being able to explain why the cost averages out.
Hash map: key to node, for O(1) lookup.
Doubly linked list: order by recency; move a node to the recent end in O(1), evict from the old end in O(1).
Keep in sync: get moves the node; put inserts or updates, then evicts from both past capacity.
In Python: OrderedDict already pairs a dict with a linked list.
"A naive version keeps a plain list ordered by use, but finding an item and moving it is O(n). For O(1) I combine two structures. A hash map from key to node gives constant-time lookup. A doubly linked list keeps items in order of use, most recent at one end and least recent at the other. Because it's doubly linked and the map hands me the node, I can unlink it and move it to the recent end in O(1), and evicting is just removing the node at the old end. On get, I look up the node, move it to the recent end and return the value. On put, I insert or update at the recent end, and if I'm over capacity I remove the oldest node and delete its key from the map. In Python, OrderedDict is exactly that pairing, so I'd show it and offer to write the list by hand."
from collections import OrderedDict
class LRUCache: # get/put O(1), space O(capacity)
def __init__(self, capacity):
self.cap = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # most recent at the end
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.cap:
self.data.popitem(last=False) # evict least recent
Storing a timestamp per key and scanning for the oldest one on every eviction, which is O(n).
Brute force: for each depth, walk the whole tree collecting that depth; O(n times h).
Queue: BFS with a deque; pop from the left, push children on the right.
Level size: read the queue length at the start of a level and process exactly that many.
Cost: O(n) time; O(w) space for the widest level.
"A brute force would find the height, then for each depth run a walk that collects the nodes at that depth, which is O(n times h), and O(n squared) on a skewed tree. The natural fit is breadth-first search with a queue. I put the root in a deque. While the queue isn't empty, I read how many nodes are in it right now, and that's exactly one level. I pop that many from the left, record their values and push their children on the right. When that count is done, I've finished one level and add its list to the result. Each node enters and leaves the queue once, so it's O(n) time. Space is the widest level, which in a full tree is about half the nodes. I use a deque rather than a plain list, because popping from the front of a list is O(n)."
from collections import deque
def level_order(root): # O(n) time, O(w) space
if not root:
return []
out, q = [], deque([root])
while q:
level = []
for _ in range(len(q)):
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
Using a stack or plain recursion and getting depth-first order, or popping from the front of a Python list in a loop.
The trap: a node can be fine next to its parent but break a limit set by an ancestor higher up.
Bounds: pass down a low and a high limit; each node must sit strictly between them.
Alternative: an in-order walk of a valid BST is strictly increasing.
Cost: O(n) time, O(h) space for the recursion.
"The rule is that everything in the left subtree is smaller than the node and everything in the right subtree is larger, not just the direct children. For example, root 10, left child 5, and 5's right child 12. Twelve is bigger than 5, so a local check passes, but it sits in 10's left subtree, so the tree is invalid. A brute force checks, for every node, the largest value on its left and the smallest on its right, which is O(n squared) on a skewed tree. Instead I pass bounds down. The root can be anything. Going left, the node's value becomes the upper bound; going right, it becomes the lower bound. Every node must sit strictly inside its bounds. That's O(n) time and O(h) space, where h is the height. Another clean way is an in-order walk, checking that each value beats the previous one."
def is_bst(node, lo=float('-inf'), hi=float('inf')): # O(n) time, O(h) space
if node is None:
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))
Checking only that the left child is smaller and the right child is bigger at each node.
Brute force: record the path from the root to each node, then take the last node the two paths share; O(n) time, O(n) extra space.
One recursion: each call returns a target it found below, or None; the first node where both sides return something is the answer.
BST shortcut: from the root, go left while both values are smaller, right while both are bigger; the split point is the answer, O(h).
Cost: general tree O(n) time and O(h) stack; BST O(h) time.
"The straightforward way is to find the path from the root down to each node, then walk both paths together and take the last node they share. That's O(n) time but it stores two paths. A single recursion does it in one pass. Each call returns the target it found in its subtree, or None. If the current node is one of the targets, it returns itself. Otherwise it asks both children. If both sides come back with something, the two nodes are split across this node, so it's the lowest common ancestor. If only one side found something, it passes that up. That's O(n) time and O(h) space for the stack. In a binary search tree I can use the ordering instead: starting at the root, if both values are smaller I go left, if both are bigger I go right, and the first node where they split, or that equals one of them, is the answer. That's O(h), with no recursion needed."
def lca(node, p, q): # binary tree: O(n) time, O(h) space
if node is None or node is p or node is q:
return node
left = lca(node.left, p, q)
right = lca(node.right, p, q)
if left and right:
return node # p and q split here
return left or right
def lca_bst(node, p, q): # BST: O(h) time, O(1) space
while node:
if p.val < node.val and q.val < node.val:
node = node.left
elif p.val > node.val and q.val > node.val:
node = node.right
else:
return node
Using the BST shortcut on a plain binary tree, or returning the first node where one target is found without checking the other side.
Sort: O(n log n); fine for small inputs and easy to read.
Min-heap of size k: push each number, pop the smallest once the size passes k; the top is the answer.
Why a min-heap: the smallest of the k biggest is the one to evict; O(n log k) time, O(k) space.
Heap basics: a complete tree stored in an array; push and pop O(log size), peek O(1).
"The simplest answer is to sort in descending order and take position k minus one. That's O(n log n) and perfectly fine for small lists. When n is large and k is small, I use a min-heap of size k. A heap is a complete binary tree stored in an array where every parent is no bigger than its children, so the minimum is always at the top, and push and pop cost O(log) of the heap size. I push each number, and whenever the heap grows past k, I pop the smallest. At the end the heap holds the k largest numbers, and its top is the k-th largest. That's O(n log k) time and O(k) space, and it also works on a stream I can't hold in memory. A min-heap for the largest sounds backwards, but its minimum is exactly the item I want to throw out."
import heapq
def kth_largest_sort(nums, k): # O(n log n)
return sorted(nums, reverse=True)[k - 1]
def kth_largest(nums, k): # O(n log k) time, O(k) space
heap = []
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap)
return heap[0]
Using a max-heap of k items so the top is the wrong one to evict, or not knowing that Python's heapq is a min-heap.
Brute force: concatenate and sort, O(n log n); or merge one list at a time, up to O(n times k).
Heap of heads: hold the current front item of each list; pop the smallest, push that list's next item.
Cost: each item passes through a heap of size k once, so O(n log k) time and O(k) extra space.
Ties: store (value, list index, position) so ties never compare the items themselves.
"The quick answer is to put everything in one list and sort it, which is O(n log n) and ignores that the inputs are already sorted. Merging the lists one at a time into a growing result is worse, up to O(n times k), because early items get copied again and again. The better way is a min-heap holding the front item of each list. I pop the smallest, add it to the output, and push the next item from the same list. The heap never holds more than k items, so each push and pop is O(log k), and each of the n items goes through once, giving O(n log k) time and O(k) extra space besides the output. I store tuples of value, list index and position, so ties break on the index, which matters when the items are linked-list nodes that Python can't compare."
import heapq
def merge_k(lists): # O(n log k) time, O(k) heap
heap = [(lst[0], i, 0) for i, lst in enumerate(lists) if lst]
heapq.heapify(heap)
out = []
while heap:
val, i, j = heapq.heappop(heap)
out.append(val)
if j + 1 < len(lists[i]):
heapq.heappush(heap, (lists[i][j + 1], i, j + 1))
return out
Merging the lists one at a time into a growing result and calling it O(n).
BFS: a queue, explores in rings by distance; gives the fewest-edges path in an unweighted graph.
DFS: a stack or recursion, goes deep first; suits cycle checks, topological order, components, backtracking.
Cost: both O(V + E) time with an adjacency list; mark nodes visited to avoid loops.
Memory: BFS holds the whole frontier; DFS holds the current path, and deep recursion can overflow.
"Both visit every reachable node and edge once, so with an adjacency list they're O(V plus E) time, plus O(V) space for the visited set. The difference is the order. BFS uses a queue and explores in rings: everything one edge away, then two edges away, and so on. That makes it the right choice for the shortest path when every edge costs the same, like the fewest moves on a grid or the fewest hops between two people in a network. DFS uses a stack or recursion and goes as deep as it can before backing up. I use it for cycle detection, topological sorting, counting connected components and backtracking. On memory, BFS can hold a very wide frontier, while DFS holds the current path, but recursive DFS on a deep graph can hit Python's recursion limit, so there I'd switch to an explicit stack."
Saying DFS finds the shortest path, or forgetting the visited set and looping forever on a cycle.
Model: each land cell is a node; up, down, left and right are its edges.
Scan: for each unvisited land cell, count one island and flood-fill all land reachable from it.
Mark visited: in place or in a set, at the moment you queue a cell.
Cost: O(rows times cols) time, and the same in the worst case for the queue.
"A naive version that searches from every land cell without remembering what it has seen repeats a lot of work. So I treat the grid as a graph where each land cell connects to its land neighbours up, down, left and right. The number of islands is then the number of connected components. I scan every cell. When I find land I haven't visited, I add one to the count and flood-fill from it with BFS, marking every connected land cell so I never count it again. Here I mark by flipping it to '0' in place, though I'd first ask whether I'm allowed to change the input; if not, I keep a visited set. Every cell is handled a constant number of times, so it's O(rows times columns) time, and the queue can grow that large in the worst case. I use an iterative BFS because recursion can hit Python's limit on a big all-land grid."
from collections import deque
def num_islands(grid): # O(rows * cols) time and space
rows, cols = len(grid), len(grid[0]) if grid else 0
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] != '1':
continue
count += 1
grid[r][c] = '0'
q = deque([(r, c)])
while q:
y, x = q.popleft()
for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
if 0 <= ny < rows and 0 <= nx < cols and grid[ny][nx] == '1':
grid[ny][nx] = '0'
q.append((ny, nx))
return count
Marking a cell visited only when it's popped, so the same cell gets queued many times.
Model: a directed edge from each prerequisite to the course that needs it.
Brute force: keep scanning for a course whose prerequisites are all done; O(V times (V + E)).
Kahn's algorithm: count incoming edges; queue the zero-count courses; taking one lowers its dependants' counts.
Cycle check: if not every course comes out, there's a cycle; O(V + E) time and space.
"I model it as a directed graph with an edge from each prerequisite to the course that depends on it. An order exists exactly when the graph has no cycle, and that order is a topological sort. The slow way is to keep scanning all the courses for one whose prerequisites are all done, which means up to V passes over the whole graph. Kahn's algorithm does it in one pass. I count the incoming edges for each course. Every course with zero goes in a queue, since nothing blocks it. I pop a course, add it to the order, and lower the count of every course that depends on it; any that reach zero join the queue. If the order ends up holding every course, that's my answer. If some are left over, they're waiting on each other in a cycle, so I return an empty list. It's O(V plus E) time and space."
from collections import deque
def course_order(n, prereqs): # O(V + E) time and space
after = [[] for _ in range(n)]
indeg = [0] * n
for course, pre in prereqs: # pre must come before course
after[pre].append(course)
indeg[course] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
order = []
while q:
c = q.popleft()
order.append(c)
for nxt in after[c]:
indeg[nxt] -= 1
if indeg[nxt] == 0:
q.append(nxt)
return order if len(order) == n else []
Using an undirected-style visited check for cycles, which wrongly flags a harmless diamond of dependencies as a cycle.
Greedy: always settle the unsettled node with the smallest known distance, using a min-heap.
Relax: for each edge out of it, if going through it is shorter, update the neighbour and push it.
Cost: O((V + E) log V) with a binary heap and an adjacency list.
Negative edges: break the rule that a settled distance is final; use Bellman-Ford instead.
"Dijkstra works on graphs whose edge weights are zero or more. I keep a best-known distance for each node, with the source at zero, and a min-heap of candidates. I pop the node with the smallest distance. Because no edge is negative, no other route can come back to it cheaper later, so that distance is final. Then I relax its edges: if the distance to this node plus the edge weight beats a neighbour's current best, I update it and push it onto the heap. Old heap entries that are out of date I simply skip when they're popped. With a binary heap that's O((V plus E) log V). With negative edges, the 'settled means final' guarantee breaks, because a path through a node I haven't finished could come back cheaper. Then I'd use Bellman-Ford, which is O(V times E) and also detects negative cycles. If all edges cost the same, plain BFS is enough."
import heapq
def dijkstra(graph, src): # graph: {u: [(v, w), ...]}, w >= 0
dist = {src: 0}
heap = [(0, src)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # stale entry
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
Claiming Dijkstra copes with negative weights if you keep relaxing, or not being able to say why the greedy choice is safe.
Signals: a count, a min or max, or a yes or no over a series of choices, where the same subproblem repeats.
Recurrence first: ways(n) = ways(n - 1) + ways(n - 2); plain recursion is exponential.
Memoize, then tabulate: cache for O(n); build bottom-up; keep only the last two values for O(1) space.
"I look for two things. First, the question asks for a count, a minimum, a maximum or whether something is possible, over a series of choices. Second, when I write the brute-force recursion, the same subproblem comes up again and again. For the stairs, my last move was either one step or two, so ways of n equals ways of n minus one plus ways of n minus two. As plain recursion that's exponential, because it recomputes the same values over and over. Adding a cache so each n is computed once makes it O(n) time and O(n) space. Then I flip it bottom-up: start from the base cases and build upwards. Each value only needs the previous two, so I keep two variables, which gives O(n) time and O(1) space. That path from recursion to memo to table is how I approach any DP question."
from functools import lru_cache
def ways_slow(n): # exponential time
return 1 if n <= 1 else ways_slow(n - 1) + ways_slow(n - 2)
@lru_cache(maxsize=None)
def ways_memo(n): # O(n) time, O(n) space
return 1 if n <= 1 else ways_memo(n - 1) + ways_memo(n - 2)
def ways(n): # O(n) time, O(1) space
a, b = 1, 1 # ways(0), ways(1)
for _ in range(n - 1):
a, b = b, a + b
return b
Calling any cached recursion 'DP' without being able to state the recurrence and the base cases.
Greedy fails: coins 1, 3, 4 and amount 6; greedy gives 4 + 1 + 1, the best is 3 + 3.
Brute force: try every coin at every step; exponential, and it re-solves the same amounts.
State: best[a] = fewest coins for amount a = 1 + the min of best[a - c] over coins c that fit.
Cost: O(amount times coins) time, O(amount) space.
"Greedy works for some coin systems but not all. With coins 1, 3 and 4 and an amount of 6, greedy takes 4, then 1, then 1, which is three coins, but 3 plus 3 is only two. The brute force tries every coin at every step, which is exponential, and it keeps solving the same smaller amounts. So I define best of a as the fewest coins that make amount a. Best of zero is zero. For any a, the last coin used was some coin c, so best of a is one plus the smallest best of a minus c over all coins that fit. I fill the table from 1 up to the amount. If an amount can't be made, it stays at infinity, and at the end I return minus one for that case. That's O(amount times the number of coins) time and O(amount) space."
def coin_change(coins, amount): # O(amount * len(coins)) time
INF = float('inf')
best = [0] + [INF] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a and best[a - c] + 1 < best[a]:
best[a] = best[a - c] + 1
return best[amount] if best[amount] != INF else -1
Defending the greedy answer without trying a single counterexample.
Size: each item is in or out, so there are 2^n subsets; nothing can beat the output size.
Backtrack: add an item, recurse from the next index, then remove it to restore the state.
Copy on record: save a copy of the path, not the shared list itself.
Cost: O(n times 2^n) time and output; O(n) recursion depth.
"Each number is either in a subset or not, so there are 2 to the n subsets, and no approach can beat that, because the output itself is that big. One simple way is to count from zero to 2 to the n minus one and use each number's bits to pick items. Backtracking builds them with one shared path instead. Each call first records a copy of the current path. Then for each index from start onward, I add that number, recurse with start moved past it, and pop it off afterwards. That pop is the backtrack: it undoes the choice so the path is clean for the next option. Moving start forward means I never produce the same set in a different order. The cost is O(n times 2 to the n), since each subset takes up to n to copy. The classic bug is saving the path itself instead of a copy."
def subsets(nums): # O(n * 2^n) time and output
out, path = [], []
def backtrack(start):
out.append(path[:]) # record a copy
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1) # explore
path.pop() # un-choose
backtrack(0)
return out
Saving the shared path instead of a copy, so every recorded subset ends up empty.
Brute force: a linear scan, O(n); binary search needs sorted input and gives O(log n).
Invariant: if the target exists, it is between lo and hi, both inclusive.
Loop: while lo <= hi; move lo to mid + 1 or hi to mid - 1, never to mid itself.
Test: empty array, one element, target at each end, target missing.
"A linear scan is O(n) and works on anything. If the array is sorted, binary search halves the range each step, so it's O(log n) time and O(1) space. The bugs come from being vague about the range, so I fix an invariant: if the target is in the array, it's between lo and hi, both inclusive. That means the loop runs while lo is less than or equal to hi. I take the middle, and if it's the target I return it. If the middle value is too small, the target must be to the right, so lo becomes mid plus one. If it's too big, hi becomes mid minus one. I always move past mid and never set lo or hi to mid itself, because that's what causes infinite loops when two elements are left. Then I check the edge cases out loud: empty array, one element, target at either end, and target missing."
def binary_search(a, target): # O(log n) time, O(1) space
lo, hi = 0, len(a) - 1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == target:
return mid
if a[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
Mixing an inclusive hi with a lo < hi loop, or setting lo = mid, then patching the bugs by trial and error.
Merge sort: split, sort each half, merge; O(n log n) always, O(n) extra space for arrays, stable.
Quicksort: partition around a pivot; O(n log n) on average, O(n^2) worst; in place, not stable.
Worst case: bad pivots, like always the first element on sorted data; random pivots make it unlikely.
Choose: merge sort for stability, linked lists or data on disk; quicksort for arrays in memory.
"Both are divide and conquer. Merge sort splits the array in half, sorts each half and merges them. It's O(n log n) in every case and it's stable, meaning equal items keep their original order, but merging arrays needs O(n) extra space. Quicksort picks a pivot, moves smaller items to one side and larger to the other, then sorts each side. On average it's O(n log n), it sorts in place apart from the recursion stack, and it's usually fast in practice because it's cache-friendly. But with bad pivots, like always taking the first element of already sorted data, it drops to O(n squared); random or median-of-three pivots make that unlikely. It also isn't stable. So I'd pick merge sort when I need stability or a guaranteed time, or I'm sorting a linked list or data too big for memory, and quicksort for arrays in memory where extra space matters."
Saying quicksort is always O(n log n), or not knowing what a stable sort means.
Situation: what the code did and how the problem showed up.
Diagnosis: how you measured it and found the costly operation.
Fix: the structure you switched to and how the complexity changed.
Result and lesson: the measured improvement and what you do differently now.
"At my last company we had a nightly job that checked incoming orders against a list of blocked customer IDs. It was fine in testing, but as the blocked list grew, the job slowed down until it started missing its window. I profiled it and saw almost all the time was on one line: checking whether an ID was in a list. That's a linear scan, and it ran once per order, so the whole job cost orders times blocked IDs. I changed the list to a set, built once at the start, which made each check constant time on average. The job went from most of an hour to a couple of minutes, and I added a test with a large list so it couldn't quietly slow down again. Since then, I ask how big each input can realistically get before I choose a structure, not just whether it works on the sample data."
A story with no measurement, where the fix was a guess and the result is 'it felt faster'.
The problem: what it was, in a sentence or two.
Method: worked a small example by hand, wrote the brute force, looked for repeated work.
Breakthrough: the pattern you spotted and why it fit.
Habit: what you now do first when you're stuck.
"In my final-year project I needed to find, for each reading in a sensor log, the next reading that was higher. My first version checked every later reading for each one, which was O(n squared), and it crawled on the full data. I was stuck on how to do better, so I stopped coding and worked a tiny example by hand, eight numbers on paper. I noticed that the readings still waiting for a higher value were always in decreasing order, and each new reading settled them starting from the most recent one. That's a stack. So I kept a stack of indexes still waiting, and each new reading popped every smaller one and gave it its answer. Each index is pushed and popped once, so it became O(n). Now, when I'm stuck, I write the brute force, do a small case by hand, and ask which work I'm repeating."
A story where you simply looked up the answer, with nothing about how you'd reason through a similar problem yourself.
Baseline: what was slow, how slow, and on what input size.
Root cause: the operation whose cost grew too fast.
Change: the new approach, its complexity, and any trade-offs you accepted.
Proof: before and after timings, and how you showed the output was unchanged.
"At my last company we had a report that found overlapping bookings. The original code compared every booking with every other one, which was O(n squared), and in a busy month with tens of thousands of bookings it ran for over an hour. The first suggestion was a bigger server. I measured it and saw the pair comparison was nearly all the time, so hardware would only buy a constant factor. Instead I sorted the bookings by start time, which is O(n log n), and swept through once, keeping a min-heap of end times for bookings still open. For each new booking I dropped the ones that had already ended, and anything left in the heap overlapped it. The report came down to under a minute. Before shipping, I ran the old and new versions on three months of real data and checked they flagged exactly the same bookings."
Claiming a big speedup with no before and after numbers, or no check that the results stayed the same.
Say it out loud: name both options and their costs, and ask what the interviewer prefers.
Default: if the better idea isn't clear yet, code the brute force cleanly and correctly.
Then improve: point to the repeated work and sketch the faster approach in the time left.
If the optimal is clear: go straight to it, keeping the brute force as a fallback.
"I'd say the situation out loud rather than decide silently. Something like: I can code the O(n squared) brute force in about ten minutes and I'm confident it's right, or I can try the O(n) idea, which I'm less sure about. Would you rather see the working version first? Often the interviewer says go ahead with the brute force, and even without an answer, that's my default when the better idea isn't clear yet. A correct, tested solution with a clear explanation of how to improve it is worth more than half an optimal solution that doesn't run. Once it works, I use the remaining time to point at the wasted work, for example that it re-scans the array for every item, and sketch how a hash map would remove that. If the optimal idea is already clear to me, I skip the brute force and write it directly."
Spending the whole time on an optimal approach that never runs, without telling the interviewer what's happening.
Re-read against the plan: does each line do what you said it would?
Dry run: trace a small, normal input out loud, tracking every variable.
Edge cases: empty input, one element, duplicates, negatives, answers at either end.
Fix and re-test: change one thing, explain why, and re-run the case that failed.
"I'd stay calm and treat it like any debugging job. First I re-read the code against the plan I described, line by line, because the bug is often where the code quietly differs from what I said, like a less-than that should be less-than-or-equal. Then I dry-run a small example out loud, writing down the variables after each step, so the interviewer can see exactly where my trace and the expected result split. If the normal case passes, I go through edge cases: an empty input, a single element, duplicates, negative numbers, and answers right at the start or end. Loop bounds and off-by-one errors are the usual suspects. When I find it, I explain why it was wrong, change just that one thing, and re-run the case that failed. What I'd avoid is changing several lines at random to see what sticks."
Changing lines at random until the output looks right, with no explanation of what the bug actually was.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.