Question: DAG utility with add, validate and query
Companies: Notion
Difficulty: Medium
Problem statement
Build a small directed-acyclic-graph helper: add edges, reject ones that would create a cycle, and answer reachability or ordering queries over the graph. Reported on 1point3acres' Notion bank; reconstructed in our own words.
Clarify the setup
A helper for directed acyclic graphs, the kind behind formula dependencies, task blockers or synced blocks in a document tool:
-
add_node(n),add_edge(u, v):umust come beforev. Reject an edge that would create a cycle and leave the graph unchanged. -
has_path(u, v),descendants(n),ancestors(n). -
topological_order(): every node after all of its predecessors; deterministic (smallest name first among ready nodes).
Ask: self-loops (a cycle; reject), duplicate edges (ignore), adding an edge to an unknown node (create it, or raise; create it here), and how often edges are added versus queried. That last answer decides between the simple version and part 2.
Approach
Part 1: check reachability on every insert. Adding u → v creates a cycle exactly when v can already reach u. Run a DFS from v along out-edges; if it finds u, reject. Otherwise insert. Cost O(V + E) per insert in the worst case. Queries:
-
has_path,descendants: DFS forward.ancestors: DFS over reversed edges, which we keep aspredsfor that reason. -
topological_order: Kahn's algorithm with a min-heap of ready nodes for a deterministic order, O((V + E) log V).
Part 2: keep a topological order up to date (Pearce and Kelly). If inserts are frequent and the graph is large, a full DFS per insert is wasteful. Maintain ord[n], a position in a valid topological order:
- If
ord[u] < ord[v], the new edge already agrees with the order: no cycle is possible. Insert in O(1). In practice most edges are like this. - Otherwise only nodes with positions between
ord[v]andord[u]can be involved. DFS forward fromvbut only through nodes withord <= ord[u](if it reachesu, it is a cycle), and backward fromuthrough nodes withord >= ord[v]. Then reassign the positions of just those visited nodes: backward set first, forward set after, reusing the same pool of position numbers.
The work is proportional to the affected region, not the whole graph, and topological_order() becomes a sort by ord (or O(V) if positions are kept dense).
Code
import heapq
from typing import Dict, Hashable, List, Set
Node = Hashable
class CycleError(ValueError):
pass
class DAG:
def __init__(self) -> None:
self.succ: Dict[Node, Set[Node]] = {}
self.preds: Dict[Node, Set[Node]] = {}
def add_node(self, n: Node) -> None:
self.succ.setdefault(n, set())
self.preds.setdefault(n, set())
def add_edge(self, u: Node, v: Node) -> None:
self.add_node(u)
self.add_node(v)
if v in self.succ[u]:
return
if u == v or self.has_path(v, u):
raise CycleError(f"{u} -> {v} would create a cycle")
self.succ[u].add(v)
self.preds[v].add(u)
def remove_edge(self, u: Node, v: Node) -> None:
self.succ.get(u, set()).discard(v)
self.preds.get(v, set()).discard(u)
def _reach(self, start: Node, edges: Dict[Node, Set[Node]]) -> Set[Node]:
seen: Set[Node] = set()
stack = list(edges.get(start, ()))
while stack:
n = stack.pop()
if n not in seen:
seen.add(n)
stack.extend(edges[n])
return seen
def has_path(self, u: Node, v: Node) -> bool:
if u == v:
return u in self.succ
return v in self._reach(u, self.succ)
def descendants(self, n: Node) -> Set[Node]:
return self._reach(n, self.succ)
def ancestors(self, n: Node) -> Set[Node]:
return self._reach(n, self.preds)
def topological_order(self) -> List[Node]:
indegree = {n: len(p) for n, p in self.preds.items()}
ready = [n for n, d in indegree.items() if d == 0]
heapq.heapify(ready)
order = []
while ready:
n = heapq.heappop(ready)
order.append(n)
for m in self.succ[n]:
indegree[m] -= 1
if indegree[m] == 0:
heapq.heappush(ready, m)
return order
class IncrementalDAG(DAG):
"""Part 2: maintains a topological order so most inserts are O(1)."""
def __init__(self) -> None:
super().__init__()
self.ord: Dict[Node, int] = {}
self.checks = 0 # nodes visited by cycle checks
def add_node(self, n: Node) -> None:
if n not in self.ord:
self.ord[n] = len(self.ord)
super().add_node(n)
def add_edge(self, u: Node, v: Node) -> None:
self.add_node(u)
self.add_node(v)
if v in self.succ[u]:
return
if u == v:
raise CycleError(f"{u} -> {v} would create a cycle")
lb, ub = self.ord[v], self.ord[u]
if lb < ub: # order must change around this edge
forward = self._bounded(v, self.succ, lambda n: self.ord[n] <= ub)
if u in forward:
raise CycleError(f"{u} -> {v} would create a cycle")
backward = self._bounded(u, self.preds, lambda n: self.ord[n] >= lb)
nodes = sorted(backward, key=self.ord.get) + sorted(forward, key=self.ord.get)
slots = sorted(self.ord[n] for n in nodes)
for n, slot in zip(nodes, slots):
self.ord[n] = slot
self.succ[u].add(v)
self.preds[v].add(u)
def _bounded(self, start: Node, edges: Dict[Node, Set[Node]], keep) -> Set[Node]:
seen = {start}
stack = [start]
while stack:
n = stack.pop()
self.checks += 1
for m in edges[n]:
if m not in seen and keep(m):
seen.add(m)
stack.append(m)
return seen
def order(self) -> List[Node]:
return sorted(self.ord, key=self.ord.get)
Complexity
| Operation | Part 1 | Part 2 |
|---|---|---|
add_edge agreeing with the order |
O(V + E) | O(1) |
add_edge reordering |
O(V + E) | O(nodes and edges in the affected region) |
has_path, descendants, ancestors
|
O(V + E) | same |
| topological order | O((V + E) log V) | O(V log V) sort, no graph walk |
Space is O(V + E) for both edge maps, plus O(V) for ord.
Test cases
def test_rejects_cycles_and_leaves_graph_unchanged():
g = DAG()
g.add_edge("a", "b")
g.add_edge("b", "c")
for u, v in [("c", "a"), ("b", "b"), ("c", "b")]:
try:
g.add_edge(u, v)
assert False
except CycleError:
pass
assert g.succ == {"a": {"b"}, "b": {"c"}, "c": set()}
def test_queries_and_order():
g = DAG()
for u, v in [("shop", "cook"), ("cook", "eat"), ("invite", "eat"), ("clean", "invite")]:
g.add_edge(u, v)
g.add_node("relax")
assert g.has_path("shop", "eat") and not g.has_path("eat", "shop")
assert g.descendants("clean") == {"invite", "eat"}
assert g.ancestors("eat") == {"shop", "cook", "invite", "clean"}
assert g.topological_order() == ["clean", "invite", "relax", "shop", "cook", "eat"]
def test_order_respects_every_edge():
import random
rng = random.Random(1)
g = DAG()
for _ in range(300):
u, v = rng.randrange(40), rng.randrange(40)
try:
g.add_edge(u, v)
except CycleError:
pass
pos = {n: i for i, n in enumerate(g.topological_order())}
assert len(pos) == len(g.succ)
assert all(pos[u] < pos[v] for u in g.succ for v in g.succ[u])
def test_incremental_matches_simple_version():
import random
rng = random.Random(2)
a, b = DAG(), IncrementalDAG()
for _ in range(2000):
u, v = rng.randrange(80), rng.randrange(80)
ra = rb = "ok"
try:
a.add_edge(u, v)
except CycleError:
ra = "cycle"
try:
b.add_edge(u, v)
except CycleError:
rb = "cycle"
assert ra == rb
pos = b.ord
assert all(pos[u] < pos[v] for u in b.succ for v in b.succ[u])
assert sorted(pos.values()) == list(range(len(pos)))
def test_edges_in_order_are_constant_time():
g = IncrementalDAG()
for i in range(1000):
g.add_node(i)
for i in range(999):
g.add_edge(i, i + 1) # agrees with insertion order
assert g.checks == 0
try:
g.add_edge(999, 0)
assert False
except CycleError:
pass
Follow-ups
- Removing edges. Removal never creates a cycle and the current order stays valid, so it is O(1) in both versions.
- Batch inserts. Adding many edges at once (importing a project) is cheaper as one pass: add them all, run Kahn's algorithm once, and if nodes remain unprocessed, report the cycle (the leftover nodes contain it) and roll the batch back.
-
Explaining a cycle. Users need to see why an edge is rejected: keep parent pointers in the DFS from
vand return the pathv → … → u, so the message reads "A depends on B, which depends on C, which depends on A". -
Reachability queries at scale. For frequent
has_pathon a big, mostly static graph, precompute reachability with interval labels on a spanning tree plus exceptions, or cache transitive closures for hot nodes and invalidate on edge changes. - Persistence and concurrency. Store edges as rows with a unique constraint; serialize inserts per graph (one writer) so two concurrent inserts cannot each pass the cycle check and together form a cycle.
Interviewer expectations
Mid-level (L4)
- Correct cycle check on insert (reachability from
vtou) and a working topological sort. - Handles self-loops, duplicate edges and unknown nodes.
- Tests that rejected edges leave the graph unchanged.
Senior (L5)
- Keeps reverse edges for ancestor queries, deterministic ordering with a heap, and clear complexity per operation.
- Verifies the order against every edge in a randomized test.
- Explains the cycle to the user with the offending path.
Staff (L6+)
- Maintains a topological order incrementally so most inserts are O(1) and others touch only the affected region, and tests it against the simple version.
- Handles batch imports, concurrent writers and persistence.
- Picks between per-insert checks, incremental ordering and precomputed reachability from the workload.