Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°70

Repair the Spread Traversal

Medium FDEInterviewGraphs

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.

SOLVE IT YOURSELF

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.

YOUR TASK

Read the inherited infected(...), find the three defects against the spec, and fix them one at a time.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input pairs = [("a","b"),("b","c"),("c","d")], start = "a", rounds = 2
Output ["a", "b", "c"]
two hops along the chain, not three
EXAMPLE 2
Input pairs = [("a","b"),("a","c"),("b","d"),("c","d")], start = "a", rounds = 1
Output ["a", "b", "c"]
the diamond: d is two hops away, so one round does not reach it
EXAMPLE 3
Input pairs = [("b","a"),("c","b")], start = "a", rounds = 1
Output ["a", "b"]
a pair listed as (b, a) still connects a to b; the relation is symmetric
EXAMPLE 4
Input pairs = [("a","b"),("b","c"),("c","d")], start = "a", rounds = 0
Output ["a"]
edge case: the start is infected before any round runs
CONSTRAINTS
  • 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 pairs and are unreachable from start must not appear in the result.
  • Return the result sorted, so the comparison is order-independent.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Bug 1: the adjacency map is built in one direction only. The relation is symmetric.
  2. Bug 2: the frontier is a list with no membership check, so a node reachable two ways is processed twice.
  3. Bug 3: nodes are added to seen after the neighbour loop rather than when first reached, so they can enter the next frontier again.
  4. 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.

skip ar0 br1 cr1 dr2 roundseenfrontier 0{a}{a} 1{a, b, c}{b, c} 2{a, b, c, d}{d} first of b, c to scan d marks it; the other finds d in seen: added once
The diamond from example 2 run for two rounds. Accent arrows are the edges that discover a node; the c–d edge finds 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.

stepphaseworkseenfrontier after
1buildadj = {a: {b}, b: {a, c}, c: {b, d}, d: {c}}——
2startseed the start node{a}{a}
3round 1node a: neighbor b is new{a, b}{b}
4round 2node b: neighbor a is already seen, skip; neighbor c is new{a, b, c}{c}
5returnsorted(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 in pairs but unreachable from the start stay out.
  • pairs = [("x","y")], start = "a", 3 rounds → ["a"]. A start with no contacts works because adj.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 rounds is 0.

Follow-ups an interviewer asks next

Return the round in which each node was infected.
Replace seen with a dict dist and 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 seen and frontier with 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.

Finished this one? 0 / 76 Challenges done
NextLRU Cache, Constraint by Constraint Next on the “Interview escalations” ladder · rung 3 of 5

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.