Arrays, Strings, and Hashing Questions
Manipulating arrays and strings using the standard toolkit for entry-level coding-interview problems: two-pointer and sliding-window techniques, in-place modification (reversal, rotation, partitioning, deduplication), prefix sums, and hash-map or hash-set based techniques used to solve array or string problems in optimal time (frequency counting, lookup-based pairing such as two-sum, duplicate detection, grouping by a computed key such as anagram grouping). Hashing appears in this topic only as an applied technique for solving an array or string problem faster: how hash tables work internally (hash functions, collision resolution, load factor, resizing) and hash-based structures that are not array or string shaped (Bloom filters, HyperLogLog) belong to the separate hashing and hash tables topic, not this one. Covers the most frequent entry-level coding-interview problem shapes and the trade-offs between time, space, and readability. The default warm-up surface for any coding interview.
Implement partition(arr, predicate) with two variants: (1) an in-place O(1) extra space partition that reorders elements matching predicate before the rest (relative order not guaranteed), and (2) a variant that preserves the original relative order of both groups. Provide Python implementations and discuss the trade-offs between the two, including whether O(1) extra space and order-preservation can be achieved simultaneously.
Sample Answer
Direct answer
Two clean variants exist. Variant 1, in-place, O(1) extra space: a single write-pointer pass that swaps every element satisfying the predicate to the front; O(n) time, O(1) extra space, but does NOT preserve relative order within either group. Variant 2, order-preserving: build two lists (matched, unmatched) in a single pass and concatenate them; O(n) time, O(n) extra space, and DOES preserve relative order. In general, O(1) extra space and stability cannot both be had in O(n) time; achieving both simultaneously needs O(n log n) time instead, the same trade-off behind why C++'s std::stable_partition switches to a slower algorithm specifically when it cannot get extra memory.
Structured elaboration
Variant 1 mechanics (swap-based partition). Maintain a write pointer starting at 0. Scan read from 0 to n-1; whenever predicate(arr[read]) holds, swap arr[write] and arr[read], then increment write. After the scan, everything before write satisfies the predicate and everything from write onward does not, but a swap can move an unmatched element out of its original relative position among other unmatched elements (or vice versa for matched elements).
Variant 2 mechanics. A single pass builds two separate lists, matched and unmatched, in original encounter order, then concatenates them. Correctness is immediate: each list is built purely by appends in scan order, and appends never reorder elements relative to each other within their own list.
Can O(1) space and stability be achieved together? Yes, but not in O(n) time. A divide-and-conquer approach recursively stable-partitions each half of the array, then merges the two halves' now-partitioned matched and unmatched runs using an in-place rotation (rotation itself can be done with O(1) extra space via the three-reversals trick, in time proportional to the rotated range). This gives the recurrence T(n) = 2T(n/2) + O(n), which solves to O(n log n) time with O(1) extra space (or O(log n) if counting recursion-stack space). This mirrors exactly what C++'s standard library documents for std::stable_partition: O(n) swaps when extra memory is available for a temporary buffer, up to O(n log n) swaps if allocation fails and it must fall back to in-place. It is a genuine impossibility result at O(n) time: you cannot have in-place (O(1) space), stable, AND O(n) time simultaneously for a general predicate-based partition; you must give up exactly one of the three.
Worked example
def partition_inplace_unstable(arr, predicate):
'''O(1) extra space, O(n) time. Relative order NOT guaranteed.'''
write = 0
for read in range(len(arr)):
if predicate(arr[read]):
arr[write], arr[read] = arr[read], arr[write]
write += 1
def partition_stable(arr, predicate):
'''O(n) extra space, O(n) time. Preserves relative order in each group.'''
matched = [x for x in arr if predicate(x)]
unmatched = [x for x in arr if not predicate(x)]
return matched + unmatched
data = [1, 4, 2, 7, 3, 8, 5, 9, 6] # pinned
is_even = lambda x: x % 2 == 0
unstable_copy = data.copy()
partition_inplace_unstable(unstable_copy, is_even)
print("input =", data)
print("unstable in-place result =", unstable_copy)
stable_result = partition_stable(data, is_even)
print("stable (extra space) =", stable_result)
evens_in_order = [x for x in data if is_even(x)]
odds_in_order = [x for x in data if not is_even(x)]
print("stability check (evens):", stable_result[:len(evens_in_order)] == evens_in_order)
print("stability check (odds): ", stable_result[len(evens_in_order):] == odds_in_order)
evens_after_unstable = [x for x in unstable_copy if is_even(x)]
odds_after_unstable = [x for x in unstable_copy if not is_even(x)]
print("unstable evens order matches original order?", evens_after_unstable == evens_in_order)
print("unstable odds order matches original order? ", odds_after_unstable == odds_in_order)
Output:
input = [1, 4, 2, 7, 3, 8, 5, 9, 6]
unstable in-place result = [4, 2, 8, 6, 3, 1, 5, 9, 7]
stable (extra space) = [4, 2, 8, 6, 1, 7, 3, 5, 9]
stability check (evens): True
stability check (odds): True
unstable evens order matches original order? True
unstable odds order matches original order? False
The stable variant preserves order for BOTH groups, as guaranteed. The unstable variant's evens [4, 2, 8, 6] happen to keep their original relative order for this particular input, but the odds group does not: it ends as [3, 1, 5, 9, 7] instead of the original relative order [1, 7, 3, 5, 9]. That asymmetry is instructive: instability is not a guarantee that EVERY group gets scrambled, it is the absence of a guarantee that ANY group stays in order, and this input concretely demonstrates the odds group breaking.
Trade-offs and pitfalls
- The three-way trade-off (space, stability, time) is the deep point of this question: claiming the swap-based partition is "basically the same as the stable one, just in place" misses that the relative-order guarantee is genuinely lost, not merely an implementation detail.
- A common bug is forgetting to swap (writing
arr[write] = arr[read]instead) in the O(1)-space version, which overwrites and destroys data rather than reordering it; this only works if discarding the unmatched values is acceptable, otherwise a real swap is required. - The O(n)-space stable variant needs no swapping subtlety at all; that simplicity is itself a valid engineering argument, plenty of real systems accept O(n) extra space for a partition specifically to keep the code obviously correct.
- Do not assume "in-place" always means "faster": the swap-based version is not asymptotically faster than the two-list version, both are O(n) time, it only saves memory, and if that memory saving is not needed, the added subtlety may not be worth it.
For heavy-duty string processing in pandas, compare performance of using python loops (apply), pandas vectorized Series.str methods, and numpy.char functions. Given a 10M-row DataFrame, explain how you'd measure and optimize a tokenization pipeline for speed and memory.
Sample Answer
Direct answer
.apply() with a Python function calls the interpreter once per row, so its cost is dominated by Python function-call and frame overhead repeated 10 million times. Series.str methods look vectorized but for the default pandas object dtype they are a C-level loop that still calls Python string methods per element internally, so they mainly remove the apply/lambda call overhead, not the per-element string-processing cost itself. numpy.char gives genuine C-level looping, but it first requires converting the column to a fixed-width NumPy unicode array, and every string gets padded to the length of the LONGEST string in the column, which can be a serious memory cost with even one long outlier in 10 million rows. For real vectorized speed at that scale the accurate move is pandas' PyArrow-backed string dtype (or stepping outside pandas entirely to Polars), not numpy.char.
Structured elaboration
.apply() (Python loop). Complexity is O(n) but with a large constant factor: each row triggers a full Python function call (frame creation, bytecode dispatch inside the lambda, boxing/unboxing of Python string objects). Nothing about this is vectorized; it is a disguised Python for loop.
Series.str vectorized methods. For the default object dtype, a pandas string column is a NumPy array of POINTERS to individual Python str objects. .str.lower(), .str.strip(), and similar calls are implemented as a loop (in Cython, faster than a Python-level for) that still invokes the underlying Python string method on each element. This removes the per-row apply/lambda call overhead and Python-level loop bookkeeping, so it is typically faster than .apply(), but it is not vectorized in the CPU/SIMD sense the way numpy arithmetic on a float array is: each element still gets an individual Python-level string operation.
numpy.char functions. These operate on a fixed-width NumPy unicode array (dtype like <U12), which is genuinely vectorized C code with no per-element Python call. The cost is that building this array from a pandas string column requires padding (or truncating) every entry to a single common width, namely the length of the longest string present. A column of mostly 10-character strings with one 500-character outlier forces every row's underlying buffer to 500 characters, multiplying memory by roughly 50x for no reason related to the average case. numpy.char also does not implement every string operation (there is no vectorized split), so a full tokenization pipeline cannot be done in numpy.char alone: the final split step still needs a Python-level loop or a different tool.
Getting genuine vectorization at 10M rows. pandas 2.x's PyArrow-backed string dtype (pd.ArrowDtype(pa.string()), or the shorthand "string[pyarrow]") stores strings in Arrow's variable-length UTF-8 buffer format and executes string operations through Arrow's compiled compute kernels: no fixed-width padding, and per-element cost is a real vectorized cost reduction rather than just less interpreter overhead. This is the currently recommended path for large string columns in pandas specifically because it avoids both the numpy.char padding tax and the object-dtype per-element Python-call tax. Where the workload no longer fits comfortably in memory or on one core, moving outside pandas to Polars (native vectorized string kernels, no object-dtype layer) or Dask (chunked, parallel, out-of-core) is the next step up.
How you would actually measure it. Time comparisons should use a repeatable, environment-relative tool (timeit/%timeit in a notebook, or time.perf_counter around repeated runs) and be reported as a RATIO between approaches on the same machine and the same data, not as an absolute number, because absolute wall-clock time is hardware- and load-dependent and will not reproduce on a different machine. Memory should be measured with tracemalloc for general Python allocations or, pandas-specifically, DataFrame.memory_usage(deep=True) (the deep=True flag matters: without it, an object-dtype column reports only the size of the pointer array, not the actual string objects it points to, which drastically understates real memory use).
Optimizing the pipeline itself for 10M rows. Read in bounded chunks (pd.read_csv(..., chunksize=...)) to cap peak memory instead of loading the whole file at once. Prefer the PyArrow-backed string dtype from the start rather than converting after the fact. Collapse multiple chained .str.replace() calls into a single combined regex or str.translate pass: each .str.replace() call allocates a brand-new full-length Series, so five chained calls pay roughly five separate full-column allocations instead of one.
Worked example
import pandas as pd
import numpy as np
data = pd.DataFrame({"raw_text": [
"Hello World", " Pandas STR Methods ", "NumPy-Char Functions!",
"Tokenize, This Sentence.", "UPPER lower MiXeD",
]})
def tokenize_py(s):
return s.strip().lower().replace(",", "").replace(".", "").replace("!", "").split()
result_apply = data["raw_text"].apply(tokenize_py)
result_str = (
data["raw_text"].str.strip().str.lower()
.str.replace(",", "", regex=False).str.replace(".", "", regex=False)
.str.replace("!", "", regex=False).str.split()
)
np_arr = data["raw_text"].to_numpy(dtype=str)
np_clean = np.char.replace(np.char.replace(np.char.replace(
np.char.lower(np.char.strip(np_arr)), ",", ""), ".", ""), "!", "")
result_np = [s.split() for s in np_clean] # numpy.char has no vectorized split
assert list(result_apply) == list(result_str) == result_np
print("identical tokenization:", list(result_str)[0])
# The fixed-width memory trap, concretely:
print(pd.Series(["a", "bb", "ccc"]).to_numpy(dtype=str).dtype) # <U3
print(pd.Series(["a", "bb", "c" * 50]).to_numpy(dtype=str).dtype) # <U50
Output:
identical tokenization: ['hello', 'world']
<U3
<U50
All three approaches agree on the pinned sample (an equivalence check, not a timing benchmark). The dtype output is the concrete evidence for the fixed-width claim: adding one 50-character string to an otherwise-tiny column forces the whole array's per-element width to 50, regardless of how short the other rows are.
Trade-offs and pitfalls
The most common misconception is treating Series.str as fully vectorized the way numpy arithmetic is; for the default object dtype it only removes call overhead, not per-element cost, and a candidate who states this without the object-dtype caveat is glossing over exactly the distinction the question is testing. The numpy.char fixed-width padding trap is easy to miss because it is invisible on clean, uniform-length synthetic data and only shows up with real-world text containing outliers, exactly the situation a 10M-row production dataset is likely to have. Never cite a fixed wall-clock number ("this ran in 40ms") as a claimed fact: that number is specific to one machine's hardware and load, and does not reproduce; report methodology (which tool, what you would compare) and, if you have actually measured it yourself, a same-machine RATIO between approaches rather than an absolute duration. Chaining several separate .str calls is a subtler trap: each one is a full pass allocating a new Series, so five chained calls cost roughly five allocations where a single combined regex or translate table would cost one; this matters more, not less, as row count grows into the tens of millions.
Implement a Python function that finds all unique triplets in the array which gives the sum of zero (3Sum). Example: nums = [-1,0,1,2,-1,-4] -> [[-1,-1,2],[-1,0,1]]. Aim for O(n^2) time using sorting + two-pointer and discuss how this pattern generalizes to k-sum problems.
Sample Answer
Direct answer
Sort the array, then fix each element in turn as the "anchor" and use a two-pointer scan over the remaining sorted suffix to find pairs that complete the triplet to zero, skipping over duplicate values at every level to avoid duplicate triplets. This runs in O(n^2) time (O(n log n) sort plus an O(n) two-pointer scan for each of n anchors) and O(1) extra space beyond the output and the sort itself. The same anchor-then-two-pointer idea generalizes to k-sum by recursing: fix one more element per level until only two remain, then solve that base case with two pointers.
Algorithm: 3Sum
- Sort
nums. - For each index
i(the anchor), skip it if it's a duplicate of the previous anchor (nums[i] == nums[i-1]), which prevents emitting the same triplet-starting-value twice. - If
nums[i] > 0, stop entirely: since the array is sorted, every remaining element is also non-negative, so no triplet starting here or later can sum to zero (unless all are zero, already handled by the anchor being the smallest of the three). - Two-pointer scan
left = i+1,right = n-1over the sorted suffix: if the three-element sum is 0, record it and advance both pointers past any duplicate values; if the sum is negative, advanceleft(need a larger value); if positive, retreatright(need a smaller value).
def three_sum(nums):
nums = sorted(nums)
n = len(nums)
result = []
for i in range(n - 2):
if i > 0 and nums[i] == nums[i - 1]:
continue
if nums[i] > 0:
break
left, right = i + 1, n - 1
while left < right:
total = nums[i] + nums[left] + nums[right]
if total == 0:
result.append([nums[i], nums[left], nums[right]])
left += 1
right -= 1
while left < right and nums[left] == nums[left - 1]:
left += 1
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif total < 0:
left += 1
else:
right -= 1
return result
Generalizing to k-sum
The pattern is: fix one element, reduce the target by its value, and recurse on k-1 with the remaining sorted suffix, until k == 2, at which point solve with the same two-pointer scan used above (which is really just 3Sum with the anchor already fixed by the outer recursion). Each recursive level still needs the duplicate-skip at its own position to avoid duplicate combinations, and a similar early-exit prune (if the smallest possible sum of the remaining k elements already exceeds the target, or the largest possible sum is already below it, stop).
def k_sum(nums, target, k):
nums = sorted(nums)
def helper(start, k, target):
n = len(nums)
if k == 2:
res = []
left, right = start, n - 1
while left < right:
total = nums[left] + nums[right]
if total == target:
res.append([nums[left], nums[right]])
left += 1
right -= 1
while left < right and nums[left] == nums[left - 1]:
left += 1
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif total < target:
left += 1
else:
right -= 1
return res
res = []
for i in range(start, n - k + 1):
if i > start and nums[i] == nums[i - 1]:
continue
for sub in helper(i + 1, k - 1, target - nums[i]):
res.append([nums[i]] + sub)
return res
return helper(0, k, target)
This runs in O(n^(k-1)) time: each of the k-2 outer recursive levels contributes a factor of O(n), and the base case two-pointer scan is O(n), for a total of O(n^(k-1)). 3Sum is exactly this generalization with k=3.
Worked example
import itertools
def brute_force_k_sum(nums, target, k):
nums_sorted = sorted(nums)
seen = set()
result = []
for combo_idx in itertools.combinations(range(len(nums_sorted)), k):
combo_vals = tuple(nums_sorted[i] for i in combo_idx)
if sum(combo_vals) == target and combo_vals not in seen:
seen.add(combo_vals)
result.append(list(combo_vals))
return sorted(result)
print(three_sum([-1, 0, 1, 2, -1, -4]))
k_sum_result = k_sum([1, 0, -1, 0, -2, 2], target=0, k=4)
print(k_sum_result)
brute = brute_force_k_sum([1, 0, -1, 0, -2, 2], target=0, k=4)
print("brute-force cross-check:", brute)
print("agree:", sorted(k_sum_result) == brute)
Output (verified by execution, and k_sum's results were independently cross-checked against a brute-force itertools.combinations scan over every k-subset of indices, so agreement is real validation, not the same logic checked twice):
[[-1, -1, 2], [-1, 0, 1]]
[[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
brute-force cross-check: [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
agree: True
Tracing why [-1, -1, 2] and [-1, 0, 1] are the only two triplets on the sorted input [-4, -1, -1, 0, 1, 2]: anchor -4 (index 0) needs a pair summing to 4 from [-1,-1,0,1,2], and the two-pointer scan finds none (max reachable pair sum here is -1+2=1, which the pointers converge past without hitting 4). Anchor -1 (index 1) needs a pair summing to 1 from [-1,0,1,2]: the scan finds -1 and 2 first (giving [-1,-1,2]), then continues and finds 0 and 1 (giving [-1,0,1]). The next anchor is also -1 (index 2), but it's skipped as a duplicate of index 1's value, which is exactly what prevents [-1,-1,2] (or a duplicate [-1,0,1]) from being emitted twice.
Trade-offs and pitfalls
- The two duplicate-skip lines are the single most error-prone part of this problem. Skipping the anchor's duplicates prevents duplicate triplet-starts; skipping
left/rightduplicates after recording a match prevents duplicate triplet-ends. Missing either one produces a correct set of values but with duplicate entries in the output. - The
nums[i] > 0: breakearly exit is an optimization, not a correctness requirement for 3Sum specifically (target 0): once sorted, a non-negative anchor means the two-pointer scan can never reach a negative-enough sum to cancel it out to zero. For a general k-sum with a nonzero target, this prune needs to be a real bounds check (is the target reachable at all given the remaining sorted suffix's min/max possible sums), not just a sign check. - k-sum's O(n^(k-1)) complexity grows fast: 4Sum is already O(n^3), and this approach stops being practical well before k gets large; for genuinely large k, a hash-map-based approach (precompute all pair sums for k/2 elements when k is even) trades space for a lower time exponent, at the cost of needing to dedupe combinations across the two halves.
- Common wrong turn: using a hash set of
frozensetor sorted tuples to dedupe triplets after generating all of them (including duplicates) via brute force. This works but is O(n^3) or worse and defeats the purpose of the sorted two-pointer approach, which prevents duplicates from being generated in the first place rather than filtering them out afterward.
Write rotate_right(arr, k) in Python to rotate an array to the right by k positions in-place using O(1) extra space. Discuss how modulo arithmetic affects k when k >= n, and explain the reversal trick (reverse whole array, then reverse parts). Provide examples and complexity analysis.
Sample Answer
Direct answer
Reduce k modulo n first, since rotating by a full n is a no-op and any k can be folded into [0, n). Then reverse the whole array once, and reverse each of the two resulting parts (the first k elements and the remaining n - k), which lands every element in its rotated position using only O(1) extra space and three linear passes.
Structured elaboration
- Why
k %= nfirst. A right rotation bynpositions returns the array to its original order, so anyk >= nis equivalent tok % n. Skipping this step means an implementation either does needless repeated work for largek, or (worse) indexes out of bounds when it assumesk < n. - The reversal trick, step by step. A right rotation by
kmoves the LASTkelements to the front and the FIRSTn - kelements to the back, each preserving their own relative order:- Reverse the whole array. Everything is now in fully reversed order.
- Reverse the first
kelements of THAT reversed array. Thosekelements were originally the array's lastkelements; reversing them twice (once by the whole-array reversal, once here) restores their original relative order, now correctly sitting at the front. - Reverse the remaining
n - kelements similarly, restoring the original relative order of what were the firstn - kelements, now correctly sitting at the back.
- Complexity. Three linear passes over the array:
O(n)time total,O(1)extra space (just the swap loop), no second array allocated.
Worked example
def rotate_right(arr, k):
n = len(arr)
if n == 0:
return arr
k %= n
def reverse(lo, hi):
while lo < hi:
arr[lo], arr[hi] = arr[hi], arr[lo]
lo += 1
hi -= 1
reverse(0, n - 1)
reverse(0, k - 1)
reverse(k, n - 1)
return arr
def slice_rotate_right(arr, k):
n = len(arr)
if n == 0:
return list(arr)
k %= n
return arr[-k:] + arr[:-k] if k else list(arr)
test_cases = [
([1, 2, 3, 4, 5, 6, 7], 3),
([1, 2, 3, 4, 5, 6, 7], 10),
([1, 2, 3, 4, 5, 6, 7], 7),
([1, 2, 3, 4, 5, 6, 7], 0),
([42], 5),
]
for arr, k in test_cases:
result = rotate_right(list(arr), k)
expected = slice_rotate_right(list(arr), k)
print(f"arr={arr}, k={k} -> {result}")
assert result == expected
print("cross-check against slice-based rotation passed for all 5 cases")
Output (executed, python3 s68_rotate_right.py, cross-checked against slice-based rotation for 5 cases):
arr=[1, 2, 3, 4, 5, 6, 7], k=3 -> [5, 6, 7, 1, 2, 3, 4]
arr=[1, 2, 3, 4, 5, 6, 7], k=10 -> [5, 6, 7, 1, 2, 3, 4]
arr=[1, 2, 3, 4, 5, 6, 7], k=7 -> [1, 2, 3, 4, 5, 6, 7]
arr=[1, 2, 3, 4, 5, 6, 7], k=0 -> [1, 2, 3, 4, 5, 6, 7]
arr=[42], k=5 -> [42]
cross-check against slice-based rotation passed for all 5 cases
k=10 on a 7-element array gives the identical result to k=3 (10 % 7 == 3), and k=7 (a full rotation) correctly leaves the array unchanged.
Trade-offs & pitfalls
- Forgetting the
k %= nreduction is the most consequential bug: it can index a reversal call withk - 1larger thann - 1, or simply wasteO(k/n)extra full passes for a largek. - Off-by-one in the two split-reversal calls (
reverse(0, k-1)andreverse(k, n-1)) is the most common implementation bug; verifying against a small hand-traced example (as above) catches this quickly. - Rotating LEFT by
kis the mirror image but with the reversal ORDER changed: reverse the firstk, reverse the remainingn - k, THEN reverse the whole array (right rotation reverses the whole array FIRST). Mixing up the order between left and right rotation is an easy transcription error. - Alternatives: an extra output array is
O(n)space but trivial to write correctly; a cycle-following ("juggling") algorithm is alsoO(1)space andO(n)time but is meaningfully harder to get right, since it needs to trackgcd(n, k)independent cycles rather than three flat passes. The reversal trick is generally preferred in an interview specifically because it's simple to reason about and hard to get subtly wrong.
Implement in Python a function that finds the maximum average subarray of length k in an array of floats. While coding, narrate each step, state assumptions, discuss time and space complexity, and walk through one example including k > n and negative numbers. Provide the implementation and explanation.
Sample Answer
Direct answer
Slide a fixed-size window of length k across the array, maintaining a running SUM (add the element entering the window, subtract the one leaving), track the best sum seen, and divide by k exactly once at the end. Treat k > n as an explicit invalid-input error rather than guessing a fallback, since no window of that length exists. The time complexity is O(n) and the space complexity is O(1) extra.
Structured elaboration, narrated step by step
- State the assumption up front.
kmust satisfy1 <= k <= n; ifk > n, there is no valid window, so raise explicitly rather than returning something like the whole-array average (which would silently answer a DIFFERENT question than the one asked). - Seed the window. Compute the sum of the first
kelements directly; this is the sum for the window starting at index 0. - Slide. For each subsequent starting position, update the running sum in
O(1): add the element newly entering the window on the right, subtract the element leaving on the left (window_sum += nums[i] - nums[i - k]). This avoids recomputing a fresh sum ofkelements for every window, which is what makes the whole scanO(n)instead ofO(n*k). - Track the best. Compare each window's sum (not yet divided) against the running best sum.
- Divide once, at the end. Return
best_sum / kafter the loop finishes, rather than dividing inside the loop on every step; the comparison of sums doesn't need the division at all, since dividing by the same constantknever changes which sum is largest. - All-negative arrays. The algorithm needs no special case here: the "best" window sum is still correctly the LEAST negative one, exactly analogous to the Kadane's-initialization trap, since nothing in this loop clamps the result toward 0.
Worked example
def max_average_subarray(nums, k):
n = len(nums)
if k <= 0:
raise ValueError("max_average_subarray: k must be positive")
if k > n:
raise ValueError(f"max_average_subarray: k={k} exceeds array length n={n}")
window_sum = sum(nums[:k])
best_sum = window_sum
for i in range(k, n):
window_sum += nums[i] - nums[i - k]
if window_sum > best_sum:
best_sum = window_sum
return best_sum / k
def max_average_subarray_brute_force(nums, k):
n = len(nums)
best = sum(nums[:k]) / k
for i in range(1, n - k + 1):
avg = sum(nums[i:i + k]) / k
if avg > best:
best = avg
return best
cases = [
([1.0, 12.0, -5.0, -6.0, 50.0, 3.0], 4),
([-1.0, -2.0, -3.0, -4.0], 2),
([5.0], 1),
]
for nums, k in cases:
result = max_average_subarray(nums, k)
brute = max_average_subarray_brute_force(nums, k)
print(f"nums={nums}, k={k} -> max_average={result}")
assert abs(result - brute) < 1e-9
try:
max_average_subarray([1.0, 2.0, 3.0], 5)
except ValueError as e:
print(f"k > n raised ValueError as expected: {e}")
print("brute-force cross-check passed for all cases")
Output (executed, python3 s70_max_avg_subarray.py, cross-checked against a brute-force O(n*k) implementation for every case):
nums=[1.0, 12.0, -5.0, -6.0, 50.0, 3.0], k=4 -> max_average=12.75
nums=[-1.0, -2.0, -3.0, -4.0], k=2 -> max_average=-1.5
nums=[5.0], k=1 -> max_average=5.0
k > n raised ValueError as expected: max_average_subarray: k=5 exceeds array length n=3
brute-force cross-check passed for all cases
The all-negative case correctly reports -1.5 (the window [-1.0, -2.0], the least-bad pair), not 0 or an unclamped positive value, and the k > n case raises with a message naming both the offending k and the actual array length rather than failing somewhere less informative.
Trade-offs & pitfalls
- All-negative arrays. As with Kadane's, don't clamp the result toward
0; the least-negative window average is the CORRECT answer, and a version seeded withbest_sum = 0would silently and wrongly prefer an empty or zero-sum comparison over the true (negative) best. - Dividing inside the loop on every step, instead of once at the end, is unnecessary floating-point work and a common code-review nit, though not a correctness bug at this scale; it also makes the "compare sums directly" optimization harder to see.
- At scale, the same technique underlies real trailing-moving-average code, for example computing a moving average of per-second request counts for rate-limiting or monitoring: the array becomes a live counter rather than a stored list, but the sliding-sum idea is identical. A Go port of that framing would typically keep the running sum as an integer type (e.g.
int64, since request counts are naturally integers, and to avoid float accumulation error over a long-running process) and convert tofloat64only at the final division, exactly mirroring the "divide once, at the end" principle above; it would also express thek > ncheck as an explicit returnederrorvalue rather than a raised exception, matching Go's idiomatic error-handling convention instead of Python's. - A vectorized (numpy) equivalent trades the explicit Python loop for a cumulative-sum array and slicing, which is the same
O(n)asymptotic work but with a much smaller constant factor in practice, useful once the array is large enough that Python's own per-iteration interpreter overhead dominates:
import numpy as np
def max_average_subarray_numpy(nums, k):
arr = np.asarray(nums, dtype=float)
n = arr.shape[0]
if k <= 0 or k > n:
raise ValueError(f"max_average_subarray_numpy: invalid k={k} for n={n}")
csum = np.cumsum(arr)
window_sums = np.empty(n - k + 1)
window_sums[0] = csum[k - 1]
window_sums[1:] = csum[k:] - csum[:-k]
return float(window_sums.max() / k)
cases = [
([1.0, 12.0, -5.0, -6.0, 50.0, 3.0], 4),
([-1.0, -2.0, -3.0, -4.0], 2),
([5.0], 1),
]
for nums, k in cases:
loop_result = max_average_subarray(nums, k)
numpy_result = max_average_subarray_numpy(nums, k)
print(f"nums={nums}, k={k} -> loop={loop_result}, numpy={numpy_result}, match={abs(loop_result - numpy_result) < 1e-9}")
assert abs(loop_result - numpy_result) < 1e-9
print("numpy variant agrees with the loop-based version to within 1e-9 for all three cases")
Output (executed, continuing in the same session as the loop-based version above):
nums=[1.0, 12.0, -5.0, -6.0, 50.0, 3.0], k=4 -> loop=12.75, numpy=12.75, match=True
nums=[-1.0, -2.0, -3.0, -4.0], k=2 -> loop=-1.5, numpy=-1.5, match=True
nums=[5.0], k=1 -> loop=5.0, numpy=5.0, match=True
numpy variant agrees with the loop-based version to within 1e-9 for all three cases
The numpy variant agrees with the loop-based version to within 1e-9 for all three cases, confirmed by the explicit comparison above rather than asserted.
Unlock Full Question Bank
Get access to all Arrays, Strings, and Hashing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.