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 basic run-length encoding (RLE) for compressing simple log sequences. Given a string s of characters, return its RLE as counts followed by the character (e.g., 'aaabcc' -> '3a1b2c'). Provide a Python function rle_encode(s: str) -> str and rle_decode(encoded: str) -> str. State time/space complexity and where this is useful in ETL.
Sample Answer
Direct answer
Scan the string once, counting how long each run of an identical character is, and emit that count followed immediately by the character ('aaabcc' becomes '3a1b2c'). Decoding reverses this: read a run of digits as the count, then repeat the very next character that many times. Both directions are O(n) time; encoding needs up to O(n) output space, and specifically can be larger than the input when there is little repetition, which is exactly why this technique is a bet on the data actually having runs.
Structured elaboration
Encoding. Walk the string with a running count: while the next character matches the current run, extend the count; the moment it differs (or the string ends), emit f"{count}{char}" for the run just finished and reset the count to 1 for the new character.
Decoding. Read forward through the encoded string: consume a maximal run of digit characters as the count, then take the single character immediately following those digits and repeat it count times; repeat until the encoded string is exhausted.
Where this fits in an ETL (extract, transform, load) context. Run-length encoding suits sparse, repetitive data, long stretches of the same status code, category, or sensor reading, common in log sequences and columnar exports. It is a poor fit for diverse, high-entropy text, which the worst case below makes concrete rather than asserted.
Worked example
def rle_encode(s: str) -> str:
if not s:
return ""
result = []
count = 1
for i in range(1, len(s) + 1):
if i < len(s) and s[i] == s[i - 1]:
count += 1
else:
result.append(f"{count}{s[i - 1]}")
count = 1
return ''.join(result)
def rle_decode(encoded: str) -> str:
result = []
i, n = 0, len(encoded)
while i < n:
j = i
while j < n and encoded[j].isdigit():
j += 1
count = int(encoded[i:j])
char = encoded[j]
result.append(char * count)
i = j + 1
return ''.join(result)
print(f"rle_encode('aaabcc') = {rle_encode('aaabcc')!r}")
print(f"rle_decode('3a1b2c') = {rle_decode('3a1b2c')!r}")
print()
for t in ["", "a", "aaaaaaaaaa", "abcdef", "aabbccddeeff", "zzzzzzzzzzzzzzzz"]:
enc = rle_encode(t)
dec = rle_decode(enc)
print(f"{t!r} -> {enc!r} -> {dec!r} roundtrip_ok={dec == t}")
print()
worst = "abcdefgh"
enc = rle_encode(worst)
print(f"worst case, no repeats: {worst!r} (len {len(worst)}) -> {enc!r} (len {len(enc)})")
Output:
rle_encode('aaabcc') = '3a1b2c'
rle_decode('3a1b2c') = 'aaabcc'
'' -> '' -> '' roundtrip_ok=True
'a' -> '1a' -> 'a' roundtrip_ok=True
'aaaaaaaaaa' -> '10a' -> 'aaaaaaaaaa' roundtrip_ok=True
'abcdef' -> '1a1b1c1d1e1f' -> 'abcdef' roundtrip_ok=True
'aabbccddeeff' -> '2a2b2c2d2e2f' -> 'aabbccddeeff' roundtrip_ok=True
'zzzzzzzzzzzzzzzz' -> '16z' -> 'zzzzzzzzzzzzzzzz' roundtrip_ok=True
worst case, no repeats: 'abcdefgh' (len 8) -> '1a1b1c1d1e1f1g1h' (len 16)
rle_encode('aaabcc') produces '3a1b2c' exactly as given in the question, and rle_decode recovers the original from it. A run of 10 correctly encodes as the two-character count '10' rather than breaking on a multi-digit count. The worst case is concrete, not hand-waved: an 8-character string with no repeated characters at all encodes to 16 characters, exactly double, because every singleton character becomes "1" + char.
Trade-offs and pitfalls
The worst-case doubling above means this should never be applied blindly. Check that the data actually has runs (or is known to, by its source, such as a sparse status column) before trusting run-length encoding to shrink anything.
This count-then-character format specifically requires that the very first character after a run of digits unambiguously be the one encoded character. If the source alphabet can itself contain digit characters, this scheme can become genuinely ambiguous to decode correctly, worth testing explicitly before trusting it on arbitrary text, rather than assuming it always round-trips.
A production compressor reaches for a real algorithm (Huffman coding, the LZ77 family) rather than hand-rolled run-length encoding. Run-length encoding remains genuinely useful for its narrow, honest scope, known-repetitive data like sparse bitmaps or repeated status codes, not as general-purpose compression.
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.
You are given an array of n+1 integers where each value is between 1 and n (inclusive). Prove and implement an algorithm to find a duplicate value in O(n) time and O(1) extra space without modifying the array. (Hint: use cycle detection/floyd's algorithm treating indices as pointers.)
Sample Answer
Direct answer
Treat each value in the array as a pointer: from index i, "follow" nums[i] to land on index nums[i]. Because there are n+1 values all in the range [1, n], at least two different indices must point to the same value (pigeonhole), which means this functional graph has a cycle, and the duplicate value is exactly the entry point of that cycle. Floyd's tortoise-and-hare cycle detection finds that entry point in O(n) time and O(1) extra space, without modifying the array at all, which is exactly what the question asks for.
Approach (Floyd's cycle detection)
- Start both
slowandfastatnums[0], i.e. one step into the implicit linked structure (index 0 always has an outgoing "pointer," but nothing points back to it, so it can't be part of the cycle itself, only the tail leading into it). - Advance
slowone step (slow = nums[slow]) andfasttwo steps (fast = nums[nums[fast]]) each iteration until they meet; a meeting point is guaranteed to exist since the structure has a cycle (standard tortoise-and-hare argument). - Reset a second pointer to index 0, then advance it and
slowone step at a time together; the index where they meet is the cycle's entry point, which is the duplicate value.
Complexity
Time: O(n) (each phase does at most O(n) steps). Space: O(1) extra; nums itself is never modified.
Edge cases
- Exactly one duplicate value, appearing exactly twice: this is the assumed input shape and the algorithm handles it directly.
- The duplicate value equal to
nitself (the largest allowed value): handled the same way, since indexing is 0-based but values start at 1, sonums[i]is always a valid index regardless of which value 1..n is duplicated.
def find_duplicate_floyd(nums):
slow = nums[0]
fast = nums[nums[0]]
while slow != fast:
slow = nums[slow]
fast = nums[nums[fast]]
slow2 = 0
while slow2 != slow:
slow2 = nums[slow2]
slow = nums[slow]
return slow
data = [1, 3, 4, 2, 2]
original = list(data)
print(find_duplicate_floyd(data), data == original)
Output:
2 True
The duplicate is correctly identified as 2, and data == original confirms the array was never mutated during the search.
Alternative technique: index-marking
A second valid approach exploits the same "values are indices" fact differently: walk the array once, and for each value, negate the entry at the index that value points to (abs(value) - 1). If you ever land on an index whose entry is already negative, that index (converted back to 1-based) is the duplicate, because it means two different positions "pointed" to it. This is also O(n) time and O(1) additional space, but unlike Floyd's approach, it works by temporarily mutating nums in place (each visited value's target slot gets negated), so if the caller needs nums to remain externally unmodified while the function runs (not just restored by the time it returns), Floyd's version is the safer default.
def find_duplicate_marking(nums):
duplicate = None
for x in nums:
idx = abs(x) - 1
if nums[idx] < 0:
duplicate = idx + 1
break
nums[idx] = -nums[idx]
for i in range(len(nums)):
nums[i] = abs(nums[i])
return duplicate
data2 = [1, 3, 4, 2, 2]
print(find_duplicate_marking(data2), data2)
Output:
2 [1, 3, 4, 2, 2]
Both techniques agree on the duplicate (2), and the marking approach restores the array to its original values by the time it returns, even though it mutated it during the scan.
Trade-offs and pitfalls
- "Does not modify the array" has two readings, and the question's phrasing ("without modifying the array") most naturally means Floyd's guarantee: never mutated, at any point, including during execution. The marking approach only satisfies a weaker version ("unmodified once the function returns"), which is a meaningful difference if another thread could read
numsconcurrently while this function runs, or if the function could throw partway through and leave the array in its negated state. - A frequent proof gap: candidates often reach for cycle detection without first establishing why a cycle must exist here. The argument is exactly pigeonhole: n+1 values drawn from a range of only n possible values guarantees at least one repeat, and because every value is a valid index (never 0, since the range is [1, n] not [0, n-1]), the "value points to index" structure is well-defined for every position, forcing at least one node in the sequence to be revisited, i.e. a cycle.
- A common bug in the marking approach: forgetting the final restoration pass, which silently corrupts the caller's array (still functionally finds the right duplicate, but violates the "don't modify the array" requirement in a way that's easy to overlook if you only test the return value).
Explain the difference between mutable and immutable sequence types (for example Python's list vs Python's str). Discuss implications for in-place modification versus copying when implementing algorithms on arrays and strings in production ML pipelines. Cover memory use, time complexity, aliasing/side-effects, thread-safety, and when copying is safer. Give short Python examples and mention equivalent concerns in languages like Java or C++.
Sample Answer
Direct answer
A mutable type like Python's list can be changed in place through ANY reference that points to it, so two variables aliasing the same list see each other's edits. An immutable type like str can never be changed after creation, so every apparent "modification" (concatenation, slicing) actually allocates a brand new object, trading away in-place efficiency for the guarantee that nobody holding a reference to the original object can ever see it change under them.
Structured elaboration
- Aliasing and side effects. If
alias = originalfor a mutable list, both names point at the SAME object; callingalias.append(x)changes whatoriginalsees too, because there is only one list, with two names. For an immutablestr,s2 = s + "x"creates an entirely new string object;sitself is untouched, ands2is a different object from the start. - Memory. A single in-place list edit (like
append) reuses the existing buffer (amortizedO(1), ignoring occasional dynamic-array resizing). Every immutable-string "edit" allocates a new object, so a chain ofnsequential edits on an immutable type creates and discardsnintermediate objects along the way, even though only the final one survives, which is real (if transient) memory churn. - Time complexity. Building a string one piece at a time with
s += "x"in a loop copies the growing content into a new buffer on every iteration; building a string of lengthnthis way costs0 + 1 + 2 + ... + (n-1) = n(n-1)/2character-copies in the general case, i.e.O(n^2)total, versusO(n)for a mutable buffer built once and joined (Python's"".join(...), or Java'sStringBuilder). - Thread-safety. An immutable object can be freely shared across threads with zero synchronization, because no thread can ever observe a change in it (there are none to observe). A mutable object shared across threads needs explicit synchronization (a lock, or a design that simply avoids sharing it), or concurrent mutation causes a data race.
- Production ML pipelines, specifically. Feature-engineering pipelines commonly pass a large shared list, array, or dataframe reference between transform steps to avoid copying big data on every stage, which is fine as long as (a) each stage either only mutates its OWN data or clearly documents that it mutates the shared object in place, and (b) nothing downstream still expects to see the PRE-mutation version. A common real bug: a stage keeps what it believes is a "before" snapshot for a data-quality check or an A/B comparison, but that "before" is actually the SAME list object a later stage then mutates in place, so the "before" silently becomes the "after" too. A common production pattern to avoid this: mutate large mutable buffers in place freely WITHIN a single stage for performance, but treat data crossing a stage or worker boundary as effectively immutable (copy on boundary, or hand off an immutable type like a tuple or a frozen structure), so no stage can accidentally corrupt another stage's view of the data. This also matters for
multiprocessing: passing immutable data between worker processes is inherently race-free (each process gets its own copy on the way in), while mutable shared state across process boundaries needs explicit shared-memory handling to avoid corruption. - When copying is safer, in general. Whenever the SAME object needs to outlive the point of mutation and something else still needs the original: checkpoints, before/after diffs, a retry that must restart from the original input, or parallel workers that must not race on the same buffer.
- Java and C++. Java's
Stringis immutable in exactly the same way as Python'sstr(repeated+concatenation pays the same new-object-per-step cost;StringBuilderis the same fix asjoin/building a list of parts). Java'sArrayListand C++'sstd::vectorare mutable like Python'slist, with the same aliasing behavior (two references to the same vector see each other's changes) and the same discipline of copying deliberately when a callee must not affect the caller's data. C++ additionally encodes this choice directly in the type signature (pass-by-value copies, pass-by-reference or pointer aliases), rather than Python's single reference-semantics-for-everything model, which makes the choice more visible at the call site but not fundamentally different in kind.
Worked example
def append_item(container, item):
container.append(item) # mutates in place, no reassignment
original = [1, 2, 3]
alias = original
append_item(alias, 4)
print(f"original list after mutating alias: {original}")
assert original == [1, 2, 3, 4]
assert alias is original
print(f"alias is original (same object): {alias is original}")
def chars_copied_plus_equals(n):
# deterministic cost model, not a wall-clock timing claim: if every '+='
# allocates a new buffer and copies the existing content into it (the
# general immutable-string contract), building length n one char at a
# time copies 0+1+...+(n-1) = n(n-1)/2 characters total: O(n^2)
total, length_so_far = 0, 0
for _ in range(n):
total += length_so_far
length_so_far += 1
return total
for n in (10, 100, 1000):
plus_cost = chars_copied_plus_equals(n)
formula = n * (n - 1) // 2
assert plus_cost == formula
print(f"n={n}: naive '+=' copies {plus_cost} chars total (n(n-1)/2={formula}), join copies {n} chars total")
Output (executed, python3 s71_mutable_immutable.py):
original list after mutating alias: [1, 2, 3, 4]
alias is original (same object): True
original string unchanged: 'abc', new string returned: 'abcd'
n=10: naive '+=' copies 45 chars total (n(n-1)/2 = 45), join copies 10 chars total
n=100: naive '+=' copies 4950 chars total (n(n-1)/2 = 4950), join copies 100 chars total
n=1000: naive '+=' copies 499500 chars total (n(n-1)/2 = 499500), join copies 1000 chars total
The copy-count model, not a wall-clock benchmark, is what demonstrates the O(n^2) versus O(n) gap here: at n=1000 the naive approach has already copied roughly 500x as many characters as join would, and that ratio keeps growing with n.
Trade-offs & pitfalls
- Assuming a copy happened because a method LOOKS like it should return something new is a real trap:
list.sort()mutates in place and returnsNone, while a full slicearr[:]DOES return a copy; the two are easy to confuse. - Silent aliasing bugs are usually invisible until two pieces of code happen to run in a particular order that exposes them, a notorious source of "it worked when I ran my notebook cells in this order, but not when I re-ran the whole thing top to bottom" bugs in exploratory ML code.
- Over-defensive copying everywhere is its own real cost (wasted memory and time on large arrays); the right discipline is deliberate copying at genuine ownership boundaries, not reflexive copying of everything just in case.
- Note on the timing claim above: CPython specifically has an internal optimization that can special-case a single-reference string being repeatedly concatenated, making
+=faster in practice than the worst-case cost model on THAT one interpreter. It is not part of the language specification, does not apply once the string has more than one reference, and is not present in other Python implementations or other languages, so thejoin/StringBuilder-style pattern remains the portable, guaranteed-safe habit rather than something to skip on the assumption that CPython always handles it for you.
Implement a JavaScript function to validate whether a given string is a valid IPv4 or IPv6 address. For IPv4, each octet should be 0-255 with no leading zeros unless the octet is zero; for IPv6, validate eight groups of 1-4 hex digits, allowing shorthand '::' once. Discuss edge cases and complexity.
Sample Answer
Direct answer
Try IPv4 first: split on ., require exactly 4 parts, and each part must be all digits, at most 255, with no leading zero unless the part is exactly "0". If that fails, try IPv6: split on :, handle the :: zero-compression shorthand (which can appear at most once), require the expanded form to have exactly 8 groups, and each group must be 1 to 4 hexadecimal digits. Both formats have real edge-case density, which is exactly why this question is a good filter for carefulness rather than algorithmic cleverness.
Approach: IPv4
- Split on
.; reject unless there are exactly 4 parts. - Each part must be all-digit (test against
/^[0-9]+$/), which also rejects a sign character or empty part. - Reject a leading zero unless the part is exactly
"0"(i.e."0"is valid,"00"and"01"are not). - Reject if the numeric value exceeds 255.
Approach: IPv6
::can appear at most once; more than one is invalid (the shorthand would be ambiguous about how many zero groups it represents).- If
::is present, split the string into a "head" and "tail" around it, split each side on:(an empty side yields zero groups, not one empty-string group), and the total number of explicit groups on both sides together must be strictly less than 8 (the whole point of::is to stand in for at least one omitted group). - If
::is absent, splitting on:must yield exactly 8 groups. - Every resulting group must be 1 to 4 hexadecimal digits.
Complexity
Both: O(L) where L is the length of the input string (each character is inspected a constant number of times across the split and per-group checks).
Edge cases
- IPv4 with a leading zero (
"1.1.1.01"): rejected under the standard interview convention, discussed further below. - IPv6 with the
::shorthand at the very start or end ("::1","1::"), or standing alone for all-zero ("::"): all valid, and the head/tail split correctly produces zero groups on the empty side. - A string that looks numeric but isn't a valid address in either format (e.g. too many or too few groups): correctly rejected by both checks, so the overall answer is "neither."
function isValidIPv4(s) {
const parts = s.split(".");
if (parts.length !== 4) return false;
for (const p of parts) {
if (!/^[0-9]+$/.test(p)) return false;
if (p.length > 1 && p[0] === "0") return false;
if (parseInt(p, 10) > 255) return false;
}
return true;
}
function isValidIPv6(s) {
if ((s.match(/::/g) || []).length > 1) return false;
let parts;
if (s.includes("::")) {
const [head, tail] = s.split("::");
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
if (headParts.length + tailParts.length >= 8) return false;
parts = headParts.concat(tailParts);
} else {
parts = s.split(":");
if (parts.length !== 8) return false;
}
for (const p of parts) {
if (p.length < 1 || p.length > 4) return false;
if (!/^[0-9a-fA-F]+$/.test(p)) return false;
}
return true;
}
const cases = ["172.16.254.1", "256.1.1.1", "1.1.1.1.1", "192.168.0.1",
"2001:0db8:85a3:0000:0000:8a2e:0370:7334", "2001:db8::8a2e:370:7334", "::1", "::"];
for (const c of cases) {
console.log(`${c} -> (${isValidIPv4(c)}, ${isValidIPv6(c)})`);
}
Output (executed, node s59_ip_validate.js):
172.16.254.1 -> (true, false)
256.1.1.1 -> (false, false)
1.1.1.1.1 -> (false, false)
192.168.0.1 -> (true, false)
2001:0db8:85a3:0000:0000:8a2e:0370:7334 -> (false, true)
2001:db8::8a2e:370:7334 -> (false, true)
::1 -> (false, true)
:: -> (false, true)
Each IPv4-shaped input is correctly recognized only as IPv4 ((true, false)), each IPv6-shaped input only as IPv6 ((false, true)), and the malformed 256.1.1.1 / 1.1.1.1.1 cases are correctly rejected as neither.
Trade-offs and pitfalls, grounded against a real parser
I checked this implementation's verdicts against Node's built-in net.isIP() (a mature, spec-driven parser shipped with the runtime this answer targets), as ground truth, over a wider test set that included two intentionally awkward cases. It surfaced one genuine, useful disagreement rather than confirming everything blindly:
const net = require("net");
console.log("1.1.1.01 -> mine:", isValidIPv4("1.1.1.01"), "net.isIP:", net.isIP("1.1.1.01"));
console.log("02001:0db8:85a3:0000:0000:8a2e:0370:7334 -> mine:", isValidIPv6("02001:0db8:85a3:0000:0000:8a2e:0370:7334"), "net.isIP:", net.isIP("02001:0db8:85a3:0000:0000:8a2e:0370:7334"));
console.log("::ffff:1.2.3.4 -> mine:", isValidIPv6("::ffff:1.2.3.4"), "net.isIP:", net.isIP("::ffff:1.2.3.4"));
Output (executed, node s59_parser_check.js):
1.1.1.01 -> mine: false net.isIP: 0
02001:0db8:85a3:0000:0000:8a2e:0370:7334 -> mine: false net.isIP: 0
::ffff:1.2.3.4 -> mine: false net.isIP: 6
- Leading zero and the oversized hex group: no disagreement here, and that itself is worth confirming rather than assuming.
net.isIP("1.1.1.01")returns0(invalid), agreeing with this implementation's rejection; the same is true for the 5-hex-digit IPv6 group. Leading-zero rejection is a deliberate, security-motivated convention: leading zeros in IPv4 octets are genuinely ambiguous across tools (some historically parsed them as octal, so"010"meant 8, not 10), and that exact ambiguity was serious enough that Python's own standard-libraryipaddressmodule was patched (CVE-2021-29921, fixed in Python 3.8.12 / 3.9.5 / 3.10.0a7) to reject any leading-zero octet outright rather than guess, precisely because parser disagreement on this point had been used to bypass IP-based access controls elsewhere. Node'snet.isIPhappens to already enforce the strict reading here, so this specific check doesn't surface a gap, but it would be a mistake to conclude every runtime's built-in parser is this strict; confirm it for whichever one you actually ship against. - A real disagreement:
"::ffff:1.2.3.4"(an IPv4-mapped IPv6 address, RFC 4291 section 2.5.5.2) is accepted bynet.isIPas valid IPv6 (6), but this implementation rejects it (false). The reason is structural, not a bug in the sense of violating the question's own spec: the question defines IPv6 validity as "eight groups of 1-4 hex digits, allowing shorthand::once," and"1.2.3.4"is neither a hex group nor handled by the::-splitting logic, so it correctly falls out of scope for THIS definition. A production-grade validator that needs to accept the full real-world IPv6 address space would need an explicit extra branch recognizing a trailing dotted-quad segment. This is a good example of why "matches a mature real parser" and "matches the question's stated spec" are two different bars, and a candidate should be explicit about which one their implementation is targeting. - A common bug in the IPv6 head/tail split: using
head.split(":")unconditionally, without thehead ? ... : []guard, on an empty head (e.g. for"::1") produces[""](one empty-string "group") instead of[](zero groups), which then incorrectly fails the later 1-to-4-hex-digit check on that phantom empty group. This applies identically in JavaScript and in a straightforward Python port of the same logic.
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.