What L5 looks like
- A real parser with precedence, parentheses and ranges; the dependency graph in both directions.
- Cycle detection before committing, and recomputation in topological order so each cell is computed once (diamond case).
- A failed
setleaves the sheet unchanged.
Clarify the setup
Build Sheet with:
-
set(cell, value):valueis a number or a formula string starting with=, such as"=A1 + B2 * 2"or"=SUM(A1:A3) / 3". -
get(cell) -> float: the cell's current value. Empty cells are 0.
Ask:
-
Formula grammar. Numbers, cell references,
+ - * /, parentheses, unary minus, andSUM(range). Enough to show a real parser; more functions are follow-ups. -
When are values computed? Eagerly:
setrecomputes everything that depends on the changed cell, sogetis an O(1) lookup. (The lazy alternative computes ongetand caches; mention it.) -
Cycles.
A1 = B1 + 1thenB1 = A1must be rejected, and the sheet must be left exactly as it was before the badset. -
Division by zero. Raise on
set, or store an error value? Store#DIV/0!-style errors as a follow-up; for now raise and reject theset.
Approach
Three parts:
-
Parse a formula once into an expression tree and collect the cells it references. A small recursive-descent parser over tokens keeps operator precedence right:
expr := term (("+" | "-") term)* term := factor (("*" | "/") factor)* factor := NUMBER | CELL | "-" factor | "(" expr ")" | "SUM" "(" CELL ":" CELL ")" -
Track the dependency graph.
deps[c]is the set of cellscreads;dependents[c]the reverse. Settingcreplaces its outgoing edges. -
Check and recompute.
-
Cycle check before committing: the new formula for
ccreates a cycle exactly whencis reachable from one of its new references by followingdeps. DFS from the new references; if it reachesc, reject without changing anything. -
Recompute in topological order: collect
cand everything that transitively depends on it, then evaluate them in an order where each cell comes after the cells it reads (Kahn's algorithm restricted to that set). Each affected cell is computed exactly once, even when it is reachable along several paths (the diamondA1 → B1, C1 → D1).
-
Cycle check before committing: the new formula for
If evaluation fails part-way (division by zero), restore the previous formulas, edges and values, so a rejected set leaves no trace.
Code
import re
from collections import deque
from typing import Dict, List, Set, Tuple, Union
TOKEN = re.compile(r"\s*(?:(\d+\.?\d*)|([A-Z]+[0-9]+)|(SUM)|(.))")
CELL = re.compile(r"([A-Z]+)([0-9]+)$")
class CycleError(ValueError):
pass
def _col(letters: str) -> int:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
return n
def _name(col: int, row: int) -> str:
letters = ""
while col:
col, rem = divmod(col - 1, 26)
letters = chr(65 + rem) + letters
return f"{letters}{row}"
def expand_range(a: str, b: str) -> List[str]:
(ca, ra), (cb, rb) = [(_col(m.group(1)), int(m.group(2))) for m in (CELL.match(a), CELL.match(b))]
return [_name(c, r) for c in range(min(ca, cb), max(ca, cb) + 1)
for r in range(min(ra, rb), max(ra, rb) + 1)]
class _Parser:
def __init__(self, src: str) -> None:
self.tokens: List[Tuple[str, str]] = []
for num, cell, fn, other in TOKEN.findall(src):
if num:
self.tokens.append(("num", num))
elif cell:
self.tokens.append(("cell", cell))
elif fn:
self.tokens.append(("fn", fn))
elif other.strip():
self.tokens.append(("op", other))
self.i = 0
self.refs: Set[str] = set()
def peek(self) -> Tuple[str, str]:
return self.tokens[self.i] if self.i < len(self.tokens) else ("end", "")
def take(self, kind: str, value: str = "") -> str:
k, v = self.peek()
if k != kind or (value and v != value):
raise ValueError(f"expected {value or kind}, got {v or 'end'}")
self.i += 1
return v
def parse(self):
tree = self.expr()
if self.peek()[0] != "end":
raise ValueError(f"unexpected {self.peek()[1]}")
return tree
def expr(self):
node = self.term()
while self.peek() in (("op", "+"), ("op", "-")):
node = (self.take("op"), node, self.term())
return node
def term(self):
node = self.factor()
while self.peek() in (("op", "*"), ("op", "/")):
node = (self.take("op"), node, self.factor())
return node
def factor(self):
kind, value = self.peek()
if kind == "num":
self.i += 1
return ("num", float(value))
if kind == "cell":
self.i += 1
self.refs.add(value)
return ("ref", value)
if (kind, value) == ("op", "-"):
self.i += 1
return ("neg", self.factor())
if (kind, value) == ("op", "("):
self.i += 1
node = self.expr()
self.take("op", ")")
return node
if kind == "fn":
self.i += 1
self.take("op", "(")
a = self.take("cell")
self.take("op", ":")
b = self.take("cell")
self.take("op", ")")
cells = expand_range(a, b)
self.refs.update(cells)
return ("sum", cells)
raise ValueError(f"unexpected {value or 'end'}")
class Sheet:
def __init__(self) -> None:
self.values: Dict[str, float] = {}
self.trees: Dict[str, tuple] = {}
self.deps: Dict[str, Set[str]] = {}
self.dependents: Dict[str, Set[str]] = {}
def get(self, cell: str) -> float:
return self.values.get(cell, 0.0)
def set(self, cell: str, value: Union[float, str]) -> None:
if isinstance(value, str) and value.startswith("="):
parser = _Parser(value[1:])
tree, refs = parser.parse(), parser.refs
else:
tree, refs = ("num", float(value)), set()
if cell in refs or self._reaches(refs, cell):
raise CycleError(f"{cell} would depend on itself")
saved = (dict(self.values), dict(self.trees),
{k: set(v) for k, v in self.deps.items()},
{k: set(v) for k, v in self.dependents.items()})
try:
for ref in self.deps.get(cell, set()):
self.dependents[ref].discard(cell)
self.deps[cell] = refs
for ref in refs:
self.dependents.setdefault(ref, set()).add(cell)
self.trees[cell] = tree
self._recompute(cell)
except Exception:
self.values, self.trees, self.deps, self.dependents = saved
raise
# ---- internals ----------------------------------------------------------
def _reaches(self, starts: Set[str], target: str) -> bool:
stack, seen = list(starts), set()
while stack:
c = stack.pop()
if c == target:
return True
if c not in seen:
seen.add(c)
stack.extend(self.deps.get(c, ()))
return False
def _recompute(self, cell: str) -> None:
affected, stack = {cell}, [cell]
while stack:
for d in self.dependents.get(stack.pop(), ()):
if d not in affected:
affected.add(d)
stack.append(d)
indegree = {c: len(self.deps.get(c, set()) & affected) for c in affected}
ready = deque(c for c, n in indegree.items() if n == 0)
while ready:
c = ready.popleft()
self.values[c] = self._eval(self.trees[c])
for d in self.dependents.get(c, ()):
if d in affected:
indegree[d] -= 1
if indegree[d] == 0:
ready.append(d)
def _eval(self, node: tuple) -> float:
kind = node[0]
if kind == "num":
return node[1]
if kind == "ref":
return self.get(node[1])
if kind == "neg":
return -self._eval(node[1])
if kind == "sum":
return sum(self.get(c) for c in node[1])
a, b = self._eval(node[1]), self._eval(node[2])
if kind == "+":
return a + b
if kind == "-":
return a - b
if kind == "*":
return a * b
if b == 0:
raise ZeroDivisionError("division by zero")
return a / b
Snapshotting the whole sheet on every set keeps the rollback obviously correct but costs O(sheet). In production, record only the touched cells' old values and edges (an undo log), which costs O(affected).
Complexity
Let A be the number of cells affected by a change (the cell plus its transitive dependents) and E the edges among them.
-
get: O(1). -
set: parse O(formula length + range size); cycle check O(cells reachable from the new references); recompute O(A + E) plus the evaluation cost of each affected formula. - A
SUM(A1:A1000)cell has 1,000 dependency edges; changing any of those 1,000 cells recomputes the sum in O(1,000). Follow-up 2 fixes this.
Test cases
def test_values_and_formulas():
s = Sheet()
s.set("A1", 2)
s.set("A2", "=A1 * 3 + 1")
s.set("A3", "=(A1 + A2) / 2")
assert s.get("A2") == 7 and s.get("A3") == 4.5
assert s.get("Z99") == 0
s.set("A4", "=-A1 - -3")
assert s.get("A4") == 1
def test_eager_updates_through_a_diamond():
s = Sheet()
s.set("A1", 1)
s.set("B1", "=A1 + 1")
s.set("C1", "=A1 * 10")
s.set("D1", "=B1 + C1")
assert s.get("D1") == 12
s.set("A1", 5)
assert (s.get("B1"), s.get("C1"), s.get("D1")) == (6, 50, 56)
def test_cycles_are_rejected_without_side_effects():
s = Sheet()
s.set("A1", "=B1 + 1")
s.set("B1", 4)
for cell, formula in [("B1", "=A1"), ("C1", "=C1"), ("B1", "=SUM(A1:A3)")]:
try:
s.set(cell, formula)
assert False
except CycleError:
pass
assert s.get("A1") == 5 and s.get("B1") == 4
s.set("B1", 10) # the old edges still work
assert s.get("A1") == 11
def test_sum_ranges_and_errors_roll_back():
s = Sheet()
for i, v in enumerate([1, 2, 3, 4], start=1):
s.set(f"A{i}", v)
s.set("B1", "=SUM(A1:A4) / 4")
assert s.get("B1") == 2.5
s.set("A4", 8)
assert s.get("B1") == 3.5
s.set("C1", "=SUM(A1:B2)") # 2-D range: A1, A2, B1, B2
assert s.get("C1") == 1 + 2 + 3.5 + 0
s.set("D1", 1)
s.set("D2", "=10 / D1")
try:
s.set("D1", 0)
assert False
except ZeroDivisionError:
pass
assert s.get("D1") == 1 and s.get("D2") == 10
def test_bad_formulas():
s = Sheet()
for bad in ("=1 +", "=(A1", "=A1 A2", "=SUM(A1)"):
try:
s.set("X1", bad)
assert False
except ValueError:
pass
assert s.get("X1") == 0
def test_column_names():
assert expand_range("Z1", "AB1") == ["Z1", "AA1", "AB1"]
Follow-ups
-
Lazy evaluation. Mark dependents dirty on
setand compute ongetwith memoization. Cheaper when many cells change between reads;getis no longer O(1). -
Big ranges. Instead of an edge from every cell in
SUM(A1:A100000), register the range once in an interval index per column, and on a change find the ranges that contain the cell. Incremental aggregates (add the delta to the sum) make the update O(1) forSUM. -
Error values. Store
#DIV/0!or#REF!as values that propagate through formulas, instead of rejecting theset. That is how real spreadsheets behave and it lets users save a sheet mid-edit. -
Collaboration. Two users editing cells at once: each
setis an operation on one cell; order them on a server and recompute. Cycles created by two concurrent edits (each fine alone) must be detected at apply time on the server. - Large sheets. Millions of cells: store sparse maps per column, recompute on worker threads in topological levels, and stream visible values to the client first.
Interviewer expectations
Mid-level (L4)
- Correct values for numbers and simple formulas with references, and updates that propagate to dependents.
- Detects at least direct cycles and refuses them.
- Tests a chain of dependencies.
Senior (L5)
- A real parser with precedence, parentheses and ranges; the dependency graph in both directions.
- Cycle detection before committing, and recomputation in topological order so each cell is computed once (diamond case).
- A failed
setleaves the sheet unchanged.
Staff (L6+)
- Compares eager and lazy evaluation and handles large ranges with interval indexes and incremental aggregates.
- Error values that propagate, collaborative edits and how cycles across concurrent edits are caught.
- Plans for scale: sparse storage, parallel recomputation, visible-first updates.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 2-level tests, run in your browser.



