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.
Write a function in Python to determine whether two strings are anagrams of each other in a Unicode-aware way. Consider normalization, casefolding, and handling of combining marks. Aim for O(n) time and O(k) extra space where k is the distinct character count. Discuss trade-offs between sorting-based and counting-based approaches when the alphabet is large.
Sample Answer
Direct answer
Normalize both strings to a canonical Unicode form (NFC: compose combining marks into precomposed characters wherever a composed form exists), then casefold them (a Unicode-aware, more aggressive relative of .lower()), and finally compare character frequency counts using a hash map. Two strings are anagrams exactly when their normalized, casefolded character-count maps are equal. This is O(n) time and O(k) extra space, where k is the number of distinct characters actually present, not the size of the whole alphabet.
Structured elaboration
Why raw codepoint comparison fails on Unicode text. The same visible character can be represented by different codepoint sequences: an accented letter like an e with an acute accent can be one precomposed codepoint, or two codepoints (the base letter plus a separate combining acute-accent mark). Two strings that look identical to a human, and that a user would absolutely expect to be treated as anagrams of each other, can fail a naive character-by-character or Counter-based comparison if one uses the composed form and the other the decomposed form, because they are literally different sequences of codepoints.
Normalization (NFC) before comparison. Running both strings through Unicode Normalization Form C (NFC) converts any decomposed base-plus-combining-mark sequence into its precomposed equivalent wherever one exists, so that two visually-identical strings become byte-for-byte identical at the codepoint level before any counting happens. Normalization must happen before counting, not after, since counting decomposed and precomposed forms separately would treat them as different characters.
Casefolding, not just lowercasing. Python's .casefold() is used instead of .lower() because casefolding is defined specifically for caseless string matching and handles cases .lower() doesn't, most famously the German sharp s (ß), which casefolds to the two-character sequence ss (matching how ß and ss are treated as equivalent in caseless comparisons) while .lower() leaves it unchanged. This means casefolding can change a string's length, which matters for the next step.
Order of operations for the length check. The length check (len(s) != len(t) as a fast rejection before doing full character counting) must be performed on the normalized-and-casefolded strings, never on the raw input, precisely because casefolding can change length. Checking the raw lengths first, as a shortcut before normalization, is a subtle but real bug: it would incorrectly reject valid Unicode-aware anagram pairs whose raw lengths differ only because casefolding expands one of them.
Sorting-based versus counting-based comparison, and the large-alphabet trade-off. A sorting-based approach (sort both normalized/casefolded strings, compare for equality) costs O(n log n) time but only O(1) extra space if sorting can be done on a mutable copy in place (or O(n) if the language's sort isn't in-place), and it never needs a hash map at all, which matters when the character alphabet is enormous, since a sort never allocates space proportional to the alphabet size, only to the string length. A counting-based approach (build a frequency map, compare maps) is O(n) time but pays O(k) space for the map, where k is the number of distinct characters seen; for a small, fixed alphabet like lowercase ASCII, that map is trivially small and counting wins outright on speed, but for full Unicode text (over a million possible codepoints, even though any single string only uses a tiny fraction of them), a hash-map-based counter is still the right call because k is bounded by the input length itself, not by the alphabet size, since a Python dict/Counter only allocates entries for characters that actually appear.
Worked example
Full runnable code with pinned test cases, including the composed-versus-decomposed accented-character case and the German sharp-s casefold case (a genuine subtlety, not a contrived one):
import unicodedata
from collections import Counter
def normalize_for_compare(s):
"""NFC-normalize then casefold. Length check must happen AFTER this,
never on the raw string (see the STRASSE case below)."""
return unicodedata.normalize("NFC", s).casefold()
def is_anagram(s, t):
"""O(n) time, O(k) extra space where k is the distinct-character count,
counting-based rather than sorting-based."""
s_norm = normalize_for_compare(s)
t_norm = normalize_for_compare(t)
if len(s_norm) != len(t_norm):
return False
return Counter(s_norm) == Counter(t_norm)
if __name__ == "__main__":
# Built from explicit codepoint escapes so the composed/decomposed
# distinction is unambiguous regardless of source-file encoding.
precomposed = "caf" + "\u00e9" # c a f e-acute (1 codepoint, U+00E9)
decomposed = "caf" + "e" + "\u0301" # c a f e + combining acute (U+0301)
print("raw codepoint lengths (decomposed vs precomposed):", len(decomposed), len(precomposed))
print("raw equal (no normalization)?", decomposed == precomposed)
print("NFC-normalized equal?", unicodedata.normalize("NFC", decomposed) == unicodedata.normalize("NFC", precomposed))
reordered_precomposed = "\u00e9" + "fac" # e-acute f a c (reordered anagram)
strasse_lower = "stra" + "\u00df" + "e" # stra-sharp_s-e
tests = [
("listen", "silent", True),
("Listen", "Silent", True),
(precomposed, decomposed + "x", False),
(precomposed, reordered_precomposed, True),
(decomposed, reordered_precomposed, True),
("STRASSE", strasse_lower, True), # casefold('ss') == casefold('\u00df')
("ab", "abc", False),
]
for a, b, expected in tests:
result = is_anagram(a, b)
print(f"is_anagram({a!r}, {b!r}) = {result} (expected {expected})")
print("raw len(strasse_lower) =", len(strasse_lower), " raw len('STRASSE') =", len("STRASSE"))
print("casefold(strasse_lower) =", strasse_lower.casefold())
print("casefold('STRASSE') =", "STRASSE".casefold())
Output (actual run):
raw codepoint lengths (decomposed vs precomposed): 5 4
raw equal (no normalization)? False
NFC-normalized equal? True
is_anagram('listen', 'silent') = True (expected True)
is_anagram('Listen', 'Silent') = True (expected True)
is_anagram('café', 'caféx') = False (expected False)
is_anagram('café', 'éfac') = True (expected True)
is_anagram('café', 'éfac') = True (expected True)
is_anagram('STRASSE', 'straße') = True (expected True)
is_anagram('ab', 'abc') = False (expected False)
raw len(strasse_lower) = 6 raw len('STRASSE') = 7
casefold(strasse_lower) = strasse
casefold('STRASSE') = strasse
The STRASSE / straße case is the one to walk through out loud in an interview: the raw strings have different lengths (7 versus 6 codepoints), so a fast-reject on raw length would wrongly report "not an anagram." But ß casefolds to the two characters ss, so both strings casefold to the same 7-character string strasse, and they are correctly identified as an anagram pair. This is exactly why the length check must run on the normalized-and-casefolded strings.
Trade-offs and pitfalls
- Comparing raw strings, or even lowercasing with
.lower()instead of.casefold(), silently fails on real internationalized input like theß/sscase above;.lower()alone leavesßunchanged and would reportSTRASSEandstraßeas not anagrams, which is wrong under Unicode caseless matching rules. - Checking length before normalizing (a tempting micro-optimization to avoid normalizing strings that "obviously" can't match) is a genuine bug source, not a harmless shortcut, precisely because casefolding and normalization can both change apparent length.
- Sorting-based comparison is the right call when the alphabet is small and fixed (interview-classic lowercase-English anagram checks) since it avoids hash-map overhead entirely; counting-based comparison is the right call once the input might be full Unicode text, since a hash map's cost scales with how many distinct characters actually appear in this particular input, not with the size of the Unicode codepoint space.
- Grapheme-cluster-level correctness (treating an emoji-with-modifier sequence, or a base character plus multiple combining marks, as a single user-perceived "character") is a further layer beyond codepoint-level NFC normalization; this answer normalizes and compares at the codepoint level, which is sufficient for the vast majority of real anagram-style interview questions, but a fully grapheme-aware comparison would need a dedicated segmentation library, which is depth beyond what this question is testing.
- NFC (compose) rather than NFD (decompose) is used here because it produces the more compact, more widely-used-as-a-default form; either would work correctly as long as it's applied consistently to both strings before comparison, since the point is only that both strings land on the same normalized form, not which specific form is chosen.
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.
Implement is_subsequence(short: str, long: str) -> bool in Python that checks whether 'short' is a subsequence of 'long' (characters in order but not necessarily contiguous). This is used in approximate matching and fuzzy token mapping. Your solution should be O(n) time where n is length of 'long'. Provide an example and handle edge cases.
Sample Answer
Direct answer
Walk through long once with a single pointer, and advance a second pointer into short only when the current character of long matches the character short is currently waiting for. If the pointer into short reaches the end before long runs out, every character of short was found in order, so short is a subsequence of long.
Structured elaboration
Approach
def is_subsequence(short, long):
i = 0
if not short:
return True
for ch in long:
if i < len(short) and ch == short[i]:
i += 1
if i == len(short):
return True
return i == len(short)
Only one pass over long is made, and the pointer into short never moves backward, so the total work is O(n) where n is the length of long, matching the question's explicit complexity requirement. No extra data structure is needed since matching only ever needs to compare the CURRENT position of short against the current character of long.
Application context (the question's explicit ask)
This same one-pass check is the building block behind approximate matching and fuzzy token mapping: for example, checking whether a user's typed abbreviation could plausibly expand to a longer canonical term ("gcm" as a subsequence of "google cloud monitoring"), or filtering a large candidate list down to the ones that could still match a partially typed query, before applying a more expensive scoring step only to that smaller candidate set.
Worked example
Executed with python3 s83.py, five pinned cases including two explicit edge cases (empty short, empty long):
is_subsequence('abc', 'ahbgdc') = True expected=True match=True
is_subsequence('axc', 'ahbgdc') = False expected=False match=True
is_subsequence('', 'anything') = True expected=True match=True
is_subsequence('abc', '') = False expected=False match=True
is_subsequence('ace', 'abcde') = True expected=True match=True
'abc' is found in order inside 'ahbgdc' (a, then b, then c, each appearing later than the last), so it returns True. 'axc' fails because after matching 'a', no 'x' appears anywhere later in 'ahbgdc', so the pointer into short never reaches the end.
Trade-offs and pitfalls
- The empty-
short-is-always-a-subsequence edge case (is_subsequence('', 'anything')returningTrue) is easy to get backwards if the loop logic is written slightly differently; the explicitif not short: return Trueguard above makes this an intentional decision rather than an accident of how the loop happens to terminate. - If this check needs to run many times against the SAME
longstring with many differentshortcandidates, a smarter structure (precomputing, for each position and character, the next occurrence of that character) avoids repeating the full O(n) scan per query, at the cost of O(n * alphabet size) preprocessing; that preprocessing trade is only worth it when the number of queries against the samelongstring is large. - This only answers yes/no. If the caller also needs the actual matched positions in
long(for example, to highlight which characters satisfied the match), track and return the index list as the pointer advances, rather than just the boolean.
You receive a log line in this format (single line):
2025-12-06T12:00:00Z service=auth pid=1234 level=ERROR msg='Failed login for user bob: invalid password'
Write a Python parser that extracts timestamp, service, pid (int), level, and msg into a dict. Handle missing or quoted messages safely. Discuss performance considerations when parsing millions of lines per hour.
Sample Answer
Direct answer
Parse the line as a leading timestamp token followed by a run of key=value tokens, where a value is either a bare token or a quoted string that can itself contain spaces or an escaped quote. Scan it manually, character by character, rather than with one big anchored regular expression: a single anchored pattern matches the whole line or fails the whole line, and "handle missing fields safely" specifically means a missing field should leave that one key unset, not blow up the entire parse.
Structured elaboration
Tokenizing approach. Read the leading whitespace-delimited token as the timestamp (an ISO 8601 style stamp, 2025-12-06T12:00:00Z, though the parser does not need to validate that format, only extract it). Then repeatedly: skip whitespace, read a key up to =, and read the value either as a bare token (up to the next space) or, if the next character is a quote, as everything up to the matching unescaped quote, unescaping any \' along the way.
Safe field handling. Every expected key (timestamp, service, pid, level, msg) starts at None in the result dict. A key that never appears in the line simply stays None; a malformed pid (not all digits) is caught with a try/except around the int() conversion and also left as None, instead of raising and losing the whole line. That is the concrete meaning of "handle missing or quoted messages safely" here: partial, defensive results instead of an exception.
Performance at millions of lines per hour. The scan itself is a single O(n) pass per line, O(1) extra space beyond the output dict, no backtracking. The throughput number matters for scale: 1,000,000 / 3600 ≈ 277.8 lines per second, a modest rate for a single-threaded O(n)-per-line parse. The parsing technique's job at that rate is to not add avoidable overhead: avoid rebuilding the same intermediate list-and-rejoin structure that line.split() plus manual reassembly would need, and avoid a regex whose quantifiers could backtrack badly on adversarial input (many repeated escaped quotes inside msg). Everything past that (batching, buffering, multiple worker processes) is an ingestion-pipeline design question, a different skill from the array/string parsing technique this question is actually testing.
Worked example
def parse_log_line(line: str) -> dict:
result = {"timestamp": None, "service": None, "pid": None, "level": None, "msg": None}
n = len(line)
i = 0
# 1. timestamp is the leading whitespace-delimited token
while i < n and line[i] == ' ':
i += 1
start = i
while i < n and line[i] != ' ':
i += 1
if i > start:
result["timestamp"] = line[start:i]
# 2. remaining key=value tokens, respecting quoted values
while i < n:
while i < n and line[i] == ' ':
i += 1
if i >= n:
break
key_start = i
while i < n and line[i] != '=' and line[i] != ' ':
i += 1
if i >= n or line[i] != '=':
while i < n and line[i] != ' ':
i += 1
continue
key = line[key_start:i]
i += 1 # skip '='
if i < n and line[i] in ("'", '"'):
quote = line[i]
i += 1
val_start = i
while i < n:
if line[i] == '\\' and i + 1 < n and line[i + 1] == quote:
i += 2
continue
if line[i] == quote:
break
i += 1
raw_val = line[val_start:i]
value = raw_val.replace('\\' + quote, quote)
if i < n:
i += 1 # skip closing quote
else:
val_start = i
while i < n and line[i] != ' ':
i += 1
value = line[val_start:i]
if key in result:
result[key] = value
if result["pid"] is not None:
try:
result["pid"] = int(result["pid"])
except ValueError:
result["pid"] = None
return result
test_lines = [
"2025-12-06T12:00:00Z service=auth pid=1234 level=ERROR msg='Failed login for user bob: invalid password'",
"2025-12-06T12:00:01Z service=auth level=INFO msg='pid missing on this line'",
"2025-12-06T12:00:02Z service=auth pid=5678 level=WARN msg='He said \\'hi\\' twice'",
"2025-12-06T12:00:04Z service=auth pid=notanumber level=INFO msg='bad pid value'",
]
for t in test_lines:
print(repr(t))
print(parse_log_line(t))
print("1,000,000 lines/hour =", round(1_000_000 / 3600, 1), "lines/second")
Output, run against the sample line plus edge cases:
"2025-12-06T12:00:00Z service=auth pid=1234 level=ERROR msg='Failed login for user bob: invalid password'"
{'timestamp': '2025-12-06T12:00:00Z', 'service': 'auth', 'pid': 1234, 'level': 'ERROR', 'msg': 'Failed login for user bob: invalid password'}
"2025-12-06T12:00:01Z service=auth level=INFO msg='pid missing on this line'"
{'timestamp': '2025-12-06T12:00:01Z', 'service': 'auth', 'pid': None, 'level': 'INFO', 'msg': 'pid missing on this line'}
"2025-12-06T12:00:02Z service=auth pid=5678 level=WARN msg='He said \\'hi\\' twice'"
{'timestamp': '2025-12-06T12:00:02Z', 'service': 'auth', 'pid': 5678, 'level': 'WARN', 'msg': "He said 'hi' twice"}
"2025-12-06T12:00:04Z service=auth pid=notanumber level=INFO msg='bad pid value'"
{'timestamp': '2025-12-06T12:00:04Z', 'service': 'auth', 'pid': None, 'level': 'INFO', 'msg': 'bad pid value'}
1,000,000 lines/hour = 277.8 lines/second
Every field from the question (timestamp, service, pid as int, level, msg) is produced, a missing pid and a missing quoted value both degrade to None instead of raising, and the escaped-quote case (\'hi\') is correctly unescaped to a literal quote inside the message.
Trade-offs and pitfalls
A single anchored regex (^(?P<timestamp>\S+)\s+service=...) is more compact to write, but it is all-or-nothing: reorder the fields, drop one, or add an unexpected key, and the whole line fails to match instead of degrading field by field. If you do reach for a regex per field, keep quantifiers bounded (avoid nested .* inside the quoted-value group) so a message packed with escaped quotes cannot trigger catastrophic backtracking.
Swallowing a bad pid to None is the right behavior for a pipeline that must never crash on one bad line, but silently doing nothing else is its own risk: track a counter of how many lines failed to parse a given field, or a silent upstream format change (a service starts emitting pid as a hex string, say) goes unnoticed indefinitely.
Real log files are commonly UTF-8; if you are reading raw bytes rather than already-decoded text, quote and escape detection has to run on the decoded string, not the raw byte sequence, or a multi-byte character could be split mid-character by a byte-oriented scan.
Write a Python function to compute the longest common prefix string amongst an array of strings. If there is no common prefix, return an empty string. Example: ['flower','flow','flight'] -> 'fl'. Discuss O(n * m) naive complexity and ways to optimize using vertical scanning or binary search.
Sample Answer
Direct answer
Compare characters column by column across all the strings at once (vertical scanning): at each position i, check that every string shares the same character at that position. The first mismatch, or the first string that runs out of characters, marks the end of the common prefix. This is simple, correct, and exits as soon as a genuine mismatch is found, rather than fully comparing whole strings pairwise the way a naive approach might.
Structured elaboration
Vertical scanning approach
def longest_common_prefix_vertical(strs):
if not strs:
return ""
for i, ch in enumerate(strs[0]):
for other in strs[1:]:
if i >= len(other) or other[i] != ch:
return strs[0][:i]
return strs[0]
O(n*m) naive complexity (the question's explicit ask)
With n strings and m the length of the shortest string, ANY correct approach touches at most n * m character comparisons in the worst case, imagine every string identical except for a mismatch right at the very last position checked. Vertical scanning does not beat this worst-case bound; what it buys you is exiting early on realistic negative cases, where a mismatch is usually found within the first handful of characters and strings, not at the theoretical worst-case position.
Binary-search-on-prefix-length approach (the question's explicit ask)
Binary search over candidate prefix lengths from 0 to the shortest string's length, using an O(n*m) helper ("is this length a common prefix of every string") as the check at each step:
def _is_common_prefix(strs, length):
prefix = strs[0][:length]
return all(s[:length] == prefix for s in strs)
def longest_common_prefix_binary_search(strs):
if not strs:
return ""
min_len = min(len(s) for s in strs)
lo, hi = 0, min_len
while lo < hi:
mid = (lo + hi + 1) // 2
if _is_common_prefix(strs, mid):
lo = mid
else:
hi = mid - 1
return strs[0][:lo]
Being honest about what this buys you: the per-step check itself costs O(nm) in the worst case, and binary search runs that check O(log m) times, so the WORST-CASE complexity of this version is actually O(n * m * log m), asymptotically worse than vertical scanning's O(nm), not better. Binary-search-over-the-answer is a genuinely useful general problem-solving pattern, and it can pay off here if the per-length check can be made cheap (for example, comparing precomputed rolling hashes of each candidate-length prefix instead of a fresh character-by-character comparison every time), but for the plain character-comparison check shown above, it is not a real optimization over vertical scanning. If a true asymptotic improvement is the goal, the standard technique is building a trie once over all the strings and walking it, which finds the common prefix in a single O(S) pass, where S is the total character count across all strings.
Worked example
Executed with python3 s78.py, both approaches run on the same inputs and confirmed to agree:
['flower', 'flow', 'flight'] vertical='fl' binary_search='fl' agree=True
['dog', 'racecar', 'car'] vertical='' binary_search='' agree=True
['interview', 'internal', 'interstate'] vertical='inter' binary_search='inter' agree=True
['single'] vertical='single' binary_search='single' agree=True
['flower', 'flow', 'flight'] maps to 'fl', matching the question's own stated example exactly.
Trade-offs and pitfalls
- Do not claim the binary-search version is asymptotically faster than vertical scanning for the plain per-length character check shown here; it is not, and overclaiming this specific trade-off is a common mistake that a careful interviewer will probe.
- Vertical scanning's early exit is a genuine, practical win on typical negative inputs (most real-world string sets diverge within the first few characters), even though its worst-case bound matches the fully naive pairwise approach.
- Edge cases worth naming explicitly: an empty list of strings (handled here by convention, returning
""), a single string (the whole string is trivially its own prefix), and one empty string anywhere in the list (the prefix is immediately"", which the vertical-scan loop handles correctly sincestrs[0]may be empty and itsenumeratesimply never executes).
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.