๐Ÿฆ€ Functional Rust

253: Graph BFS

Difficulty: 2 Level: Intermediate Traverse a graph level by level, visiting all reachable nodes in order of distance from the start.

The Problem This Solves

Breadth-first search answers "what's reachable from here, and how far away is it?" It visits every node at distance 1 before any node at distance 2. This ordering makes BFS the algorithm for shortest-path problems in unweighted graphs: social network degrees of separation, web crawl horizons, game state reachability. The key data structure is a queue (first-in, first-out). You put the start node in the queue. Then loop: pull a node from the front, mark it visited, push its unvisited neighbours to the back. Because you always process the oldest entry first, nodes are visited level by level. This example translates OCaml's BFS โ€” which uses `Hashtbl` for visited tracking and a mutable `Queue` โ€” to idiomatic Rust with `HashMap`, `HashSet`, and `VecDeque`. One Rust idiom stands out: `HashSet::insert` returns `false` if the element was already present, so you can test and insert in one step.

The Intuition

Picture ripples spreading from a stone dropped in water. The first ripple reaches nodes 1 hop away. The second ripple reaches nodes 2 hops away โ€” and so on. BFS expands these ripples one level at a time. The queue is the pending "ripple front". Every node goes into the queue at most once (the visited set prevents re-queueing). When the queue is empty, all reachable nodes have been visited. OCaml uses an association list `(node, [neighbours])` for the graph, looked up with `List.assoc` โ€” O(n) per lookup. Rust uses `HashMap` for O(1) average lookup. The structural difference matters at scale.

How It Works in Rust

use std::collections::{HashMap, HashSet, VecDeque};

pub fn bfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
 let mut visited: HashSet<&str> = HashSet::new();
 let mut queue: VecDeque<&str> = VecDeque::new();
 let mut result: Vec<&str> = Vec::new();

 queue.push_back(start);
 visited.insert(start);  // mark before entering queue (not after dequeue)

 while let Some(node) = queue.pop_front() {   // FIFO: pop from front
     result.push(node);
     if let Some(neighbors) = graph.get(node) {
         for &neighbor in neighbors {
             if visited.insert(neighbor) {     // insert returns false if already present
                 queue.push_back(neighbor);    // only queue truly new nodes
             }
         }
     }
 }
 result
}
The `visited.insert(neighbor)` idiom combines the membership check and insertion into one call โ€” cleaner than OCaml's `Hashtbl.mem` + `Hashtbl.add` two-step.

What This Unlocks

Key Differences

ConceptOCamlRust
Graph representation`(string * string list) list``HashMap<&str, Vec<&str>>`
Lookup cost`List.assoc` โ€” O(n)`HashMap::get` โ€” O(1) average
QueueMutable `Queue` module`VecDeque` with `push_back`/`pop_front`
Visited test + insert`Hashtbl.mem` then `Hashtbl.add``HashSet::insert` returns `bool`
Result accumulation`ref` list + `List.rev` at end`Vec::push` in traversal order
use std::collections::{HashMap, HashSet, VecDeque};

/// Idiomatic Rust BFS using HashMap adjacency list and VecDeque queue.
pub fn bfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
    let mut visited: HashSet<&str> = HashSet::new();
    let mut queue: VecDeque<&str> = VecDeque::new();
    let mut result: Vec<&str> = Vec::new();

    queue.push_back(start);
    visited.insert(start);

    while let Some(node) = queue.pop_front() {
        result.push(node);
        if let Some(neighbors) = graph.get(node) {
            for &neighbor in neighbors {
                if visited.insert(neighbor) {
                    queue.push_back(neighbor);
                }
            }
        }
    }

    result
}

/// Functional-style BFS using slice-based adjacency list (mirrors OCaml's List.assoc).
pub fn bfs_assoc<'a>(graph: &[(&'a str, Vec<&'a str>)], start: &'a str) -> Vec<&'a str> {
    let mut visited: HashSet<&str> = HashSet::new();
    let mut queue: VecDeque<&str> = VecDeque::new();
    let mut result: Vec<&str> = Vec::new();

    queue.push_back(start);
    visited.insert(start);

    while let Some(node) = queue.pop_front() {
        result.push(node);
        if let Some((_, neighbors)) = graph.iter().find(|(n, _)| *n == node) {
            for &neighbor in neighbors {
                if visited.insert(neighbor) {
                    queue.push_back(neighbor);
                }
            }
        }
    }

    result
}

fn main() {
    // HashMap-based graph (idiomatic Rust)
    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 = bfs(&graph, "a");
    println!("BFS from 'a': {:?}", order);

    // Slice-based graph (mirrors OCaml List.assoc style)
    let graph_assoc = vec![
        ("a", vec!["b", "c"]),
        ("b", vec!["d"]),
        ("c", vec!["d"]),
        ("d", vec![]),
    ];
    let order2 = bfs_assoc(&graph_assoc, "a");
    println!("BFS (assoc) from 'a': {:?}", order2);

    // Larger example: tree-shaped graph
    let mut tree = HashMap::new();
    tree.insert("root", vec!["L1a", "L1b"]);
    tree.insert("L1a", vec!["L2a", "L2b"]);
    tree.insert("L1b", vec!["L2c"]);
    tree.insert("L2a", vec![]);
    tree.insert("L2b", vec![]);
    tree.insert("L2c", vec![]);
    let order3 = bfs(&tree, "root");
    println!("BFS tree: {:?}", order3);
}

/* Output:
   BFS from 'a': ["a", "b", "c", "d"]
   BFS (assoc) from 'a': ["a", "b", "c", "d"]
   BFS tree: ["root", "L1a", "L1b", "L2a", "L2b", "L2c"]
*/
(* Idiomatic OCaml BFS: Hashtbl for visited, Queue for frontier *)
let bfs graph start =
  let visited = Hashtbl.create 16 in
  let queue = Queue.create () in
  Queue.push start queue;
  Hashtbl.add visited start true;
  let result = ref [] in
  while not (Queue.is_empty queue) do
    let node = Queue.pop queue in
    result := node :: !result;
    List.iter (fun neighbor ->
      if not (Hashtbl.mem visited neighbor) then begin
        Hashtbl.add visited neighbor true;
        Queue.push neighbor queue
      end
    ) (List.assoc node graph)
  done;
  List.rev !result

(* Purely functional BFS using list-based queue โ€” shows explicit recursion *)
let bfs_pure graph start =
  let rec loop visited queue result =
    match queue with
    | [] -> List.rev result
    | node :: rest ->
      let neighbors = List.assoc node graph in
      let new_nodes = List.filter (fun n -> not (List.mem n visited)) neighbors in
      loop (visited @ new_nodes) (rest @ new_nodes) (node :: result)
  in
  loop [start] [start] []

let () =
  let graph = [("a", ["b";"c"]); ("b", ["d"]); ("c", ["d"]); ("d", [])] in
  let order = bfs graph "a" in
  assert (List.hd order = "a");
  assert (List.length order = 4);
  assert (List.mem "d" order);
  let order2 = bfs_pure graph "a" in
  assert (List.hd order2 = "a");
  assert (List.length order2 = 4);
  print_endline "ok"

๐Ÿ“Š Detailed Comparison

OCaml vs Rust: Graph BFS

Side-by-Side Code

OCaml

๐Ÿช Show OCaml equivalent
let bfs graph start =
let visited = Hashtbl.create 16 in
let queue = Queue.create () in
Queue.push start queue;
Hashtbl.add visited start true;
let result = ref [] in
while not (Queue.is_empty queue) do
 let node = Queue.pop queue in
 result := node :: !result;
 List.iter (fun neighbor ->
   if not (Hashtbl.mem visited neighbor) then begin
     Hashtbl.add visited neighbor true;
     Queue.push neighbor queue
   end
 ) (List.assoc node graph)
done;
List.rev !result

Rust (idiomatic)

pub fn bfs<'a>(graph: &'a HashMap<&str, Vec<&str>>, start: &'a str) -> Vec<&'a str> {
 let mut visited: HashSet<&str> = HashSet::new();
 let mut queue: VecDeque<&str> = VecDeque::new();
 let mut result: Vec<&str> = Vec::new();

 queue.push_back(start);
 visited.insert(start);

 while let Some(node) = queue.pop_front() {
     result.push(node);
     if let Some(neighbors) = graph.get(node) {
         for &neighbor in neighbors {
             if visited.insert(neighbor) {
                 queue.push_back(neighbor);
             }
         }
     }
 }

 result
}

Rust (assoc-list style, mirrors OCaml)

pub fn bfs_assoc<'a>(graph: &[(&'a str, Vec<&'a str>)], start: &'a str) -> Vec<&'a str> {
 let mut visited: HashSet<&str> = HashSet::new();
 let mut queue: VecDeque<&str> = VecDeque::new();
 let mut result: Vec<&str> = Vec::new();

 queue.push_back(start);
 visited.insert(start);

 while let Some(node) = queue.pop_front() {
     result.push(node);
     if let Some((_, neighbors)) = graph.iter().find(|(n, _)| *n == node) {
         for &neighbor in neighbors {
             if visited.insert(neighbor) {
                 queue.push_back(neighbor);
             }
         }
     }
 }

 result
}

Type Signatures

ConceptOCamlRust
Graph type`(string * string list) list``&HashMap<&str, Vec<&str>>`
Queue`'a Queue.t` (mutable)`VecDeque<&str>`
Visited set`(string, bool) Hashtbl.t``HashSet<&str>`
Result`string list` (via `ref` + `List.rev`)`Vec<&str>` (pushed in order)
Neighbor lookup`List.assoc node graph` โ€” O(n)`graph.get(node)` โ€” O(1) average

Key Insights

1. `HashSet::insert` returns `bool`: OCaml needs two calls (`Hashtbl.mem` to check, `Hashtbl.add` to mark). Rust's `HashSet::insert` returns `false` if already present, enabling `if visited.insert(neighbor)` as a single atomic check-and-mark expression. This eliminates a TOCTOU-style bug in concurrent code and reads more clearly.

2. `VecDeque` makes queue semantics explicit: OCaml's `Queue.push`/`Queue.pop` always operate on the back/front respectively. Rust's `VecDeque` names the ends explicitly (`push_back`, `pop_front`), making the BFS invariant self-documenting in the code.

3. `while let Some(...)` vs imperative `while`: OCaml uses `while not (Queue.is_empty queue) do ... Queue.pop queue` โ€” two operations. Rust's `while let Some(node) = queue.pop_front()` combines the emptiness check and destructuring pop into one ergonomic expression.

4. Association list vs HashMap: OCaml's `List.assoc` is idiomatic for small graphs but O(n) per lookup. Rust naturally uses `HashMap` for O(1) average. The `bfs_assoc` variant preserves the OCaml structure but at the cost of linear scans โ€” a useful reminder that idioms have performance implications.

5. Result accumulation: OCaml prepends to a `ref` list (O(1) per step) then calls `List.rev` (O(n)) at the end. Rust pushes to a `Vec` directly in order, also O(1) amortized per step โ€” both are linear overall, but Rust's version avoids the reversal step.

When to Use Each Style

Use idiomatic Rust (`HashMap`) when: building production graph algorithms where O(1) lookup matters, working with large graphs, or when performance is a concern.

Use assoc-list style when: porting OCaml code directly for comparison, working with very small fixed graphs, or teaching the parallel between OCaml's `List.assoc` and Rust's iterator `find`.