Algorithmic Coding Interview Guide for ML Engineers
Ziyi Zhu / July 08, 2026
12 min read • ––– views
Many ML interview loops include an algorithmic round that looks more like LeetCode or HackerRank than a modelling exercise: you solve one or two problems live in a shared editor, not train a classifier or run a notebook. Because ML scientists ship production code, these rounds filter for engineering quality — how clearly you communicate while solving, how you reason through ambiguity, your grasp of core data structures and algorithms, whether you write correct and readable code under time pressure, and whether you can improve an initial approach thoughtfully. The bar is a solid engineering floor plus composure and clarity, and strong candidates get there by clarifying before coding, proposing a baseline before optimizing, and testing against edge cases as they go. This guide walks through that process, then the core data structures, a pattern playbook with copy-paste-quality Python templates, and how to talk about complexity and write bug-resistant code.
Run every problem through this loop
Communication is scored as heavily as the code — coding without a stated plan gives weak signal even when it works.
- Clarify (60–90s). Restate the problem in your own words. Ask about input size and ranges, empty or single-element inputs, duplicates, negatives, sorted vs unsorted, whether you can mutate the input, and what to return when there's no answer.
- Trace a small example by hand to confirm you and the interviewer agree on the expected output.
- State a brute-force baseline and its complexity, even if it's slow. It proves you can always produce something correct and frames the improvement.
- Name the pattern for your improved approach ("this is a sliding window because…") and get a nod before typing.
- Code it while narrating the non-obvious lines. Use real names, small helpers, and early returns.
- Test the happy path and one edge case out loud. Fixing a bug visibly is a strong signal.
- Give time and space complexity and one trade-off you'd make if the input were huge, streamed, or memory-bound.
Python quick reference
Know these cold so your attention goes to the algorithm, not the syntax.
from collections import defaultdict, Counter, deque
from heapq import heappush, heappop, nlargest, nsmallest
import bisect
from functools import lru_cache
d = defaultdict(list); d[k].append(v) # auto-creates [] on missing key
cnt = Counter(iterable) # {item: freq}; cnt.most_common(k)
q = deque(); q.append(x); q.popleft() # O(1) at both ends (queue or stack)
seen = set() # O(1) membership
h = [] # heapq is a MIN-heap
heappush(h, (priority, item)); heappop(h) # push -value for a max-heap
i = bisect.bisect_left(arr, x) # leftmost insertion point in sorted arr
arr.sort(key=lambda x: (x[1], -x[0])) # 2nd asc, then 1st desc
@lru_cache(maxsize=None) # memoize recursion
def f(i, j): ...
matrix = [[0]*cols for _ in range(rows)] # NEVER [[0]*cols]*rows (aliased rows!)
Traps to watch: [[0]*c]*r aliases every row; mutable default arguments persist across calls; // is integer division; dict/set lookups are average O(1) but worst-case O(n); Python's recursion limit is ~1000, so use an explicit stack for deep recursion.
Data structures
Arrays / strings — O(1) index, O(n) middle insert. Strings are immutable, so build with a list and "".join(...) rather than repeated += (which is O(n²)).
Hash maps & sets — average O(1) operations; the default answer to "seen it," "how many," or "does a complement exist." Counter for frequencies, defaultdict to skip existence checks.
Stacks & queues — a deque serves both. Stacks suit matching pairs, monotonic "next greater element," and iterative DFS; queues drive BFS and level-order traversal.
Heaps — heapq is a binary min-heap (O(log n) push/pop, O(1) peek). Use for top-k, k-way merge, streaming medians, and Dijkstra; push -value for a max-heap.
Linked lists — O(1) insert/delete at a known node. Patterns: dummy head, fast/slow pointers (cycle detection, find middle), in-place reversal.
Trees — traverse depth-first (pre/in/post-order) or breadth-first (level-order); a BST's in-order traversal is sorted. Height is O(log n) balanced, O(n) degenerate. Tries (prefix trees) power autocomplete and prefix queries.
Graphs — adjacency list for sparse, matrix for dense. BFS for unweighted shortest paths, DFS for connectivity and topological sort, Dijkstra for non-negative weights, Union-Find for dynamic connectivity. Always track visited.
Union-Find (DSU) — near-O(1) amortized; use for connected components, undirected cycle detection, and Kruskal's MST.
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path compression
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected -> cycle
if self.rank[ra] < self.rank[rb]:
ra, rb = rb, ra
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1
return True
Pattern playbook
A dozen patterns cover most interview problems. Each entry: when to use it, its cost, and a template.
Two pointers
Sorted array, pair/triplet toward a target, comparing from both ends, in-place partition, or removing duplicates. O(n) time, O(1) space.
def two_sum_sorted(arr, target):
lo, hi = 0, len(arr) - 1
while lo < hi:
s = arr[lo] + arr[hi]
if s == target: return [lo, hi]
elif s < target: lo += 1
else: hi -= 1
return []
Sliding window
Longest/shortest contiguous window under a constraint. O(n): expand right, shrink left while invalid.
def longest_substring_no_repeat(s):
seen = {} # char -> last index
left = best = 0
for right, c in enumerate(s):
if c in seen and seen[c] >= left:
left = seen[c] + 1 # jump past the duplicate
seen[c] = right
best = max(best, right - left + 1)
return best
For a variable-size window, grow, then shrink with a while until valid; for a fixed-size window, add the new element and remove the one that fell out.
Hash map / frequency counting
Complement lookup, dedup, group-by, anagrams. O(n) time and space.
def two_sum(nums, target):
seen = {} # value -> index
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
return []
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
groups[tuple(sorted(w))].append(w) # or a 26-count tuple for O(n)
return list(groups.values())
Binary search
Sorted data, or "search the answer space" for the smallest value satisfying a monotonic predicate. O(log n) — mind the bounds.
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1 # inclusive bounds
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target: return mid
elif arr[mid] < target: lo = mid + 1
else: hi = mid - 1
return -1
def min_feasible(lo, hi, feasible):
# smallest x with feasible(x) True (predicate monotonic)
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid): hi = mid
else: lo = mid + 1
return lo
Binary-searching the answer (minimum capacity, minimum eating speed) is a senior-level move worth naming whenever a min/max value can be validated by a boolean check.
BFS
Shortest path in an unweighted graph or grid, and level-order traversal. O(V + E).
def bfs_shortest(start, target, neighbors):
q = deque([(start, 0)])
seen = {start}
while q:
node, dist = q.popleft()
if node == target:
return dist
for nxt in neighbors(node):
if nxt not in seen:
seen.add(nxt) # mark on ENQUEUE, not dequeue
q.append((nxt, dist + 1))
return -1
DFS
Connectivity, path existence, tree traversal, topological order. O(V + E).
def num_islands(grid):
rows, cols = len(grid), len(grid[0])
count = 0
def dfs(r, c):
if r < 0 or c < 0 or r >= rows or c >= cols or grid[r][c] != '1':
return
grid[r][c] = '0' # mark visited in place
dfs(r+1, c); dfs(r-1, c); dfs(r, c+1); dfs(r, c-1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return count
Backtracking
Enumerate subsets/permutations/combinations, or constraint satisfaction. Exponential — prune hard. Template: choose → recurse → un-choose.
def subsets(nums):
res = []
def backtrack(start, path):
res.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1, path) # explore
path.pop() # un-choose
backtrack(0, [])
return res
Heap / top-k
"k largest/smallest," streaming top-k, merge k sorted lists. O(n log k) with a size-k heap.
def k_largest(nums, k):
h = [] # min-heap of size k
for x in nums:
heappush(h, x)
if len(h) > k:
heappop(h) # evict smallest -> h holds k largest
return sorted(h, reverse=True)
For a streaming median, keep a max-heap for the lower half and a min-heap for the upper half, rebalanced so sizes differ by at most one.
Intervals
Merge/insert/overlap, meeting rooms, scheduling. Sort by start (or by end for greedy max-non-overlapping); O(n log n).
def merge_intervals(intervals):
intervals.sort(key=lambda iv: iv[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]: # overlap
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
Two intervals overlap when a.start <= b.end and b.start <= a.end. "Minimum meeting rooms" is a sweep over sorted start and end times, or a min-heap of end times.
Dynamic programming
Overlapping subproblems plus optimal substructure — counting ways, min/max cost, reachability. Define the state, recurrence, base cases, and answer cell; start top-down with @lru_cache, convert to bottom-up if asked.
def coin_change(coins, amount):
dp = [0] + [float('inf')] * amount # dp[a] = min coins for amount a
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
Common 1-D cases: climbing stairs, house robber, coin change, longest increasing subsequence (O(n log n) with bisect). Common 2-D: edit distance, longest common subsequence, unique paths, 0/1 knapsack. State the recurrence aloud before coding — that's the whole battle.
Monotonic stack
"Next greater/smaller element," histogram area, daily temperatures. O(n) — each element is pushed and popped once.
def daily_temperatures(temps):
res = [0] * len(temps)
stack = [] # indices, temps decreasing
for i, t in enumerate(temps):
while stack and temps[stack[-1]] < t:
j = stack.pop()
res[j] = i - j
stack.append(i)
return res
Prefix sums
Many range-sum queries or sub-array sums equal to k. O(n) build, O(1) per query; pair with a hash map to count sub-arrays summing to k.
def subarray_sum_equals_k(nums, k):
count = prefix = 0
seen = defaultdict(int); seen[0] = 1
for x in nums:
prefix += x
count += seen[prefix - k] # a prior prefix closes a window summing to k
seen[prefix] += 1
return count
Pattern-recognition cheat sheet
| Clue in the problem | Reach for |
|---|---|
| Sorted array, pair/triplet toward a target | Two pointers |
| Longest/shortest contiguous window under a constraint | Sliding window |
| "Seen it," complement, group-by, anagram | Hash map / Counter |
| Sorted data, or a min/max value that satisfies a check | Binary search (incl. on the answer) |
| Shortest path in unweighted graph/grid, level order | BFS |
| Connectivity, all paths, cycle, topological order | DFS |
| All subsets/permutations/combinations | Backtracking |
| "k largest/smallest," streaming top-k, merge k lists | Heap |
| Merge/insert/overlap, meeting rooms, scheduling | Intervals (sort first) |
| Count ways / min cost / reachability | Dynamic programming |
| "Next greater/smaller element," histogram | Monotonic stack |
| Many range-sum queries, sub-array sum = k | Prefix sums |
| Connected components, dynamic connectivity | Union-Find |
| Linked-list cycle, find the middle | Fast/slow pointers |
Talking about complexity
Give both time and space, and note best/average/worst when they diverge (quicksort averages O(n log n), worst O(n²); hash lookups average O(1), worst O(n)). The ladder, best to worst: O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!).
Rules of thumb: a single pass is O(n); nested loops over the input tend toward O(n²); halving the search space each step is O(log n); any comparison sort is Ω(n log n); recursion cost ≈ (nodes in the recursion tree) × (work per node), which the Master Theorem formalizes for divide-and-conquer, and it also costs O(depth) in stack space.
Use the stated constraints to reverse-engineer the target complexity (assuming ~10⁸ ops/sec): n ≤ 20 invites O(2ⁿ)/O(n!); n up to a few thousand tolerates O(n²); n at 10⁶–10⁷ demands O(n) or O(n log n). For space, count auxiliary structures and the recursion stack — "O(1) extra" means in-place. Naming a trade-off ("O(1) space if I sort in place, at O(n log n) time") reads as senior.
Writing bug-resistant code
Correctness before speed. The habits that prevent most live bugs:
Handle edge cases first with early returns — empty input, single element, all-equal or all-negative values, absent target, k larger than the array, disconnected graphs, cycles.
Guard boundaries. In binary search, pin the invariant (inclusive vs exclusive) and be deliberate about lo <= hi vs lo < hi and mid ± 1; in sliding windows, be explicit about when left moves. Off-by-ones are the most common failure.
Favor clarity: descriptive names, small helpers, a dummy head for linked lists, and marking BFS nodes visited on enqueue to avoid duplicates. When a bug appears, state what you observed, hypothesize, and fix — debugging in the open is a positive signal.
A worked example
Problem: return the indices of two numbers that sum to a target.
Clarify: exactly one solution? reuse an element? negatives or duplicates? what if none exists? Assume one solution, no reuse, return [] if none.
Baseline → improve: checking every pair is O(n²). One pass with a hash map of value → index, looking for each element's complement, is O(n) time and space — a classic space-for-time trade.
def two_sum(nums, target):
seen = {}
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
return []
Test: [2,7,11,15], 9 -> [0,1]; [3,3], 6 -> [0,1] (the duplicate works because we check before inserting); [1,2], 10 -> []. If extra space were banned, sort with original indices and use two pointers — O(n log n) time, O(1) extra.
This is the arc that scores: clarify → baseline with complexity → named improvement → clean code → tested → trade-offs.