The vocabulary a coding round assumes you already have: complexity stated precisely, what each structure costs, the technique families and the signal that selects each one, and the handful of implementation details interviewers reliably probe. Fifty-four items, thirteen worked with code or a diagram.
An upper bound on how the cost grows as the input grows, with constant factors
and lower-order terms deliberately discarded. So O(n) says the work grows
roughly in proportion to the input, not that it takes n operations. Discarding
the constants is what makes it useful for comparing algorithms and what makes it
misleading in production, where an O(n log n) algorithm with a huge constant can
lose to an O(n²) one at the sizes you actually run. State the bound, then say
what the constants are doing if it matters.
What is the difference between worst, average and amortised?
Worst case is the cost on the least favourable input of that size, which is what
Big-O usually reports. Average case assumes a distribution over inputs, which
means it depends on an assumption you should state. Amortised is different in
kind: it is the average per operation across a worst-case sequence, so it is a
guarantee rather than a probabilistic claim. A dynamic array's append is
amortised constant because the occasional doubling is paid for by the cheap
appends around it.
Show me why an array append is amortised constant.
Each doubling copies everything, which looks expensive until you total the
copying across a whole sequence of appends.
appends: 1 2 3 4 5 6 7 8 9 ...
capacity: 1 2 4 4 8 8 8 8 16 ...
copies: - 1 2 - 4 - - - 8 ...
Total copying to reach n elements: 1 + 2 + 4 + ... + n/2 < n
So n appends cost fewer than 2n operations -> O(1) each, amortised.
The sum of a doubling series is less than its final term, which is the whole
argument. Any growth factor greater than one gives the same result; growing by a
fixed amount does not.
# Growing by one each time: every append copies everything.# 1 + 2 + 3 + ... + n = n(n+1)/2 operations -> O(n) per append, O(n^2) total.
Two consequences worth carrying. Amortised constant is not the same as constant:
a single append can take O(n), so it is the wrong guarantee for a real-time
system where one slow operation matters more than a good average. And if you
know the final size, preallocating avoids the copying entirely — the same
reasoning that makes sizing a hash map at construction worthwhile.
What complexity mistakes decide rounds?
Four recur. Naming the wrong n, when a problem has two dimensions such as rows
and columns, or a list of strings where sorting costs comparisons times string
length. Forgetting space the language allocates for you, particularly the
recursion stack and the copies made by slicing. Confusing the cost of a call
with the cost of what it does, so a membership test that is constant on a set
and linear on a list looks identical in code. And stopping at the bound without
checking it against the stated input limits.
How do input limits tell you the intended solution?
They are given to you for free and eliminate whole families in a sentence. A
limit of about twenty invites an exponential solution over subsets. A few
thousand accommodates quadratic, which usually means a two-dimensional
dynamic-programming table. A hundred thousand rules out quadratic and points at
sorting, a single pass with a structure, or something logarithmic per element. A
billion means you are not iterating at all and the answer is mathematical.
What is an invariant, and why do interviewers care?
A property your code guarantees is true between steps — the window contains at
most one of each character, everything left of the pointer is already sorted,
the heap's root is the smallest element. It matters because it is the difference
between an argument for correctness and a hope. A candidate who names the
invariant has designed the solution; one who cannot has recalled it, and the
distinction shows immediately when the interviewer changes a constraint.
What does O(log n) usually mean in practice?
That the work halves at each step, which is why binary search, balanced trees
and heap operations all land there. The practical significance is how flat it
is: doubling the input adds one step, so a structure with logarithmic operations
scales to enormous sizes without meaningful slowdown. Log base is irrelevant in
Big-O because changing base is a constant factor, which is why nobody writes
log₂.
Show me how to derive a bound rather than recall one.
Count the work at each level and multiply by the number of levels — that single
habit covers most divide-and-conquer analysis.
defmerge_sort(items):
iflen(items) <= 1:
return items
mid = len(items) // 2
left = merge_sort(items[:mid]) # T(n/2)
right = merge_sort(items[mid:]) # T(n/2)return merge(left, right) # O(n) to merge
level 0: one problem of size n -> n work
level 1: two of size n/2 -> n work
level 2: four of size n/4 -> n work
...
level k: n problems of size 1 -> n work
Levels: how many halvings reach 1, which is log2(n).
Total: n work per level x log n levels = O(n log n)
The space is the part people miss. The slicing in the code above copies at every
level, so it allocates O(n log n) in total over the run. Peak live space is
still O(n), because sibling calls are sequential rather than concurrent - the
same order as an in-place merge sort's buffer, with a much worse constant and far
more allocator pressure. The recursion stack adds O(log n) on top.
The same counting applies when the branching factor and the shrinkage differ. A
recursion making two calls on n−1 has 2ⁿ nodes, which is why naive Fibonacci is
exponential — the tree is wide and deep. Recognising that shape in a call tree is
what tells you memoisation will collapse it.
Arrays, strings and hashing
Why is a hash map the most common interview improvement?
Because it converts a nested scan into a single pass: instead of comparing every
element with every other, you record what you have seen and look up in constant
average time. That turns O(n²) into O(n) at the cost of memory, which is the
trade almost every "optimise this" prompt is asking for. It is also the most
common real fix in application code, where a membership test inside a loop over
a list is the usual accidental quadratic.
What is a hash collision, and what happens then?
Two distinct keys producing the same bucket index, which is unavoidable because
the key space is larger than the table. Implementations resolve it by chaining
entries in the bucket, or by probing for another slot. The consequence is that
constant-time lookup is an average rather than a guarantee: with adversarial or
pathological keys everything lands in one bucket and lookups degrade to linear,
which is a real denial-of-service vector and why some languages randomise their
hash seed.
Show me the classic hash-map conversion.
Two-sum is the canonical example because both versions are short and the
difference is the whole point.
# O(n^2): for each element, scan the rest.deftwo_sum_slow(nums, target):
for i inrange(len(nums)):
for j inrange(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
# O(n): remember what you have seen, and look up the complement.deftwo_sum(nums, target):
seen = {} # value -> indexfor i, n inenumerate(nums):
if target - n in seen: # constant average lookupreturn [seen[target - n], i]
seen[n] = i # record AFTER checkingreturn []
The ordering in the loop is the detail that gets missed. Recording before
checking would let an element pair with itself when target is twice its value,
so a single 4 in the list would be returned as a valid pair for a target of 8.
The general shape transfers well beyond this problem: whenever the brute force is
"for each element, look for a matching element", the question to ask is what you
would need to have remembered to answer in one pass. For anagram grouping it is a
sorted-letters key; for a subarray summing to k it is the running prefix sum; for
first-duplicate it is simply the set of things seen.
The cost is memory — O(n) rather than O(1) — and that is worth saying aloud
rather than presenting the improvement as free.
What is a prefix sum for?
Precomputing cumulative totals so any range sum can be answered in constant time
by subtracting two entries. It converts a problem where you would otherwise sum
a subarray per query, which is O(n) each, into O(n) preprocessing plus O(1) per
query. The same idea extends to two dimensions for rectangle sums, and the
running-prefix variant with a hash map solves "count subarrays summing to k" in
one pass.
Why is string concatenation in a loop a problem?
Because strings are immutable in most languages, so each concatenation allocates
a new string and copies both operands. Doing that n times copies a growing
prefix each time, which totals quadratic work. The fix is to collect the pieces
in a list and join once at the end, or to use a builder. It is one of the few
performance rules worth applying without measuring, because the alternative is
no harder to write.
What is the difference between a subarray, a subsequence and a subset?
A subarray is contiguous, a subsequence preserves order but may skip elements,
and a subset ignores order entirely. The distinction decides the technique: a
contiguous constraint invites a sliding window, a subsequence usually needs
dynamic programming because you are choosing at each position, and subsets over
a small n invite bitmask enumeration. Misreading which one the problem wants is
one of the more expensive misunderstandings, because it sends you to the wrong
family entirely.
How would you detect a duplicate in an array?
A set gives O(n) time and O(n) space, which is the default answer. Sorting first
gives O(n log n) time and O(1) extra space if sorting in place is allowed, which
is the answer when memory is the constraint. If the values are known to be in a
fixed range, marking presence by negating or offsetting values in place gives
O(n) time and no extra space. Which is right depends on the constraint the
interviewer stated, so ask before choosing.
Two pointers and sliding window
What signal tells you to use two pointers?
A sorted input where you are looking for a pair or a partition, or a problem
where moving an index one way can never require moving it back. The underlying
property is monotonicity: if the sum is too large, advancing the left pointer
can only make it larger, so the right must move. That argument is the reason the
technique is correct, and being able to state it is what distinguishes applying
it from having memorised it.
What signal tells you to use a sliding window?
A contiguous run — the words "subarray" or "substring" — combined with a
constraint that behaves monotonically as the window grows. Longest substring
without repeats, smallest subarray with a sum at least k, at most k distinct
characters. The window expands from the right while the constraint holds and
shrinks from the left when it breaks, and each index enters and leaves once,
which is why the whole thing is linear despite the nested loop.
Show me the sliding window template.
Nearly every window problem is this shape, and knowing it means the work is
deciding what goes in the state rather than how to structure the loop.
deflongest_without_repeats(s):
last_seen = {} # the window state
left = 0
best = 0for right, ch inenumerate(s):
# Shrink until the invariant holds again.if ch in last_seen and last_seen[ch] >= left:
left = last_seen[ch] + 1
last_seen[ch] = right
best = max(best, right - left + 1) # window is [left, right]return best
The invariant is the sentence to say out loud: the window contains no repeated
character. Everything else follows — the right pointer always advances, the left
only moves forward, and each index is visited at most twice, so it is O(n)
despite looking nested.
The >= left check is the part that catches people. A character seen before but
already outside the window must not move left backwards, and omitting the
comparison produces a subtly wrong answer only on inputs where a repeat falls
behind the window.
The variable-versus-fixed distinction is worth holding too. A fixed-size window
is simpler — add the entering element, remove the leaving one — while a variable
window needs the shrink loop above. If the problem states the size, you are in
the easier case and should say so.
Why is a sliding window linear when it looks quadratic?
Because although there is a loop inside a loop, each pointer only ever moves
forward and each index is entered and left exactly once. So the total number of
pointer movements is bounded by 2n regardless of how the inner loop is written.
This is an amortised argument, and stating it is what proves the solution is
linear — an interviewer asking "isn't that O(n²)?" is checking whether you know
why it is not.
When does the two-pointer approach fail?
When the constraint is not monotonic, so shrinking the window can make it valid
again in a way that requires reconsidering earlier positions. Negative numbers
in a sum problem are the classic breaker: adding an element can decrease the
total, so growing the window no longer moves the constraint in one direction,
and the window logic silently returns wrong answers. That case usually wants a
prefix sum with a hash map instead.
Stacks, queues and heaps
What problems are stack-shaped?
Anything where the most recent unmatched thing is what you need next: bracket
matching, expression evaluation, undo history, and depth-first traversal written
iteratively rather than recursively. The tell in a problem statement is a nesting
or pairing structure, or a phrase like "the most recent" or "the previous smaller
element". If you find yourself wanting to look backwards for the nearest
something, a stack is almost always the structure, and the recursion you were
about to write is a stack the language is managing for you.
Show me a monotonic stack and what it buys.
A monotonic stack keeps its contents ordered by discarding elements that can
never be the answer again, which turns a quadratic scan into a linear one.
defnext_greater(nums):
"""For each element, the next element to its right that is larger."""
result = [-1] * len(nums)
stack = [] # holds indices, values decreasingfor i, n inenumerate(nums):
# Anything smaller than n has found its answer: it is n.while stack and nums[stack[-1]] < n:
result[stack.pop()] = n
stack.append(i)
return result
The efficiency argument is the same amortised one as the sliding window: each
index is pushed once and popped at most once, so the inner while cannot make
the whole thing quadratic even though it looks like it might.
What the stack is really holding is unanswered questions. An element stays
until something resolves it, and the monotonic property means once an element is
popped it can never be needed again — the value that popped it is closer and
larger, so it would have answered anything the popped element could. That
observation is the invariant, and it generalises to largest rectangle in a
histogram, daily temperatures and trapping rain water.
What is a heap, and what is it not?
A heap is a partially ordered tree stored in an array, where each parent
compares favourably with its children, so the extreme element is at the root and
retrievable in constant time. Insertion and removal are logarithmic. What it is
not is sorted: the second-smallest element could be either child of the root,
and iterating a heap gives no useful order. That distinction is the answer to
most heap questions — it gives you the extreme cheaply and tells you nothing
else.
When is a heap better than sorting?
When you need the top k rather than the whole order. Sorting is O(n log n);
maintaining a heap of size k while scanning is O(n log k), which is
substantially better when k is small relative to n and is the difference between
feasible and not on a stream you cannot hold in memory. It also works when the
input arrives incrementally, where sorting requires having everything first.
Show me the running median trick.
Two heaps facing each other keep the middle of a stream available in constant
time, which no single ordered structure does as cheaply.
import heapq
classRunningMedian:
def__init__(self):
self.low = [] # max-heap, negated because Python's heap is a min-heapself.high = [] # min-heapdefadd(self, n):
heapq.heappush(self.low, -n) # always add to low
heapq.heappush(self.high, -heapq.heappop(self.low)) # move its max over# Rebalance so low is never smaller than high.iflen(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
defmedian(self):
iflen(self.low) > len(self.high):
return -self.low[0]
return (-self.low[0] + self.high[0]) / 2
The invariant is that every element in low is at most every element in high,
and the sizes differ by at most one. Given both, the median is either the top of
the larger heap or the average of the two tops.
The push-then-move step is the part worth understanding rather than memorising.
Adding straight to whichever heap looks right can violate the ordering, because
the new value might belong on the other side. Pushing to low and immediately
moving its maximum to high guarantees the ordering holds, and the rebalance
then fixes only the sizes. It costs one extra heap operation and removes every
edge case.
What is a deque, and when do you need one?
A double-ended queue supporting constant-time insertion and removal at both
ends. You need it for a queue, since removing from the front of an array is
linear, and for sliding-window maximum, where you keep indices in decreasing
order and pop from both ends. It is also the right structure for a bounded
history buffer. The mistake it prevents is using a list as a queue, which turns
an O(1) operation into O(n) and a loop into a quadratic.
Trees
What do the traversal orders actually differ in?
Only where the node is visited relative to its children. Pre-order visits the
node first, which suits copying a tree or serialising it. In-order visits left,
node, right, which on a binary search tree yields sorted order — that is the
single most useful fact about in-order. Post-order visits children first, which
is what you need when a node's result depends on its subtrees, such as computing
heights or deleting a tree safely.
Show me the difference between doing work before and after the recursive calls.
The position of the work decides what information is available, and most tree
problems are really a question about which one you need.
# Work BEFORE the calls: the node knows about its ancestors.defdepths(node, depth=0):
ifnot node:
returnprint(node.value, "is at depth", depth) # uses information passed down
depths(node.left, depth + 1)
depths(node.right, depth + 1)
# Work AFTER the calls: the node knows about its subtrees.defheight(node):
ifnot node:
return0
left = height(node.left) # results come back up
right = height(node.right)
return1 + max(left, right)
Information flows down through parameters and up through return values, and
choosing the direction is the design decision. Depth is inherited from a parent
so it is a parameter; height is derived from children so it is a return value.
Some problems need both, which is where candidates get stuck:
defis_valid_bst(node, low=None, high=None):
ifnot node:
returnTrue# Bounds come DOWN from ancestors...if (low isnotNoneand node.value <= low) or (high isnotNoneand node.value >= high):
returnFalse# ...and validity comes UP from children.return (is_valid_bst(node.left, low, node.value)
and is_valid_bst(node.right, node.value, high))
Validating a BST by comparing each node only with its two children is the classic
wrong answer, because the constraint is over whole subtrees rather than parent
and child pairs — a node deep in the left subtree can still be larger than the
root.
What makes a binary search tree degenerate?
Inserting sorted or nearly sorted data, which produces a chain rather than a
tree and turns every operation from O(log n) into O(n). This is why self-
balancing variants exist — red-black trees, AVL trees — which perform rotations
on insertion to keep the height logarithmic. The practical takeaway is that a
plain BST has good average behaviour and a bad worst case, and library tree maps
are balanced for exactly this reason.
What is the difference between height and depth?
Depth is measured from the root down to the node; height is measured from the
node down to its deepest leaf. So the root has depth zero and height equal to
the tree's height, and a leaf has height zero. They are computed in opposite
directions, which is why depth is passed down as a parameter and height comes
back as a return value. Mixing them up is a common source of off-by-one answers.
How do you find the lowest common ancestor?
In a binary search tree, walk down from the root: if both targets are smaller go
left, if both are larger go right, and the first node where they diverge is the
answer. In a general binary tree, recurse and return the node if it matches
either target, otherwise return whichever side produced a non-null result — if
both sides do, this node is the ancestor. The second version is a good example of
a post-order algorithm where the return value carries the meaning.
What is a trie for?
Storing strings by shared prefix, so lookup costs the length of the key rather
than depending on how many keys exist, and prefix queries become natural.
Autocomplete is the standard use, because a hash map can tell you whether a
string is present and cannot tell you which keys start with something. The cost
is memory: a node per character per branch, which is why tries suit dense key
sets and waste space on sparse ones.
Graphs
How do you recognise a graph problem?
Whenever entities have relationships and the question is about reachability,
ordering or connection. Build dependencies, mutual follows, a maze, currency
conversions, state machines, prerequisites — all graphs. This recognition is
where most candidates fail rather than in the traversal itself, because the
problem statement rarely uses the word. If you can phrase the question as "can I
get from here to there" or "what must happen first", it is a graph.
What is the practical difference between BFS and DFS?
BFS explores by distance, so it finds the shortest path in an unweighted graph
and needs a queue plus memory proportional to the width of the frontier. DFS
explores one branch fully before backtracking, so it uses memory proportional to
depth and suits cycle detection, topological ordering and exhaustive search. If
the question asks for the fewest steps, it is BFS; if it asks whether something
is reachable or asks for an ordering, DFS is usually simpler.
Show me BFS and why the visited set goes where it does.
The traversal is short; the placement of the visited check is what separates a
correct implementation from one that degrades badly.
from collections import deque
defshortest_path_length(graph, start, goal):
queue = deque([(start, 0)])
visited = {start} # marked when ENQUEUED, not when dequeuedwhile queue:
node, dist = queue.popleft()
if node == goal:
return dist
for neighbour in graph[node]:
if neighbour notin visited:
visited.add(neighbour) # before pushing
queue.append((neighbour, dist + 1))
return -1
Marking on enqueue rather than on dequeue is the detail. If you mark only when
you pop, a node reachable from several frontier nodes gets pushed several times,
so the queue grows with duplicates and the work becomes exponential in a dense
graph. Nothing is incorrect about the answer — it is just far slower, which makes
it the kind of bug that passes a small test.
flowchart TD
A[Start node in queue and visited] --> B[Pop the front]
B --> C{Is it the goal}
C -- Yes --> D[Return the distance]
C -- No --> E[For each unvisited neighbour]
E --> F[Mark visited and enqueue with distance plus one]
F --> B
The correctness argument for shortest path is that BFS visits nodes in
non-decreasing distance order, so the first time you reach the goal you have
reached it by a shortest route. That holds only on an unweighted graph — with
weights, a longer-hop path can be cheaper, which is what Dijkstra's algorithm
exists to handle by using a priority queue instead of a plain one.
What is a topological sort, and when does it not exist?
An ordering of a directed graph's nodes such that every edge points forwards,
which is what you want for build steps, task dependencies or course
prerequisites. It exists only if the graph is acyclic: a cycle means each node in
it must come before the others, which no ordering satisfies. That makes
topological sort a cycle detector as a side effect, which is often the more
useful half — the answer to "why can't this build" is the cycle it found.
Show me cycle detection in a directed graph.
The three-colour method is the one to know, because the two-state version does
not work on a directed graph.
WHITE, GREY, BLACK = 0, 1, 2# unvisited, on the current path, finisheddefhas_cycle(graph):
state = {node: WHITE for node in graph}
defvisit(node):
state[node] = GREY # entering: on the current pathfor neighbour in graph[node]:
if state[neighbour] == GREY: # back edge: a cyclereturnTrueif state[neighbour] == WHITE and visit(neighbour):
returnTrue
state[node] = BLACK # leaving: fully exploredreturnFalsereturnany(visit(n) for n in graph if state[n] == WHITE)
The reason a simple visited set fails is that reaching an already-visited node is
not itself a cycle — it may just be a node you reached earlier by another route,
which is perfectly normal in a directed acyclic graph. A cycle requires reaching
a node that is currently on the path you are exploring, which is what GREY
records.
A ──▶ B ──▶ D Visiting A: grey
│ ▲ B: grey, then D: grey, then black, B black
└────▶ C ───┘ C: grey, sees D which is BLACK -> not a cycle
Correct: this graph is acyclic
An undirected graph is different again: there, every edge you came in on looks
like a back edge, so you have to skip the immediate parent rather than track
three states.
What is union-find for?
Answering connectivity questions — are these two nodes in the same component —
in near-constant time per operation, with two optimisations: union by rank and
path compression. It suits problems where connections arrive incrementally and
you never remove them, such as detecting a cycle while building a graph, or
Kruskal's minimum spanning tree. It cannot answer path questions, only
membership, which is the trade for how fast it is.
When do you need Dijkstra rather than BFS?
When edges have different weights, so the fewest hops is no longer the cheapest
route and BFS returns a path that is short but expensive. Dijkstra replaces the
plain queue with a priority queue ordered by distance so far, and settles nodes
in increasing cost order, which is the same correctness argument as BFS with cost
substituted for hops. The constraint worth knowing is that it requires
non-negative weights: a negative edge can make an already-settled node cheaper
later, which breaks the argument entirely, and that case needs Bellman-Ford
instead.
Recursion, backtracking and dynamic programming
What are the two parts of any recursive solution?
A base case that returns without recursing, and a recursive step that makes
progress towards it. Missing the base case gives infinite recursion; a step that
does not shrink the problem gives the same. Beyond correctness, the useful
framing is that recursion is a way to express "solve a smaller version and
combine", so the design question is always what the smaller version is and how
the results combine.
What is the difference between recursion and backtracking?
Backtracking is recursion that explores choices and undoes them, so the same
state structure is reused rather than copied at every level. The undo step is
the distinguishing feature: make a choice, recurse, then unmake it. That is what
lets you enumerate permutations or solve a constraint problem without allocating
a new candidate at every node, and forgetting the undo is the most common bug in
the family.
Show me backtracking with the pruning that makes it feasible.
Exhaustive search is easy to write and usually too slow; the pruning is what
makes it finish.
defcombination_sum(candidates, target):
candidates.sort() # sorting enables the prune below
results, current = [], []
defbacktrack(start, remaining):
if remaining == 0:
results.append(current[:]) # copy: current keeps mutatingreturnfor i inrange(start, len(candidates)):
n = candidates[i]
if n > remaining: # THE prune: sorted, so all later are toobreak
current.append(n) # choose
backtrack(i, remaining - n) # explore, i not i+1 to allow reuse
current.pop() # un-choose
backtrack(0, target)
return results
Three lines carry the algorithm. The break is the prune, and it works only
because the input is sorted — without it the search explores branches that
cannot possibly succeed, which is the difference between finishing and not. The
current[:] copy is required because current is mutated throughout; appending
it directly stores a reference that will be empty by the end. And the pop is
the backtrack itself.
The general recipe is choose, explore, un-choose, with a prune as early as
possible. The prune is where nearly all the performance is: pushing a check from
the base case up into the loop can turn an intractable search into an instant
one, because it eliminates an entire subtree rather than one leaf.
What tells you a problem wants dynamic programming?
Two properties together: overlapping subproblems, meaning the same smaller
problem is solved repeatedly, and optimal substructure, meaning the best answer
to the whole is built from best answers to its parts. The practical signal is a
recursion whose call tree repeats itself — draw two levels by hand and see
whether the same arguments appear more than once. If every subproblem is
distinct, memoising buys nothing and what you have is divide-and-conquer, where
the win comes from the splitting rather than from caching.
Show me the move from exponential to linear.
The change is small and the change in complexity class is total, which is why
this pair is the canonical demonstration.
# Exponential: fib(n-2) is computed along two separate branches, so the# call tree has roughly 2**n nodes.deffib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# Linear: each subproblem is solved once and cached.deffib_memo(n, seen=None):
seen = {} if seen isNoneelse seen
if n < 2:
return n
if n notin seen: # the line that removes the branching
seen[n] = fib_memo(n - 1, seen) + fib_memo(n - 2, seen)
return seen[n]
# Bottom-up: same complexity, no recursion stack, and space can drop to O(1)# because only the last two values are ever needed.deffib_iter(n):
a, b = 0, 1for _ inrange(n):
a, b = b, a + b
return a
Nothing in the first function looks expensive line by line; the cost is entirely
in the shape of the recursion, which is invisible until you draw the call tree.
"Overlapping subproblems" is the phrase for what you are looking for.
Drawing the call tree is what makes the repetition visible, and it is worth doing
by hand once rather than taking it on trust:
flowchart TD
A[fib 5] --> B[fib 4]
A --> C[fib 3]
B --> D[fib 3]
B --> E[fib 2]
C --> F[fib 2]
C --> G[fib 1]
D --> H[fib 2]
fib 3 appears twice and fib 2 three times, at depth four of a tiny example.
At fib 40 the same duplication compounds into some 331 million nodes, and the
cache collapses the whole tree into one node per distinct argument.
The choice between top-down and bottom-up is mostly ergonomic. Memoisation
follows the recursive definition directly, so it is easier to derive and only
computes states you actually reach. Tabulation avoids the recursion stack, which
matters when the depth could overflow it, and it makes space optimisation
obvious — the third version keeps two variables because that is all the
recurrence looks back at.
What is the hardest part of a dynamic programming problem?
Defining the state. Once you can say what dp[i] or dp[i][j] means in one
sentence, the transition usually follows and the implementation is mechanical.
Get the state wrong and no amount of correct coding recovers it, which is why
experienced candidates spend most of their time on that sentence and why "what
does this cell represent" is the question an interviewer asks when you are
stuck.
When is greedy correct rather than merely plausible?
When you can argue that a locally best choice never rules out a globally best
solution, usually via an exchange argument: take any optimal solution, show it
can be transformed into your greedy one without getting worse. Interval
scheduling by earliest finishing time has such an argument; the coin change
problem does not for arbitrary denominations, which is why the greedy answer is
wrong there. The interview question is almost never whether you can code it but
why it is safe.
Sorting, searching and the round itself
What does a stable sort guarantee?
That elements comparing equal keep their original relative order. That matters
whenever you sort by one key and then another to get a compound ordering — sort
by name, then stably by department, and within each department the names remain
sorted. Merge sort is stable and quicksort is not, which is one of the few
practical reasons to care which a library uses. Python's and Java's object sorts
are stable; several primitive sorts are not.
Why is quicksort used when its worst case is quadratic?
Because the worst case requires consistently bad pivots, which randomised or
median-of-three pivot selection makes vanishingly unlikely, and because its
constant factors and cache behaviour are excellent — it sorts in place with good
locality. Merge sort guarantees O(n log n) but needs O(n) extra space. Real
library sorts are usually hybrids that switch to insertion sort on small
partitions and fall back to a guaranteed algorithm if recursion goes too deep.
Show me binary search on the answer.
The form that appears in interviews is usually not searching a sorted array — it
is searching the space of possible answers, where nothing is sorted because
there is no array.
defmin_capacity(weights, days):
"""Smallest ship capacity that ships all weights within `days`."""deffeasible(capacity):
needed, load = 1, 0for w in weights:
if load + w > capacity:
needed += 1
load = 0
load += w
return needed <= days
lo, hi = max(weights), sum(weights) # bounds that must contain the answerwhile lo < hi:
mid = (lo + hi) // 2if feasible(mid):
hi = mid # mid might be the answer, keep itelse:
lo = mid + 1# mid is too small, discard itreturn lo
The property that makes it valid is monotonic feasibility: if a capacity works,
every larger one works too. That turns the answer space into a sorted sequence of
false values followed by true values, and binary search finds the boundary.
Two implementation details cause most of the bugs. The bounds must be chosen so
the answer is definitely inside them — here the lower bound is the heaviest
single item, since no smaller ship can carry it. And the update must not exclude
a candidate answer: hi = mid rather than mid - 1, because mid itself may be
the smallest feasible value, while lo = mid + 1 is safe because mid is known
to fail.
The signal in a problem statement is a question asking for a minimum or maximum
value subject to a condition you can check more easily than you can compute
the answer.
Why is `(lo + hi) // 2` sometimes written differently?
Because in languages with fixed-width integers the sum can overflow before the
division, producing a negative index. Writing lo + (hi - lo) // 2 avoids it.
It is a genuine historical bug found in widely used library implementations
years after publication, which makes it a good illustration that correct-looking
code can hide a defect for a long time. In Python integers are arbitrary
precision, so it does not apply, but interviewers still ask.
What is the interviewer actually scoring in a coding round?
Not just whether the code runs. The rubric usually has axes for communication,
approach, correctness and complexity, so a partially complete solution narrated
clearly outscores a complete one produced in silence. The moment that decides
most rounds is the added constraint near the end, because it cannot be prepared
for — it tests whether you understood your own solution well enough to modify
it, or merely reproduced one.
How do you pick a technique family in the first ninety seconds?
The skill a coding round measures is not writing the solution, it is the ninety
seconds between reading the statement and committing to an approach. That period
has a structure you can rehearse: look for a small number of signals, each of
which rules a family in or out.
flowchart TD
A[Read the statement and the input limits] --> B{Contiguous run over an array or string}
B -- Yes --> C[Sliding window or two pointers]
B -- No --> D{Answer is a number with a monotonic feasibility test}
D -- Yes --> E[Binary search on the answer]
D -- No --> F{Same subproblem recurs across choices}
F -- Yes --> G[Dynamic programming or memoisation]
F -- No --> H{Entities with relationships}
H -- Yes --> I[Model as a graph and traverse]
H -- No --> J[Sort first and see what falls out]
The branch most often missed is the second. Binary search is taught as a way to
find a value in a sorted array, and the version that appears in interviews is
usually the other one — you are searching the space of possible answers, and
there is no array at all.
The last branch is worth keeping as a fallback rather than a guess. A surprising
number of problems become obvious once sorted: intervals collapse to a sweep,
duplicates become adjacent, and a two-pointer scan becomes available. If nothing
else fits, asking "what does sorting make true?" is a productive thirty seconds.
Say the signal out loud when you spot it. "This asks for the longest contiguous
run satisfying a condition, and the condition is monotonic in the window width,
so this is a sliding window" is a sentence that earns marks on its own — it shows
the choice was derived rather than pattern-matched, which is exactly what the
interviewer cannot tell from the code alone.
What should you do in the first two minutes?
Restate the problem in your own words and ask about input size, edge inputs and
whether the input is sorted or contains duplicates or negatives. Then name a
brute force and its cost out loud before optimising, because it establishes a
baseline, proves you understood the problem, and gives you something to submit
if time runs out. Starting to type immediately is the most common way to solve
the wrong problem elegantly.
How do you talk about complexity so it sounds derived rather than recalled?
State your variables first — "n is the number of orders and m is the average
number of lines" — then account for each part of the algorithm, then give the
total. Mention space separately, including the recursion stack. And check the
result against the stated limits: noticing yourself that a quadratic solution
will not finish at the given input size is worth real credit, because it shows
the analysis is a tool you use rather than a label you attach at the end.