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
- Shortest path โ extend the visited set to track distance; BFS guarantees the first time you reach a node is via the shortest path.
- Connected components โ run BFS from each unvisited node; each run discovers one component.
- Level-order tree traversal โ trees are graphs without cycles; BFS gives breadth-first level order naturally.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Graph representation | `(string * string list) list` | `HashMap<&str, Vec<&str>>` |
| Lookup cost | `List.assoc` โ O(n) | `HashMap::get` โ O(1) average |
| Queue | Mutable `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
| Concept | OCaml | Rust |
|---|---|---|
| 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`.