Algorithmic Coding Interview Guide for ML Engineers

Ziyi Zhu

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.

  1. 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.
  2. Trace a small example by hand to confirm you and the interviewer agree on the expected output.
  3. 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.
  4. Name the pattern for your improved approach ("this is a sliding window because…") and get a nod before typing.
  5. Code it while narrating the non-obvious lines. Use real names, small helpers, and early returns.
  6. Test the happy path and one edge case out loud. Fixing a bug visibly is a strong signal.
  7. 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.

Heapsheapq 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())

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 problemReach for
Sorted array, pair/triplet toward a targetTwo pointers
Longest/shortest contiguous window under a constraintSliding window
"Seen it," complement, group-by, anagramHash map / Counter
Sorted data, or a min/max value that satisfies a checkBinary search (incl. on the answer)
Shortest path in unweighted graph/grid, level orderBFS
Connectivity, all paths, cycle, topological orderDFS
All subsets/permutations/combinationsBacktracking
"k largest/smallest," streaming top-k, merge k listsHeap
Merge/insert/overlap, meeting rooms, schedulingIntervals (sort first)
Count ways / min cost / reachabilityDynamic programming
"Next greater/smaller element," histogramMonotonic stack
Many range-sum queries, sub-array sum = kPrefix sums
Connected components, dynamic connectivityUnion-Find
Linked-list cycle, find the middleFast/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.