DFS in C++ and C#: Recursion Limits, Cycles, Topo Sort

Recursive vs stack-based DFS order, the N at which C++ and C# recursion crash on a path graph, plus cycle detection and topological sort, all measured.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled both examples with Apple clang 21 on an Apple M1 Pro, using -Wall -Wextra and -fsanitize=address,undefined. Both compiled cleanly and matched the original output, but the maze example printed a wrong distance when no path existed, and because it enumerates every path it took 46.65 seconds on an empty 7×7 grid. The diagrams are new, and the measured recursion limits for C++ and C#, cycle detection, topological sort and the recursive-versus-iterative order comparison are additions.

This article is for anyone whose recursive DFS passed every test and then crashed on a large input, and for anyone who rewrote DFS with a stack and watched their expected output change. The short version: a recursive DFS runs out of the default stack somewhere between 100,000 and 175,000 levels deep, and an explicit-stack DFS only reproduces the recursive order if it marks vertices when they are popped and pushes neighbours in reverse.

The breadth-first search article runs BFS on the same graph, and the stack article covers the data structure itself.

Recursive DFS and its visit order

Depth-first search follows one direction as far as it can, then backs up to the most recent vertex that still has an unvisited neighbour. Remembering "where to back up to" is the job of a stack. In a recursive implementation the call stack does that job for you, which is what the 2021 article meant when it said DFS "uses a stack" while showing recursive code.

We use the same graph as the BFS article: 8 vertices, edges 1-2, 1-3, 1-4, 2-5, 3-5, 3-6, 4-7, 5-8, 6-8 and 7-8, neighbours checked in ascending order. BFS visited it in distance order, 1 2 3 4 5 6 7 8. DFS dives instead:

The 8-vertex graph in the same four-column layout as the BFS figure. DFS tree arrows: step 1 from 1 to 2, step 2 from 2 to 5, step 3 from 5 back to 3, step 4 from 3 to 6, step 5 from 6 to 8, step 6 from 8 to 7, step 7 from 7 to 4. Dashed unused edges: 1 to 3, 1 to 4, and 5 to 8. The search goes seven calls deep along one path.
Recursive DFS from vertex 1 on the same 8-vertex graph used in the BFS article, neighbours tried in ascending order. Numbered arrows are the calls in the order they happen; dashed lines are edges that lead to an already visited vertex. Visit order: 1, 2, 5, 3, 6, 8, 7, 4.

From 2 it goes to 5, then climbs back to 3, and continues 3 → 6 → 8 → 7 → 4 without backing up once: seven levels deep. Vertex 4 is directly connected to the start, yet it is visited last; in BFS it had distance 1. DFS order has nothing to do with distance, so to get a shortest path out of DFS you must try every path. We measure what that costs with the 2021 maze code further down.

Recursion vs an explicit stack: why the order changes

There is more than one way to replace recursion with std::stack, and they do not all produce the same order. We ran four versions on the same graph:

void dfsRec(int v) {
    seen[v] = 1;
    order.push_back(v);
    for (int w : adj[v])
        if (!seen[w]) dfsRec(w);
}

// mark on pop; reverse = push neighbours largest-first so the smallest is on top
void dfsStack(int s, bool reverse) {
    std::stack<int> st;
    st.push(s);
    while (!st.empty()) {
        int v = st.top(); st.pop();
        if (seen[v]) continue;             // already visited via another path
        seen[v] = 1;
        order.push_back(v);
        if (reverse) {
            for (auto it = adj[v].rbegin(); it != adj[v].rend(); ++it)
                if (!seen[*it]) st.push(*it);
        } else {
            for (int w : adj[v]) if (!seen[w]) st.push(w);
        }
    }
}

// BFS code with the queue swapped for a stack (marks on push): NOT a DFS
void stackMarkOnPush(int s) {
    std::stack<int> st;
    st.push(s); seen[s] = 1;
    while (!st.empty()) {
        int v = st.top(); st.pop();
        order.push_back(v);
        for (auto it = adj[v].rbegin(); it != adj[v].rend(); ++it)
            if (!seen[*it]) { seen[*it] = 1; st.push(*it); }
    }
}

Output (built with -Wall -Wextra -fsanitize=address,undefined, no warnings):

recursive                          1 2 5 3 6 8 7 4
stack, push in list order          1 4 7 8 6 3 5 2
stack, push in reverse order       1 2 5 3 6 8 7 4
stack, mark on push (not DFS)      1 2 5 8 6 7 3 4
  • Pushing neighbours in list order means the last one pushed, 4, comes out first. That is a valid DFS, just one that prefers the largest neighbour.
  • Pushing in reverse puts the smallest neighbour on top and reproduces the recursive order exactly. Use this when you convert recursive code and need identical output, for example to keep existing tests green.
  • Taking BFS code and swapping the queue for a stack (marking on push) is not DFS. When the search reaches 6, its neighbour 3 has not been visited, so a real DFS would go there next. Here 3 was already "marked" back when 1 was processed, so it is skipped. No neighbour ordering can produce this sequence. It is harmless for counting connected components, but wrong for anything that depends on when a vertex finishes, such as cycle detection and topological sort.

The rule of thumb: BFS marks on push (measured in the BFS article), iterative DFS marks on pop. Because the same vertex can sit on the stack more than once, the if (seen[v]) continue; right after popping is essential.

Recursion depth limits: where C++ and C# crash

Recursion depth depends on the shape of the graph. The worst case is a path graph, 0-1-2-...-(N-1), where the depth equals N. Linked-list-shaped trees and long single-lane corridors in a maze look exactly like this. We increased N and bisected for the first value that crashes.

// Path graph 0-1-2-...-(N-1): recursive DFS goes N calls deep.
std::vector<std::vector<int>> adj;
std::vector<char> seen;
int visited = 0;

void dfs(int v) {
    seen[v] = 1;
    visited++;
    for (int w : adj[v])
        if (!seen[w]) dfs(w);
}

int main(int argc, char** argv) {
    int n = std::atoi(argv[1]);
    adj.assign(n, {});
    seen.assign(n, 0);
    for (int i = 0; i + 1 < n; i++) { adj[i].push_back(i + 1); adj[i + 1].push_back(i); }
    dfs(0);   // or dfsIter(0): the explicit-stack version from above
    std::printf("visited %d\n", visited);
}

The C# version has the same structure (dotnet new console, net10.0, C# 14, Release build):

int n = int.Parse(args[0]);
var adj = new List<int>[n];
for (int i = 0; i < n; i++) adj[i] = new List<int>(2);
for (int i = 0; i + 1 < n; i++) { adj[i].Add(i + 1); adj[i + 1].Add(i); }
var seen = new bool[n];
int visited = 0;

void Dfs(int v)
{
    seen[v] = true;
    visited++;
    foreach (int w in adj[v])
        if (!seen[w]) Dfs(w);
}

Dfs(0);
Console.WriteLine($"visited {visited}");

On an Apple M1 Pro the macOS main-thread stack is 8,176 KB according to ulimit -s. Results:

Build Largest N that worked First N that crashed How it died
C++ clang -O0 130,702 130,703 SIGSEGV, exit status 139
C++ clang -O2 174,273 174,274 SIGSEGV, exit status 139
C++ -O2 with ulimit -s 65520 1,397,603 1,397,604 SIGSEGV
C++ AddressSanitizer build — already at 100,000 AddressSanitizer: stack-overflow
C# .NET 10 Release (default) 104,488 104,489 "Stack overflow.", exit status 134
C# Release, DOTNET_TieredCompilation=0 174,192 174,193 same
C# on a thread with a 256 MB stack 3,000,000 worked — —
Explicit stack (C++ and C#) 10,000,000 worked — —

The exact boundary moves by a handful depending on the environment: the same C++ -O2 binary reached 174,273 from the shell but 174,264 when launched from Python's subprocess, and the C# boundary shifted by one or two between builds. Remember it as roughly 170k for C++ -O2, 130k for -O0 and 100k for C#. Dividing 8 MB by N, each call costs about 48 bytes at -O2 and about 64 bytes at -O0.

Here is what C# printed for N = 1,000,000:

Stack overflow.
Repeated 104514 times:
--------------------------------
   at Program+<>c__DisplayClass0_0.<<Main>$>g__Dfs|0(Int32)
--------------------------------
   at Program.<Main>$(System.String[])

Two things stand out for C#. First, a StackOverflowException cannot be caught with try/catch; the process is terminated, as the StackOverflowException page on Microsoft Learn states. On a server, one deep request takes the whole process down. Second, with the default tiered compilation a method starts in unoptimized Tier 0 code. A deep recursion reaches the bottom before the optimized version is swapped in, which is the likely reason the default run died at about 100k with larger frames, while DOTNET_TieredCompilation=0 reached the same 174k as C++ -O2. Do not treat that switch as a fix.

The actual fixes, in order of preference: (1) use an explicit stack — the table shows 10 million vertices with no trouble; (2) run the recursion on a thread with a bigger stack — new Thread(() => Dfs(0), maxStackSize: 256 * 1024 * 1024) in C#, ulimit -s or thread attributes in C++; (3) switch to an algorithm whose depth does not grow with the input, such as BFS. Online judges and interview sandboxes rarely let you change the stack size, so (1) is the safe default.

Cycle detection and topological sort

Where DFS clearly beats BFS is cycle detection and topological sorting on directed graphs. The trick is three colours:

  • White: not visited yet
  • Gray: entered, and still on the current recursion path
  • Black: finished; everything reachable from it is done

An edge into a gray vertex points back to an ancestor on the current path, so it closes a cycle. And the order in which vertices turn black (post-order), reversed, is a topological order: a vertex finishes only after everything that must come after it has finished.

Our example is a small build pipeline, where an arrow means "must finish before".

Directed graph with six tasks. checkout points to deps and codegen; deps and codegen both point to compile; compile points to test and package; test points to package. Each node shows its position in the topological order: 1 checkout, 2 codegen, 3 deps, 4 compile, 5 test, 6 package.
A small build pipeline as a directed acyclic graph. An arrow means 'must finish before'. DFS post-order, reversed, gives a valid run order: checkout, codegen, deps, compile, test, package.
enum Color { WHITE, GRAY, BLACK };   // unvisited / on the current path / finished

bool dfs(int v) {
    color[v] = GRAY;
    for (int w : out[v]) {
        if (color[w] == GRAY) {                // back edge: w is an ancestor of v
            for (int x = v; x != w; x = parent[x]) cycle.push_back(x);
            cycle.push_back(w);
            std::reverse(cycle.begin(), cycle.end());
            return false;
        }
        if (color[w] == WHITE) {
            parent[w] = v;
            if (!dfs(w)) return false;
        }
    }
    color[v] = BLACK;
    post.push_back(v);                         // all dependants are done
    return true;
}
// sort(): call dfs on every WHITE vertex, then print post in reverse

We ran it once on the graph above, then again after adding a mistaken edge package -> deps:

order: checkout codegen deps compile test package
cycle: deps -> compile -> test -> package -> deps

Following the parent links lets you print the actual loop, not just "there is a cycle". That is how build tools and package managers can report "circular dependency: A -> B -> C -> A".

The classic mistake is to skip the gray/black distinction and treat any visited vertex as a cycle. We changed the condition to color[w] != WHITE and ran it again: the acyclic graph was reported as cyclic on the edge compile -> package. All that happened was that package had already finished (via test) when compile looked at it a second time. For undirected graphs "a visited neighbour that is not my parent" works; for directed graphs you need all three colours.

Topological order can also be computed with Kahn's algorithm, which repeatedly removes vertices with in-degree zero using a queue. It is BFS-flavoured and has no recursion-depth risk on huge graphs; DFS is handier when you want to show the offending cycle.

Checking the 2021 code: shortest paths with DFS

We copied both 2021 examples verbatim and compiled them:

  • Example 1, matrix DFS: zero warnings with -Wall -Wextra, and the original input produced the original output (visit order 1 2 4 8 5 6 3 7). As in the BFS article, map[30][30] limits vertex numbers to 29.
  • Example 2, maze shortest distance with DFS: compiled cleanly and printed the original answer, 13, after 27 DFS calls. It had two real problems.

First, it prints a wrong value for an unreachable goal. It prints the initial min = n * n unchanged, so a 3×3 maze with the middle row blocked printed a shortest distance of 9. You need a sentinel such as -1 to tell "no path" apart.

Second, it enumerates every path, so it is exponential. It is backtracking: the visited mark is undone on the way out (map[y][x] = 1), so the more open the grid, the more paths it explores. Built with clang -O2, on empty n×n grids:

Empty grid DFS calls Run time
5×5 90,111 under 0.01 s
6×6 18,470,411 0.08 s
7×7 10,966,485,141 46.65 s

BFS solves the same 7×7 grid by pushing each of its 49 cells once. At 10×10, the size limit of the 2021 code's map[10][10], the DFS version would not finish in any reasonable time. Use BFS for shortest distances; use DFS for "is there a path", "count every combination" and "is there a cycle". Every 2021 diagram (graph, adjacency matrix, maze grids) has been replaced with the new figures and real program output.

When to reach for DFS

Goal Use
Fewest moves BFS
Reachability, counting connected components DFS or BFS (iterative if depth may be large)
Detect a cycle in a directed graph and print it DFS with three colours
Topological sort DFS post-order, or Kahn's algorithm (queue)
Enumerate all paths or combinations (backtracking) Recursive DFS
Depth may exceed ~100,000 Iterative DFS with an explicit stack

Recursive DFS is short and readable, but as soon as the depth can grow with the input, the 100k–170k numbers above become a hard ceiling. Small test inputs will not reveal it, so if your graphs can exceed 100,000 vertices, write the iterative version from the start.

FAQ

How deep can a recursive DFS go before the stack overflows?

On a path graph (0-1-2-...) with the default 8 MB main-thread stack on macOS (Apple M1 Pro), C++ crashed with a segmentation fault at about 130,700 vertices at -O0 and about 174,270 at -O2. C# on .NET 10 (Release) died with 'Stack overflow.' at about 104,490. An explicit stack handled 10 million vertices in both languages.

Why does my stack-based DFS visit vertices in a different order than the recursive one?

A stack pops the neighbour pushed last. Pushing neighbours in list order gave 1 4 7 8 6 3 5 2; pushing them in reverse gave 1 2 5 3 6 8 7 4, the same as recursion. Marking vertices when they are pushed gave 1 2 5 8 6 7 3 4, which is not a DFS order at all.

Can I detect a cycle in a directed graph with a single visited array?

No. You need to tell finished vertices (black) from vertices on the current path (gray). With only a visited flag, the acyclic build graph in this article was reported as having a cycle on the edge compile -> package.

  • #DFS
  • #depth-first search
  • #topological sort
  • #stack overflow
  • #C++
  • #C#

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑