Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Repair the Spread Traversal
Inherited code computes how far something spreads through a contact graph, and the answers are wrong in a way that is invisible on small inputs. Three planted defects: a one-way adjacency map, a frontier with no dedupe, and a seen-set updated one step too late. Hidden tests.
The problem
You are given pairs, a list of two-element contact pairs describing an undirected graph, a start node, and a number of rounds. In each round, every currently infected node infects all of its direct contacts. Implement infected(pairs, start, rounds) returning the sorted list of infected node names after that many rounds. The start node counts as infected from round 0. The starting code is what you inherited — it is wrong in three places.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Read the inherited infected(...), find the three defects against the spec, and fix them one at a time.
Examples and constraints 4 examples
pairs = [("a","b"),("b","c"),("c","d")], start = "a", rounds = 2["a", "b", "c"]pairs = [("a","b"),("a","c"),("b","d"),("c","d")], start = "a", rounds = 1["a", "b", "c"]pairs = [("b","a"),("c","b")], start = "a", rounds = 1["a", "b"]pairs = [("a","b"),("b","c"),("c","d")], start = "a", rounds = 0["a"]- The graph is undirected: a pair
(x, y)means x contacts y and y contacts x. - A node is infected in the earliest round it can be reached; it never enters a later frontier.
- Nodes that appear only in
pairsand are unreachable fromstartmust not appear in the result. - Return the result sorted, so the comparison is order-independent.
Hints 4 ideas
- Bug 1: the adjacency map is built in one direction only. The relation is symmetric.
- Bug 2: the frontier is a list with no membership check, so a node reachable two ways is processed twice.
- Bug 3: nodes are added to
seenafter the neighbour loop rather than when first reached, so they can enter the next frontier again. - Test with a diamond (a-b, a-c, b-d, c-d) — the duplicate bugs are invisible on a straight chain.
Approach, complexity & discussion — open after you solve
Start from brute force
Skip the adjacency map and simulate the rounds directly: each round, snapshot the infected set, scan every pair, and infect an endpoint whenever the other endpoint was in the snapshot. Checking both endpoints handles the undirected relation, and the snapshot keeps each round to exactly one hop, so the answer is correct. It costs O(R · E) time for R rounds and E pairs, and O(V) space. Every round rescans every pair, including pairs whose ends were infected long ago, and rounds beyond the graph’s diameter change nothing. Drop the snapshot and update in place, and the chain a-b, b-c, c-d is fully infected after one round.
The approach
Breadth-first search, one level per round. The reference infected builds adj with adj.setdefault(a, set()).add(b) and the mirror line, so every contact is visible from both ends, which repairs the first defect. It starts with seen = {start} and frontier = {start}, then each round builds a fresh set nxt: for every node in the frontier and every neighbor nb not yet in seen, it adds nb to seen and to nxt on the spot. Marking at first reach, and collecting into a set, are the other two repairs: a node that two frontier nodes both reach enters the next frontier once.
The invariant after round r: seen holds exactly the nodes within r hops of start, and frontier holds the ones at exactly r hops. Each node therefore joins one frontier, in the round of its shortest distance, and its contacts are scanned once. sorted(seen) is the answer. The TypeScript version mirrors it with a Record of Sets.
d already in seen, so d joins the round-2 frontier once. The inherited code, with only the map fixed, builds that frontier as ["d", "d"].Walk through an example
On the first example, the chain a-b, b-c, c-d with two rounds, the two-way map lets b see a, and seen skips it.
| step | phase | work | seen | frontier after |
|---|---|---|---|---|
| 1 | build | adj = {a: {b}, b: {a, c}, c: {b, d}, d: {c}} | — | — |
| 2 | start | seed the start node | {a} | {a} |
| 3 | round 1 | node a: neighbor b is new | {a, b} | {b} |
| 4 | round 2 | node b: neighbor a is already seen, skip; neighbor c is new | {a, b, c} | {c} |
| 5 | return | sorted(seen); d is three hops away | ["a", "b", "c"] | — |
Complexity
Time O(V log V + E + R) for V nodes, E pairs and R rounds: building adj is O(E), each node joins one frontier and has its contacts scanned once, the loop itself runs R times, and the final sort is O(V log V). Space O(V + E) for the map and the sets. The pair-scan simulation is O(R · E) time and O(V) space.
Edge cases to test
pairs = [("b","a"), ("c","b")],start = "a", 1 round →["a", "b"]. The inherited one-way map gives["a"]; this is the only hidden test the inherited code fails.- The chain with
rounds = 0→["a"]. The start is infected before any round runs. pairs = [("a","b"), ("x","y")], 5 rounds →["a", "b"]. Nodes named inpairsbut unreachable from the start stay out.pairs = [("x","y")],start = "a", 3 rounds →["a"]. A start with no contacts works becauseadj.get(node, ())returns an empty tuple instead of raising.- A ladder of 10 layers, each with two nodes joined to both nodes of the next: the answer is all 21 nodes either way, but with duplicates allowed the frontier doubles every round, to 1,024 entries by round 10, while the repaired frontier never holds more than 2.
- The chain with
rounds = 10**6→["a", "b", "c", "d"], yet the loop still runs a million empty rounds. Break as soon as the frontier is empty.
Common mistakes
- Building a one-directional adjacency map from pairs that describe a symmetric relationship.
- Marking a node seen when you process it rather than when you first reach it — the classic BFS duplicate bug.
- Mutating the frontier while iterating it, which quietly merges round N and round N+1.
- Forgetting that the start node is infected at round 0 even when
roundsis 0.
Follow-ups an interviewer asks next
- Return the round in which each node was infected.
- Replace
seenwith a dictdistand store the round number at the moment a node is first reached. That is unweighted single-source shortest path: BFS reaches every node first along a shortest path, so the stored round is its hop distance from the start. Time and space stay O(V + E), and the infected list is the dict’s keys. - Several people start out infected.
- Use multi-source BFS: seed
seenandfrontierwith every start node, then run the same rounds. The result after r rounds is every node within r hops of any start, and the cost stays O(V + E), against O(S · (V + E)) for running one search per source and merging the results. - Each contact transmits only with some probability.
- Then the spread is random and one run is one sample. Simulate many runs, giving each newly infected node one weighted coin flip per uninfected neighbor (the independent cascade model), and report how often each node ends up infected. If edges carry transmission delays instead, earliest infection time is a shortest-path problem and Dijkstra replaces BFS.
- The contact graph does not fit on one machine.
- Partition the nodes across workers and run level-synchronous BFS: each round, every worker expands the frontier nodes it owns and sends newly reached neighbors to the workers that own them, then all workers wait at a barrier before the next round. One round is one superstep, the model that graph systems such as Pregel use.
Where this shows up
Level-order spread over a contact graph is the same computation as blast-radius analysis for a change, permission propagation through a group hierarchy, and dependency impact in a build system — all things an FDE gets asked for on a customer’s real data. Debugging rounds favor it because the bugs are invisible in small examples and obvious in the right one, which is exactly the skill being tested.
Check yourself
Predict infected([("a","b"), ("b","c"), ("c","a"), ("c","d")], "a", 1), then the same call with 2 rounds.
["a", "b", "c"], then ["a", "b", "c", "d"]. The pair ("c","a") makes c a direct contact of a because the map stores it under both ends, so one round reaches b and c; d hangs off c and needs a second hop.
Fix only the one-way map in the inherited code and run it for 3 rounds on a-b, a-c, b-d, c-d, b-e, c-e, d-f, e-f. The answer is correct. What does the round-3 frontier look like, and why does no test notice?
It is ["f", "f", "f", "f"], after ["d", "e", "d", "e"] in round 2: every node reached by two parents is copied twice, and the copies compound. The answer is built from seen, a set, so the duplicates never reach the output; only the work grows, which a correctness test cannot see.
The spec changes: a pair (x, y) now means x infects y but not the reverse. What do you delete, and what does example 3 ([("b","a"), ("c","b")], start "a", 1 round) return?
Delete the mirror line adj.setdefault(b, set()).add(a). Example 3 then returns ["a"], because nothing points out of a, while starting from "c" with 2 rounds returns ["a", "b", "c"]. Bug 1 was only a bug because the spec said undirected.
A hub h touches leaves "1" to "5". Starting at leaf "1", how many rounds until all six are infected, and how many edge scans does the reference do over those rounds?
Two rounds: round 1 gives ["1", "h"], round 2 gives all six. Six edge scans in total: one from leaf 1 in round 1, five from h in round 2; leaves 2 to 5 would only be expanded in a third round. No edge is ever examined more than once from each end, which is the O(E) term, and h is expanded exactly once however many leaves point back at it.
Reference solution
Reference solution — Python
CHAIN = [("a", "b"), ("b", "c"), ("c", "d")] # provided fixtures
DIAMOND = [("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")]
REVERSED = [("b", "a"), ("c", "b")]
ISLAND = [("a", "b"), ("x", "y")]
def infected(pairs, start, rounds):
adj = {}
for a, b in pairs:
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a) # undirected: both directions
seen = {start}
frontier = {start}
for _ in range(rounds):
nxt = set()
for node in frontier:
for nb in adj.get(node, ()):
if nb not in seen:
seen.add(nb) # mark when first reached
nxt.add(nb)
frontier = nxt
return sorted(seen)
print(infected(CHAIN, "a", 2)) # -> ['a', 'b', 'c']
print(infected(DIAMOND, "a", 1)) # -> ['a', 'b', 'c']Reference solution — TypeScript
const CHAIN: [string, string][] = [["a", "b"], ["b", "c"], ["c", "d"]]; // provided fixtures
const DIAMOND: [string, string][] = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]];
const REVERSED: [string, string][] = [["b", "a"], ["c", "b"]];
const ISLAND: [string, string][] = [["a", "b"], ["x", "y"]];
function infected(pairs: [string, string][], start: string, rounds: number): string[] {
const adj: Record<string, Set<string>> = {};
for (const [a, b] of pairs) {
(adj[a] = adj[a] || new Set()).add(b);
(adj[b] = adj[b] || new Set()).add(a); // undirected: both directions
}
const seen = new Set<string>([start]);
let frontier = new Set<string>([start]);
for (let r = 0; r < rounds; r++) {
const nxt = new Set<string>();
frontier.forEach(function (node) {
(adj[node] || new Set<string>()).forEach(function (nb) {
if (!seen.has(nb)) { seen.add(nb); nxt.add(nb); } // mark when first reached
});
});
frontier = nxt;
}
return Array.from(seen).sort();
}
console.log(infected(CHAIN, "a", 2)); // -> ["a","b","c"]
console.log(infected(DIAMOND, "a", 1)); // -> ["a","b","c"]Questions about Repair the Spread Traversal
Why mark a node visited when it is first reached in BFS, not when it is processed?
Because a discovered node waits in the next frontier before it is processed, and any other path that reaches it in the meantime would add it again. In a diamond with edges a-b, a-c, b-d and c-d, both b and c reach d in the same round. Marking at discovery adds d once; marking at processing adds it twice, and each copy is expanded again in the next round. On graphs with many parallel paths the copies multiply every round. The set of nodes reached stays the same, which is why the bug hides on small tests, but the work grows.
How do you find every node within k hops of a starting node?
Run breadth-first search one level at a time for k levels. Keep a set of visited nodes and a frontier holding the nodes discovered in the previous level. Each level, look at the neighbors of every frontier node, keep the ones not visited yet, mark them visited, and make them the new frontier. After k levels the visited set is exactly the nodes within k hops. Stop early if the frontier becomes empty, because nothing new can be reached. Once the adjacency list exists, the search only touches nodes and edges inside that neighborhood.
Why does an undirected graph need both directions in its adjacency list?
Because a traversal only follows edges stored under the node it is standing on. A pair such as (b, a) describes a contact both ways, but if the code stores it only under b, a search that starts at a finds no neighbors and stops, even though a and b touch. Storing each pair twice, once under each endpoint, makes the stored graph match the real relation. The bug is easy to miss because test data is often listed in a convenient order, like a chain written from its start, and those tests pass anyway.
What is the time complexity of breadth-first search?
With an adjacency list, BFS runs in O(V + E) time for V vertices and E edges. Each vertex is marked once and enters the frontier once, and its adjacency list is scanned once when it is expanded, so in an undirected graph each edge is examined twice, once from each end. Space is O(V) for the visited set and the frontier, plus the adjacency list itself. With an adjacency matrix, scanning one vertex costs O(V), so BFS becomes O(V²), which is why sparse graphs are stored as lists.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →