254: Graph DFS
Difficulty: 2 Level: Intermediate Traverse a graph by diving deep along each path before backtracking โ using an explicit stack or recursion.The Problem This Solves
Depth-first search explores as far as possible along one path before trying alternatives. It's the algorithm for topological sorting, cycle detection, maze solving, reachability analysis, and tree traversal. When you don't need shortest-path ordering and do need to explore complete paths, DFS is the right choice. BFS uses a queue to expand level by level. DFS uses a stack (last-in, first-out) to dive deep. The structural difference is one word: replace `push_back`/`pop_front` with `push`/`pop` and you have DFS. Understanding this symmetry reveals the deep relationship between the two algorithms. OCaml's natural DFS is recursive โ thread a visited set through returns, fold over neighbours. Rust can do the same recursively, but for large graphs risks stack overflow. The idiomatic Rust solution uses an explicit stack on the heap, giving the same traversal order without recursion limits.The Intuition
Imagine exploring a cave network. DFS says: pick any passage, follow it all the way to a dead end (or a room you've been to before), then backtrack to the last junction and try the next passage. You fully explore one branch before touching another. The explicit stack mirrors the call stack of the recursive version. Each "push a node" is like "make a recursive call". Each "pop a node" is like "return from a recursive call". The heap stack has no depth limit; the call stack does. OCaml's version threads the visited set as an immutable value through returns โ `go` returns `(new_visited, path)`. Rust's version passes `&mut HashSet`, which is more efficient but keeps the same invariant: a node is visited at most once.How It Works in Rust
use std::collections::{HashMap, HashSet};
// Iterative DFS โ no recursion limit, O(1) stack ops
pub fn dfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<&str> = vec![start];
let mut result: Vec<&str> = Vec::new();
while let Some(node) = stack.pop() { // LIFO: pop from back
if !visited.insert(node) { continue; } // skip if already visited
result.push(node);
if let Some(neighbors) = graph.get(node) {
// Push in reverse order so first neighbour is processed first
for &neighbor in neighbors.iter().rev() {
if !visited.contains(neighbor) {
stack.push(neighbor);
}
}
}
}
result
}
// Recursive DFS โ mirrors OCaml's `go` helper, uses &mut visited
pub fn dfs_recursive<'a>(graph: &[(&'a str, Vec<&'a str>)], start: &'a str) -> Vec<&'a str> {
fn go<'a>(graph: &[(&'a str, Vec<&'a str>)], visited: &mut HashSet<&'a str>, node: &'a str, result: &mut Vec<&'a str>) {
if !visited.insert(node) { return; }
result.push(node);
if let Some((_, neighbors)) = graph.iter().find(|(k, _)| *k == node) {
for &n in neighbors { go(graph, visited, n, result); }
}
}
let mut visited = HashSet::new();
let mut result = Vec::new();
go(graph, &mut visited, start, &mut result);
result
}
What This Unlocks
- Topological sort โ DFS post-order gives reverse topological order; essential for build systems and dependency graphs.
- Cycle detection โ track "currently in stack" vs "fully visited"; a back edge to "in stack" means a cycle.
- Connected components โ run DFS from each unvisited node; same pattern as BFS but explores one component fully before starting the next.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Visited set | Immutable `Set.Make(String)` threaded through returns | Mutable `HashSet<&str>` passed by `&mut` |
| Recursion vs iteration | Natural recursive style | Iterative stack preferred (no overflow risk) |
| Graph representation | Association list, `List.assoc` O(n) | `HashMap` O(1) average |
| Insert + check | `SS.mem` then `SS.add` (two operations) | `HashSet::insert` returns `bool` (one call) |
| Neighbour order | Left-to-right `List.fold_left` | Push reversed, pop in forward order |
use std::collections::{HashMap, HashSet};
/// Idiomatic Rust DFS: iterative with an explicit stack and HashSet for visited tracking.
pub fn dfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<&str> = vec![start];
let mut result: Vec<&str> = Vec::new();
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
result.push(node);
if let Some(neighbors) = graph.get(node) {
for &neighbor in neighbors.iter().rev() {
if !visited.contains(neighbor) {
stack.push(neighbor);
}
}
}
}
result
}
/// Functional-style DFS using an assoc-list graph (mirrors OCaml's `List.assoc`).
pub fn dfs_recursive<'a>(graph: &[(&'a str, Vec<&'a str>)], start: &'a str) -> Vec<&'a str> {
fn go<'a>(
graph: &[(&'a str, Vec<&'a str>)],
visited: &mut HashSet<&'a str>,
node: &'a str,
) -> Vec<&'a str> {
if !visited.insert(node) {
return vec![];
}
let neighbors = graph
.iter()
.find(|(n, _)| *n == node)
.map(|(_, ns)| ns.as_slice())
.unwrap_or(&[]);
let mut path = vec![node];
for &neighbor in neighbors {
path.extend(go(graph, visited, neighbor));
}
path
}
let mut visited = HashSet::new();
go(graph, &mut visited, start)
}
fn main() {
// Idiomatic DFS with HashMap
let mut graph = HashMap::new();
graph.insert("a", vec!["b", "c"]);
graph.insert("b", vec!["d"]);
graph.insert("c", vec!["d"]);
graph.insert("d", vec![]);
let order = dfs(&graph, "a");
println!("dfs (iterative): {:?}", order);
// Functional DFS with assoc-list โ mirrors OCaml directly
let assoc = vec![
("a", vec!["b", "c"]),
("b", vec!["d"]),
("c", vec!["d"]),
("d", vec![]),
];
let order2 = dfs_recursive(&assoc, "a");
println!("dfs_recursive: {:?}", order2);
}
/* Output:
dfs (iterative): ["a", "b", "d", "c"]
dfs_recursive: ["a", "b", "d", "c"]
*/
(* OCaml Depth-First Search using a pure functional visited set *)
module SS = Set.Make(String)
(* Idiomatic OCaml DFS โ visited set threaded as a pure value through recursion *)
let dfs graph start =
let rec go visited node =
if SS.mem node visited then (visited, [])
else
let visited = SS.add node visited in
let neighbors = try List.assoc node graph with Not_found -> [] in
let visited, paths = List.fold_left (fun (vis, acc) n ->
let vis, path = go vis n in
(vis, acc @ path)
) (visited, []) neighbors in
(visited, node :: paths)
in
snd (go SS.empty start)
(* Recursive DFS returning only the path โ slightly more direct *)
let dfs_simple graph start =
let rec go visited node =
if SS.mem node visited then (visited, [])
else
let visited' = SS.add node visited in
let neighbors = try List.assoc node graph with Not_found -> [] in
let visited'', rest = List.fold_left (fun (v, acc) n ->
let v', p = go v n in (v', acc @ p)
) (visited', []) neighbors in
(visited'', node :: rest)
in
snd (go SS.empty start)
let () =
let g = [("a", ["b"; "c"]); ("b", ["d"]); ("c", ["d"]); ("d", [])] in
let result = dfs g "a" in
assert (result = ["a"; "b"; "d"; "c"]);
let result2 = dfs_simple g "a" in
assert (result2 = ["a"; "b"; "d"; "c"]);
(* Single node *)
let single = [("x", [])] in
assert (dfs single "x" = ["x"]);
(* Linear chain *)
let chain = [("1", ["2"]); ("2", ["3"]); ("3", [])] in
assert (dfs chain "1" = ["1"; "2"; "3"]);
print_endline "ok"
๐ Detailed Comparison
OCaml vs Rust: Graph DFS
Side-by-Side Code
OCaml
๐ช Show OCaml equivalent
module SS = Set.Make(String)
let dfs graph start =
let rec go visited node =
if SS.mem node visited then (visited, [])
else
let visited = SS.add node visited in
let neighbors = try List.assoc node graph with Not_found -> [] in
let visited, paths = List.fold_left (fun (vis, acc) n ->
let vis, path = go vis n in
(vis, acc @ path)
) (visited, []) neighbors in
(visited, node :: paths)
in
snd (go SS.empty start)
Rust (idiomatic โ iterative stack)
pub fn dfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<&str> = vec![start];
let mut result: Vec<&str> = Vec::new();
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
result.push(node);
if let Some(neighbors) = graph.get(node) {
for &neighbor in neighbors.iter().rev() {
if !visited.contains(neighbor) {
stack.push(neighbor);
}
}
}
}
result
}Rust (functional/recursive โ mirrors OCaml)
pub fn dfs_recursive<'a>(graph: &[(&'a str, Vec<&'a str>)], start: &'a str) -> Vec<&'a str> {
fn go<'a>(
graph: &[(&'a str, Vec<&'a str>)],
visited: &mut HashSet<&'a str>,
node: &'a str,
) -> Vec<&'a str> {
if !visited.insert(node) {
return vec![];
}
let neighbors = graph
.iter()
.find(|(n, _)| *n == node)
.map(|(_, ns)| ns.as_slice())
.unwrap_or(&[]);
let mut path = vec![node];
for &neighbor in neighbors {
path.extend(go(graph, visited, neighbor));
}
path
}
let mut visited = HashSet::new();
go(graph, &mut visited, start)
}Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| DFS function | `val dfs : (string string list) list -> string -> string list` | `fn dfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str>` |
| Graph type | `(string string list) list` | `HashMap<&str, Vec<&str>>` |
| Visited set | `SS.t` (balanced BST, O(log n)) | `HashSet<&str>` (hash table, O(1)) |
| Node type | `string` (owned, GC-managed) | `&str` (borrowed, lifetime-tracked) |
| Neighbor lookup | `List.assoc node graph` (O(n)) | `graph.get(node)` (O(1)) |
Key Insights
1. Pure vs. mutable state: OCaml threads `visited` as an immutable value through every return โ `go` returns `(new_visited, path)`. Rust uses `&mut HashSet` to achieve the same result with a single allocation and no copying. The observable behaviour is identical; only the mechanism differs.
2. `insert` as a membership test: `HashSet::insert` returns `bool` โ `false` means already present. This lets Rust combine "is visited?" and "mark visited" into one atomic call, eliminating the OCaml pattern of `SS.mem` followed by `SS.add`.
3. Stack vs. recursion: OCaml's natural style is deep recursion with tail-call optimisation available when the compiler detects it. Rust prefers an explicit stack for DFS because Rust does not guarantee TCO, and deep recursion risks a stack overflow on large graphs.
4. Graph representation trade-off: OCaml's association list (`(string * string list) list`) is idiomatic for small graphs and mirrors the lambda-calculus tradition. Rust's `HashMap` gives O(1) lookups; the assoc-list variant (`&[(&str, Vec<&str>)]`) is provided for a direct structural parallel to the OCaml code.
5. Lifetime annotations: The `'a` lifetime ties the output `Vec<&'a str>` to the graph's borrowed string data โ the compiler proves at compile time that returned node references never outlive the graph. OCaml's GC makes this invisible; Rust makes it explicit.
When to Use Each Style
Use idiomatic Rust (iterative stack) when: your graph can be large or deep โ iterative DFS will not overflow the call stack and avoids the overhead of allocating intermediate `Vec`s for every recursive call.
Use recursive Rust (functional style) when: you are translating OCaml or Haskell DFS code directly and want to preserve the structural correspondence for review or teaching, and the graph depth is bounded.