skip to content

Why does Kahn's topological sort return a short list rather than fail when the graph has a cycle?

level: middleimportance: must knowfreq 80%

answer

  1. what does the loop actually test for?
  2. a cycle node keeps one incoming edge
  3. an in-degree that never reaches zero
  4. queue empties with nodes left over
  5. compare output length with node count

basics

~20 s

Nothing in the loop looks for cycles. Nodes on a cycle never reach in-degree zero, so they are never enqueued; the queue empties early and returns a short, valid-looking list. Compare its length to the node count.

solid answer

~50 s

Kahn's algorithm emits a node only when its in-degree has been decremented to zero, and it decrements an edge only when the edge's source is emitted. Every node on a cycle has an incoming edge from another node on that same cycle, and that source is itself never emitted — so the count never reaches zero for any of them. The loop terminates the moment the ready queue is empty, which happens with all cycle nodes and everything downstream of them still unprocessed. What comes back is a genuine topological order of the acyclic part, just incomplete. The contract is therefore `length(order) == V` or the graph has a cycle, and the missing nodes are exactly the cycle members plus their descendants — a useful diagnostic to report. Skipping that one comparison is how a build or recalculation ships an order that silently omits work.

code

pseudocode · 16 lines
pseudocode
for each u in V: indeg[u] = 0
for each edge (u, v): indeg[v] = indeg[v] + 1

Q = empty queue
for each u in V:
    if indeg[u] == 0: enqueue(Q, u)

order = empty list
while Q is not empty:
    u = dequeue(Q)
    append(order, u)
    for each v in adj[u]:
        indeg[v] = indeg[v] - 1
        if indeg[v] == 0: enqueue(Q, v)

// caller must check: length(order) == |V| ?

go deeper

for a junior

Know the loop by heart: seed the queue with in-degree-zero nodes, emit one, decrement its neighbours, enqueue any that reach zero. Then remember the one line that is not in the loop — the length check the caller must perform.

for a middle

Explain the invariant that a node is enqueued only after all its predecessors are emitted, and use it to show why cycle nodes are stranded. Derive O(V + E) by counting each node once and each edge once.

for a senior

Show the diagnostic instinct: not just "it has a cycle" but which nodes, walked backwards from the leftovers, plus an error message a user can act on. Talk about the silent-skip incident and where the assertion belongs in the pipeline.

for a principal

Own the contract, not the code: an ordering routine that can return a partial answer without signalling it is a defect in the interface. Decide whether the API returns a result-or-cycle union, throws, or forces the caller to inspect, and make that decision consistent across the system.

## What the algorithm actually does Kahn's method is the emit-and-delete construction made concrete. Count incoming edges per node (`indeg`), seed a queue with every node whose count is zero, then repeatedly take a node out, append it to the output, and decrement the count of each node it points to — enqueuing any that hit zero. The loop condition is "the ready queue is non-empty". That is the whole algorithm, and reading it carefully is the point of the question: **there is no cycle test anywhere in it.** ## Why cycle nodes are never emitted The invariant is: a node is enqueued exactly when every one of its predecessors has already been emitted. Now take any node `c` on a cycle. It has a predecessor `p` on the same cycle. For `c` to be enqueued, `p` must first be emitted; for `p` to be emitted, its own cycle predecessor must be emitted; walk around the ring and the requirement returns to `c` itself. No node on the ring can be first, so none is ever enqueued, and the same argument extends to every node reachable from the ring, since those keep an undecremented incoming edge too. So the loop ends normally with the queue empty. It does not error, it does not loop forever, and the list it returns is a correct topological order of the part of the graph it managed to process. That is what makes the bug dangerous: the output is *valid*, just not *complete*. ## The check, and what it tells you Compare `length(order)` with the number of nodes: | Outcome | Meaning | |---|---| | `length(order) == V` | The graph is a DAG; the list is a full topological order | | `length(order) < V` | The graph has at least one cycle | | `length(order) > V` | Impossible — each node is emitted at most once | The unemitted set is not noise. It is exactly the union of all cycles and everything reachable from them, which is precisely the set a user needs to see. You can even name an actual cycle cheaply: among the leftover nodes, every one still has residual in-degree above zero, and the source of any such remaining edge is itself a leftover node. So walk backwards from any leftover node through remaining edges; the walk can never get stuck, and because the node set is finite it must revisit a node — and the segment between the two visits is a concrete cycle to print. ## Cost Building `indeg` touches every edge once: O(V + E). Every node is enqueued and dequeued at most once, and every edge is decremented at most once, so the main loop is also O(V + E). Extra space is O(V) for the counts plus O(V) for the queue, on top of the graph itself. The completeness check is a single integer comparison — the cheapest safety net in the whole algorithm, which makes omitting it indefensible rather than a performance tradeoff. ## Variations that do not change correctness The ready set does not have to be a queue. A stack works, and so does a heap keyed on anything you like. The invariant only says "emit a node whose predecessors are all emitted", and any node currently in the ready set satisfies that. Different disciplines yield different valid orders — the choice is about which order you want, never about whether the output is correct. ## The failure story The canonical incident: a recalculation or dependency pipeline runs the in-degree method, gets back a list, iterates it, and finishes green. Some nodes never ran because they were downstream of a small cycle someone introduced that week. Nothing crashed, nothing logged, the outputs from the skipped work were simply stale. The fix is not in the algorithm — the algorithm did what it says — it is the one assertion the caller never wrote. When you write this in an interview, add the length check and say out loud why it is there; it is a strong signal at middle level.

  • The output came back short. How do you report which nodes form the actual cycle?
    Every leftover node still has an incoming edge whose source is also a leftover node — that is why it was never freed. So start at any leftover node and walk backwards along such edges. The walk can never get stuck, and with finitely many nodes it must revisit one; the segment between the two visits is a concrete cycle you can print. Linear extra work.
  • Does replacing the ready queue with a stack break correctness?
    No. The invariant is only that a node is emitted after all its predecessors, and every node sitting in the ready set already satisfies that, so any removal discipline yields a valid topological order. You get a different valid order — depth-leaning with a stack, level-leaning with a queue — and the same O(V + E) cost. Correctness never depends on the discipline; determinism does.
  • Where exactly does the O(V + E) bound come from?
    Two linear passes. Building the in-degree counts visits every edge once. In the main loop each node is enqueued and dequeued at most once — it is enqueued only on the transition of its count to zero, which happens once — and each edge is decremented exactly once, when its source is emitted. Summing gives O(V + E) time with O(V) auxiliary space.

saying these in an interview costs you the question

  • Assumes the algorithm raises an error on a cycle
  • Thinks leftover nodes are ones with in-degree zero
  • Says the emitted prefix is wrong and unusable
  • Treats an empty ready queue as proof of completion
  • Blames a short output on a disconnected graph

context