Middle · Algorithms

A shared descendant is not necessarily a directed cycle

A DFS marks every visited node as a cycle when reached again. Why does it incorrectly reject a DAG with a shared descendant?

Try explaining this aloud before opening the answer. State your assumptions and trace a boundary case.

EXAMPLE · FRAGMENT UNLESS A COMPLETE PROGRAM IS SHOWN

go
package main

import "fmt"

type frame struct{ node, next int }

func cyclic(g [][]int) (bool, error) {
	colors := make([]uint8, len(g))
	for root := range g {
		if colors[root] != 0 {
			continue
		}
		colors[root] = 1
		stack := []frame{{node: root}}
		for len(stack) > 0 {
			top := &stack[len(stack)-1]
			if top.next == len(g[top.node]) {
				colors[top.node] = 2
				stack = stack[:len(stack)-1]
				continue
			}
			child := g[top.node][top.next]
			top.next++
			if child < 0 || child >= len(g) {
				return false, fmt.Errorf("invalid edge")
			}
			if colors[child] == 1 {
				return true, nil
			}
			if colors[child] == 0 {
				colors[child] = 1
				stack = append(stack, frame{node: child})
			}
		}
	}
	return false, nil
}
func main() {
	fmt.Println(cyclic([][]int{{1, 2}, {3}, {3}, {}}))
	fmt.Println(cyclic([][]int{{1}, {2}, {0}}))
}
Reveal a strong answer
  1. Distinguish unvisited, on the current DFS path, and fully processed nodes. An edge to an active gray node is a back edge and proves a directed cycle; an edge to a completed black node can be an ordinary shared descendant.

  2. An explicit stack stores both the node and the next edge to examine, preserving the same enter/exit structure as recursive DFS. It avoids unbounded reliance on the call stack, but still needs an input-size budget and valid vertex indexes. Visit every connected component, not only the first root.

  3. For undirected graphs, a parent edge needs different treatment, and multigraph/self-loop policy must be explicit. Graph mutation during traversal requires ownership or a consistent snapshot. Test empty graphs, isolated vertices, shared descendants, self-loops and a cycle outside the initial component.

Common trap

One visited bit cannot distinguish a current-path back edge from a previously completed node in a directed graph.

Complexity & use

O(V + E) time and O(V) auxiliary state for a fixed adjacency-list graph.

Try it

Trace colors and stack frames for 0→1, 0→2, 1→3, 2→3, then add 3→0 and explain the first cycle-proving edge.

Worked solution

Use three colors and finish a vertex only after all its outgoing edges are processed. Reject invalid edges rather than indexing outside the graph. The iterative frame advances its edge index before descending and marks the vertex black on exit. The shared descendant is already black; an edge to gray is the cycle evidence.

Change the constraint

How would you return a cycle witness, and what additional predecessor state would that require?

How did you do?

Self-assessment, not a compiler pass or interview score. Explain the reasoning unaided before marking understood.

Not marked. Progress stays on this browser, separate from course completion.

Primary references

Checked 2026-10-28. Use documentation for your pinned runtime, driver and tool version.