What L5 looks like
- Compares versions as integer tuples and explains why
"1.9" < "1.70"breaks string comparison. - States the policy choices (unversioned applicant vs. versioned requirement, duplicates, ties) before coding.
- Builds ranking on the same matcher as the yes/no check, and uses a deterministic, integer sort key.
- Moves through the follow-up parts quickly because the parser and matcher are separate functions.
Approach
The work is mostly parsing. Once every skill is a clean (name, version) pair, matching is a dictionary lookup. Most bugs in this question come from the string handling, not the algorithm, so settle the parsing rules out loud before you write code.
Parsing rules.
- Split on commas. Trim each token and collapse inner whitespace. Drop empty tokens, so
"a,,b"and a trailing comma are harmless. - A token has a version only when its last hyphen segment is numeric (
3,1.70,2.0.1)."Python-3"ispythonat version(3,)."Go-Lang"is the single namego-lang, becauseLangis not a number. Split on the last hyphen (rpartition), never the first, so names that contain hyphens survive. - Compare names case-insensitively. Store versions as integer tuples and strip trailing zeros, so
3.0 == 3and1.9 < 1.70. A string comparison gets"1.9" > "1.70"wrong. This is the classic trap. - The applicant list is in preference order. If a name repeats, keep the first copy, since that is the stronger claim.
Brute force. For each required skill, scan the applicant's list. That is O(R x A) per check. It is fine for a few skills, and it is how most candidates start.
Better. Build a map once from name to (position, skill) for the applicant. Each requirement is then an O(1) lookup, so a check costs O(A + R).
Version rule. A requirement with no version accepts any version of that skill. A requirement with a version needs an applicant version at least that high. An applicant who lists the skill without a version does not meet a versioned requirement, because we cannot assume a version they never stated. State this choice; interviewers accept either rule if you name it.
Ranking. Proficiency order is the signal the input gives us. Among qualified applicants, sort by:
- More preferred ("nice to have") skills met.
- Smaller sum of the positions where the required skills appear. Listing the required skills first means they are the applicant's strongest.
- Name, for a deterministic order.
Integer sort keys avoid floating-point weights and make ties explicit.
Invariant. match_positions returns a list exactly when every requirement is met, and entry i is the 0-based rank of requirement i in the applicant's list. Both is_qualified and the ranking are built on that one function, so the two can never disagree.
Code
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
Version = Tuple[int, ...]
_VERSION = re.compile(r"\d+(?:\.\d+)*")
@dataclass(frozen=True)
class Skill:
name: str # normalized: lowercase, single spaces
version: Optional[Version] # None when the token carries no version
def parse_skill(token: str) -> Optional[Skill]:
token = " ".join(token.split()) # trim and collapse whitespace
if not token:
return None
base, sep, tail = token.rpartition("-")
if sep and base.strip() and _VERSION.fullmatch(tail.strip()):
version = tuple(int(p) for p in tail.strip().split("."))
while len(version) > 1 and version[-1] == 0:
version = version[:-1] # "3.0" == "3"
return Skill(base.strip().lower(), version)
return Skill(token.lower(), None) # "Go-Lang" stays one name
def parse_skills(text: str) -> List[Skill]:
"""Comma-separated skills in preference order; keep the first copy of each name."""
seen, out = set(), []
for token in text.split(","):
skill = parse_skill(token)
if skill and skill.name not in seen:
seen.add(skill.name)
out.append(skill)
return out
def satisfies(have: Skill, need: Skill) -> bool:
if have.name != need.name:
return False
if need.version is None:
return True
return have.version is not None and have.version >= need.version
def match_positions(applicant: str, required: str) -> Optional[List[int]]:
"""Position (0 = strongest) of each required skill in the applicant's list,
or None if any requirement is missing or below the required version."""
index: Dict[str, Tuple[int, Skill]] = {
s.name: (i, s) for i, s in enumerate(parse_skills(applicant))
}
positions = []
for need in parse_skills(required):
hit = index.get(need.name)
if hit is None or not satisfies(hit[1], need):
return None
positions.append(hit[0])
return positions
def is_qualified(applicant: str, required: str) -> bool:
return match_positions(applicant, required) is not None
def rank_applicants(applicants: Dict[str, str], required: str,
preferred: str = "") -> List[str]:
"""Qualified applicants, best first: more preferred skills met, then required
skills listed earlier (smaller position sum), then name."""
pref = parse_skills(preferred)
scored = []
for name, skills_text in applicants.items():
positions = match_positions(skills_text, required)
if positions is None:
continue
have = {s.name: s for s in parse_skills(skills_text)}
bonus = sum(1 for p in pref if p.name in have and satisfies(have[p.name], p))
scored.append((-bonus, sum(positions), name))
scored.sort()
return [name for _, _, name in scored]
Complexity
Let A be the number of applicant tokens, R the number of required skills, P the number of preferred skills, N the number of applicants, and L the input length in characters.
-
parse_skills: O(L) time and O(A) space. -
is_qualified/match_positions: O(L + A + R) time to parse both strings, build the map, and look up each requirement. O(A + R) space. Version comparisons cost O(length of the version), which is effectively constant. -
rank_applicants: O(N (A + R + P) + N log N) time, counting the input length in. O(N + A + R + P) extra space. The required and preferred lists could be parsed once outside the loop, a small cleanup worth mentioning.
Test cases
-
parse_skills(" Rust-1.70 , go-lang,SQL, rust-2")→[rust 1.70, go-lang, sql]. Whitespace and case are normalized,go-langkeeps its hyphen, and the duplicaterust-2is dropped. -
parse_skills("")andparse_skills(" , ,")→[]. -
parse_skill("Python-3.0")→pythonat(3,).parse_skill("-3")→ the name-3with no version, because the base is empty. -
is_qualified("Rust-1.70, Go-Lang, SQL", "sql, go-lang")→True. -
is_qualified("Rust-1.70, Go-Lang", "SQL")→False(missing). -
is_qualified("Rust-1.70", "Rust-1.9")→True, because 70 > 9 numerically.is_qualified("Rust-1.9", "Rust-1.70")→False. -
is_qualified("Rust", "Rust-1")→False(no stated version).is_qualified("Rust-2", "Rust")→True. -
is_qualified("anything", "")→True(no requirements). -
match_positions("Rust-1.70, Go-Lang, SQL", "SQL, Rust")→[2, 0]. - Ranking with applicants
ana: "SQL, Kotlin-1.9, Rust-1.75",bo: "Rust-1.80, SQL",cy: "Rust-1.60, SQL, Kotlin-2",di: "Kotlin-2, Rust-1.72, SQL"and required"Rust-1.70, SQL":-
rank_applicants(apps, "Rust-1.70, SQL")→["bo", "ana", "di"]. The position sums are 1, 2 and 3, andcyfails because Rust 1.60 < 1.70. -
rank_applicants(apps, "Rust-1.70, SQL", "Kotlin-2")→["di", "bo", "ana"].dimeets the preferred skill.anahas Kotlin 1.9, which is below 2.
-
-
rank_applicants({}, "x")→[].
Follow-ups
-
Aliases.
JSshould matchJavaScript, andGolangshould matchGo-Lang. Add a canonicalization map that runs after normalization,name = ALIASES.get(name, name), on both sides. If an alias can also carry a version, apply the map to the name only. -
Version ranges and wildcards. Requirements like
Python-3.x,Java-8..11or>=1.70. Parse the requirement into a predicate (lo <= v < hi) instead of a single minimum. Keep the applicant side as plain version tuples. -
"Any of" requirements. For example,
"Java | Kotlin, SQL". Parse each requirement as a list of alternatives and accept it if any alternative matches. If the score should use the best one, take the minimum position among the matching alternatives. -
Partial matches and a threshold. Report the fraction of requirements met and the missing skills instead of a yes/no answer, then filter at 80%. Return
(met, missing)from the matcher. The ranking key starts with-met. - Many applicants, many jobs. Build an inverted index from skill name to the applicants who have it, each entry with its position and version. To answer a job, intersect the posting lists for its required skills, starting with the rarest, and then check versions only on the survivors. This turns a scan over all N applicants into work proportional to the smallest posting list.
Interviewer expectations
Mid-level (L4)
- Splits, trims and lowercases correctly, and handles empty tokens.
- Gets
Go-Langright by splitting on the last hyphen only when the tail is numeric. - Uses a set or dict instead of nested scans, and writes a few asserts unprompted.
Senior (L5)
- Compares versions as integer tuples and explains why
"1.9" < "1.70"breaks string comparison. - States the policy choices (unversioned applicant vs. versioned requirement, duplicates, ties) before coding.
- Builds ranking on the same matcher as the yes/no check, and uses a deterministic, integer sort key.
- Moves through the follow-up parts quickly because the parser and matcher are separate functions.
Staff (L6+)
- Treats the format as an input contract. Asks who produces these strings, how to report malformed tokens, and whether to reject them or keep going and log.
- Discusses fairness and gaming in a ranking driven by self-reported order. Order is a weak signal, so it should break ties, not decide outcomes.
- Sketches the inverted-index design for N applicants × M jobs, with incremental updates when a profile changes.
- Keeps alias tables and version policy as data, not code, so recruiters can change them without a deploy.
Make it yours
Write each chapter yourself, check it against this answer, then let the coach grill you.
