Depth-first search: recursive and iterative implementations, pre/post order, three-colour cycle detection, topological sort, connected components and backtracking.
DFS goes as deep as possible along one branch before backtracking. It uses recursion or an explicit stack, plus a visited set. O(V + E) time, O(V) space (the longest path).
Key uses:
A
/ \
B C
/ \ \
D E F
DFS from A (children left to right):
A -> B -> D (dead end, back to B) -> E (back to B, back to A) -> C -> F
pre-order : A B D E C F (when first entered)
post-order: D E B F C A (when finished)
DFS is the natural shape of recursion: call Visit(child) for each child. Iteratively, push neighbours on a Stack<T>.
A plain visited set is not enough: in a diamond A → B → D, A → C → D, reaching D twice is not a cycle. You need to know whether a node is on the current path:
| State | Meaning |
|---|---|
| White (0) | not visited yet |
| Grey (1) | in progress, on the current recursion path |
| Black (2) | finished, all descendants explored |
An edge to a grey node is a back edge, so a cycle. An edge to a black node is fine.
For dependencies "A depends on B", visit B before finishing A. When a node turns black, append it to the output. The output lists dependencies before dependants. This is how build systems (MSBuild project references), package managers, migration runners and DI containers order work, and how they report "circular dependency detected" with the actual cycle.
Kahn's algorithm (BFS with in-degrees) gives the same result; DFS is shorter and naturally yields the cycle path, while Kahn's makes it easy to process independent items in parallel by levels.
| Question | Use |
|---|---|
| Fewest hops / nearest | BFS |
| Is there any path? | either (DFS uses less memory on wide graphs) |
| Cycle detection, topological order | DFS (or Kahn) |
| Explore all combinations (backtracking) | DFS |
| Connected components | either |
Our migration runner let each data migration declare DependsOn. Someone added a dependency that created a cycle (AddInvoiceIndex → BackfillInvoices → AddInvoiceIndex) and the runner hung in an infinite loop at deploy time. We rewrote ordering as a DFS topological sort with three colours; on a back edge it throws with the full cycle path (AddInvoiceIndex → BackfillInvoices → AddInvoiceIndex), and a unit test runs the sort over all registered migrations so the cycle fails the build instead of the deploy.
Checks DFS implementation skill and understanding of its main applications.
Green flags
Red flags
void Visit(Node n)
{
if (!visited.Add(n)) return;
Pre(n);
foreach (var m in n.Next) Visit(m);
Post(n);
}
The recursion depth equals the length of the current path. A chain of 100,000 nodes means 100,000 frames, which can overflow the 1 MB default thread stack, and a StackOverflowException kills the .NET process. For untrusted or very deep graphs, use the iterative form.
A simple Stack<T> gives pre-order easily. For post-order (needed for topological sort) push a marker or keep an enumerator per frame:
stack of (node, enumerator over its neighbours)
push (start, start.Next.GetEnumerator())
while stack not empty:
(n, it) = peek
if it.MoveNext():
m = it.Current
if m is white: colour grey, push (m, m.Next.GetEnumerator())
else if m is grey: cycle!
else:
pop; colour n black; output.Add(n) <- post-order
This mirrors exactly what the recursive version does with the call stack, but the stack lives on the heap.
During DFS on a directed graph every edge u → v is one of:
| Edge | v's colour when seen | Meaning |
|---|---|---|
| Tree edge | white | first discovery |
| Back edge | grey | cycle |
| Forward edge | black, v descendant of u | fine |
| Cross edge | black, other branch | fine |
Only back edges matter for cycle detection. In undirected graphs, a cycle is any edge to a visited node that is not the parent you came from.
Dependencies (X -> Y means X depends on Y):
Api -> Domain, Infra
Infra -> Domain
Tests -> Api
DFS from Tests: Tests(grey) -> Api(grey) -> Domain(grey, no deps) -> black, output [Domain]
-> Infra(grey) -> Domain(black, skip) -> black, output [Domain, Infra]
Api black, output [Domain, Infra, Api]
Tests black, output [Domain, Infra, Api, Tests]
Build order: Domain, Infra, Api, Tests
If edges point the other way ("X is needed by Y"), reverse the final list.
Keep a parent map or the current path stack. When you hit a grey node g, walk the path back from the current node until g:
path stack: [Tests, Api, Infra, Shared]
edge Shared -> Api (Api is grey)
cycle: Api -> Infra -> Shared -> Api
Reporting the cycle is what turns a hang into a fixable error message.
Generating permutations, solving sudoku, or exploring configuration combinations is DFS where each level is a choice. Undo the choice when you return (backtrack). Prune branches early when constraints already fail; that pruning is what makes backtracking usable.
var deps = new Dictionary<string, string[]>
{
["Tests"] = ["Api"],
["Api"] = ["Domain", "Infra"],
["Infra"] = ["Domain"],
["Domain"] = [],
};
Console.WriteLine(string.Join(" -> ", TopoSort.Order(deps))); // Domain -> Infra -> Api -> Tests
deps["Domain"] = ["Tests"]; // introduce a cycle
try { TopoSort.Order(deps); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
Use the TopoSort class from the code example.
public static class TopoSort
{
private enum Colour : byte { White, Grey, Black }
// deps[x] = things x depends on. Returns dependencies first. Iterative: no stack overflow.
public static List<string> Order(IReadOnlyDictionary<string, string[]> deps)
{
var colour = new Dictionary<string, Colour>();
var output = new List<string>(deps.Count);
var path = new Stack<(string Node, IEnumerator<string> Next)>();
foreach (var root in deps.Keys)
{
if (colour.GetValueOrDefault(root) != Colour.White) continue;
colour[root] = Colour.Grey;
path.Push((root, Neighbours(deps, root)));
while (path.Count > 0)
{
var (node, next) = path.Peek();
if (next.MoveNext())
{
var dep = next.Current;
switch (colour.GetValueOrDefault(dep))
{
case Colour.White:
colour[dep] = Colour.Grey;
path.Push((dep, Neighbours(deps, dep)));
break;
case Colour.Grey:
throw new InvalidOperationException("Circular dependency: " + DescribeCycle(path, dep));
// Black: already ordered, nothing to do
}
}
else
{
path.Pop();
colour[node] = Colour.Black;
output.Add(node); // post-order: after all its dependencies
}
}
}
return output;
}
private static IEnumerator<string> Neighbours(IReadOnlyDictionary<string, string[]> deps, string n) =>
((IEnumerable<string>)(deps.TryGetValue(n, out var d) ? d : [])).GetEnumerator();
private static string DescribeCycle(Stack<(string Node, IEnumerator<string> Next)> path, string start)
{
var nodes = path.Select(p => p.Node).Reverse().ToList(); // bottom of stack first
var cycle = nodes.Skip(nodes.IndexOf(start)).Append(start);
return string.Join(" -> ", cycle);
}
}
// Recursive DFS for comparison: connected components in an undirected graph
public static int CountComponents(Dictionary<int, List<int>> g)
{
var seen = new HashSet<int>();
int count = 0;
foreach (var v in g.Keys)
if (seen.Add(v)) { count++; Dfs(v); }
return count;
void Dfs(int u)
{
foreach (var w in g[u])
if (seen.Add(w)) Dfs(w); // recursion depth up to V: fine for small graphs only
}
}
Mistake: Using a single visited set to detect cycles in a directed graph. Why it is wrong: In a diamond (A→B→D and A→C→D), D is reached twice without any cycle, so you report false cycles. Instead: Use three states (white, grey, black); an edge to a grey node is a cycle. Keep the current path so you can report which nodes form it.
Mistake: Adding nodes to the topological order on entry (pre-order). Why it is wrong: A node is emitted before its dependencies have finished, so the order is invalid. Instead: Emit on finish (post-order), and reverse the list if your edges point from dependency to dependant.
Mistake: Recursive DFS on large or untrusted graphs.
Why it is wrong: Recursion depth equals path length; a long chain overflows the thread stack, and .NET cannot catch StackOverflowException, so the process dies.
Instead: Use an explicit stack with a per-node enumerator, which keeps the same post-order behaviour on the heap.
Mistake: Reporting only "cycle detected".
Why it is wrong: Engineers cannot tell which of hundreds of dependencies is wrong, so the fix takes hours.
Instead: Track the current path and report the full cycle, such as Api -> Infra -> Shared -> Api.
Mistake: Using DFS to find the shortest path. Why it is wrong: DFS returns the first path it finds, which can be very long. Instead: Use BFS for unweighted graphs or Dijkstra for weighted graphs.
Mistake: Starting DFS from only one node. Why it is wrong: Nodes in other components are never visited, so a cycle there is missed and those nodes are missing from the topological order. Instead: Loop over every node and start a DFS from each one that is still unvisited.
A senior engineer recognises DFS-based ordering inside everyday tools: MSBuild ordering project references, NuGet resolving packages, the .NET DI container reporting a circular dependency, EF Core migrations, and workflow engines. When they build something with dependencies, they add a topological sort with cycle reporting and run it in a unit test, so a bad dependency fails CI rather than hanging a deploy.
| Need | Choice | Why |
|---|---|---|
| Build or migration order with clear cycle errors | DFS post-order, three colours | Short code, gives the exact cycle path |
| Run independent tasks in parallel by level | Kahn's algorithm | Each wave of zero in-degree nodes can run together |
| Deep or data-driven graphs | Iterative DFS | No stack overflow risk in .NET |
| Small trusted trees | Recursive DFS | Clearest code |
| Explore combinations (permutations, configurations) | Backtracking (DFS with pruning) | Prune early or cost is exponential |
| Shortest hops | BFS, not DFS | DFS gives no distance guarantee |
They also treat a detected cycle as a design signal, not only an error: two modules that depend on each other usually need an interface or a shared lower layer extracted.
Graph traversal means visiting every node reachable from a start node exactly once, using a visited set so cycles do not loop forever. The two basic orders are BFS (breadth-first, uses a queue) and DFS (depth-first, uses a stack or recursion).
BFS visits nodes in layers of distance 0, 1, 2, ... so the first time it reaches a node is via the fewest edges: it finds shortest paths in unweighted graphs. O(V + E) time, O(V) space. Uses: degrees of separation, nearest available item, impact of a change in a dependency graph, web crawling by depth.
A graph is a set of vertices (nodes) connected by edges. Edges can be directed or undirected, weighted or not, and the graph may contain cycles. Trees are a special case (connected, no cycles).
Representations:
Dictionary<TNode, List<TNode>>): O(V + E) memory, best for sparse graphs (most real ones).bool[V,V]): O(V²) memory, O(1) edge check, for small dense graphs.Backend graphs: service and package dependencies, DI containers, build systems (project references), workflow/job DAGs, permission inheritance, routing, social links.
For dependencies you need a DAG: find an execution order with topological sort (Kahn's algorithm, O(V + E)); if not all nodes can be ordered, there is a cycle.
Recursion is a function solving a problem by calling itself on a smaller version of it. Every recursive function needs:
Each call pushes a frame on the thread's call stack (about 1 MB by default on Windows). Too deep and you get a StackOverflowException, which cannot be caught and kills the process. Recursion fits naturally recursive data (trees, nested JSON, folders), but use a loop with an explicit Stack<T> or Queue<T> when depth can be large or is controlled by input.
How to tell it (STAR)
At [company], a release hung at the migration step with no error. I took a dump and saw the ordering loop spinning on two migrations that depended on each other. I fixed the dependency, then rewrote ordering as a DFS topological sort with three states, so it now fails fast with a message like 'AddInvoiceIndex -> BackfillInvoices -> AddInvoiceIndex'. I added a unit test over all migrations, and since then cycles get caught in the PR rather than in a deploy.
Last updated 23 Oct 2026.