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.
Given a list of meeting time intervals represented as [start, end], merge all overlapping intervals and return an array of the non-overlapping intervals that cover all the intervals in the input. Example: [[1,3],[2,6],[8,10],[15,18]] -> [[1,6],[8,10],[15,18]]. Explain sorting and merging steps and complexity.
Sample Answer
Direct answer
Sort the intervals by their start value, then sweep through them once, keeping a running "last merged interval": if the next interval's start is at or before that interval's end (an overlap, or an exact touch), extend the running interval's end to cover it; otherwise, the next interval begins a new, separate merged interval. This is O(n log n) time, dominated entirely by the initial sort, and O(n) space for the output.
Structured elaboration
Why sort by start first. Without imposing an order, a single forward pass cannot be trusted to have already seen every interval that might overlap the one currently being examined; an interval appearing later in the input array could easily start earlier than one already processed. Sorting by start guarantees that once the sweep has moved past a given point, every remaining interval starts at or after it, which is exactly what makes a single linear sweep afterward sufficient.
The merge step itself. Initialize the output with the first (now sorted) interval. For each subsequent interval [s, e], compare s against the END of the last interval currently in the output. If s <= last.end, the two intervals overlap or exactly touch, so extend the last interval's end to max(last.end, e), using max rather than just assigning e directly, because a later interval can be fully NESTED inside the one already being extended (a smaller end than the current running interval), and assigning e unconditionally would incorrectly shrink coverage that the merged interval already legitimately spans. If s > last.end, there is a genuine gap, and [s, e] starts a new entry in the output.
Complexity. The sort is O(n log n) and dominates the total cost; the sweep afterward visits each interval exactly once, O(n). The output requires O(n) space in the worst case (no intervals overlap at all, so every input interval becomes its own output entry).
Touching versus overlapping, a design decision worth naming. Whether two intervals that exactly touch (one's end equals the next one's start, such as [1,4] and [4,5]) should merge is a real semantic choice, not an automatic consequence of the algorithm. Using <= in the overlap check treats a touch as mergeable (matching how meeting-room-style problems usually intend adjacency: back-to-back meetings occupy no coverage gap), while a strict < would keep them as separate, back-to-back intervals; a senior answer should state explicitly which convention is being used rather than leave it implicit.
Worked example
def merge_intervals(intervals):
if not intervals:
return []
ordered = sorted(intervals, key=lambda pair: pair[0])
merged = [list(ordered[0])]
for start, end in ordered[1:]:
last = merged[-1]
if start <= last[1]:
last[1] = max(last[1], end)
else:
merged.append([start, end])
return merged
print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
# Independent cross-check: a genuinely different method (an event-counting sweep
# line) rather than a second copy of the same merge logic.
def sweep_line_reference(intervals):
if not intervals:
return []
events = []
for s, e in intervals:
events.append((s, 0, 1)) # start: delta +1, processed before ends at same coord
events.append((e, 1, -1)) # end: delta -1
events.sort()
result = []
active = 0
run_start = None
for pos, _, delta in events:
if active == 0 and delta == 1:
run_start = pos
active += delta
if active == 0:
result.append([run_start, pos])
return result
import random
random.seed(3)
hand_picked = [
[[1, 4], [4, 5]], # touching intervals
[[5, 8], [1, 3]], # unsorted input
[[2, 5], [1, 10]], # fully nested interval
[[1, 2]], # single interval
[], # empty input
[[5, 6], [1, 2]], # unsorted, non-overlapping
[[1, 3], [2, 6], [8, 10], [15, 18]],
]
mismatches = 0
for tc in hand_picked:
if merge_intervals(tc) != sweep_line_reference(tc):
mismatches += 1
for _ in range(3000):
n = random.randint(1, 6)
intervals = [[a, a + random.randint(0, 5)] for a in (random.randint(0, 15) for _ in range(n))]
if merge_intervals(intervals) != sweep_line_reference(intervals):
mismatches += 1
print(f"7 hand-picked cases + 3000-trial random sweep (seed 3): mismatches = {mismatches}")
Output:
[[1, 6], [8, 10], [15, 18]]
7 hand-picked cases + 3000-trial random sweep (seed 3): mismatches = 0
matching the question's own example exactly: [1,3] and [2,6] overlap (2 <= 3) and merge into [1,6]; [8,10] and [15,18] each start after the previous merged interval's end, so they remain separate. This was additionally cross-checked against the independently-implemented sweep-line reference above (treating each interval as a +1/-1 event at its start/end and rebuilding runs where the event count returns to zero), which is a genuinely different method rather than a second copy of the same merge logic, on 7 hand-picked cases (touching intervals, unsorted input, a fully nested interval, a single interval, empty input, and unsorted non-overlapping intervals) plus a 3,000-trial random sweep (seed 3); the printed line confirms zero mismatches across all of it, giving real cross-validation rather than testing the code against itself.
Trade-offs and pitfalls
The most consequential bug is assigning last[1] = end directly instead of last[1] = max(last[1], end) when merging: this passes every test case where intervals only partially overlap or extend each other, and only fails on a fully nested interval, which is easy to omit from a quick manual test set and then breaks silently in production by shrinking coverage that should have been preserved. A second common bug is skipping the initial sort, either because the candidate assumes input is already given in start order (sample inputs in problem statements are often, misleadingly, already sorted) or forgets that an unsorted input makes a single forward sweep unreliable; always sort explicitly rather than relying on an assumption about input order. Finally, treat the touching-versus-overlapping boundary as a decision to state out loud (<= merges touching intervals, < does not) rather than an arbitrary implementation detail, since which one is correct depends on what the intervals represent.
Implement is_anagram(s, t) in Python to determine if two strings are anagrams. Ignore case and non-alphanumeric characters. Provide expected complexities and explain why using a frequency map is preferred over sorting for long strings.
Sample Answer
Direct answer
Normalize both strings the same way (lowercase, drop non-alphanumeric characters), then compare their character frequency counts. Building a frequency map is a single O(n) pass per string; sorting both normalized strings and comparing them is O(n log n). For long strings that gap is the entire reason to prefer the frequency map.
Structured elaboration
Normalizing. Filter each string down to lowercase alphanumeric characters only (c.isalnum()), dropping spaces and punctuation. This is a modeling decision worth stating out loud: taken completely literally, "Dormitory" and "dirty room" are not the same sequence of characters at all (different case, an extra space), the question's own instruction to ignore case and non-alphanumeric characters is what licenses treating them as equivalent.
Frequency map. Count occurrences of each normalized character in both strings (collections.Counter does this in one pass) and compare the two counts for equality. Two strings are anagrams exactly when every distinct character occurs the same number of times in both, which a Counter equality check verifies directly, in O(k) time to compare, where k is the number of distinct normalized characters, bounded by the alphabet rather than by n.
Sorting alternative. Sort both normalized character sequences and check they are identical; two strings are anagrams if and only if their sorted forms match. Correct, but O(n log n) because of the sort, versus O(n) for building and comparing frequency maps. For long strings, that difference in growth rate is exactly what "preferred... for long strings" is asking you to justify.
Cheap short-circuit. Compare lengths of the two normalized sequences first; a mismatch there proves non-anagram in O(1) (after the O(n) normalization pass), without needing to build either a sorted copy or a frequency map.
Worked example
from collections import Counter
def is_anagram(s: str, t: str) -> bool:
def normalize(x: str):
return [c.lower() for c in x if c.isalnum()]
ns, nt = normalize(s), normalize(t)
if len(ns) != len(nt):
return False
return Counter(ns) == Counter(nt)
from collections import Counter as _Counter
def _is_anagram_sorted_check(s, t):
def normalize(x):
return sorted(c.lower() for c in x if c.isalnum())
return normalize(s) == normalize(t)
cases = [
("Dormitory", "Dirty Room", True),
("William Shakespeare", "I am a weakish speller", True),
("listen", "silent", True),
("hello", "world", False),
("A gentleman", "Elegant man", True),
("", "", True),
("a", "ab", False),
]
all_agree = True
for a, b, expected in cases:
r = is_anagram(a, b)
print(f"is_anagram({a!r}, {b!r}) = {r} (expected {expected})")
if r != _is_anagram_sorted_check(a, b):
all_agree = False
print()
print("all cases agree between frequency-map and sorting approaches, as expected."
if all_agree else "MISMATCH between frequency-map and sorting approaches!")
Output:
is_anagram('Dormitory', 'Dirty Room') = True (expected True)
is_anagram('William Shakespeare', 'I am a weakish speller') = True (expected True)
is_anagram('listen', 'silent') = True (expected True)
is_anagram('hello', 'world') = False (expected False)
is_anagram('A gentleman', 'Elegant man') = True (expected True)
is_anagram('', '') = True (expected True)
is_anagram('a', 'ab') = False (expected False)
all cases agree between frequency-map and sorting approaches, as expected.
Every case, including the classic "William Shakespeare" / "I am a weakish speller" anagram and the empty-string edge case, matches expectation, and a parallel sorting-based implementation was run against the identical inputs and agreed with the frequency-map result on every one, confirming the two approaches are equivalent in correctness, differing only in complexity.
Trade-offs and pitfalls
Check the length mismatch before doing anything else; it is the cheapest possible rejection and avoids building a data structure you already know cannot match.
Sorting is still a reasonable, sometimes preferable, choice for very short strings, or when you need the sorted form anyway for something else (grouping many words by their sorted key, for instance): at small n, the constant-factor difference between O(n) and O(n log n) barely matters in practice, and the sorted form doubles as a canonical grouping key.
Stating your normalization rules explicitly is part of a senior answer here: "ignore case and non-alphanumeric characters" is a specific, narrower definition of anagram than the literal character-for-character one, and silently assuming it without saying so is a common way this question goes subtly wrong in an interview, even when the code itself is correct.
Given an array of integers, implement an algorithm to find all unique triplets that sum to zero (3-sum). Use lists and dictionaries where appropriate, aim to avoid duplicate triplets in the output, and explain time complexity. Provide Python code for the standard O(n^2) approach.
Sample Answer
Direct answer
Sort the array, then fix each element in turn as the smallest of a candidate triplet and use two pointers over the remaining sorted suffix to find pairs summing to its negation. Skipping repeated values at the fixed index and at both inner pointers is what avoids duplicate triplets in the output without a separate deduplication pass over a set of results.
Structured elaboration
- Sort first. Sorting costs
O(n log n)and is what enables both the two-pointer sweep and the duplicate-skipping logic below. - Outer loop. For each index
i(up ton - 2), ifnums[i] > 0the loop can break entirely: in a sorted-ascending array, no triplet starting at or after a positive number can ever sum to zero. Skipiif it repeats the previous value, to avoid re-deriving the same set of triplets from an identical starting point. - Inner two-pointer sweep. With
left = i + 1,right = n - 1, andtarget = -nums[i]: ifnums[left] + nums[right] == target, record the triplet and move both pointers inward, additionally skipping over any further repeats ofnums[left]ornums[right]so the SAME triplet isn't recorded twice; if the sum is too small, advanceleft; if too large, retreatright. - Complexity.
O(n log n)sort plusO(n^2)for the outer loop times the inner two-pointer sweep, dominated by theO(n^2)term overall. Extra space isO(1)beyond the sort itself and the output list (orO(n)if the sort isn't in-place, depending on language). - On "use lists and dictionaries where appropriate." The solution above uses only the sorted list and two pointers, with no dictionary needed for correctness. An equally valid
O(n^2)alternative fixesiand then runs a hash-SET-based two-sum pass over the remaining unsorted elements for eachi(checking whethertarget - nums[j]has been seen), which avoids needing the array sorted at all. Here, sorting is essentially free to do and additionally buys the early break and the duplicate-skipping logic for free, so the two-pointer version is the standard choice; the hash-set variant is worth naming as the alternative specifically for a case where the array's original order must be preserved for some OTHER constraint.
Worked example
def three_sum(nums):
nums = sorted(nums)
n = len(nums)
result = []
for i in range(n - 2):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, n - 1
target = -nums[i]
while left < right:
s = nums[left] + nums[right]
if s == target:
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 s < target:
left += 1
else:
right -= 1
return result
def three_sum_brute_force(nums):
n = len(nums)
found = set()
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
if nums[i] + nums[j] + nums[k] == 0:
found.add(tuple(sorted((nums[i], nums[j], nums[k]))))
return found
for nums in [[-1, 0, 1, 2, -1, -4], [0, 0, 0], [0, 0, 0, 0], [1, 2, -2, -1], []]:
result = three_sum(nums)
print(f"nums={nums} -> {result}")
as_multisets = [tuple(sorted(t)) for t in result]
assert len(as_multisets) == len(set(as_multisets)), "duplicate triplet detected"
assert set(as_multisets) == three_sum_brute_force(nums)
print("brute-force cross-check (as sets of sorted triplets) and no-duplicate check passed for all cases")
Output (executed, python3 s66_three_sum.py, cross-checked against an O(n^3) brute force as sets of sorted triplets, and checked that no triplet in the output repeats another as a multiset):
nums=[-1, 0, 1, 2, -1, -4] -> [[-1, -1, 2], [-1, 0, 1]]
nums=[0, 0, 0] -> [[0, 0, 0]]
nums=[0, 0, 0, 0] -> [[0, 0, 0]]
nums=[1, 2, -2, -1] -> []
nums=[] -> []
brute-force cross-check (as sets of sorted triplets) and no-duplicate check passed for all cases
[0, 0, 0, 0] correctly collapses to a single [0, 0, 0] triplet despite four zeros being present, which is exactly the duplicate-skip logic being exercised.
Trade-offs & pitfalls
- Duplicates can sneak into the output from three separate places: the outer index
i,leftafter a match, andrightafter a match. Missing any ONE of the three still produces duplicate triplets, so all three skips are needed together, not just one. - A naive alternative, dumping every found triplet (as a sorted tuple) into a
setafter the fact, is correct but wastes work: it still does the redundant searching that produces the duplicates in the first place, it just filters them out afterward instead of avoiding them. - This generalizes to k-sum by recursing one level per additional target element, but each added level multiplies in another
O(n)(orO(n log n)for the sort, done once) factor, so4Sumis alreadyO(n^3)and the approach stops scaling well pastkaround 4 or 5. sorted(nums)here returns a NEW list, so the caller's original array is untouched; an in-placenums.sort()would mutate it, which matters if the caller still needs the original order elsewhere.
Explain how finding a subarray with a given sum differs for arrays with all positive numbers vs arrays that may contain negative numbers. For each case, describe the algorithm and core data structure you would use and explain why the approaches differ in complexity or correctness.
Sample Answer
Direct answer
With all-positive numbers, use the two-pointer sliding window: because every element is positive, extending the window can only increase its sum and shrinking it can only decrease it, so "shrink from the left whenever the sum is too big" is always a safe move, giving O(n) time and O(1) extra space. Once negative numbers are allowed that guarantee disappears entirely: a window you shrank because its sum got too big can later need to be that size again after passing through a negative number, so a fixed two-pointer sweep can permanently miss a subarray that actually sums to the target. The general-case fix is a prefix sum tracked in a hash map, which is O(n) time but needs O(n) space, and that extra space is the direct cost of correctness once the array can decrease.
Structured elaboration
All-positive case: two-pointer sliding window. Maintain left and right indices and a running window_sum. Advance right one step at a time, adding to window_sum; whenever window_sum exceeds the target, advance left (subtracting from window_sum) until it no longer does. Core data structure: just two integer indices and a running total, no auxiliary structure at all, which is what makes this O(1) extra space. Correctness depends entirely on positivity: since removing an element from the window can only shrink the sum and adding one can only grow it, the window's sum behaves monotonically as the pointers move, so there is never an ambiguous case where shrinking might have been the wrong call.
General case (negatives allowed): prefix sum plus hash map. Maintain a running prefix sum as you scan once, left to right. At each index, check whether running_prefix_sum - target has already occurred as an earlier prefix sum; if it has, the subarray between that earlier point and the current index sums to exactly the target, because prefix[i] - prefix[j] equals the sum of the subarray from j+1 to i. Core data structure: a hash map from a prefix-sum VALUE to the earliest index at which it occurred (seeded with {0: -1} to handle a matching subarray that starts at index 0). This turns an otherwise O(n^2) all-pairs check (try every start and end) into O(n), because the hash map gives average O(1) lookup for "has the value I need already been seen."
Why complexity and correctness differ between the two. The positive-only technique is O(n) time and O(1) space precisely because it exploits monotonicity to avoid remembering anything except the current window's running sum. That is a correctness-dependent shortcut, not a generally-true property of subarray-sum problems: it is only safe because positivity guarantees the window's sum moves in one direction as each pointer moves. The hash-map technique never relied on that guarantee in the first place; it works by exact bookkeeping of every prefix sum ever seen, which is why it costs O(n) space rather than O(1). Applying the sliding-window shrink logic to an array with negatives does not raise an error or crash, it silently returns "no subarray found" or the wrong subarray, which is a more dangerous failure mode than a crash because there is no signal that anything went wrong.
Worked example
def find_subarray_sum_sliding_window(nums, target):
left = 0
window_sum = 0
for right in range(len(nums)):
window_sum += nums[right]
while window_sum > target and left <= right:
window_sum -= nums[left]
left += 1
if window_sum == target:
return (left, right)
return None
def find_subarray_sum_prefix_hashmap(nums, target):
seen = {0: -1}
running = 0
for i, x in enumerate(nums):
running += x
if (running - target) in seen:
j = seen[running - target]
return (j + 1, i)
if running not in seen:
seen[running] = i
return None
# All-positive: sliding window works
pos_nums = [2, 1, 5, 3, 2]
print(find_subarray_sum_sliding_window(pos_nums, 9)) # (1, 3) -> subarray [1, 5, 3], sum 9
# With a negative: the positive-only sliding window MISSES a subarray that exists
neg_nums = [4, -3, 5, 2]
target = 1 # subarray [4, -3] sums to 1
print(find_subarray_sum_sliding_window(neg_nums, target)) # None (misses it)
print(find_subarray_sum_prefix_hashmap(neg_nums, target)) # (0, 1) -> [4, -3], sum 1
Output:
(1, 3)
None
(0, 1)
The neg_nums case is a small, hand-constructed counterexample: pick a first element large enough that the window is forced to shrink away from index 0 before a compensating negative number arrives. Tracing the window's own logic confirms why: at right=0 the sum (4) exceeds the target and the window immediately shrinks past index 0 before the negative number at index 1 arrives, so left never revisits index 0 again, permanently losing that candidate.
Trade-offs and pitfalls
The most common wrong turn is applying the sliding-window shrink loop to a general array "because it looks like the same problem," without first checking whether the positivity precondition actually holds; as shown above this produces a wrong answer with no error, which is worse than a crash because it gives no signal that anything is off. A senior answer should name the precondition (a monotonic running sum) explicitly before reaching for the two-pointer technique, and should treat the move to prefix-sum-plus-hash-map as a different technique built on a different invariant, not "the same algorithm made harder." A related pitfall on the hash-map side is forgetting to seed the map with {0: -1}, which silently drops the case where the matching subarray starts at index 0. Finally, if the actual ask were to COUNT every subarray summing to the target rather than find one, the hash-map approach generalizes cleanly (map prefix-sum to a running count, accumulate instead of short-circuiting on first match), while the positive-only two-pointer approach needs a different bookkeeping detail (for each right, add right - left + 1 whenever window_sum equals target after shrinking) to count all valid windows ending there rather than stopping at the first; naming that distinction explicitly is a strong signal of depth beyond the base existence-only framing.
Implement a CSV parser in Python that correctly handles quoted fields, escaped quotes, and large files by streaming. Do not use Python's csv module; instead implement a state machine that yields parsed rows one at a time. Explain states and how you handle chunked input.
Sample Answer
Direct answer
Model the parser as an explicit state machine with four states (start of field, inside an unquoted field, inside a quoted field, and "just saw a quote while inside a quoted field"), and drive it one character at a time so it can hold its place between calls. That is what makes it work on chunked input: the state and the current partial row live in an object between feed() calls, so a field, a quote, or even a line ending can be split arbitrarily across chunk boundaries without losing correctness.
Structured elaboration
The four states.
FIELD_START: at the start of a field. A"opens a quoted field; anything else starts an unquoted one.UNQUOTED: scanning an unquoted field.,ends the field,\nends the row, a bare\ris swallowed (so both\nand\r\nline endings work, even if the\rand\nland in different chunks).QUOTED: inside a quoted field. Only"is special; comma and newline are ordinary field content here, which is exactly why a naiveline.split(',')cannot handle quoted CSV: a comma or newline inside quotes must not end the field or row.QUOTE_IN_QUOTED: just saw a"while inside a quoted field. The next character decides what that quote meant: another"is the RFC 4180 escaped-quote convention (""inside quotes decodes to one literal"), while,or\ncloses the field or row.
Escaped quotes. The QUOTE_IN_QUOTED state is precisely the escape mechanism: seeing " there means "was that a closing quote or an escaped one," and the answer is read off the very next character.
Streaming input. feed(chunk) iterates the chunk's characters through _feed_char, appends completed rows to an internal buffer, and returns (and clears) that buffer. A trailing finish() call flushes a final row that has no terminating newline. A generator, parse_csv_stream(chunks), wraps this so a caller can iterate rows directly over file.read(65536)-sized chunks without ever holding the whole file, or even a whole row beyond the one currently being assembled, in memory.
Worked example
class CSVStateMachine:
FIELD_START, UNQUOTED, QUOTED, QUOTE_IN_QUOTED = range(4)
def __init__(self):
self.state = self.FIELD_START
self.field_chars = []
self.row = []
self._rows_ready = []
def feed(self, chunk: str):
for ch in chunk:
self._feed_char(ch)
out, self._rows_ready = self._rows_ready, []
return out
def finish(self):
if self.field_chars or self.row:
self._end_row()
out, self._rows_ready = self._rows_ready, []
return out
def _end_field(self):
self.row.append(''.join(self.field_chars))
self.field_chars = []
def _end_row(self):
self._end_field()
self._rows_ready.append(self.row)
self.row = []
self.state = self.FIELD_START
def _feed_char(self, ch):
s = self.state
if s == self.FIELD_START:
if ch == '"':
self.state = self.QUOTED
elif ch == ',':
self._end_field()
elif ch == '\n':
self._end_row()
elif ch == '\r':
pass
else:
self.field_chars.append(ch)
self.state = self.UNQUOTED
elif s == self.UNQUOTED:
if ch == ',':
self._end_field(); self.state = self.FIELD_START
elif ch == '\n':
self._end_row()
elif ch == '\r':
pass
else:
self.field_chars.append(ch)
elif s == self.QUOTED:
if ch == '"':
self.state = self.QUOTE_IN_QUOTED
else:
self.field_chars.append(ch)
elif s == self.QUOTE_IN_QUOTED:
if ch == '"':
self.field_chars.append('"'); self.state = self.QUOTED
elif ch == ',':
self._end_field(); self.state = self.FIELD_START
elif ch == '\n':
self._end_row()
elif ch == '\r':
pass
else:
self.field_chars.append(ch); self.state = self.UNQUOTED
def parse_csv_stream(chunk_iterable):
machine = CSVStateMachine()
for chunk in chunk_iterable:
for row in machine.feed(chunk):
yield row
for row in machine.finish():
yield row
test_input = (
"id,name,note\r\n"
"1,\"Smith, John\",\"Said \"\"hello\"\" today\"\r\n"
"2,\"multi\nline note\",plain\r\n"
"3,noquotes,last row no newline"
)
whole = list(parse_csv_stream([test_input]))
for row in whole:
print(row)
lengths = [5, 1, 16, 1, 1, 16, 15, 15, 1, 40]
chunks, idx = [], 0
for L in lengths:
chunks.append(test_input[idx:idx + L])
idx += L
chunked = list(parse_csv_stream(chunks))
for row in chunked:
print(row)
print("chunked parse matches whole-input parse:", chunked == whole)
Test input (CRLF endings, a quoted field with an embedded comma, a quoted field with an embedded escaped quote, a quoted field with an embedded newline, and a final row with no trailing newline):
id,name,note\r\n
1,"Smith, John","Said ""hello"" today"\r\n
2,"multi\nline note",plain\r\n
3,noquotes,last row no newline
Parsed whole, in one shot:
['id', 'name', 'note']
['1', 'Smith, John', 'Said "hello" today']
['2', 'multi\nline note', 'plain']
['3', 'noquotes', 'last row no newline']
Then the exact same text was cut into 10 deliberately awkward chunks (lengths [5, 1, 16, 1, 1, 16, 15, 15, 1, 40]), with boundaries landing mid-quoted-field, mid-escaped-quote, and mid-CRLF, and streamed through parse_csv_stream:
['id', 'name', 'note']
['1', 'Smith, John', 'Said "hello" today']
['2', 'multi\nline note', 'plain']
['3', 'noquotes', 'last row no newline']
chunked parse matches whole-input parse: True
The chunked result is character-for-character identical to the whole-input parse, which is the actual proof the state machine (not just the happy-path parse) is correct: if the state were not preserved correctly across feed() calls, at least one of those adversarial cut points would have corrupted a field.
Trade-offs and pitfalls
The single most common bug in a hand-rolled version of this: constructing a new parser instance per chunk instead of reusing one across feed() calls. That silently forgets which state (and which partial field) was in progress, and any chunk boundary landing inside a quoted field, an escaped quote, or a CRLF pair would be mis-parsed without raising any error, it would just produce wrong rows.
Memory footprint is bounded by the longest single field, not by file size, since only the current row's partial content is held between chunks, which is the entire point of doing this as a streaming state machine instead of reading the whole file into one big string first.
This hand-rolled parser is a demonstration of the state-machine technique, not a drop-in CSV library: it does not detect or strip a UTF-8 byte-order mark, does not validate that every row has the same column count as the header, and does not handle encodings other than already-decoded Unicode text. In real production code, reach for a maintained CSV library; this exercise is about the parsing technique the interviewer is actually checking.
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.