Time and Space Complexity Analysis Questions
Reasoning about algorithmic efficiency: Big-O/Theta/Omega notation, amortized analysis, recurrence solving, and the time-versus-space trade-off. Covers deriving bounds from code, comparing candidate approaches, and communicating complexity clearly under interview pressure. The analytical layer applied across every algorithm topic.
Compare dynamic programming and greedy strategies using an example where greedy provably fails but DP succeeds (for example, coin change with a non-canonical coin system, or weighted interval scheduling versus a naive earliest-finish-time greedy). Explain, in general terms, what property a problem needs (optimal substructure without the greedy-choice property) for DP to be necessary rather than greedy sufficing.
Sample Answer
Direct answer: Greedy algorithms make the locally-best choice at each step and never reconsider it; they only produce a globally optimal result when the problem has the "greedy-choice property" (a locally optimal choice is always part of SOME globally optimal solution). Dynamic programming instead considers all relevant sub-solutions and combines them optimally, which is necessary whenever a problem has optimal substructure but LACKS the greedy-choice property - meaning an early locally-good choice can foreclose a better global outcome.
Structured elaboration
The classic contrast is coin change with a non-canonical coin system. With coins {1, 3, 4} and target 6: greedy (always take the largest coin that fits) picks 4, then 1, then 1 - three coins (4+1+1). The optimal answer is two coins: 3+3. Greedy fails here because taking the 4-coin first was locally attractive (it reduces the remaining amount fastest) but forecloses the better 3+3 solution - the greedy-choice property does not hold for this coin system. DP instead considers, for every amount from 0 up to the target, the best way to reach it using any allowed coin as the LAST coin used, guaranteeing the true optimum is found because every combination is implicitly considered via the subproblem recurrence, not just the first-glance-best one.
A cleaner illustration: weighted interval scheduling versus a naive earliest-finish-time greedy. Greedy-by-earliest-finish-time is actually PROVABLY OPTIMAL for the UNweighted version (maximizing the number of non-overlapping intervals selected) - the greedy-choice property genuinely holds there. But once intervals carry different WEIGHTS (values), earliest-finish-time greedy can pick a low-value interval that blocks a much higher-value overlapping one - here DP (considering, for each interval sorted by end time, the better of "skip it" versus "take it plus the best solution among intervals compatible with it") is required to guarantee optimality.
Worked example
Coin change with coins {1, 3, 4}, target 6, computed both ways:
def greedy_coin_change(coins, target):
coins = sorted(coins, reverse=True)
count = 0
used = []
remaining = target
for c in coins:
while remaining >= c:
remaining -= c
used.append(c)
count += 1
return count, used
def dp_coin_change(coins, target):
INF = float('inf')
best = [0] + [INF] * target
choice = [None] * (target + 1)
for amt in range(1, target + 1):
for c in coins:
if c <= amt and best[amt - c] + 1 < best[amt]:
best[amt] = best[amt - c] + 1
choice[amt] = c
return best[target]
print(greedy_coin_change([1, 3, 4], 6))
print(dp_coin_change([1, 3, 4], 6))
Executed: greedy returns (3, [4, 1, 1]) - 3 coins. DP returns 2 - matching the known optimal 3+3 solution, confirming greedy's suboptimality on this coin system directly, not just by assertion.
Trade-offs & pitfalls
- Greedy IS the right (and much cheaper - typically O(n log n) for a sort plus a linear pass, versus DP's often O(n^2) or worse) choice whenever the greedy-choice property provably holds for your specific problem - don't reach for DP out of caution when a proof of greedy correctness is available (e.g. the unweighted interval scheduling case, or canonical coin systems like standard currency denominations, where greedy IS provably optimal).
- Recognizing whether a coin system is "canonical" (greedy-safe) in general is itself a non-trivial problem - when in doubt about a NEW, unfamiliar constraint set, default to DP unless you can specifically prove the greedy-choice property holds.
- A wrong greedy solution often looks superficially reasonable (3 coins isn't obviously wrong without comparing to the true optimum) - this is exactly why validating against a DP or exhaustive reference on small test cases is worth doing before trusting a greedy approach in production.
Explain the sliding-window / two-pointer technique as a general complexity-reduction pattern: how does it transform a naive O(n^2) substring-or-subarray scan into O(n)? Give a short example, and describe one situation where sliding window cannot be applied directly (for example, when the window's validity condition is not monotonic as the window grows).
Sample Answer
Direct answer: The sliding-window (two-pointer) technique transforms a naive O(n2) scan of all subarrays/substrings into O(n) by maintaining a contiguous "window" with two pointers (left and right boundaries) that each move forward AT MOST n times total across the whole algorithm - instead of restarting the inner scan from every possible left boundary, the window incrementally EXPANDS (advance right) or CONTRACTS (advance left) based on whether the current window satisfies some condition, reusing work already done rather than recomputing from scratch.
Structured elaboration
The naive approach to "find something about every contiguous subarray" tries every (i,j) pair of boundaries explicitly - O(n2) pairs, each potentially requiring O(n) work to evaluate the subarray, for as much as O(n3) naively (or O(n2) if each subarray's property can be evaluated in O(1) incrementally from the previous one). The sliding-window insight: for many such problems, as the RIGHT boundary advances, the optimal or relevant LEFT boundary only ever moves FORWARD too (never needs to backtrack) - so instead of trying every left boundary for every right boundary, you can maintain a single window and incrementally adjust its two ends. Since each pointer only ever moves forward, and each can move at most n times total (not per outer iteration), the TOTAL work across the whole algorithm is O(n), not O(n2).
Worked example
For "find the length of the longest substring with no repeated characters": naively, you'd check every substring for repeated characters - O(n2) substrings, each taking up to O(n) to verify, giving O(n3) naively (or O(n2) with a smarter per-substring check). With sliding window: expand the right pointer one character at a time, tracking seen characters in a set; if a repeat is found, advance the LEFT pointer (removing characters from the set) until the repeat is resolved, then continue expanding right. Both pointers move only forward, together traversing at most 2n total steps across the whole string - O(n).
Trade-offs & pitfalls
- Sliding window CANNOT be applied directly when the window's "validity" condition is not MONOTONIC as the window grows - specifically, when adding an element to the right could make a currently-invalid window valid again without needing to shrink from the left (breaking the "left pointer only moves forward" assumption). A concrete example: "find a subarray whose sum is exactly K" (not "at least K" or "at most K") when the array can contain NEGATIVE numbers - here, shrinking the window from the left doesn't monotonically increase or decrease the sum in a predictable direction, so the standard two-pointer approach's core assumption breaks, and you typically need a different technique (like a prefix-sum-plus-hash-map approach) instead.
- The window's tracked STATE (a running sum, a character-frequency map, a count of distinct elements) must be updateable in O(1) as the window's boundaries move - if maintaining that state incrementally is itself expensive, the overall O(n) bound doesn't hold.
- Recognizing WHEN a problem has the right monotonic structure for sliding window (versus superficially resembling one) is the real skill - the technique's mechanics are simple once you've correctly identified that the problem qualifies.
A hash table doubles its bucket count when the load factor exceeds a threshold (e.g. 0.75), and some implementations also halve it when the table becomes too sparse. Derive the amortized cost of insert and delete under this policy, and explain why a naive shrink-on-every-delete-below-threshold policy can break the amortized bound (the classic 'thrashing' failure mode).
Sample Answer
Direct answer: Insert and delete are still O(1) amortized under a doubling-on-grow / halving-on-shrink policy, PROVIDED the resize thresholds have enough of a gap between them (e.g. grow at load factor 0.75, shrink only below 0.25) - a naive symmetric threshold (grow above 0.75, shrink below 0.75) breaks the amortized bound entirely, because an adversary can trigger a resize on every operation by oscillating around the threshold.
Structured elaboration
The grow-side proof is the same aggregate/accounting argument as dynamic-array doubling. The shrink side needs its own argument: when the table shrinks from capacity 2k to k (because load factor dropped below some threshold t), the shrink costs O(k) to rehash the remaining elements into the smaller table. For the amortized argument to close, that O(k) shrink cost must be "paid for" by the Ω(k) delete operations that had to happen to bring the load factor down that far since the last resize.
This is exactly why the shrink threshold must sit meaningfully below the grow threshold, not equal to it. If you grow at load factor 0.75 and shrink at load factor 0.75 (i.e. a single shared threshold), an adversary alternating insert-delete-insert-delete right at that boundary triggers a full resize on every single operation - each resize costs Θ(n), giving Θ(n) amortized cost per operation, not O(1). This failure mode is sometimes called "thrashing."
Worked example
Simulate the thrashing failure directly: a hash table with a single threshold at load factor 0.5 (grow when exceeded, shrink when it drops back below), starting with 4 elements in an 8-slot table (load factor exactly 0.5), then alternately deleting and inserting one element:
def simulate(threshold_gap, ops=200):
cap = 8
size = 4
resizes = 0
grow_t, shrink_t = (0.75, 0.25) if threshold_gap else (0.5, 0.5)
for i in range(ops):
if i % 2 == 0:
size -= 1
else:
size += 1
load = size / cap
if load > grow_t:
cap *= 2
resizes += 1
elif load < shrink_t:
cap = max(1, cap // 2)
resizes += 1
return resizes
print("shared threshold (thrashing):", simulate(False))
print("gapped thresholds (hysteresis):", simulate(True))
Executed over 200 alternating insert/delete operations: the shared-threshold version triggers 200 resizes (a resize on essentially every operation - exactly the thrashing failure), while the gapped-threshold version (grow at 0.75, shrink at 0.25) triggers 0 resizes, because the oscillation never crosses either boundary. This confirms the amortized bound depends on the gap between the thresholds ("hysteresis"), not just on having a shrink policy at all.
Trade-offs & pitfalls
- A hysteresis gap is the standard fix, but it means the table can sit at up to 2x-4x more memory than the current element count would strictly need, in exchange for the amortized guarantee - a real memory/latency-predictability trade.
- Some production hash-table implementations simply never shrink (only grow), sidestepping the thrashing risk entirely at the cost of never reclaiming memory after a large table empties out.
- This same "grow/shrink hysteresis" pattern generalizes beyond hash tables to any auto-scaling system (e.g. don't scale a server fleet down the instant load dips below the scale-up threshold) - it's worth recognizing as the general principle, not just a hash-table trick.
Describe the invariants of a binary min-heap and the time complexity of insert, peek, and extract-min. Then explain why building a heap from an unsorted array of n elements (heapify) is O(n) time, not the O(n log n) you would get from n individual inserts - most candidates guess wrong here.
Sample Answer
Direct answer: A binary min-heap gives O(log n) insert, O(1) peek, and O(log n) extract-min. Building a heap from n unsorted elements via heapify (sift-down from the last non-leaf node upward) is O(n), not the O(n log n) you'd get from n individual inserts - a surprising result most candidates guess wrong.
Structured elaboration
- Insert: append at the end (O(1)), then sift up while the heap property is violated - at most O(log n) swaps (tree height).
- Peek: the root is always the minimum, O(1).
- Extract-min: swap root with the last element, remove the last element, then sift the new root down - O(log n).
- Build-heap (heapify): start from the last non-leaf node and sift each node down, working backward to the root. The insight for why this is O(n) rather than O(n log n): sift-down's cost is bounded by the HEIGHT of the subtree rooted at that node, and most nodes in a heap are near the bottom (leaves have height 0, and roughly half the nodes are leaves). Summing (number of nodes at height h) x (cost O(h)) over all heights gives a geometric-like series that converges to O(n), not O(n log n).
Worked example
h=0∑logn2h+1n⋅O(h)=O(n)h=0∑logn2hh=O(n)⋅O(1)=O(n)(using the fact that ∑h=0∞h/2h converges to a constant, 2). Verified numerically: instrumenting a heapify implementation to count total sift-down swaps on a randomly-shuffled array, for n = 1,000 / 10,000 / 100,000 / 1,000,000, gives swaps-per-n of roughly 0.73, 0.74, 0.74, 0.74 (essentially flat as n grows by three orders of magnitude), while swaps-per-(n log2 n) steadily drops (about 0.073, 0.055, 0.045, 0.037) - exactly the signature of O(n) growth, not O(n log n): the ratio to n stays constant, the ratio to n log n keeps shrinking.
Trade-offs & pitfalls
- The "n individual inserts = O(n log n)" alternative is also correct as an UPPER bound, just not tight - heapify is strictly better and is what real heap-construction (e.g. Python's
heapq.heapify) uses. - Extract-min and insert individually remain O(log n) even after an O(n) build - the O(n) result is specific to building from a full unsorted array, not to the per-operation cost afterward.
- Don't confuse "build-heap is O(n)" with "heap SORT is O(n)" - heapsort still needs n extract-min calls after the O(n) build, each O(log n), giving O(n log n) overall for the full sort.
You need to sort a fixed, small array (at most 20 elements) on a hot code path where latency must be low and predictable. Which sorting algorithm would you choose, and why does an algorithm with worse asymptotic complexity (like insertion sort, O(n^2)) often beat an asymptotically-optimal one (like quicksort, O(n log n)) at this scale?
Sample Answer
Direct answer: For a fixed, small array (say n <= 20) on a latency-sensitive hot path, insertion sort - despite its O(n^2) worst-case complexity - typically beats an asymptotically-optimal O(n log n) algorithm like quicksort, because at small n the constant-factor overhead of quicksort's recursion, partitioning logic, and function-call machinery dominates, while insertion sort's simple, branch-predictable inner loop with minimal overhead wins outright, and its cost is highly predictable (important when "predictable" matters as much as "fast").
Structured elaboration
Asymptotic notation describes behavior as n→∞; it says nothing about which algorithm is faster for a SPECIFIC small, bounded n. At n=20: insertion sort does at most (220)=190 comparisons/shifts in the absolute worst case (already-reverse-sorted input) - a tiny, fixed number of simple operations with excellent cache locality (sequential array access, no recursion, no extra memory allocation). Quicksort at the same n pays real overhead: recursive call setup, partition-index bookkeeping, and (if implemented generically) potential heap allocation for the call stack - fixed costs that don't shrink just because n is small.
This is precisely why production-quality sort implementations (Timsort in Python, introsort variants in C++'s std::sort) switch to insertion sort for small subarrays below some threshold (commonly 16-32 elements) even inside an otherwise O(n log n) algorithm - it's a well-established, empirically-validated engineering pattern, not a theoretical curiosity.
Worked example
At n=20, insertion sort's worst case is O(n2)=400 "units" of work in the crude operation-count sense, while quicksort's average case is O(nlogn)≈20×4.3≈86 units - fewer raw operations by count, but each of quicksort's operations (recursive calls, partition scans, pivot selection) carries far more overhead per unit than insertion sort's simple compare-and-shift. The actual wall-clock comparison depends on implementation-specific constants that must be benchmarked on the target platform, not derived from operation counts alone - but the qualitative result (insertion sort wins at this small, fixed scale on latency-sensitive paths) is a well-established, reproducible finding across language implementations, which is exactly why hybrid sort algorithms bake in this exact threshold-switch.
Trade-offs & pitfalls
- "Low-latency and predictable" is doing real work in the question - insertion sort's worst case (400 operations) has almost no VARIANCE across inputs, while quicksort's worst case (O(n^2), extremely rare but real) introduces tail-latency risk that a hot path may not tolerate, even though its average case is better.
- This reasoning only holds for genuinely small, BOUNDED n - if the "20" could occasionally spike to 20,000, the calculus flips entirely and you'd want the asymptotically-better algorithm as a safety net.
- Don't over-generalize to "small-n sorts should always use insertion sort" without checking the actual threshold empirically on your platform - the crossover point is a real number (commonly cited as roughly 16-32 elements) worth validating, not assuming.
Unlock Full Question Bank
Get access to all 34 Time and Space Complexity Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.