Find every cell downstream of the change with a graph search, then topologically sort just that set with Kahn's algorithm, ignoring edges out of the changed cell. If the sort cannot place every affected cell, the leftovers contain a cycle: walk back through what each stuck cell reads to name the chain. Linear in the affected cells and their edges.
hardPattern: GraphsFrom Devana's Microsoft question set
Problem
A spreadsheet cell can hold a formula that reads other cells. When one cell changes, everything that depends on it, directly or through other cells, has to be recomputed, and each cell has to be recomputed after every cell it reads. Recomputing the whole sheet on every keystroke is too slow.
Write recalculate(dependents, changed), where dependents maps a cell to the cells that read it. Return the cells to recompute, in a valid order. If the formulas contain a circular reference, raise an error that names the exact chain, such as A1 -> B1 -> A1, instead of looping forever.
def recalculate(dependents: dict[str, list[str]], changed: str) -> list[str]:
"""Cells to recompute in dependency order; raises CircularReference on a cycle."""This is one of Devana's own questions, written in the style of Microsoft's interviews. It is not a question leaked from Microsoft, and Devana is not affiliated with Microsoft.
Constraints
- Return only the affected cells. The changed cell itself is not in the result.
- A cell reachable along two paths appears once, after all of its inputs that are being recomputed.
- When several cells are ready at the same time, the one that sorts first alphabetically goes first, so the order is deterministic.
- A cycle is reported starting from its alphabetically smallest cell.
- Up to 10^5 cells with formulas.
Examples
Example 1
Input: dependents = {"A1": ["B1", "C1"], "B1": ["C1"]}, changed = "A1"
Output: ["B1", "C1"]C1 reads A1 directly, but it also reads B1, so it has to wait for B1.
Example 2
Input: dependents = {"A1": ["B1"], "B1": ["C1"], "C1": ["B1"]}, changed = "A1"
Output: CircularReference: B1 -> C1 -> B1B1 and C1 read each other. The error names that loop, not A1, which feeds it from outside.
Hints
Open one at a time, and try the problem again after each.
Hint 1
First decide which cells change at all. Which search finds everything reachable from the changed cell?
Hint 2
Within that set, a cell is ready when every cell it reads that is also being recomputed is done. Count those inputs.
Hint 3
Edges out of the changed cell are already satisfied, because its new value is known. Counting them makes every direct dependent look blocked.
Hint 4
If nothing is ready while cells are still waiting, every waiting cell reads another waiting cell. Following that backward has to repeat a cell.
Approach: from brute force to optimal
Brute force: recompute everything until it settles
Recompute every formula, repeat until no value changes, and give up after some number of rounds to catch cycles. Simple and slow, and a cycle shows up as "did not converge" rather than a named chain.
Complexity: O(rounds × every formula in the sheet)
The affected set, then Kahn's algorithm
A depth-first search from the changed cell collects the affected cells. For each one, count its inputs inside the affected set. Start from the cells with a count of zero, kept in a min-heap so ties come out alphabetically; each time a cell is placed, decrement the counts of the cells that read it and add any that reach zero.
If fewer cells are placed than were affected, every leftover cell has an unplaced input among the leftovers. Walk backward from one of them through its inputs until a cell repeats; the repeated stretch, reversed, is a cycle in formula order.
Complexity: O(V + E + V log V) for the V affected cells and E edges between them; O(V + E) space
How to say it in the interview
Split the problem into two questions out loud: which cells change, and in what order. The first is reachability and the second is a topological sort, and the cycle report falls out of the second. Mention why edges out of the changed cell are not counted; it is the bug most candidates hit.
Solution
Python
import heapq
class CircularReference(Exception):
pass
def recalculate(dependents, changed):
# 1. Everything downstream of the change, and nothing else.
affected, stack = set(), [changed]
while stack:
for d in dependents.get(stack.pop(), []):
if d not in affected:
affected.add(d)
stack.append(d)
# 2. Kahn's algorithm inside that set. The changed cell's value is
# already known, so edges out of it are not waited on.
waiting = {cell: 0 for cell in affected}
for cell in affected:
for d in dependents.get(cell, []):
if d in waiting:
waiting[d] += 1
ready = [cell for cell in affected if waiting[cell] == 0]
heapq.heapify(ready)
order = []
while ready:
cell = heapq.heappop(ready)
order.append(cell)
for d in dependents.get(cell, []):
if d in waiting:
waiting[d] -= 1
if waiting[d] == 0:
heapq.heappush(ready, d)
if len(order) < len(affected):
stuck = affected - set(order)
raise CircularReference(" -> ".join(find_cycle(dependents, stuck)))
return order
def find_cycle(dependents, stuck):
reads = {cell: [] for cell in stuck} # cell -> stuck cells it reads
for cell in stuck:
for d in dependents.get(cell, []):
if d in reads:
reads[d].append(cell)
# Every stuck cell reads another stuck cell, so walking back must repeat.
path, seen_at = [], {}
cell = min(stuck)
while cell not in seen_at:
seen_at[cell] = len(path)
path.append(cell)
cell = min(reads[cell])
loop = path[seen_at[cell]:][::-1] # reversed: now in formula order
start = loop.index(min(loop))
loop = loop[start:] + loop[:start]
return loop + [loop[0]]JavaScript
class CircularReference extends Error {}
function recalculate(dependents, changed) {
const readers = (cell) => dependents[cell] ?? [];
// 1. Everything downstream of the change, and nothing else.
const affected = new Set();
const stack = [changed];
while (stack.length > 0) {
for (const d of readers(stack.pop())) {
if (!affected.has(d)) {
affected.add(d);
stack.push(d);
}
}
}
// 2. Kahn's algorithm inside that set; edges out of the changed cell are not waited on.
const waiting = new Map([...affected].map((cell) => [cell, 0]));
for (const cell of affected) {
for (const d of readers(cell)) if (waiting.has(d)) waiting.set(d, waiting.get(d) + 1);
}
const ready = [...affected].filter((cell) => waiting.get(cell) === 0);
const order = [];
while (ready.length > 0) {
ready.sort(); // alphabetical among the ready cells
const cell = ready.shift();
order.push(cell);
for (const d of readers(cell)) {
if (!waiting.has(d)) continue;
waiting.set(d, waiting.get(d) - 1);
if (waiting.get(d) === 0) ready.push(d);
}
}
if (order.length < affected.size) {
const placed = new Set(order);
const stuck = [...affected].filter((cell) => !placed.has(cell));
throw new CircularReference(findCycle(dependents, stuck).join(" -> "));
}
return order;
}
function findCycle(dependents, stuck) {
const reads = new Map(stuck.map((cell) => [cell, []])); // cell -> stuck cells it reads
for (const cell of stuck) {
for (const d of dependents[cell] ?? []) if (reads.has(d)) reads.get(d).push(cell);
}
const min = (cells) => cells.reduce((a, b) => (b < a ? b : a));
// Every stuck cell reads another stuck cell, so walking back must repeat.
const path = [];
const seenAt = new Map();
let cell = min(stuck);
while (!seenAt.has(cell)) {
seenAt.set(cell, path.length);
path.push(cell);
cell = min(reads.get(cell));
}
const loop = path.slice(seenAt.get(cell)).reverse(); // now in formula order
const start = loop.indexOf(min(loop));
const rotated = [...loop.slice(start), ...loop.slice(0, start)];
return [...rotated, rotated[0]];
}Both versions are tested against the same cases, including every example on this page.
- The search in step 1 uses an explicit stack, so a long chain of formulas cannot overflow the call stack.
- Only edges inside the affected set are counted. Counting the changed cell's own edges would leave every direct dependent waiting forever and look like a cycle.
- JavaScript has no built-in heap, so the JavaScript version sorts the ready list instead. That is fine for small sheets; for large ones, use a binary heap as the Python version does.
Follow-up questions
How do you compute the minimal affected set rather than recalculating everything?
Step 1 already does: reachability from the changed cell. A further cut is to stop at any cell whose recomputed value did not change, since nothing downstream of it needs to move, which works because the topological order computes inputs first.
Which cells can be recomputed in parallel?
Cells that become ready at the same time have no dependency between them. Process the ready set level by level and run each level in parallel.
How would you handle a formula referencing an entire column range?
Do not expand a whole column into a million edges. Store range dependencies separately as intervals and check each changed cell against them with an interval index.
Can you catch the cycle when the formula is typed, rather than at recalculation?
Yes. Before accepting a new formula in cell X, check whether any cell the formula reads is downstream of X. If one is, the formula would close a loop, so reject it and show the chain.
Common mistakes
- Recomputing in the order the search discovered the cells, which can compute C1 before B1.
- Counting the changed cell's outgoing edges, which reports a cycle on every input.
- Detecting a cycle but reporting only "circular reference", with no chain.
- Recursing once per level of formulas, which overflows the call stack on long chains.
Practice it in a mock interview
Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.
Practice this problem in a mock interview
Related problems
- Degrees of Separation Between Members: Breadth-first search from both ends, expanding the smaller frontier, stopping at depth three.
- Build a Document Outline from Headings: Keep the open chain of headings on a stack and pop until the top is a shallower level.
- Graphs: the pattern guide, with more practice questions.