Chapter 21

Exploring Trees and Graphs

Sorting organizes data for efficient access. Traversal navigates structure to answer questions about it. Once data is represented as a tree or graph, nearly every meaningful computation over that data — finding connected components, determining reachability, discovering paths, computing aggregates over hierarchies — is some form of traversal: a systematic visit to the nodes of the structure according to a defined order.

Two fundamental orders exist, and they are not interchangeable. The right choice between them depends on what the traversal is trying to compute. A team that uses depth-first search where breadth-first is required will produce correct code that answers the wrong question.

Traversal Safety: Three Required Controls

A traversal that visits the right nodes in the right order is correct. A traversal without safety controls is correct on graphs that cooperate and incorrect on graphs that do not.

Visited tracking is the first required control, and it is not optional for any graph that may contain cycles. A traversal that does not record which nodes have been visited will follow a cycle indefinitely, revisiting the same nodes without making progress. The visited set — introduced in Thinking Recursively for recursive DFS — is equally necessary for iterative BFS. Every node is added to the visited set at the moment it is first discovered, before it is processed, so that subsequent encounters with the same node are recognized and skipped.

Depth and expansion bounds are the second required control for traversals that must be bounded. A BFS over a document graph with no depth limit will explore the entire connected component of the starting node, which may be the entire document collection. A depth limit of two means: visit the start node and its direct neighbors (depth one) and their neighbors (depth two), then stop. The bound must be enforced explicitly — it does not emerge automatically from the traversal structure — and it must be chosen based on what the system needs to compute, not on what happens to work in testing.

Deterministic ordering is the third required control for traversals whose callers depend on output order. The order in which a node’s neighbors are processed during traversal may depend on the order in which they appear in the adjacency structure — and, ultimately, on when entities were created. A traversal that produces different output on different runs, or different output on different machines, is not a correct implementation of a deterministic algorithm — it is an algorithm whose correctness depends on an assumption it never stated. When the traversal’s output order is meaningful, the ordering must be declared explicitly and enforced.

From Requirement to Traversal Design

Consider the requirement:

“From a starting document, discover connected documents up to depth two.”

Step 1: Define the reachability scope. Depth two means: the start document, the documents directly linked to it (depth one), and the documents linked from those (depth two). Documents at depth three or beyond are outside scope. The depth limit is a correctness constraint, not a performance optimization — the result at depth two is not a subset of the result at depth three; it is a different, defined output.

Step 2: Choose the traversal. BFS is the right choice because the requirement is organized by depth layer. BFS naturally produces depth-one results before depth-two results, which matches the structure of the output. DFS would find documents at varying depths in an order that depends on the graph’s shape, not on the depth criterion. The choice of BFS is a correctness decision.

Step 3: Define the duplicate policy. Each document is visited at most once. If document D is reachable from the start through two different paths — one of length one and one of length two — it appears in the output once, at the depth it was first reached. This policy is enforced by the visited set.

Step 4: Define the output semantics. The output is the sequence of discovered document identifiers in the order they were first reached: the start document, then its neighbors in the order they appear in the adjacency structure, then their unvisited neighbors in order. The visit order must be deterministic.

Step 5: Define failure behavior. An unknown start identifier produces INVALID_START. A start document with no outgoing links produces a result containing only the start document itself with status ISOLATED. Both cases are declared outcomes.

A Traversal in Code

class Explore_Result
create
  make(status, discovered: String) do
    this.status := status
    this.discovered := discovered
  end
feature
  status: String
  discovered: String
invariant
  valid_status:
    status = "OK" or
    status = "ISOLATED" or
    status = "INVALID_START"
end

class Graph_Explorer
feature
  -- Document -> documents it links to, in a fixed order.
  links: Map[String, Array[String]]

  explore(start: String, max_depth: Integer): Explore_Result
  require
    start_present: start /= ""
    depth_valid: max_depth >= 0
  do
    if not links.contains_key(start) then
      result := create Explore_Result.make("INVALID_START", "")
    elseif links.get(start).is_empty then
      result := create Explore_Result.make("ISOLATED", start)
    else
      -- depth doubles as the visited set; order is 
      -- both the BFS queue and the discovery order.
      let depth: Map[String, Integer] := {}
      depth.put(start, 0)
      let order: Array[String] := [start]
      from let i := 0 until i >= order.length do
        let doc := order.get(i)
        if depth.get(doc) < max_depth then
          across links.try_get(doc, []) as next do
            if not depth.contains_key(next) then
              depth.put(next, depth.get(doc) + 1)
              order.add(next)
            end
          end
        end
        i := i + 1
      end
      let discovered := start
      from let k := 1 until k >= order.length do
        discovered := discovered + "," + order.get(k)
        k := k + 1
      end
      result := create Explore_Result.make("OK", discovered)
    end
  ensure
    declared_status:
      result.status = "OK" or
      result.status = "ISOLATED" or
      result.status = "INVALID_START"
  end

create
  make(links: Map[String, Array[String]]) do
    this.links := links
  end
end

The structure of explore encodes the five design decisions from the worked path. The depth limit from Step 1 is a parameter, and a document is expanded only while its depth is below the limit, so documents at depth three are discovered by nothing when the limit is two. The traversal from Step 2 is breadth-first: order serves as the queue, consumed from the front, so every document at depth one is discovered before any document at depth two. The duplicate policy from Step 3 is the depth map, which doubles as the visited set — a document reached a second time, by a longer path or around a cycle, is already in it and is ignored. Because order records documents in the order they were first reached, it is also the output from Step 4, and because each document’s links are visited in the order the map stores them, that output is deterministic. The two failure outcomes from Step 5, INVALID_START and ISOLATED, are checked before the traversal begins.

The adjacency map links is the explicit representation of the graph’s structure. The traversal consults it directly rather than inferring connectivity from its own control flow. This is the same separation established in Graphs — Networks of Everything: the graph structure and the traversal algorithm are distinct, and the traversal’s correctness depends only on what the adjacency structure reports, not on how it is stored.

With links A → B, A → C, B → D, and C → D, exploring from A to depth two yields A,B,C,D: both depth-one documents before the depth-two document, and D once, although two paths reach it. To depth one, the same call yields A,B,C. Those two results are worth turning into tests, because they are exactly the properties the requirement states — layer order and no duplicates — and a traversal that violated either would still return a plausible-looking list.

The Question Decides the Traversal

The delivery system shows most plainly that the traversal follows the question, not the graph. DFS over the road graph explores every location reachable from a starting point, which answers whether a destination is reachable at all and enumerates alternate routes when the primary one is blocked. BFS over the same graph finds the route with the fewest hops. The graph is the same; the question decides the traversal.

The knowledge engine uses BFS to discover related documents in layers — those cited directly by a result, then those cited by them — up to a depth limit that stops the traversal from expanding to the whole collection when connectivity is dense. The virtual world uses both: DFS over the containment hierarchy visits every object in a zone before moving to the next, which suits operations that aggregate over a scope, while BFS over the interaction graph finds entities within a given number of interaction hops, for effects that propagate through chains of interactions.

Where Traversal Design Fails

No visited policy. A traversal without a visited set will follow cycles indefinitely. This is a latent bug in any codebase that uses traversal over graphs that may contain cycles — which includes every real-world graph. The visited set is mandatory. Omitting it because the current test graphs happen to be acyclic is not a defense; the first cyclic graph the traversal encounters will expose the missing control.

Wrong traversal for the objective. DFS used to find a shortest path will find a path, not necessarily the shortest one. BFS used for exhaustive structure exploration in a deep graph will require a large frontier queue before reaching deep nodes. The traversal algorithm must match the objective, and the objective must be stated before the algorithm is chosen. A team that always uses DFS because it is easy to write recursively, or always uses BFS because it feels safer, is making the choice before asking the question.

Implicit or absent bounds. A traversal with no depth or expansion limit will process the entire reachable component of the starting node. For a knowledge engine document graph where any document may be linked to any other, this can mean processing the entire collection. For a delivery network where the graph is fully connected, this means processing every location. Bounds must be chosen deliberately based on what the computation needs, not omitted because the test graphs are small. When a bound is required, its value must be visible in the design — a configurable parameter or a named constant — not buried in a loop condition.

Quick Exercise

Choose one graph or tree operation in your system and specify its traversal design with five components: the traversal type (DFS or BFS) and a one-sentence justification based on the objective, the visited policy and where it is enforced, the depth or expansion bound and how it is chosen, the output order rule and whether it must be deterministic, and the declared behavior for invalid input.

Then consider: would swapping DFS for BFS, or BFS for DFS, change the results? If it would not, the traversal type may not have been chosen for a principled reason.

Takeaways

  • DFS and BFS are not interchangeable. DFS is suited to exhaustive exploration and recursive decomposition; BFS is suited to minimum-hop reachability and layer-by-layer discovery. The objective determines the choice.
  • A visited set is mandatory for any traversal over a graph that may contain cycles. It is not optional and not a performance concern — it is a correctness requirement.
  • Depth and expansion bounds must be chosen deliberately and stated explicitly. A traversal without explicit bounds will process arbitrarily large subgraphs on sufficiently connected inputs.
  • When callers depend on traversal output order, the ordering must be declared and enforced. An ordering that depends on insertion order or machine state is not a deterministic algorithm.
  • The graph structure and the traversal algorithm are separate concerns. The traversal’s correctness depends on what the adjacency structure reports, not on how it is implemented.

The next chapter, Finding the Best Path, builds on traversal to address a more demanding question: not just which nodes are reachable, but which path to a destination is best by some defined criterion. Path optimization requires both traversal and a cost model, and the interplay between the two determines which algorithms are applicable and what guarantees they can provide.