Algorithmic Complexity & Code-Level Optimization Questions
Reasoning about the time and space complexity of code and applying local optimizations that materially change performance. Covers Big-O and amortized analysis, performance modeling, data-structure selection, hot-loop and allocation reduction, hardware-aware work such as cache locality, vectorization and false sharing, knowing when an algorithmic change beats micro-optimization, and the measurement practice that grounds those calls: flame graphs, hardware counters, and microbenchmarks that survive warmup, noise and statistical scrutiny. Emphasizes performance-aware coding grounded in complexity and measurement rather than premature tuning, scoped to code you can change rather than production incident response or capacity planning.
You must speed up a CPU-bound numeric transformation over 100 million floats. Compare expected speedups, memory usage, and cache behavior for (a) a pure Python loop, (b) vectorized NumPy, and (c) a Numba/Cython implementation. Explain benchmarking approach and criteria to choose which to deploy in production.
Sample Answer
Direct answer
All three approaches are O(n) in time complexity for a single pass over 100 million floats, the difference is entirely in the constant factor and in memory behavior, and that difference is large enough to be the whole decision. A pure Python loop pays per-element interpreter and object overhead; vectorized NumPy replaces that with one call into a compiled C loop over contiguous memory; Numba or Cython compile the loop itself, closing most of the remaining gap and sometimes beating NumPy when the transform doesn't reduce to one or two clean vectorized expressions.
(a) Pure Python loop
Each of the 100 million iterations pays Python bytecode dispatch overhead plus the cost of boxing: a Python float is a heap-allocated object (roughly three times the size of a raw 8-byte double once you include the object header), and a Python list stores pointers to those objects rather than the values themselves. That means the data isn't contiguous in memory: iterating the list involves chasing pointers scattered across the heap, which defeats CPU hardware prefetching and produces poor cache behavior on top of the per-element interpreter cost. This option is the correctness baseline, not a production candidate, at this data size.
(b) Vectorized NumPy
A vectorized NumPy expression dispatches ONE call into a compiled C loop operating on a single contiguous buffer of raw doubles (100 million floats at 8 bytes each is 800 MB). Sequential access over that buffer is exactly the pattern CPU hardware prefetchers and SIMD (single-instruction-multiple-data) execution are good at, so per-element overhead effectively disappears. The remaining cost is memory bandwidth and any temporary arrays an expression allocates: chaining several operations ((x * 2 + 1) ** 2) can materialize an intermediate 800 MB array per step unless written to reuse buffers (out= parameters or in-place operators).
(c) Numba or Cython
Both compile the LOOP itself to native code instead of relying on a library call: Numba JIT-compiles (Just-In-Time compiles: turns the function into native machine code the first time it actually runs, instead of ahead of time) a Python function at runtime (@njit), Cython compiles a statically-typed, Python-like source file ahead of time into a C extension. Either can match or beat NumPy specifically when the transform has per-element branching or state that doesn't reduce to one or two vectorized expressions, because a hand-written compiled loop can do the whole computation in a single pass without materializing NumPy's intermediate temporary arrays.
Expected speedups
The ranges below are order-of-magnitude shapes to expect, not a benchmark of record: the exact factors depend on the machine, the transform, and the NumPy build, which is why the benchmarking section follows. For a simple elementwise transform such as (x * 2 + 1) ** 2 over float64:
- Pure Python loop to vectorized NumPy: expect roughly two orders of magnitude, in the region of 50x to 150x. That is not a mystery number, it is the per-element interpreter and boxing overhead disappearing: the Python loop spends the large majority of its time on bytecode dispatch and object handling rather than on the arithmetic.
- Vectorized NumPy to a compiled Numba or Cython loop: usually a small factor, often between 1x (no gain at all) and about 3x, and the size of that factor is predictable from MEMORY TRAFFIC rather than from raw instruction speed. Both end up running compiled code over the same contiguous buffer, so the only structural difference is temporaries. At 100 million float64 the array is 800 MB, and the chained NumPy expression
(x * 2 + 1) ** 2materializes three intermediate 800 MB arrays, moving roughly 4.8 GB through the memory bus (read plus write for each of three passes), while a fused compiled loop reads 800 MB and writes 800 MB once, about 1.6 GB. The ratio, about 3x, is the ceiling on what fusion can buy you, and it is why the compiled win shrinks toward 1x for a single-operation transform and grows toward 3x for a long chain. - Pure Python loop to a compiled loop: the product of the two, so several hundred times.
Use these to decide whether a step is worth taking at all. Going from the Python loop to NumPy is almost always worth it at 100 million elements; going from NumPy to a compiled loop is worth it only when the chain is long enough, or branchy enough, for that 3x-at-best fusion win to matter more than the build and maintenance cost.
Benchmarking approach
Measure your own numbers rather than trusting the ranges above. Warm up before timing (Numba's JIT compiles on first call, so that compilation cost must be excluded from the steady-state measurement), run multiple repetitions on the SAME input to reduce measurement noise, and verify correctness (near-identical output within floating-point tolerance) across all three BEFORE comparing speed, a faster wrong answer is not a candidate. Measure peak memory alongside time, not just time, since the memory-versus-speed shape differs across the three options.
Criteria for choosing what to deploy
- If the transform is one or two vectorized NumPy expressions, prefer NumPy: no separate compiled build step, easiest for the whole team to read and modify, no JIT warm-up latency on process startup.
- If profiling shows the algorithm has data-dependent branching or NumPy is forced to allocate large temporaries the transform doesn't logically need, move to Numba first (smallest code change from a plain loop) or Cython if you need a distributable compiled artifact, tighter control, or interop with other C/C++ code.
- Weigh maintainability explicitly: fewer engineers on most teams are comfortable debugging Cython or JIT-compiled code than debugging NumPy, and Numba's cold-start compilation cost matters if the process is short-lived (for example, a serverless function). The fastest option only wins if the team can keep it correct after you've moved on.
Trade-offs and pitfalls
Reaching for Cython or Numba before establishing that NumPy genuinely can't express the transform cleanly adds build complexity for a win that may not exist; conversely, forcing an awkward transform into a single NumPy expression just to avoid a compiled step can produce code that allocates far more temporary memory than a straightforward compiled loop would.
Explain why hash table lookup is said to be O(1) on average but O(n) in the worst case. Provide an amortized analysis of dynamic resizing when capacity doubles: derive the amortized cost per insert and show the steps of the argument. Finally, list production techniques to reduce worst-case lookup time (e.g., hash design, collision strategies, bounded worst-case guarantees).
Sample Answer
Direct answer
A hash table lookup is O(1) on average because a good hash function spreads keys roughly evenly across buckets, so each bucket holds close to a constant number of entries regardless of how many total entries exist. It degrades to O(n) in the worst case when many or all keys collide into the same bucket, turning that bucket into something you have to scan linearly, whether by bad luck, a poorly chosen hash function, or a deliberate attack.
Structured elaboration
Average-case argument: with n entries spread across m buckets using chaining, the average bucket holds n/m entries (the load factor). A well-designed hash table keeps the load factor bounded by a small constant (commonly resizing once it exceeds around 0.7-1.0), so the average chain length stays O(1), and a lookup does O(1) hashing plus O(1) expected chain traversal.
Worst case: if the hash function maps every key to the same bucket (either through bad luck with a weak hash, or through an attacker deliberately choosing keys that collide, a hash-flooding denial-of-service technique), that bucket's chain has length n, and looking something up in it costs O(n).
Amortized resizing derivation: when the table's load factor crosses its threshold, it allocates a new bucket array (typically double the size) and re-inserts every existing entry into it. This is structurally identical to the dynamic-array-doubling argument used for array appends: resizes happen when the table holds 1, 2, 4, 8, ... entries relative to its capacity, so the total re-insertion work across n inserts is bounded by the geometric series ∑2i<2n, plus the n individual insert costs, giving total work under 3n for n inserts, hence an amortized cost per insert of a small constant, O(1), by the exact same aggregate-method argument as array doubling.
Production techniques to reduce worst-case lookup time:
- Hash design: use a hash function with good avalanche properties and, for adversarial-input resistance, one that is keyed/seeded per process (so an attacker who knows the algorithm still can't predict collisions without knowing the runtime's random seed). Python randomizes string hashing by default (via
PYTHONHASHSEED) for exactly this reason. - Collision strategy: chaining (linked lists or small trees per bucket) versus open addressing (probing for the next free slot) trade off differently under high load factor and cache behavior; open addressing tends to be more cache-friendly at moderate load factors but degrades faster as the table fills.
- Bounded worst-case guarantees: some hash table implementations convert an over-long chain into a balanced structure once it passes a threshold. Java's
HashMap(Java 8+), for example, treeifies a bucket into a red-black tree once that bucket's chain length passes a threshold (with a further condition on overall table capacity), turning a pathological O(n) bucket into O(log n), a bounded-worst-case technique that doesn't require changing the hash function at all.
Worked example
Simulating the same doubling argument used for arrays, but for hash-table resizing: starting capacity 1, doubling at load factor 1.0, inserting 16 keys gives resize events at inserts 2, 3, 5, 9 (copying 1, 2, 4, 8 existing entries respectively), for total copy cost 1+2+4+8=15, plus 16 insert costs, for total work 31 across 16 inserts, an average of 31/16=1.9375 per insert, the identical arithmetic to doubling a dynamic array, because the underlying doubling argument is the same regardless of what's being resized.
Trade-offs and pitfalls
Treefying buckets (or any bounded-worst-case scheme) adds implementation complexity and a small constant-factor overhead to every operation in exchange for capping the damage from a bad key distribution; it's a mitigation for the worst case, not a way to make the average case faster. A common pitfall is assuming resizing alone protects against collision attacks: doubling the table helps when collisions are due to random bad luck (since a resize also re-hashes with the same hash function, so spreading only improves if the hash function itself is good), but it does nothing against an attacker who can predict the hash function's behavior and keeps choosing colliding keys regardless of table size, which is why seeding/randomizing the hash function is a separate and necessary defense from resizing.
Compare hash-based maps and balanced binary search trees for lookup and insert operations in terms of average-case and worst-case time complexity. For a data pipeline that must handle both typical loads and adversarial inputs (e.g., user-provided keys), which would you choose and what mitigation techniques would you apply to prevent worst-case degradation?
Sample Answer
Direct answer
For typical loads, a hash map's average-case O(1) lookup and insert beats a balanced binary search tree's O(log n); but under adversarial input, a hash map can degrade to O(n) while a balanced tree guarantees O(log n) no matter what keys arrive. For a pipeline that must handle user-provided keys, that worst-case guarantee is usually worth the constant-factor cost, unless you can also afford to harden the hash map itself.
Structured elaboration
| Hash map (chaining/open addressing) | Balanced BST (e.g., red-black tree) | |
|---|---|---|
| Average-case lookup/insert | O(1) | O(log n) |
| Worst-case lookup/insert | O(n) (pathological collisions) | O(log n) (guaranteed by rebalancing) |
| Ordered traversal | Not supported directly | Supported (in-order traversal) |
| Adversarial-input resistance | Weak unless hash is seeded/hardened | Strong (guarantee holds by construction) |
The hash map's O(1) average case relies on keys spreading evenly across buckets; if an attacker can choose keys that all hash to the same bucket (a hash-flooding attack), every one of those keys collapses the map's behavior to a single long chain, O(n) per operation, and doing this to enough keys turns a normally-fast service into one that's trivially deniable-of-service. A balanced BST never has this failure mode: its rebalancing logic mathematically guarantees O(logn) height regardless of what keys are inserted or in what order, because the tree's shape is actively maintained rather than depending on key distribution at all. In production, that rebalancing is almost always done with red-black rules (each node is colored red or black under a small set of rules that keeps the longest root-to-leaf path no more than twice the shortest one, so a full rebalance never needs more than a couple of local rotations); it's what backs C++'s std::map, Java's TreeMap, and the Linux kernel's scheduler trees. AVL rotations are a stricter alternative (every node's two subtrees must differ in height by at most 1, which gives slightly faster lookups but costs more rotations per insert to maintain) and show up mostly in textbooks rather than in the balanced maps you'd actually reach for; you won't usually be asked to implement either from scratch, but knowing red-black is the one production libraries default to is worth remembering.
For a data pipeline handling both typical loads and adversarial inputs, the choice depends on how much control you have over the hash function versus how much you need average-case speed:
- If you can control the hash implementation, a seeded or keyed hash function (like SipHash, which many languages use by default for exactly this reason, or Python's randomized string hashing via
PYTHONHASHSEED) removes the attacker's ability to predict collisions, letting you keep the hash map's average-case speed with a much smaller realistic worst-case risk. This is the lower-effort fix when it's available. - If the hash function can't be hardened, or the guarantee itself matters more than average speed (e.g., an SLA that can't tolerate a rare O(n) stall), a balanced BST removes the risk entirely at the cost of roughly log(n) versus O(1) typical-case performance, plus the ability to do ordered operations (range queries, "next key after X") that a hash map can't do at all.
- A middle ground some production hash tables use: treeify individual over-long buckets into small balanced trees once a threshold is crossed (Java's
HashMapdoes exactly this in modern versions), keeping O(1) average performance everywhere else while bounding any single bucket's worst case to O(log n) instead of O(n), without giving up the hash map's overall interface or losing the average-case speed for well-behaved keys.
Worked example
For n=1,000,000 user-provided keys under a normal, well-distributed workload, a hash map does lookups in a small constant number of probes regardless of n, while a balanced BST needs about log2(1,000,000)≈20 comparisons per lookup, a real but modest constant-factor cost. If an attacker instead sends a crafted batch of keys engineered to collide under a known, unseeded hash function, a hash map's per-lookup cost for those keys can degrade toward n = 1,000,000 comparisons each, while the balanced BST's cost stays at the same ~20 comparisons it always has, because its guarantee doesn't depend on key distribution.
Trade-offs and pitfalls
The most common mistake is treating "hash map is O(1), so it's just faster" as the whole answer without asking who controls the keys; for a pipeline that's purely internal with fully trusted key generation, the adversarial-input concern doesn't apply and a hash map is the right default. For anything accepting externally-supplied keys (user IDs, form field names, request headers used as map keys), the worst case is a real operational risk, not a theoretical curiosity, and it's worth pairing the choice with actual production mitigation: monitoring bucket-length distribution or per-key lookup latency to catch a flooding attempt in progress, alongside whichever structural choice (hardened hash, balanced tree, or treeified buckets) you pick up front.
A PyTorch model's activations dominate GPU memory usage during training. Propose practical strategies to reduce peak memory usage, and for each explain the likely effect on throughput and any numerical or implementation trade-offs.
Sample Answer
Direct answer
Attack peak activation memory with three levers that each trade something else for it: precision (store activations in fewer bits), compute (recompute activations instead of storing them), or batch composition (simulate one large batch out of several small ones). All three apply on a single GPU, which is why they come first. Multi-GPU sharding is a real fourth lever and it is scoped out below for a reason worth stating accurately rather than waved away.
1. Mixed precision
Storing activations and gradients in 16-bit floating point (fp16 or bf16) instead of 32-bit (fp32) roughly halves the memory footprint of every stored activation, and is usually a THROUGHPUT WIN, not a cost, on GPUs with hardware support for reduced-precision math.
Concrete worked example: an activation tensor shaped (batch=32, channels=256, height=56, width=56) has 32 x 256 x 56 x 56 = 25,690,112 elements. At 4 bytes per element (fp32) that is about 102.76MB; at 2 bytes (fp16) it is about 51.38MB, a saving of about 51.38MB for that ONE tensor, which compounds linearly across every layer's activations kept for the backward pass.
Numerical trade-off: fp16 has a much narrower exponent range than fp32 and can silently underflow small gradients to zero, so it needs loss scaling (multiplying the loss before backpropagation, then unscaling the gradients afterward) to stay numerically stable. bf16 keeps fp32's exponent range and gives up mantissa bits instead, which is why it generally does not need loss scaling at all, at the cost of coarser precision per value. Some operations, like softmax and batch-norm reductions, are typically still computed in fp32 even inside an otherwise mixed-precision model, "mixed" is selective, not everything switches.
2. Activation (gradient) checkpointing
Instead of keeping every intermediate activation for the backward pass, store only a subset of checkpoint activations and RECOMPUTE the ones in between during backward. Memory drops roughly in proportion to how sparsely you checkpoint, checkpointing every sqrt(L)-th layer of an L-layer network is the textbook square-root memory for extra-compute trade, at the direct cost of redoing part of the forward pass during backward, which lowers throughput because real floating-point operations are repeated rather than saved. The usual accounting is roughly one extra forward pass, so about a third more compute for a forward-plus-backward step, in exchange for activation memory falling from order L to order sqrt(L).
3. Gradient accumulation (the same trade, applied to batch size)
Run several smaller micro-batches, summing their gradients WITHOUT stepping the optimizer, then step once after N micro-batches, simulating an effective batch size of N times the micro-batch size while only ever holding one micro-batch's activations in memory at a time. This does not reduce total compute (per-sample forward and backward cost is unchanged), but it does cost wall-clock throughput relative to one large batch, since small micro-batches usually underutilize a GPU's parallel compute units compared to a single large batch that saturates them better.
4. Why multi-GPU sharding is scoped out here, stated correctly
It would be wrong to say distributed training only moves compute around. Plain DATA parallelism does not shard a single rank's activations at all (each rank holds its own local batch's activations in full), and parameter-sharding schemes of the ZeRO or FSDP family shard weights, gradients and optimizer states rather than activations. But TENSOR and SEQUENCE parallelism do split the activation tensors themselves across ranks, so per-GPU activation memory genuinely falls with the degree of parallelism. The reason to reach for the three levers above first is not that sharding fails to help activations, it is that sharding requires more than one GPU, a change to the model's parallelism topology, and added collective-communication traffic on every layer, whereas precision, checkpointing and accumulation are all configuration-level changes on the hardware already in hand.
A rarer, more advanced option worth a brief, clearly-labeled mention: reversible layers (used for example in RevNets) let a layer's input be exactly reconstructed from its output during backward instead of being stored at all. This is a much less commonly asked interview topic than the three above, since it requires choosing a reversible architecture from the start rather than retrofitting it onto an existing model.
Trade-offs and pitfalls
Mixed precision is close to a free win and is usually the first lever worth trying. Checkpointing and gradient accumulation both explicitly buy memory by spending compute time, so applying all three at once can leave a model memory-safe but meaningfully slower per epoch than expected, worth quantifying which specific layers actually need checkpointing rather than checkpointing the entire network by default.
Provide a high-level pseudocode and explanation for a fused CUDA kernel (conceptual, you may write simplified CUDA-like pseudocode) that computes y = alpha * x + beta * relu(x) in one pass. Explain why fusing reduces memory bandwidth and detail considerations for coalesced loads, boundary handling, and warp occupancy.
Sample Answer
Direct answer
A fused kernel for y = alpha*x + beta*relu(x) reads x once and writes y once inside a single launch, computing relu and the two scalar multiplies plus the add entirely in registers, instead of materializing two or three intermediate arrays to global memory between separate kernel launches.
Structured elaboration
Illustrative CUDA-like pseudocode (the question itself allows simplified pseudocode here; there is no GPU in this environment to compile it against, so it is presented as pseudocode, not as a compiled artifact):
// illustrative, not compiled: shows the shape of a fused elementwise kernel
__global__ void fused_axpy_relu(const float* __restrict__ x, float* __restrict__ y,
float alpha, float beta, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
for (; i < n; i += blockDim.x * gridDim.x) { // grid-stride loop covers any n
float xi = x[i]; // one coalesced global load
float r = xi > 0.0f ? xi : 0.0f; // relu lives in a register, no memory traffic
y[i] = alpha * xi + beta * r; // one coalesced global store
}
}
- Coalescing. Consecutive
threadIdx.xmap to consecutive addresses inxandy, so a warp of 32 threads issues one contiguous transaction instead of 32 scattered ones. This only holds ifxandyare contiguous with unit stride; a strided or gathered access pattern breaks coalescing regardless of fusion. - Boundary handling. The grid-stride form with the
i < nguard avoids assumingnis a multiple ofblockDim*gridDim, so the last partial block never reads or writes past the array. - Warp occupancy. The fused kernel uses only a couple of registers per thread (
xi,r) and no shared memory, so occupancy is typically limited by register/block-size choices, not by the fusion itself: fusing this particular chain costs no extra registers over computingalpha*xalone, while removing two of the kernel's three memory round-trips.
This is also the same idea behind a general operator-fusion pass in a deep learning compiler: chaining matmul, bias-add, and GELU into one kernel avoids writing the matmul output and the bias-add output to global memory just to immediately re-read them in the next op; the compiler fuses adjacent elementwise (and sometimes reduction) ops into one kernel body for exactly the same memory-traffic reason shown below, not because the arithmetic itself is expensive.
Worked example
Fusion helps a memory-bound kernel because it cuts bytes moved, not FLOPs. Counting reads and writes per element (float32, 4 bytes) for the unfused pipeline (relu, then two scales, then an add, each as a separate kernel writing its result to global memory) versus the fused single pass:
# Analytical memory-traffic model for y = alpha*x + beta*relu(x), unfused vs fused.
# Not a timing benchmark: this counts BYTES MOVED to/from global memory, which is
# what determines cost for a memory-bound elementwise kernel, independent of any GPU.
N = 1_000_000 # elements
dtype_bytes = 4 # float32
def unfused_bytes(n, elem_bytes):
# k1: r = relu(x) -> read x, write r
# k2: s = beta * r -> read r, write s
# k3: t = alpha * x -> read x, write t
# k4: y = s + t -> read s, read t, write y
reads = n*1 + n*1 + n*1 + n*2
writes = n*1 + n*1 + n*1 + n*1
return (reads + writes) * elem_bytes, reads * elem_bytes, writes * elem_bytes
def fused_bytes(n, elem_bytes):
# one pass: read x once, write y once
reads = n*1
writes = n*1
return (reads + writes) * elem_bytes, reads * elem_bytes, writes * elem_bytes
total_unfused, r_u, w_u = unfused_bytes(N, dtype_bytes)
total_fused, r_f, w_f = fused_bytes(N, dtype_bytes)
print(f"N={N} elements, {dtype_bytes} bytes/elem (float32)")
print(f"unfused: reads={r_u/1e6:.1f} MB writes={w_u/1e6:.1f} MB total={total_unfused/1e6:.1f} MB")
print(f"fused: reads={r_f/1e6:.1f} MB writes={w_f/1e6:.1f} MB total={total_fused/1e6:.1f} MB")
print(f"traffic reduction ratio: {total_unfused/total_fused:.2f}x")
Output:
N=1000000 elements, 4 bytes/elem (float32)
unfused: reads=20.0 MB writes=16.0 MB total=36.0 MB
fused: reads=4.0 MB writes=4.0 MB total=8.0 MB
traffic reduction ratio: 4.50x
For a memory-bandwidth-bound kernel, bytes moved is a good proxy for cost independent of any specific GPU's clock speed, so this 4.5x reduction in traffic is the real reason fusion helps here, not a wall-clock claim about any particular device.
Trade-offs & pitfalls
Fusion only pays off when the chain is memory-bandwidth-bound (low arithmetic intensity); if an intermediate step were a reduction across elements (a sum or max, not elementwise) you cannot trivially fuse it into the same per-thread loop without a different pattern (shared-memory reduction, warp shuffles). Fusing too aggressively also has a real failure mode: a very long fusion chain increases live registers per thread, and if that pushes past the register file's capacity the compiler spills to local (still global) memory, which can make the "fused" kernel slower than two well-chosen simpler kernels. Profile occupancy before and after fusing, don't assume fusing further is always a win.
Unlock Full Question Bank
Get access to all Algorithmic Complexity & Code-Level Optimization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.