Chapter 22 · Interview preparation

Data Structures and Algorithms by Pattern

Eleven patterns cover the overwhelming majority of algorithm interview questions. This chapter is organised by pattern rather than by data structure, because the hard part of an interview is never implementing a heap — it is recognising, from a paragraph of prose, that the question wants one.

27 sections 11 patterns Every solution executed 12 interview drills Reading time ~4.5 hours

[!] The failure this chapter is designed to prevent

The common way to prepare is to grind problems one at a time and hope the interview reuses one you have seen. This scales badly, and it fails against a company that deliberately retires leaked questions. Worse, it trains the wrong reflex: recall instead of derivation. When the question is unfamiliar — which is the normal case — recall has nothing to offer and you freeze.

Pattern-based preparation trains a different reflex. You read a question you have never seen, notice that it is asking for the best of all contiguous chunks, and think "contiguous plus best implies sliding window" before you have any idea what the final code looks like. That reflex transfers to unseen problems. Memorised solutions do not.

[i] How to read this chapter

Each pattern section has the same four parts, deliberately: the signal (what in the problem statement tells you it is this pattern), why it works (the invariant that makes it correct, so you can rebuild it rather than recall it), the template (working code), and what to say (how a strong candidate narrates it).

Every code block in this chapter was executed and asserted against test cases before publication, including edge cases like empty input. The heap construction in 22.10 was additionally checked against 300 random arrays. Nothing here is untested pseudocode.

[≡] Notation used throughout

n is the input size unless stated otherwise. O(·) is worst-case time unless labelled as space or amortised. Code is Python 3, chosen because it is the most common interview language and the least noisy to read; the patterns are language-independent. a[i:j] is the usual half-open slice: includes i, excludes j.

22.1 Why patterns, not problems

There are effectively infinite algorithm questions and about eleven ideas. Preparation that targets questions scales linearly and runs out; preparation that targets ideas compounds, because every new question is a recombination of things you already know.

The evidence for this is uncomfortable but useful. Companies that interview at volume actively retire questions once they leak, so the specific problems circulating publicly are, almost by definition, the ones you will not be asked. What survives that churn is structural: the interviewer needs a problem that can be stated in two minutes, solved in twenty, and admits a naive solution plus at least one meaningful optimisation. That constraint is what generates the eleven patterns. It is far more stable than any individual question.

[!] The specific trap: pattern-matching on surface features

Recognising patterns is not the same as recognising wording. A question about an array of integers summing to a target looks like two-sum whether the array is sorted or not — but sorted means two pointers in O(n) and O(1) space, while unsorted means a hash map in O(n) time and O(n) space, and "return all unique triples" means something different again. The pattern is determined by the structure and the constraints, never by the nouns. Section 22.20 is devoted entirely to questions engineered to exploit this confusion.

22.2 The first ninety seconds

What you do immediately after hearing the problem matters more than anything else in the interview, because it determines whether the next twenty minutes are spent solving the right problem. It is also the most commonly skipped step: the instinct under stress is to start coding, and that instinct is wrong.

[→] A repeatable opening sequence

  1. Restate the problem in your own words. Ten seconds, and it catches misunderstandings while they are still free to fix. "So I have a list of meetings with start and end times, and I want the minimum number of rooms such that no two overlapping meetings share a room — is that right?"
  2. Ask about input size. This is not small talk; it is the single most informative question you can ask, because it eliminates most of the solution space immediately (22.3).
  3. Ask about the edge cases you actually care about. Empty input, one element, duplicates, negative numbers, and whether the input is sorted or can be mutated. Each answer either simplifies your code or reveals a constraint the interviewer was waiting for you to discover.
  4. Give a concrete small example and walk it by hand. Use the interviewer's example if they gave one; invent a three-element one if not. This frequently surfaces an ambiguity in the specification, and it gives you a test case to check your code against later.
  5. State the brute force and its complexity out loud — then say you can do better. This establishes a correct baseline in about fifteen seconds and buys you room to think. Never implement it unless you are stuck (22.21).

[+] Why the input-size question does so much work

"How large can the input get?" sounds like a clarifying question but is really a solution-space filter. If n can be a million, an O(n²) approach is dead and you have narrowed to roughly O(n log n) or better before writing a line. If n is at most 20, an exponential brute force over subsets is not just acceptable but probably the intended answer, and you have saved yourself from over-engineering a problem that wanted a straightforward recursion.

22.3 Complexity as a search hint

Complexity analysis is usually taught as something you do after writing code, to describe it. In an interview it is far more valuable used in reverse: as a constraint that tells you what kind of algorithm can possibly work, before you have one.

The reasoning runs backwards from the input size. Roughly 108 simple operations is the most that runs comfortably in a second, so the size of n implies a ceiling on the complexity, and the complexity implies a very short list of techniques.

Input size to plausible complexity to candidate technique
If n is up to You can afford Which usually means
10–20 O(2ⁿ) or O(n!) Enumerate subsets or permutations — backtracking (22.13). The tiny bound is the interviewer telling you exponential is fine.
100–500 O(n³) Interval or matrix DP over all (i, j) pairs with an inner loop (22.17).
1,000–5,000 O(n²) Two-dimensional DP, or an all-pairs comparison (22.17).
10⁵–10⁶ O(n log n) Sort first (22.11), a heap (22.10), or binary search on the answer (22.9).
10⁷ and above O(n) or O(log n) One pass: two pointers, sliding window, hash map, or a greedy sweep (22.5–22.8).

[+] Reading the target complexity backwards

This works in the other direction too, and it is one of the highest-leverage tricks in the chapter. If you know the answer should be O(n log n) but the problem has no obvious ordering, the "log n" is a strong hint that something is being sorted or binary searched. If the target is O(n) on a problem that looks quadratic, some structure — a hash map, or a window that never moves backwards — must be collapsing the inner loop.

Saying this reasoning out loud is valuable even when it does not immediately produce the answer: "I want O(n log n), and there is a log in there, so I suspect I should sort by start time first" is exactly the kind of narration that reads as competence rather than guessing.

22.4 The pattern recognition table

This is the index for the rest of the chapter: the signal in the problem statement, and the pattern it points to. The signals are structural, not lexical — each one describes something true about the problem, not a phrase to grep for.

Eleven patterns and the structural signal that identifies each
Signal in the problem Pattern Typical cost Section
Sorted array, looking for a pair or triple with some property Two pointers O(n) 22.5
Contiguous subarray or substring, and a best/longest/shortest Sliding window O(n) 22.6
"Have I seen this before", counting, grouping, or complements Hash map O(n) 22.7
Start and end times; overlaps, merges, or room allocation Intervals O(n log n) 22.8
"Minimise the maximum", "maximise the minimum", or a monotone feasibility test Binary search on the answer O(n log R) 22.9
"Top k", "k-th largest", or a running median Heap O(n log k) 22.10
Order matters but the input arrives unordered Sort first O(n log n) 22.11
Fewest steps, shortest path with unweighted edges, level by level BFS O(V+E) 22.12
Explore all paths, generate all combinations, connected components DFS / backtracking O(V+E) or exponential 22.13
Dependencies, prerequisites, ordering constraints, "before" Topological sort O(V+E) 22.14
Count the ways, or optimise over overlapping subproblems Dynamic programming O(states × transition) 22.15–22.17

[!] Two signals that are constantly confused

Contiguous versus not. "Subarray" and "substring" mean contiguous, and point at sliding window. "Subsequence" and "subset" do not, and usually point at DP. This single word changes the entire approach, and interviewers choose it deliberately. The longest common subsequence in 22.17 is a DP; the longest substring without repeats in 22.6 is a window.

Sorted versus sortable. If the input is already sorted, two pointers is often free. If it is not, ask whether sorting is allowed — sometimes the O(n log n) of sorting is irrelevant because another part of the algorithm already costs that much, and sometimes it destroys an index-ordering the problem depends on, which makes a hash map the right answer instead.

22.5 Two pointers

The signal: the input is sorted (or you are allowed to sort it), and you are looking for a pair, triple, or partition with some property. Also: any time you would otherwise write two nested loops over the same array.

Why it works. The whole pattern rests on one observation: when the array is sorted, comparing the current pair to the target tells you unambiguously which pointer to move. If the sum is too small, no amount of moving the right pointer down will help — it only makes the sum smaller. So the left pointer must move up. That "unambiguously" is the entire justification, and it is what you should say out loud. Each step eliminates at least one candidate permanently, so the total work is linear.

Two pointers: pair summing to a targetpython
def two_sum_sorted(a, target):
    """a is sorted ascending. Return the pair of indices summing to target."""
    i, j = 0, len(a) - 1
    while i < j:
        s = a[i] + a[j]
        if s == target:
            return (i, j)
        if s < target:
            i += 1        # too small: only moving i up can increase the sum
        else:
            j -= 1        # too big: only moving j down can decrease the sum
    return None

The invariant worth naming: every pair not yet examined lies strictly between i and j. When they meet, the space is exhausted, which is why returning None at that point is provably correct rather than a guess.

[+] What to say

"Since it is sorted, I can start at both ends. If the sum is too small I have to move the left pointer up, because moving the right one down only shrinks the sum further — so each comparison eliminates a whole row of the implicit pair matrix. That gives me O(n) time and O(1) space instead of the O(n²) nested loop."

[≡] The common variants

Opposite ends (above) for pair-sum and palindrome checks. Same direction at different speeds (fast/slow) for cycle detection in a linked list and for finding a midpoint in one pass. Three-sum is a loop over the first element wrapping a two-pointer scan, giving O(n²) rather than O(n³) — recognising it as "two pointers with an outer loop" is what makes it easy.

22.6 Sliding window

The signal: the word contiguous (or "subarray" or "substring") plus a superlative — longest, shortest, maximum, or "contains at most k distinct".

Why it works. The brute force examines every (start, end) pair, which is O(n²) windows. The window pattern collapses this by exploiting a monotonicity: as the right edge advances, the leftmost valid start never moves backwards. That means each index enters the window once and leaves at most once, so despite the nested-looking structure, the total work is O(n). Whenever you use this pattern, that non-backtracking property is the thing to justify.

Variable-size window: longest substring without repeatspython
def longest_unique(s):
    """Length of the longest substring with no repeated character."""
    last_seen = {}
    best = start = 0
    for i, c in enumerate(s):
        # Only shrink if the duplicate is INSIDE the current window.
        if c in last_seen and last_seen[c] >= start:
            start = last_seen[c] + 1
        last_seen[c] = i
        best = max(best, i - start + 1)
    return best

[!] The bug almost everyone writes first

The guard last_seen[c] >= start is essential and is the single most common omission. Without it, a repeat that occurred before the current window drags start backwards, and the window grows to include characters it already excluded. Test with "abba": the correct answer is 2, and the buggy version returns 3. Any window solution should be tested against an input where a character repeats outside the current window.

When the window size is fixed rather than variable, the pattern gets simpler — no shrink condition, just add the entering element and subtract the leaving one:

Fixed-size window: best sum of k consecutivepython
def max_sum_k(a, k):
    """Largest sum of any k consecutive elements."""
    if len(a) < k:
        return None
    window = sum(a[:k])
    best = window
    for i in range(k, len(a)):
        window += a[i] - a[i - k]   # add entering, remove leaving: O(1) per step
        best = max(best, window)
    return best

[+] What to say

"Because the answer has to be contiguous, I can maintain a window instead of checking every pair. The key property is that the valid start never moves left as the right edge advances, so each element is added once and removed at most once — that is what makes it O(n) rather than O(n²), even though it looks like two loops."

22.7 Hash map and frequency

The signal: "have I seen this before", counting occurrences, grouping by a derived key, or looking up a complement. Whenever a nested loop exists only to search for a matching element, a hash map removes the inner loop.

Why it works. It trades space for time in the most direct way available: O(n) memory buys O(1) lookup, converting a scan into a question. The insight to articulate is the reframing — instead of asking "is there a pair summing to target", ask, for each element, "have I already seen target - v". The second question is answerable in constant time; the first is not.

[i] Why lookup is O(1) — and when it quietly is not

A hash map lookup computes a hash of the key (O(1) for fixed-size keys like ints or short strings), uses it to index directly into an underlying array, and then compares against whatever landed in that slot. No searching, no scanning — the hash function tells you exactly where to look. That is the entire mechanism; there is no cleverness beyond it.

The O(1) is an average case, not a guarantee, and knowing why separates a real understanding from a memorised fact. Two or more keys can hash to the same slot (a collision), and the table resolves it by chaining a small list at that slot or probing to another one — either way, a lookup that collides degrades toward O(k) for k colliding keys. With a good hash function and a table that resizes to keep load low, collisions stay rare enough that the amortised cost is O(1). With a poor hash function, or an adversarial input designed to collide, every key can land in the same slot and the hash map degrades to a linked list — O(n) per lookup. This is also why hashing a large, variable-length object (a whole array as a dict key, say) is not O(1): computing the hash itself must touch every element, costing O(size of the object).

Hash map: two-sum in one pass, unsortedpython
def two_sum(a, target):
    """Unsorted input. One pass: remember what we have seen."""
    seen = {}                       # value -> index
    for i, v in enumerate(a):
        if target - v in seen:      # the partner arrived earlier
            return (seen[target - v], i)
        seen[v] = i
    return None

[≡] The three shapes this takes

Complement lookup (above): store what you have seen, ask for what you need. Frequency counting: map value to count, then answer questions about the counts — most frequent, first unique, anagram detection. Grouping by derived key: map a canonical form to a list, as in grouping anagrams by their sorted letters. When you find yourself thinking "I need to find all the things that share a property", the property itself is usually the key.

22.8 Intervals

The signal: anything with start and end times — overlaps, merges, room or resource allocation, scheduling.

Why it works. Almost every interval problem becomes easy after sorting, and the entire skill is knowing what to sort by. Sort by start time to merge overlapping intervals; sort by end time for greedy "maximum non-overlapping" selection. The meeting rooms problem below uses a third option that is worth understanding properly, because it is the one candidates rarely find unprompted.

Intervals: minimum meeting roomspython
def min_meeting_rooms(intervals):
    """Fewest rooms so that no two overlapping meetings share one."""
    if not intervals:
        return 0
    starts = sorted(s for s, _ in intervals)
    ends   = sorted(e for _, e in intervals)
    i = j = in_use = best = 0
    while i < len(starts):
        if starts[i] < ends[j]:     # a meeting begins before the next one ends
            in_use += 1
            best = max(best, in_use)
            i += 1
        else:                       # a room frees up first
            in_use -= 1
            j += 1
    return best

The trick is that starts and ends are sorted separately, decoupling them. The algorithm then sweeps a virtual clock: every start before the next end means a new concurrent meeting, every end means a room is released. The maximum concurrency observed is exactly the number of rooms needed, because at that instant that many meetings are genuinely running at once.

[!] The boundary case that decides correctness

Is a meeting ending at 10 and another starting at 10 an overlap? The strict < in the comparison says no — the room is freed in time. If the problem intends otherwise, it becomes <= and the answer changes. This is worth asking about explicitly in the first ninety seconds (22.2); it is a deliberate ambiguity in many versions of the question, and noticing it is part of what is being assessed.

[+] What to say

"I will sort the start times and the end times independently. Then I sweep: each start that comes before the next end increments the number of rooms in use, each end decrements it. The peak value is the answer, because that is the moment of maximum concurrency. Sorting dominates, so it is O(n log n) time and O(n) space."

22.9 Binary search on the answer

The signal: "minimise the maximum", "maximise the minimum", "the smallest capacity such that…", or any problem where checking a candidate answer is easy but constructing the best one directly is hard.

Why it works. This is the pattern most candidates fail to recognise, and the one that most impresses when found. The realisation is that you are not searching an array — you are searching the space of possible answers. It applies whenever feasibility is monotone: if a capacity of 18 works, then 19, 20, and everything above also work. That monotone boundary is exactly what binary search locates, and saying the word "monotone" out loud is what signals you know why this is valid rather than having pattern-matched.

[i] Where the log n actually comes from

Binary search costs O(log R), where R is the size of the answer range (sum(nums) - max(nums) here), because each comparison discards exactly half of whatever range remains — regardless of which half. The question "how many operations does this take" is really the question "how many times can R be cut in half before one value is left", and that count is log₂(R) by definition. Figure 22.3 makes this concrete on a 16-element array before you generalise it to an answer range.

Binary search shrinking the search range by half at every step A row of 16 boxes representing a sorted array, followed by five shrinking bars showing the remaining search range after each comparison: 16, then 8, then 4, then 2, then 1 element. Each step discards exactly half of what remained, taking four steps total to go from 16 elements to 1. Binary search on 16 elements — each step halves what is left to check the sorted array, 16 elements n=16 start: the full array, nothing checked yet n=8 step 1: check the middle element, discard the other half n=4 step 2: check the middle element, discard the other half n=2 step 3: check the middle element, discard the other half n=1 found after step 4 16 → 8 → 4 → 2 → 1: four halvings. In general, halving n until 1 remains takes log₂(n) steps → O(log n).
Figure 22.3 — Why the cost is log₂(n): the question is not "how many elements are there" but "how many times can n be halved before reaching 1". For n = 16 that is 4; for n = 1,000,000 it is about 20. Doubling the input adds only one more step — the defining signature of logarithmic growth.
Binary search on the answer: split arraypython
def split_array_min_largest(nums, k):
    """Split nums into k contiguous parts, minimising the largest part sum."""

    def can_do_with_cap(cap):
        """Greedy: how few parts can we get if no part may exceed cap?"""
        parts, current = 1, 0
        for n in nums:
            if current + n > cap:
                parts += 1
                current = n
                if parts > k:
                    return False
            else:
                current += n
        return True

    lo, hi = max(nums), sum(nums)   # cap must fit one element; sum always works
    while lo < hi:
        mid = (lo + hi) // 2
        if can_do_with_cap(mid):
            hi = mid                # feasible: try to do better
        else:
            lo = mid + 1            # infeasible: need a bigger cap
    return lo

Note the structure: an inner greedy function that answers a yes/no question, wrapped in a binary search over the answer range. The greedy check is easy to write and easy to verify. Directly computing the optimal split is not. That asymmetry — hard to construct, easy to verify — is the real signal for this pattern.

[→] How to derive it in the room

  1. Identify what you are searching over. Here, the largest allowed part sum. Not an index — a value.
  2. Establish the bounds. The lower bound is max(nums), since one element must fit in some part. The upper bound is sum(nums), which trivially works with one part. Stating both, with reasons, is half the answer.
  3. Write the feasibility check. Greedily fill parts until the cap is exceeded; count how many parts that needs. Feasible if that count is at most k.
  4. Argue monotonicity. A larger cap never needs more parts, so feasibility flips exactly once as the cap increases. Binary search finds that flip.
  5. Converge to the boundary. On feasible, move hi down to mid (keep it, try better); on infeasible, move lo past mid. Loop while lo < hi and the answer is lo.

[!] Off-by-one and the infinite loop

The pairing of while lo < hi with hi = mid and lo = mid + 1 is not arbitrary. With floor division, mid can equal lo, so if the feasible branch set hi = mid while the other set lo = mid, the range could stop shrinking and loop forever. The rule: the branch that keeps mid as a candidate assigns hi = mid; the branch that rules mid out must step past it with mid + 1.

22.10 Heaps and top-K

The signal: "the k largest", "the k-th smallest", "the k closest", or a running median over a stream. Also any time you need repeated access to the current extreme of a changing collection.

Why it works. A heap gives O(log n) insertion and O(1) access to the extreme, without paying for a full ordering you do not need. The key insight for interviews is the complexity argument: maintaining a heap of size k costs O(n log k), which beats sorting's O(n log n) whenever k is much smaller than n. If k is 10 and n is ten million, that difference is enormous, and being able to state it is the point of the question.

[i] Why push and pop are O(log n) — not O(1), not O(n)

A binary heap is stored as a flat array, but it represents a complete binary tree — every level is full except possibly the last, which fills left to right. That completeness is what pins the height to ⌊ log₂ n ⌋: a complete tree of height h holds at most 2^(h+1) - 1 nodes, so inverting that for n gives h ≈ log₂ n.

Both push and pop work by moving one element along a single root-to-leaf path, comparing it against its parent (sift-up) or its children (sift-down) at each level and swapping if the heap property is violated. One comparison per level, and the number of levels is the height — so the cost is exactly O(height) = O(log n). It is not O(1) because a single misplaced element can need to travel the full height of the tree; it is not O(n) because the tree is never taller than log₂ n, however many elements it holds. Figure 22.4 walks one sift-down step by step.

Sift-down operation in a binary min-heap after removing the root Two small trees of seven nodes each, side by side. The left tree shows the heap immediately after the root has been replaced by the value 9, violating the heap property. The right tree shows the result after sift-down: 9 has been swapped downward twice, first with its smaller child 2, then with its smaller child 4, until it reached a leaf position where it no longer has smaller children below it. Sift-down after removing the root of a 7-node min-heap before: root violates the heap property after: 9 has sifted down two levels 9 2 3 4 5 6 7 2 4 3 9 5 6 7 Level 0 → 1: compare 9 against children 2, 3 — swap with the smaller (2). Level 1 → 2: compare 9 against children 4, 5 — swap with the smaller (4). Now a leaf: stop. One comparison per level, and a heap of n nodes has height ⌊ log₂ n ⌋ → O(log n) per sift-down.
Figure 22.4 — Sift-down does one comparison per level, and stops the moment the value is smaller than both children or reaches a leaf. A heap holding n elements is a complete binary tree, so its height is always ⌊ log₂ n ⌋ — which is exactly why every heap push and pop costs O(log n): the work is bounded by the height, not by n itself.
Heaps: k closest points, k-th smallestpython
import heapq

def k_closest_to_origin(points, k):
    """The k points nearest (0,0). Compare squared distance: no sqrt needed."""
    return heapq.nsmallest(k, points, key=lambda p: p[0]*p[0] + p[1]*p[1])

def kth_smallest(a, k):
    """k-th smallest value. O(n log k) with a bounded heap, not O(n log n)."""
    return heapq.nlargest(k, a)[-1] if k > len(a)//2 else heapq.nsmallest(k, a)[-1]

[+] The counter-intuitive part: which heap to use

To keep the k largest elements you maintain a min-heap of size k, not a max-heap. The reason is that the thing you need cheap access to is the weakest survivor — the element that gets evicted when a better one arrives. Getting this backwards is a classic interview stumble, and explaining the choice correctly is a strong signal. Symmetrically, the k smallest are kept in a max-heap of size k.

For the k-closest-points variant, there is a second observation worth voicing: comparing squared distances avoids computing square roots entirely. Since squaring is monotone on non-negative values, the ordering is identical, and you have removed n transcendental operations for free.

22.11 Sorting as preprocessing

The signal: the problem is much easier when the data is in order, and nothing in the problem depends on the original positions.

Sorting is rarely the answer by itself; it is the step that makes the actual answer simple. The judgement being tested is whether you notice that O(n log n) is free in context — if the rest of your algorithm already costs that much, or if the alternative is an O(n²) scan, then sorting costs nothing you were not already paying.

[≡] What sorting unlocks

Two pointers become available (22.5). Duplicates become adjacent, so detecting them is a single pass. Greedy choices become provable — most greedy interval arguments start with "sort by end time, then always take the earliest finisher". Binary search becomes available for repeated membership queries.

[!] When sorting is the wrong move

If the problem asks for original indices (as two-sum does), sorting destroys exactly the information you need — you would have to sort index-value pairs, at which point a hash map is simpler and faster. If the input is a stream you cannot hold in memory, sorting is not available at all and you need a heap or reservoir sampling. And if an O(n) solution exists, sorting is a strictly worse answer that an interviewer will push back on.

22.12 BFS: fewest steps

The signal: "shortest path", "fewest moves", "minimum number of steps", or anything that proceeds level by level — on a grid, a graph, or an implicit state space where the edges are moves rather than stored links.

Why it works. BFS visits nodes in non-decreasing order of distance from the source, so the first time it reaches a node, it has arrived by a shortest path. That guarantee holds only when every edge costs the same; with varying weights you need Dijkstra instead. Being explicit about this precondition — "the edges are all unit cost, so BFS gives the shortest path and I do not need Dijkstra" — is a distinguishing remark.

[i] Where O(V+E) comes from

Each vertex is enqueued exactly once, guarded by the visited check, so the vertex work totals O(V). Every edge is examined exactly once too — once from each of its endpoints in an undirected graph, or once in a directed one — while looking for unvisited neighbours, contributing O(E). Neither term absorbs the other in general (a sparse graph has E close to V, a dense one has E close to V²), so both stay in the bound: O(V) + O(E) = O(V + E). The frontier expanding ring by ring is exactly what guarantees every vertex is dequeued at its true shortest distance the first time it is reached.

Multi-source BFS: walls and gatespython
from collections import deque

INF = 2**31 - 1     # an empty room

def walls_and_gates(grid):
    """Fill each empty room with its distance to the nearest gate (0). -1 is a wall.

    Multi-source BFS: seed the queue with EVERY gate at once, so the first
    time a room is reached it is reached by the nearest gate automatically.
    """
    if not grid or not grid[0]:
        return grid
    rows, cols = len(grid), len(grid[0])
    q = deque((r, c) for r in range(rows) for c in range(cols) if grid[r][c] == 0)
    while q:
        r, c = q.popleft()
        for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == INF:
                grid[nr][nc] = grid[r][c] + 1
                q.append((nr, nc))
    return grid

[+] The multi-source trick worth knowing cold

Seeding the queue with every gate before starting, rather than running one BFS per gate, is what turns an O(gates × cells) algorithm into a single O(cells) pass. It works because a BFS from a set of sources expands as one uniform frontier, so the first arrival at any cell necessarily came from the nearest source. The same trick solves "rotting oranges", "distance to nearest 0", and any "nearest of several targets" problem — recognising the family is worth more than the individual solutions.

[!] Mark as visited when you enqueue, not when you dequeue

The code above sets the distance at the moment it pushes a cell. Marking on dequeue instead lets the same cell get queued many times before it is first processed, which can blow up the queue and, in the worst case, the running time. This is a genuine bug that appears in a lot of otherwise-correct BFS code.

22.13 DFS, recursion, backtracking

The signal: explore every path, enumerate all combinations or permutations, find connected components, or any tree traversal. If the problem says "all possible", it is almost always backtracking.

Why it works. DFS commits to one branch fully before trying the next, which makes it the natural fit for exhaustive exploration and for anything defined recursively (a tree, a component, a partial solution). The distinction from BFS is worth stating crisply: DFS answers "is there a path / what are all the paths", BFS answers "what is the shortest path".

DFS: count islands by sinking thempython
def num_islands(grid):
    """Count connected components of '1' in a grid. Sinks each island as it goes."""
    if not grid or not grid[0]:
        return 0
    rows, cols = len(grid), len(grid[0])

    def sink(r, c):
        if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != '1':
            return
        grid[r][c] = '0'          # mark visited by mutating: no separate set needed
        sink(r+1, c); sink(r-1, c); sink(r, c+1); sink(r, c-1)

    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                count += 1        # a cell still standing starts a new island
                sink(r, c)
    return count

Mutating the grid to mark visits is a deliberate space optimisation over a separate visited set, and it is the kind of trade worth flagging out loud — along with the caveat that it destroys the caller's input, which you should confirm is acceptable.

Backtracking is DFS over a space of partial solutions, and every backtracking problem has the same three-line skeleton:

Backtracking: the choose / recurse / un-choose shapepython
def subsets(nums):
    """Every subset. The shape of all backtracking: choose, recurse, un-choose."""
    out, path = [], []

    def explore(start):
        out.append(path[:])            # copy: path keeps mutating underneath us
        for i in range(start, len(nums)):
            path.append(nums[i])       # choose
            explore(i + 1)             # recurse on what remains
            path.pop()                 # un-choose, restoring state for the next branch
    explore(0)
    return out

[→] The four questions that write any backtracking solution

  1. What is a partial solution? Here, path — the elements chosen so far.
  2. What are the choices at each step? Every remaining element from start onward. Starting at i + 1 rather than 0 is what prevents generating the same subset in different orders.
  3. When do I record an answer? For subsets, at every node. For permutations, only at full length. For constrained problems, only when the constraint is satisfied.
  4. What must be undone? Every mutation made before recursing. The path.pop() is not optional bookkeeping — it is what makes the next branch see a clean state.

[!] Two recursion failures worth pre-empting

Appending the live list. out.append(path) without the [:] copy stores a reference; every stored "answer" then mutates with the ongoing search and you finish with a list of identical empty lists. Stack depth. Python's default recursion limit is around 1000, so a DFS on a million-cell grid will overflow it — worth mentioning, with an iterative stack-based rewrite as the fix, if the stated input size is large.

22.14 Topological sort

The signal: dependencies, prerequisites, build order, "A must come before B", or recovering an unknown ordering from pairwise constraints.

Why it works. Kahn's algorithm repeatedly emits any node with no unmet dependencies, then removes it and its outgoing edges, which may free others. It doubles as cycle detection for free: if the algorithm stalls with nodes remaining, those nodes form a cycle, because every one of them is waiting on another. That two-for-one property is why it is preferred over the DFS-based variant in interviews.

[i] Where the O(V+E) comes from here too

Building the graph costs O(E) to record every dependency once. The queue processes each vertex exactly once when its indegree first hits zero, giving O(V). Processing a vertex means walking its outgoing edges to decrement neighbours' indegree, and summed across every vertex that is exactly one pass over every edge, O(E). Total: O(V) + O(E) to build, plus O(V) + O(E) to process — which collapses to the same O(V + E), because constants disappear under Big-O (22.22). The alien-dictionary variant adds one more O(E) pass to compare adjacent words and derive the edges in the first place, which does not change the overall order.

Topological sort: alien dictionarypython
from collections import deque, defaultdict

def alien_order(words):
    """Recover the letter order of an alien alphabet from sorted words.

    Each adjacent pair of words yields at most ONE constraint: the first
    position where they differ. Everything after that tells you nothing.
    """
    successors = defaultdict(set)
    indegree = {c: 0 for w in words for c in w}

    for first, second in zip(words, words[1:]):
        for x, y in zip(first, second):
            if x != y:
                if y not in successors[x]:
                    successors[x].add(y)
                    indegree[y] += 1
                break
        else:
            # No difference found in the overlap. "abc" before "ab" is
            # impossible in any ordering, so the input is contradictory.
            if len(first) > len(second):
                return ""

    ready = deque(sorted(c for c in indegree if indegree[c] == 0))
    order = []
    while ready:
        c = ready.popleft()
        order.append(c)
        for nxt in sorted(successors[c]):
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                ready.append(nxt)

    # Fewer letters emitted than exist means a cycle swallowed the rest.
    return "".join(order) if len(order) == len(indegree) else ""

[+] The insight the alien dictionary question is actually testing

Each adjacent pair of words gives you exactly one constraint — the first position where they differ — and nothing after it. Candidates who compare every character position generate false constraints and get the wrong order. The break after recording a constraint is the entire insight of the problem; the topological sort around it is routine.

[!] The invalid-input case most candidates miss

If one word is a strict prefix of the previous one — "abc" listed before "ab" — the input is contradictory under any alphabet, because a prefix always sorts first. That is what the for/else handles: the else runs only when the loop finished without breaking, meaning no differing character was found. An interviewer who planted this case is checking whether you validate input or assume it is well formed.

22.15 Recognising a DP problem

The signal: "count the number of ways", "the minimum/maximum cost to", or a recursive brute force that recomputes the same subproblem repeatedly. Also: the word subsequence, which almost never means sliding window and almost always means DP.

DP is the pattern candidates most often fail to start, usually because they try to find the table first. That is backwards. The reliable route is to write the recursion, notice it repeats work, and then cache it. The table is an optimisation of a recursion you have already written, never the starting point.

[→] The five-step derivation, in order

  1. 1. Write the brute-force recursion. Ignore efficiency entirely. What is the decision at each step, and what smaller problem remains after making it? For coin change: pick a coin, then solve for amount - coin.
  2. 2. Identify the state. What are the arguments to that recursive call? Those arguments are the state. If the recursion is f(amount), the state is one-dimensional. If it is f(i, j), it is two-dimensional. This step determines the shape of the table and the complexity.
  3. 3. Check for overlapping subproblems. Does the same state get computed more than once down different branches? If yes, DP helps enormously. If every state is unique, caching gains nothing and the problem is really backtracking (22.13).
  4. 4. Write the recurrence and the base case. The recurrence is step 1 restated over states rather than calls. The base case is the smallest state with an answer you know outright — usually zero or empty.
  5. 5. Choose a direction. Top-down (recursion plus a memo dictionary) is closer to the recursion you already wrote and easier to get right under pressure. Bottom-up (fill a table in order) avoids recursion depth limits and often allows a space optimisation. Either is a fine answer; say why you picked one.

[+] The complexity, before you write any code

Once you know the state, the cost follows immediately: number of states × work per state. Coin change has amount states and tries every coin at each, so it is O(amount × coins). LCS has len(a) × len(b) states with O(1) work each, so it is O(nm). Being able to state the complexity from the state definition, before implementing, is one of the strongest signals available in a DP round.

[i] Walking through the derivation once, concretely

Take coin change (22.16): the recursion is f(amount) = 1 + min(f(amount - c) for c in coins). The state is the single argument, amount, so there are exactly amount + 1 distinct states (0 through amount) — that is where the O(amount) comes from, and it is a count of distinct table entries, nothing more exotic.

The work per state is whatever the recurrence does once memoised: here, a loop over every coin, so O(coins) per state. Multiply the two independent counts and you have the full answer, O(amount × coins), without tracing a single execution path. The same two-step reading applies to every DP problem in this chapter: name the state, count how many values it can take, name the transition, count its cost, multiply.

22.16 One-dimensional DP

One-dimensional DP covers problems where the state is a single index or a single quantity. The classic is Kadane's algorithm, which is worth studying because it is DP compressed to two variables — the table is there, but only the last cell is ever needed.

1-D DP: maximum subarray (Kadane)python
def max_subarray(a):
    """Largest sum of any non-empty contiguous subarray (Kadane)."""
    best = current = a[0]
    for v in a[1:]:
        # Either v starts a fresh subarray, or it extends the previous one.
        current = max(v, current + v)
        best = max(best, current)
    return best

The recurrence hiding in that one line is best_ending_here[i] = max(a[i], best_ending_here[i-1] + a[i]): either the previous run is worth extending, or it is not and you start fresh. Since only the previous value is ever read, the array collapses to a scalar — the space optimisation described in step 5 above, applied.

[!] The all-negative case

Initialising best to 0 rather than a[0] is the standard bug: on [-3,-1,-2] it returns 0, which is only correct if the empty subarray is allowed. The version above returns −1, the best non-empty answer. Which behaviour is wanted is a genuine specification question — ask it, then test it.

Coin change is the other archetype: a state per amount, and a loop over choices at each.

1-D DP: fewest coins to make an amountpython
def coin_change(coins, amount):
    """Fewest coins summing to amount, or -1. Classic 1-D DP over subproblems."""
    INF = float("inf")
    best = [0] + [INF] * amount        # best[x] = fewest coins to make x
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x and best[x - c] + 1 < best[x]:
                best[x] = best[x - c] + 1
    return -1 if best[amount] == INF else best[amount]

[+] Why greedy fails here, and how to say so

Taking the largest coin first is wrong: for coins [1, 3, 4] and amount 6, greedy takes 4 then two 1s for three coins, while the optimum is 3 + 3 for two. Volunteering this counterexample unprompted is a strong move — it proves you considered the simpler approach and rejected it for a reason, which is exactly the "how" that process-oriented interviewers are grading.

22.17 Two-dimensional DP

When the recursion takes two arguments — usually a position in each of two sequences, or a range with two endpoints — the table becomes a grid. The canonical example is longest common subsequence, and the shape of its recurrence recurs across edit distance, string matching, and most sequence-alignment problems.

2-D DP: longest common subsequencepython
def lcs_length(a, b):
    """Length of the longest common subsequence (not substring)."""
    dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i-1] == b[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1      # match: extend the diagonal
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])   # drop one character
    return dp[-1][-1]

Read the recurrence as a decision at the last character of each string. If they match, that character is in the subsequence and you move diagonally, having consumed both. If they do not, one of them cannot be in the answer, so you try dropping each and keep the better outcome. The +1 row and column of zeros encodes the base case — an empty string shares nothing with anything — without special-casing it in the loop.

[≡] Subsequence versus substring, again

LCS allows gaps: "ace" is a subsequence of "abcde". The longest common substring requires contiguity, and its recurrence differs in exactly one place — on a mismatch the cell resets to 0 instead of inheriting the better neighbour, because the run is broken. One word in the problem statement, one line of code, entirely different answer. This is the single highest-value distinction in the DP family.

[+] The space optimisation to mention

Each row of the LCS table depends only on the row above it, so two rows suffice and the space drops from O(nm) to O(min(n, m)). Mentioning this even without implementing it shows you understand the dependency structure rather than having memorised a template. The catch, worth stating: reconstructing the actual subsequence — rather than just its length — needs the full table, so the optimisation only applies when the length is all that is asked for.

22.18 What a strong answer sounds like

Getting the optimal solution is not what separates candidates, because plenty of people get there. What separates them is whether the interviewer could follow the reasoning, and whether the reasoning looked like engineering rather than recall.

This matters more at some companies than others. Google in particular is explicitly process-oriented rather than results-oriented — multiple Google interviewers describe reaching the optimal solution as insufficient on its own, and arriving at a strong approach with clear reasoning but incomplete code as frequently sufficient. The corollary is uncomfortable but actionable: silent correct coding scores worse than narrated partial progress.

The same technical content, delivered two ways
WeakStrongWhat changed
Silence, then twenty lines of correct code. "Let me think about the brute force first so we have a baseline — checking every pair is O(n²). I think I can do better because…" The interviewer can see the reasoning, so partial progress still earns credit.
"I will use a hash map." "The inner loop only exists to search for a matching value, and that is what a hash map removes — I will trade O(n) space for O(1) lookup." Justifies the choice by naming the trade, rather than asserting it.
Writing code and hoping. "Before I code, let me walk through [1,2,3] with target 5 to check my logic." Catches errors while they are cheap, and shows a testing instinct.
"It's O(n)." "Each element enters the window once and leaves at most once, so it is O(n) time despite the nested loop, and O(k) space for the map." Proves the complexity instead of asserting it — and pre-empts the obvious follow-up.
Going quiet when stuck. "I am stuck on how to handle duplicates. My instinct is to sort first so they become adjacent — does that conflict with anything?" Turns a dead end into a collaboration, which is what the interviewer is there for.
"Done." "Let me trace the empty input and a single element… both fine. If the input were a stream instead, I would need a different approach." Self-review plus an unprompted extension, which is senior behaviour.

22.19 A full worked transcript

Below is one problem worked end to end in the voice of a strong candidate. The problem is deliberately one whose optimal solution is not obvious: given an unsorted array, find the length of the longest run of consecutive integers it contains. For [100, 4, 200, 1, 3, 2] the answer is 4, from the run 1–2–3–4.

[1] Clarify and restate

"So I need the longest sequence of consecutive integers, and they do not have to be adjacent in the array — just present. For that example it is 1, 2, 3, 4 so the answer is 4. Can the array be empty? Can it contain duplicates or negatives? How large does it get?" Assume the interviewer says: empty is possible, duplicates and negatives are possible, n can be a million.

[2] Use the constraint to eliminate approaches

"A million means O(n²) is out, so checking every element against every other is not viable. That leaves roughly O(n log n) or O(n)."

[3] State a baseline, then improve on it

"The obvious approach is to sort and then scan for the longest consecutive run, handling duplicates by skipping them. That is O(n log n) and I am confident it is correct — I will keep it as a fallback. But the problem does not actually need a total ordering, only membership tests, and that makes me think O(n) is reachable with a hash set."

[4] Find the key insight, out loud

"If I put everything in a set, I can test membership in O(1). The naive version walks upward from every element, but that is O(n²) in the worst case — on [1,2,3,4,5] I would walk the whole run five times. The fix: only start walking from an element that begins a run. And I can test that in O(1) — x starts a run exactly when x - 1 is not in the set. Every run is then walked exactly once."

[5] Justify the complexity before coding

"That gives O(n) overall. The inner while loop looks like it might make it quadratic, but each value is visited by at most one run walk, because only run-starts trigger a walk — so the total inner work across the whole algorithm is bounded by n. Space is O(n) for the set. Shall I code that?"

The O(n) solution: only walk from the start of a runpython
def longest_consecutive(nums):
    """Length of the longest run of consecutive integers. O(n) time."""
    pool = set(nums)                  # dedupes and gives O(1) membership
    best = 0
    for x in pool:
        if x - 1 in pool:
            continue                  # x is mid-run; its run gets walked from its start
        length = 1                    # x begins a run
        while x + length in pool:
            length += 1
        best = max(best, length)
    return best

[6] Test it by hand, then self-review

"Tracing [100, 4, 200, 1, 3, 2]: for 100, 99 is absent so it starts a run of length 1. For 4, 3 is present so I skip it entirely — that is the optimisation working. For 1, 0 is absent, so I walk 1, 2, 3, 4 and get 4. Empty input returns 0 from the initial value. Duplicates are collapsed by the set, so they cost nothing. Negatives work because I never assume non-negativity."

[+] What that transcript demonstrated

Six things, in order: it clarified before solving, used the input size to eliminate approaches, established a correct fallback before reaching for the clever answer, found the insight by identifying why the naive version was slow rather than by recalling a trick, proved the complexity rather than asserting it, and tested against the specific edge cases raised in step 1. Every one of those is visible to the interviewer even if the code had not compiled.

22.20 Disguised questions and red herrings

Some interviewers deliberately design questions to look like a well-known problem that they are not. This is reported specifically and repeatedly about Google — a question shaped to look like Three Sum, where Three Sum is precisely the wrong approach.

The defence is not to recognise more problems. It is to verify that the problem in front of you actually satisfies the preconditions of the pattern you are about to apply. Every pattern in this chapter has a stated precondition for exactly this reason.

Surface resemblance, and the check that dissolves it
Looks likeMight actually beThe check
Two-sum on a sorted array A hash-map problem, if the required output is original indices Does sorting destroy information the answer needs?
A sliding-window "longest substring" DP, if the word is subsequence rather than substring Must the elements be contiguous? Re-read the exact word.
BFS shortest path Dijkstra, if edges have differing weights Is every move the same cost?
A greedy interval sweep DP, if choices interact rather than compose independently Can I prove the greedy choice is safe, or only assume it?
Binary search over an array Binary search over the answer space Is the array the thing that is monotone, or is feasibility?
A simple frequency count A heap problem, if the input is an unbounded stream Does all the data fit in memory at once?

[!] Expect the follow-up that removes an assumption

A common escalation is to let you solve the problem and then withdraw a premise: "now suppose the array does not fit in memory", "now suppose it arrives as a stream", "now suppose we remove the assumption that values are distinct". These are not punishment for succeeding — they are how the interviewer finds the edge of your knowledge, which is what the round is for. The right response is to name which part of your solution the change breaks, rather than starting over from scratch.

22.21 When you are stuck

Being stuck is normal and is not, by itself, disqualifying. Being stuck silently is, because it gives the interviewer nothing to evaluate and nothing to help with.

[→] An escalating sequence to run when the approach will not come

  1. Say that you are stuck, and on what. "I am trying to avoid recomputing the overlap and not seeing it yet." Precise beats vague, and it invites a targeted hint.
  2. Work a small example by hand. Three or four elements, on paper. The mechanical act of solving one instance frequently exposes the general rule.
  3. Solve a simpler version. Drop a constraint — assume distinct values, or a sorted input, or k = 1. A solution to the easier problem is often one modification away from the real one, and it is worth partial credit regardless.
  4. Write the brute force and optimise from there. Now is when it earns its keep: correct and slow is a real answer, and looking at it usually reveals the wasted work to eliminate.
  5. Ask what the target complexity is. "Should I be aiming for linear here?" A yes tells you a hash map or a single pass is involved; a no tells you to stop chasing one.
  6. Take the hint fully. If the interviewer offers a direction, follow it visibly rather than defending your own. Hints are priced into the evaluation; ignoring them is not.

[+] The single most useful unsticking question

"What work is my current approach repeating?" Almost every optimisation in this chapter is an answer to that question. The window exists because the naive version re-scans overlapping ranges. Memoisation exists because the recursion recomputes states. The run-start check in 22.19 exists because the naive walk re-traverses runs. If you can name the repeated work, you have usually found the improvement.

22.22 What Big-O actually measures

Every pattern in this chapter has come with a complexity attached — O(n), O(log n), O(n log n). This section is the one thing that was assumed rather than explained: what that notation actually means, and why it is built the way it is.

Big-O describes how the cost of an algorithm grows as the input grows, in the limit of large inputs. It is deliberately not a measurement of speed on any one input, and it deliberately throws away information that would make it more precise but less useful. Both of those choices are worth understanding rather than accepting.

[→] The two things Big-O throws away, and why

  1. Constant factors. An algorithm that does 3n operations and one that does 300n are both O(n), even though one is a hundred times slower in practice. The reason this is the right trade-off for classifying algorithms: the constant depends on the language, the hardware, and how tightly the code is written — none of which is a property of the algorithm. Two correct implementations of the same O(n) idea can differ by a constant factor and that is an engineering detail, not an algorithmic one.
  2. Lower-order terms. An algorithm that does n² + 50n + 200 operations is O(n²), because as n grows without bound, the n² term eventually dwarfs the rest — at n = 10,000 the n² term is a hundred million and the 50n term is half a million, already two orders of magnitude smaller. "Eventually dwarfs" is doing real work in that sentence: Big-O is explicitly a statement about what happens as n gets large, not what happens at any particular n. That is also why an O(n²) algorithm can legitimately beat an O(n) one on small inputs — the crossover point where the asymptotic behaviour takes over can be arbitrarily far out.
Growth rates of the common complexity classes A chart of six complexity classes evaluated from n=1 to n=12: O(1) stays flat, O(log n) rises very slowly, O(n) rises as a straight line, O(n log n) curves up modestly faster than linear, O(n squared) curves up sharply, and O(2 to the n) grows so fast it goes off the top of the chart before n reaches 8. Six complexity classes, n = 1 to 12 — same input sizes, wildly different costs n (input size) → operations O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ) ↑ off chart by n=7
Figure 22.1 — The same six algorithms, run on the same input sizes. At n=1 they are indistinguishable. By n=12, O(2ⁿ) has already gone off a chart that O(n²) is only a third of the way up. This is the entire reason Big-O exists: to describe what happens as n grows, not what happens at any one n.

[+] Reading the chart the way it is meant to be read

The point of Figure 22.1 is not the specific numbers; it is that all six curves start together and separate only as n grows. At n = 1 every one of them costs about the same. By n = 12, O(2ⁿ) has already left the chart while O(n²) is only a third of the way up it. This is the entire justification for caring about complexity classes at all: for small n almost anything is fast enough, and the choice of algorithm only starts to matter — a then matters enormously — once n is large. Interview questions are deliberately framed around large n for exactly this reason.

The classes you will actually see, ordered from best to worst
Class Meaning Typical source
O(1) Cost does not depend on n at all. Array index access, a hash map lookup, arithmetic.
O(log n) Cost grows by a fixed amount each time n doubles. Binary search (22.9), heap push/pop (22.10), balanced tree operations.
O(n) Cost grows in direct proportion to n. A single pass: sliding window (22.6), hash map build (22.7), BFS/DFS by vertex count.
O(n log n) A linear pass repeated a logarithmic number of times, or vice versa. Comparison sorting, anything that sorts then scans (22.11).
O(n²) Cost grows with the square of n — every pair of elements considered once. Nested loops over the same input, naive pairwise comparison, bubble sort (22.23).
O(2ⁿ) Cost doubles with every additional element. Exploring every subset (22.13), naive recursion with two branches per call and no memoisation.

[≡] Big-O, Big-Omega, Big-Theta — and why interviews use one

Formally, O(f(n)) is an upper bound (never grows faster than f), Ω(f(n)) is a lower bound (never grows slower than f), and Θ(f(n)) means both at once — the algorithm's growth is exactly f, up to a constant. When people say an algorithm "is O(n)" in an interview they usually mean Θ(n): a tight bound, not merely a ceiling. This chapter uses O(·) throughout because that is the convention interviewers use, but knowing the distinction exists — and being able to state it if asked — is a small, free signal of rigour.

22.23 Combining complexities

Almost no real algorithm is a single loop. The skill this section teaches is reading a block of code — sequences of steps, nested loops, recursive calls — and deriving its complexity mechanically, rather than guessing.

[→] Three rules that cover almost everything

  1. Sequential steps: add, then take the largest. Code that does an O(n) pass followed by a separate O(n²) pass costs O(n + n²), which simplifies to O(n²) — the lower-order term drops out exactly as in 20.22. Building a hash map in O(n) and then doing an O(n log n) sort on it is O(n log n) overall, not O(n + n log n) written out in full.
  2. Nested loops: multiply. A loop of n iterations containing a loop of m iterations does n × m work, regardless of whether the inner loop's bound depends on the outer variable — section 22.24 has a worked example where this surprises people. Three nested loops over the same n multiply to O(n³), and so on.
  3. Recursion: count the calls, then multiply by the work per call. A recursive function that makes one call on a problem of size n-1 and does O(1) work per call costs O(n) total — n calls, O(1) each. A function that makes two calls each on a problem of size n-1 makes a call tree that doubles at every level, giving O(2ⁿ) total calls — the mechanism behind naive recursive Fibonacci (22.24).

Bubble sort is the clearest possible illustration of the nested-loop rule, because the quadratic cost is visible directly in the shape of the work rather than needing to be taken on faith.

Bubble sort tracing three passes over a five-element array An array of five numbers, 5 2 8 1 4, shown before sorting, after one pass, and after two passes. Each pass compares adjacent elements and swaps them if out of order, shown as red arcs above the swapped pair. After each pass one more element on the right is locked into its final sorted position, shaded green. The number of comparisons drops by one each pass: 4, then 3, then 2, then 1, summing to n times n minus 1 over 2. Bubble sort on [5, 2, 8, 1, 4] — why every pass costs one less comparison Start 5 2 8 1 4 After pass 1 2 5 1 4 8 1 locked in place After pass 2 2 1 4 5 8 2 locked in place Pass 1: 4 comparisons. Pass 2: 3 comparisons. Pass 3: 2. Pass 4: 1. Total = 4+3+2+1 = n(n−1)/2 → O(n²) comparisons, however sorted the input already is.
Figure 22.2 — Every pass of bubble sort re-scans a range that shrinks by exactly one, because the previous pass proved the rightmost untouched element is now in its final place. That shrinking triangle of work — not any single pass — is where the n² comes from: 4+3+2+1 is a triangular number, and triangular numbers are always quadratic in n.

[i] From the diagram to the formula

Bubble sort's outer loop runs a pass for each of the n elements; its inner loop compares adjacent pairs across whatever range has not yet been locked in place. Pass 1 scans n−1 pairs, pass 2 scans n−2 (the largest element is already in position and excluded), and so on down to a single comparison on the final pass. The total is

(n−1) + (n−2) + … + 1 = n(n−1)/2

which is a triangular number. Multiplying out n(n−1)/2 = (n² − n)/2 shows the n² term explicitly — and by the rule in 22.22, the lower-order −n term and the constant factor of one half both vanish under Big-O, leaving O(n²). This is not a coincidence specific to bubble sort: any algorithm whose work forms a shrinking arithmetic sequence like this is quadratic, which is why selection sort and insertion sort are also O(n²) despite looking different on the page.

[!] Best case, worst case, and why bubble sort's O(n²) is stated as worst case

A version of bubble sort that stops early when a full pass makes no swaps is O(n) on already-sorted input and O(n²) on reverse-sorted input — the worst case. When a complexity is quoted without qualification in an interview, it is almost always the worst case, because that is the guarantee that holds regardless of what input arrives. Naming which case you mean ("O(n²) worst case, O(n) best case if I add an early exit") is a small addition that reads as precision rather than pedantry.

[≡] Amortised complexity, briefly

Some operations are expensive occasionally and cheap the rest of the time, and amortised analysis averages the cost over a long sequence of operations rather than quoting the worst single one. Appending to a dynamic array (Python's list) is O(1) amortised: most appends are O(1), but occasionally the underlying array is full and must be reallocated and copied, an O(n) event. Because that reallocation doubles the capacity each time, the expensive events become exponentially rarer, and spread across n appends their total cost is O(n) — O(1) per append on average. The same idea justifies calling a hash map's O(1) lookup "amortised": occasional resizes are folded into the average.

22.24 Tricky worked examples: time complexity

The rules in 22.23 are simple to state and easy to misapply. Every example below is a case where the obvious first guess is wrong, verified by actually counting the operations rather than trusting intuition.

[!] Example 1: a nested loop that looks smaller than it is

The inner loop here starts at i instead of 0, which looks like it should save work compared to a full n×n nest. It does — by a constant factor of about one half — but the complexity class does not change.

A triangular nested loop is still O(n²)python
def count_pairs_triangular(n):
    # Looks smaller than a full n x n nest because j starts at i, not 0.
    # It is still O(n^2): the comparisons made form a triangular number.
    comparisons = 0
    for i in range(n):
        for j in range(i, n):
            comparisons += 1
    return comparisons

The comparison counts form exactly the triangular sequence from 22.23: n + (n-1) + … + 1 = n(n+1)/2. Halving a quadratic quantity is still quadratic — confirmed above by checking that doubling n quadruples the count, the defining test for O(n²). The lesson: a nested loop is O(n²) whenever the inner loop's range scales with n, regardless of where that range starts.

[!] Example 2: naive recursion versus memoisation

Naive recursive Fibonacci makes two recursive calls per level with no memory of what it has already computed, so the same sub-problems get solved repeatedly — an exponential number of times, following the recursion-tree rule from 20.23. Memoisation changes nothing about the code's shape and everything about its cost, because it turns "solve it again" into "look it up".

Counting calls: naive vs. memoised Fibonaccipython
def naive_fib_calls(n, calls=None):
    # Returns the TOTAL number of function calls made, not the Fibonacci value,
    # so the exponential blow-up is directly countable rather than inferred.
    if calls is None:
        calls = [0]
    calls[0] += 1
    if n <= 1:
        return calls[0]
    naive_fib_calls(n - 1, calls)
    naive_fib_calls(n - 2, calls)
    return calls[0]

def memo_fib_calls(n, memo=None, calls=None):
    if memo is None:
        memo, calls = {}, [0]
    calls[0] += 1
    if n in memo:
        return calls[0]
    if n <= 1:
        memo[n] = n
        return calls[0]
    memo_fib_calls(n - 1, memo, calls)
    memo_fib_calls(n - 2, memo, calls)
    memo[n] = memo[n - 1] + memo[n - 2]
    return calls[0]

[i] Why the blow-up is exponential, precisely

Each call to naive_fib_calls(n) that is not a base case makes two further calls, one on n−1 and one on n−2. That call tree roughly doubles in width at every level down to depth n, so the total node count grows like 2ⁿ (more precisely, it follows the Fibonacci sequence itself, which grows at rate φⁿ ≈ 1.618ⁿ — still exponential, just with a smaller base than 2). The check above confirms this empirically: going from n=10 to n=20 multiplies the call count by more than 100×, far beyond anything a polynomial function could produce. Memoisation caches each of the n distinct sub-problems the first time it is seen, so every subsequent request for that value is O(1) — collapsing the exponential tree down to O(n) total calls.

[!] Example 3: string concatenation inside a loop

Building a string with s = s + piece inside a loop looks like n operations, and it is n lines executed — but each one is not O(1). Python strings are immutable, so every + allocates a brand new string and copies the entire existing content into it.

String += in a loop vs. building a list and joiningpython
def concat_cost(n):
    # Every += on a Python str creates a brand-new string and copies the old
    # content into it, because strings are immutable. Track total characters
    # copied rather than wall-clock time, for a deterministic assertion.
    s = ""
    copied = 0
    for i in range(n):
        piece = "x"
        copied += len(s) + len(piece)   # cost of building the new string
        s = s + piece
    return copied

def join_cost(n):
    # str.join builds the result once, from a list, in a single pass.
    parts = []
    copied = 0
    for i in range(n):
        parts.append("x")
        copied += 1                     # appending to a list is O(1) amortised
    copied += sum(len(p) for p in parts)  # the one final join pass
    return copied

Copying grows with the string built so far, so the total work across the loop is 1 + 2 + … + n — the same triangular shape as bubble sort and Example 1, giving O(n²). Building a list with append (O(1) amortised per call, 22.23) and joining once at the end does the copying exactly once, in a single O(n) pass. The check above confirms it directly: doubling n roughly quadruples the concatenation cost but only doubles the append-and-join cost.

[+] Example 4: a loop that is not O(n)

Not every trap makes things worse than they look — some loops are faster than the syntax suggests. A while loop is not automatically O(n) just because it is a loop; what matters is how many times it actually executes.

A halving loop is O(log n) with no recursion in sightpython
def halving_steps(n):
    # A loop, not a recursive call -- and still O(log n), because what matters
    # for complexity is how the WORK shrinks, not whether it is spelled with
    # a loop or a recursion.
    steps = 0
    while n > 1:
        n //= 2
        steps += 1
    return steps

This is a loop, not a recursive call, and yet it is O(log n): the check confirms n = 1,000,000 takes only 19 iterations, and that doubling n from 1024 to 2048 adds exactly one more step. Complexity is a property of how the work shrinks, never of which syntax (loop or recursion) was used to express it — a fact worth stating explicitly if an interviewer tries to anchor you to "loops are O(n)".

22.25 Tricky worked examples: space complexity

Space complexity is asked about less often than time, which makes it easier to get wrong in the moment. The most common trap is conflating the two: assuming a slow algorithm must also use a lot of memory, or a fast one must use little.

[!] Example 1: exponential time does not mean exponential space

Naive recursive Fibonacci costs O(2ⁿ) time (22.24), and it is tempting to assume the space cost matches. It does not. Space is bounded by the call stack depth at any single instant, not by the total number of calls made over the algorithm's lifetime.

Fibonacci: exponential time, but only linear spacepython
def naive_fib_depth(n):
    # O(2^n) TIME (branches out), but the call STACK at any instant only
    # holds one path from root to leaf -- so space is O(n), not O(2^n).
    def go(n, depth, max_depth):
        max_depth[0] = max(max_depth[0], depth)
        if n <= 1:
            return
        go(n - 1, depth + 1, max_depth)
        go(n - 2, depth + 1, max_depth)
    max_depth = [0]
    go(n, 0, max_depth)
    return max_depth[0]

def iterative_fib(n):
    # O(n) time, O(1) space: only ever holds the last two values.
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

[i] Why the stack stays shallow while the call count explodes

The call tree branches into two at every node, but a program executes recursive calls one at a time: the stack only ever holds the single path from the root call down to whichever leaf is currently executing, and old frames are popped before their sibling branch is even started. That path has length proportional to n, so the space is O(n) — even though the tree it is traversing has O(2ⁿ) nodes in total. Time counts every node ever visited; space counts only the nodes currently on the path. The iterative version needs even less: O(1), since it only ever keeps the previous two values and never recurses at all.

[!] Example 2: where merge sort's space actually goes

Merge sort is O(log n) deep as a recursive call tree, which is easy to mistake for its space complexity. The recursion stack genuinely is O(log n) — but the merge step at every level allocates new arrays to hold the merged result, and that is where the real cost lives.

Merge sort's O(n) auxiliary space, counted directlypython
def merge_sort_counting_space(a):
    # Counts the total auxiliary array cells allocated across the whole sort,
    # to make the O(n) auxiliary space claim concrete rather than asserted.
    allocated = [0]

    def sort(a):
        if len(a) <= 1:
            return a
        mid = len(a) // 2
        left = sort(a[:mid])
        right = sort(a[mid:])
        allocated[0] += len(left) + len(right)   # the merge buffer for this call
        merged = []
        i = j = 0
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:
                merged.append(left[i]); i += 1
            else:
                merged.append(right[j]); j += 1
        merged.extend(left[i:])
        merged.extend(right[j:])
        return merged

    result = sort(a)
    return result, allocated[0]

Summed across one full level of the recursion, the merge buffers at that level together hold all n elements exactly once; there are O(log n) levels, but each level's buffers are released before the next level needs its own, so the peak memory in use at any instant is O(n), not O(n log n). The O(log n) recursion stack is real but small by comparison — it is the merge buffers, not the recursion itself, that define this algorithm's O(n) auxiliary space. This is also the standard argument for preferring quicksort when memory is tight: a well-implemented in-place partition needs no separate merge buffer at all, at the cost of losing the stability and worst-case guarantees that merge sort provides.

[!] Example 3: output space is not optional, auxiliary space is what you control

Asked for the space complexity of "generate all subsets" (22.13), the honest answer has two parts, and interviewers listen for whether you separate them.

Output space vs. auxiliary space: generating all subsetspython
def subsets_space_accounting(nums):
    # Distinguishes OUTPUT space (the unavoidable answer itself) from
    # AUXILIARY space (the extra bookkeeping the algorithm needs beyond
    # producing that answer).
    out, path = [], []
    max_path_len = [0]

    def explore(start):
        max_path_len[0] = max(max_path_len[0], len(path))
        out.append(path[:])
        for i in range(start, len(nums)):
            path.append(nums[i])
            explore(i + 1)
            path.pop()

    explore(0)
    output_cells = sum(len(s) for s in out)   # every element, in every subset
    auxiliary_cells = max_path_len[0]          # the deepest the path list ever got
    return out, output_cells, auxiliary_cells

[+] Two numbers, not one

There are 2ⁿ subsets, and on average each holds about n/2 elements, so the output itself needs O(n · 2ⁿ) cells — confirmed above exactly: n × 2ⁿ⁻¹. That cost is unavoidable; any correct solution must produce that much output. The auxiliary space — what the algorithm needs beyond the answer itself — is only O(n), the depth of the path list during the deepest recursive call, confirmed never to exceed n. Quoting only "O(n · 2ⁿ)" without this distinction is not wrong, but quoting both numbers and naming which is which reads as a candidate who has actually thought about where the memory goes, rather than one who memorised a formula.

22.26 Key takeaways

  1. Prepare for patterns, not problems. Companies retire leaked questions, so the specific problems circulating publicly are the ones you will not be asked. The eleven structural patterns survive that churn.
  2. Ask the input size before anything else. It eliminates most of the solution space in one question: a million rules out O(n²), and twenty invites exponential search.
  3. Use complexity as a search hint, not a postscript. A target of O(n log n) with no obvious ordering means something is being sorted or binary searched.
  4. "Contiguous" means window; "subsequence" means DP. One word changes the entire approach, and interviewers choose it deliberately.
  5. Two pointers works because sortedness makes the move unambiguous — if the sum is too small, only the left pointer can help. That is the whole proof.
  6. A sliding window is O(n) because the start never moves backwards, so each element enters once and leaves at most once despite the nested-looking loops.
  7. Check that a repeat is inside the window before shrinking. Omitting that guard is the single most common window bug; test it with "abba".
  8. A hash map removes any inner loop that exists only to search. Reframe "is there a pair" into "have I seen the complement", which is answerable in O(1).
  9. Interval problems are decided by what you sort by: start time to merge, end time for greedy selection, and starts and ends independently for maximum concurrency.
  10. "Minimise the maximum" means binary search on the answer, and it is valid precisely because feasibility is monotone. Say the word monotone.
  11. Keep the k largest in a min-heap, not a max-heap. You need cheap access to the weakest survivor, since that is what gets evicted.
  12. Multi-source BFS seeds every source at once, turning "nearest of many targets" from one BFS per target into a single linear pass.
  13. Mark BFS nodes visited when you enqueue them, not when you dequeue, or the same node gets queued repeatedly.
  14. Backtracking is always choose, recurse, un-choose — and always append a copy of the path, never the live list.
  15. Kahn's algorithm gives cycle detection for free: if it stalls with nodes remaining, those nodes form a cycle.
  16. Derive DP from a recursion, never from a table. Write the brute force, identify the arguments as the state, check for overlap, then cache.
  17. DP complexity is states times work per state, and you can state it before writing a line of code.
  18. Volunteer the counterexample that kills the simpler approach. Greedy coin change fails on [1,3,4] for 6 — saying so proves you rejected it for a reason.
  19. Narrated partial progress beats silent correct code, especially at process-oriented companies where the reasoning is the artifact being graded.
  20. Verify the pattern's precondition before applying it. Questions are sometimes designed to look like a well-known problem that they are not.
  21. When stuck, ask what work is being repeated. Nearly every optimisation in this chapter is an answer to that one question.

[i] Vocabulary check

You should be able to explain: amortised versus worst-case complexity, the difference between subarray, substring, subsequence and subset, two pointers, sliding window invariant, hash map complement lookup, sweep line, monotone predicate, binary search on the answer, min-heap versus max-heap for top-k, multi-source BFS, DFS versus BFS guarantees, backtracking state restoration, topological order, indegree, cycle detection via Kahn's algorithm, overlapping subproblems, optimal substructure, memoisation versus tabulation, and state-space complexity.

22.27 Interview drills

These test pattern recognition and the reasoning around it, which is what actually fails in interviews — implementation is rarely the bottleneck. Answer out loud before expanding.

1. You are given an unsorted array and asked for two numbers summing to a target, returning their original indices. Why is sorting the wrong instinct?

Because sorting destroys exactly the information the answer requires. The question asks for original indices, and sorting permutes them. I could sort pairs of (value, original index) to preserve them, but at that point I have paid O(n log n) to enable a two-pointer scan that is not faster than the alternative.

The better approach is a hash map in one pass: for each value, check whether target - value has already been seen. That is O(n) time and O(n) space, and it preserves indices naturally because I store them as I go. The general rule is that sorting is free when nothing depends on the original order, and expensive when something does.

2. Explain why the sliding window is O(n) even though it contains a nested loop.

Because the complexity is not determined by loop nesting but by total work. The inner movement only ever advances the left edge forward, and the left edge never moves backwards across the whole run. So each index is added to the window exactly once and removed at most once.

That gives at most 2n pointer movements in total, which is O(n). The nesting is misleading because the inner loop's total iterations are bounded across the entire outer loop, not per iteration of it — this is the same amortised argument that makes a dynamic array's append O(1) despite occasional resizes.

3. "Split an array into k contiguous parts minimising the largest part sum." How do you recognise the approach, and why is it valid?

The phrase "minimise the maximum" is the signal for binary search on the answer. The realisation is that I am not searching the array, I am searching the space of possible answers — candidate values for the largest part sum.

It is valid because feasibility is monotone: if a cap of 18 can be achieved within k parts, then any larger cap can too, since a looser constraint never requires more parts. So feasibility flips exactly once as the cap increases, and binary search finds that boundary. The bounds are max(nums) below, since every single element must fit somewhere, and sum(nums) above, which trivially works with one part. The feasibility check itself is a simple greedy fill, which is much easier to write correctly than constructing the optimal split directly — that asymmetry is the real signal.

4. To keep the k largest elements of a stream, which heap do you use, and why is the other one wrong?

A min-heap of size k. It feels backwards, which is why it is asked. The reason is that the operation I perform constantly is eviction: when a new element arrives, I need to compare it against the weakest element currently kept, and drop that one if the newcomer beats it.

A min-heap puts exactly that weakest survivor at the root, in O(1). A max-heap would give me instant access to the largest element, which I never need to remove, and finding the minimum would cost O(k). The complexity argument is the point of the question: maintaining a size-k heap is O(n log k), which beats sorting's O(n log n) substantially when k is small relative to n, and it also works on a stream that does not fit in memory, which sorting cannot.

5. When is BFS the right choice over DFS, and when is BFS not enough?

BFS when I need the shortest path or the fewest steps, because it visits nodes in non-decreasing order of distance, so the first arrival at a node is via a shortest path. DFS when I need to explore all paths, enumerate combinations, or find connected components, where the order of discovery does not matter.

BFS is not enough as soon as edges have different weights — its guarantee depends on every edge costing the same, so with varying weights I need Dijkstra, or Bellman-Ford if there are negative edges. I would also mention that DFS on a very large graph risks exceeding the recursion limit, so I would write it iteratively with an explicit stack if the input size warranted it.

6. Given a list of sorted words in an unknown alphabet, how do you recover the letter order?

This is a topological sort. Each adjacent pair of words yields at most one ordering constraint: the first position at which they differ. That is the key insight — everything after the first difference tells me nothing, so I must break immediately after recording the constraint. Comparing all positions generates false edges and a wrong answer.

Then I run Kahn's algorithm: repeatedly emit letters with indegree zero, decrementing their successors. If I emit fewer letters than exist, there is a cycle and the input is contradictory, so I return empty. I would also check the invalid-prefix case: "abc" appearing before "ab" is impossible under any alphabet, because a prefix always sorts first. That case is usually planted deliberately.

7. Walk through how you would derive a DP solution to a problem you have never seen.

I never start from a table. First I write the brute-force recursion: what is the decision at each step, and what smaller problem is left afterwards. Then I look at the arguments of that recursive call, because those arguments are the state. Then I check whether the same state recurs down different branches — if it does not, this is backtracking and caching buys nothing.

Once I have the state I can state the complexity immediately as states times work per state, before writing any code, which is a useful thing to say out loud. Then I write the recurrence and the base case, and pick a direction: top-down memoisation stays closest to the recursion I already have and is easier to get right under pressure, while bottom-up avoids recursion limits and often permits a space optimisation, since most tables only depend on the previous row.

8. Why does greedy fail for coin change, and how would you demonstrate it?

Because taking the largest coin first can strand you with a remainder that needs many small coins, while a slightly worse first choice divides evenly. The counterexample I would give immediately is coins [1, 3, 4] and amount 6: greedy takes 4, then needs two 1s, for three coins total. The optimum is 3 + 3, which is two.

Greedy does work for certain coin systems, including standard currency, which is why the intuition is so tempting — but that is a property of those specific denominations, not of the problem. Since the problem gives arbitrary coins, I need DP. Volunteering the counterexample unprompted is worth doing: it proves I considered the simpler approach and rejected it for a concrete reason rather than not thinking of it.

9. What is the difference between the longest common subsequence and the longest common substring, in code?

One line, in the mismatch branch. For subsequence, a mismatch inherits the better of the two neighbours, max(dp[i-1][j], dp[i][j-1]), because I am allowed to skip a character and continue. For substring, a mismatch resets the cell to 0, because contiguity is broken and the run must restart.

There is a second difference that follows from it: for subsequence the answer is in the last cell, since the table accumulates monotonically, whereas for substring the answer is the maximum cell anywhere in the table, because the best run may end in the middle. Both are O(nm) time. This is the clearest example of why I read the problem statement for the exact word rather than the general shape.

10. Find the length of the longest run of consecutive integers in an unsorted array, in O(n).

Put everything in a hash set for O(1) membership and deduplication. The naive idea is to walk upward from every element, but that re-traverses the same run repeatedly and is O(n²) in the worst case.

The fix is to walk only from elements that start a run, which I can test in O(1): x starts a run exactly when x - 1 is absent from the set. Every run is then walked exactly once, so the total inner work across the whole algorithm is bounded by n, making it O(n) time and O(n) space. The sorting solution is O(n log n) and I would mention it as a correct fallback, but the hash-set version is better because the problem only needs membership tests, not a total ordering.

11. Your interviewer says "now assume the input does not fit in memory." How do you respond?

First I name which part of my solution the change breaks, rather than starting over. If I built a hash map over all n elements, that is the part that fails; if I sorted in place, that fails too. Being specific shows I understand my own solution's dependencies.

Then the standard adaptations: for top-k, a bounded heap of size k works on a stream and needs only O(k) memory. For sorting, external merge sort processes chunks that fit and merges them. For deduplication or membership at scale, a Bloom filter trades a controlled false-positive rate for constant memory. For a uniform random sample, reservoir sampling works in one pass. I would also ask whether a single pass is required or whether I can re-read the data, because that changes which of these is available.

12. You are twelve minutes in with no working approach. What do you do?

Say so explicitly and precisely — "I am stuck on avoiding the repeated overlap computation" — because silence gives the interviewer nothing to evaluate and nothing to help with, and a precise statement invites a targeted hint.

Then I escalate deliberately: work a tiny example by hand, since solving one instance often exposes the general rule; solve a simplified version with a constraint dropped, which is worth partial credit and is often one step from the real solution; or write the brute force, which is a real answer and usually reveals the wasted work to eliminate. I would also just ask whether I should be aiming for linear time, since that immediately tells me whether to keep looking for a hash-map trick. And if a hint comes, I follow it visibly rather than defending my own direction — hints are priced into the evaluation, but ignoring them is not.

Where this leaves you

Eleven patterns, each with the signal that identifies it, the invariant that makes it correct, and working code. That covers the coding rounds, which at Google are weighted more heavily than system design — the only FAANG company where that is true.

The next chapter covers the other kind of coding question that appears in these loops: probability and simulation problems, where the answer is a number you estimate rather than an algorithm you implement.