Topic 02

Breadth-First and Depth-First Search

Graphs

Almost every graph algorithm is, underneath, a way of visiting every reachable node exactly once. There are two basic orders. Breadth-first search spreads out in rings, finishing everything one hop away before touching anything two hops away. Depth-first search follows one path as far as it goes, then backs up and tries the next branch.

Both cost time in proportion to the nodes plus the edges they reach. They differ in what they discover along the way, and in how much they hold in memory while they do it: a wide frontier for one, a long path for the other. Choosing between them is choosing which of those two costs a given graph can afford.

Visiting Once: The Visited Set

Every traversal keeps a record of the nodes it has already seen and skips any node that comes up again. Without that record, a cycle sends the walk round forever. Even a graph with no cycles at all gets walked again and again wherever paths reconverge, because each route into a shared node re-walks everything beyond it.

The figure shows the effect on a chain of three diamonds, the shape two packages sharing a dependency make. Without a visited set, the first join is reached twice, the second four times and the third eight times: the count doubles at every diamond. A chain of ten diamonds reaches its last node 1,024 times instead of once. With the visited set, each node is expanded once, and the whole traversal costs O(nodes + edges).

Shared paths without a visited set: the count doubles per diamond
s×1a×1b×1j×2a×2b×2j×4a×4b×4j×8red: how many times each node is reached without a visited setwith a visited set: every node is expanded exactly once

The visited set is therefore part of the algorithm's correctness, not a speed-up bolted on. Its key must be a stable identity for the node: a package name and version, a URL after normalization, a state's canonical form. Two keys for the same node reopen the exponential walk.

Breadth-First Search

Breadth-first search, BFS for short, keeps its frontier in a queue, the first-in, first-out structure from Chapter 5. It takes the oldest node from the front, adds that node's unseen neighbours to the back, and repeats. Because every node one hop away enters the queue before any node two hops away, nodes come out in order of their distance from the start, counted in edges.

Breadth-first search from S: rings by hop count, and the queue
SABCDEFGH0 hops1 hop2 hops3 hopstakenqueue afterwardsSABABCDBCDECDEFHDEFHEFHGFHGHGGempty

In the figure, S is taken first and puts A and B in the queue. A and B come out next and add C, D and E. Those three add F, G and H. The colours are the rings: 0, 1, 2 and 3 hops from S. The first time BFS reaches a node, it has reached it by a path with the fewest possible edges, which is the answer to "fewest hops between two routers", "degrees of separation" or "fewest moves to solve this puzzle".

Breadth-first search, recording each node's distance in hops
from collections import deque

def bfs(graph, start):
    dist = {start: 0}                 # doubles as the visited set
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for nxt in graph[node]:
            if nxt not in dist:
                dist[nxt] = dist[node] + 1
                queue.append(nxt)
    return dist

That short function is all of breadth-first search. A dictionary of distances starts with the start node at zero and doubles as the visited set. A double-ended queue holds the frontier. Each node taken from the front gives every neighbour not yet in the dictionary a distance one greater than its own, and joins the back of the queue. Run on the graph in the figure, it returns 0 for S, 1 for A and B, 2 for C, D and E, and 3 for F, G and H.

Depth-First Search

Depth-first search, DFS, holds the current path on a stack. In the recursive version that stack is the call stack from Chapter 3: each call handles one node and recurses into the first unseen neighbour before looking at the next. It goes as deep as it can, and only when a node has no unseen neighbours left does it return and let its caller try the next branch.

On the same graph, with neighbours tried in alphabetical order, DFS discovers S, A, C, F, D, B, E and G in one long dive. At G every neighbour is already seen, so it backs up through E, B, D and F to C, which still has H unvisited. That single back-up is the dashed curve in the figure. DFS reached G along a path seven edges long, where BFS found one of three.

Depth-first search from S: discovery order and the back-up
S1A2B6C3D5E7F4G8H9discovery order1:S2:A3:C4:F5:D6:B7:E8:G9:Hedge that found a nodeback up to an ancestoredge to a seen node

That is the trade. DFS is poor at distance and good at structure: is there any route at all, is there a cycle, in what order do things finish. The next topic builds a whole algorithm on the order in which DFS finishes nodes.

Finding Cycles

In a directed graph, DFS can mark each node in one of three states: not yet visited, on the current path or finished. Reaching a node that is still on the current path means following an edge back into the path the search is standing on, and that edge closes a cycle. Reaching a finished node is harmless; it only means two routes lead to the same place.

This check is what prints "circular dependency detected" in a build tool or a package manager, and the same test on a graph of which thread waits for which lock is deadlock detection, which Chapter 11 covers. It costs nothing extra: the cycle is found during the same O(nodes + edges) walk.

What Each Holds in Memory

BFS stores its whole frontier, and on a wide graph the frontier is most of the graph. A web crawler is a breadth-first search, and its queue of URLs waiting to be fetched reaches millions of entries. DFS stores one path, which on a narrow graph is short and on a deep graph is long.

Depth is where recursion bites. Measured on CPython 3.15, whose default recursion limit is 1,000 frames, a recursive DFS down a simple chain succeeds at 990 nodes and raises RecursionError at 1,000. Any structure deeper than that, a long linked chain of objects or a dependency chain generated by a tool, needs a DFS that keeps its own stack in a list, where depth is limited by memory rather than by the interpreter.

What a Traversal Costs in Running Systems

The garbage collector's mark phase is a traversal from the program's roots, and Chapter 4 prices it in proportion to the live objects it reaches. "Which services fail if this one does" is a traversal over reversed call edges. A crawler is BFS with a politeness delay per site. All of them cost O(nodes + edges).

In real graphs the edges usually dominate that sum. One account with a million followers is a million steps on its own, whatever the size of the rest of the graph. A traversal that visits a few high-degree nodes can cost more than one that visits thousands of ordinary ones, which is where the time goes when a "simple" graph walk takes seconds.

Misconceptions
  • "BFS and DFS find the same paths in a different order." Only BFS guarantees the path with the fewest edges. DFS finds a path, often a long, winding one: seven edges from S to G in the figure, where three suffice.
  • "The visited set is an optimization." It is correctness. Without it a cycle never terminates, and shared substructure is walked exponentially many times: 1,024 visits to the end of ten diamonds.
  • "Recursion is the natural way to write DFS, so it is fine." CPython's default limit of 1,000 frames raises RecursionError on a chain about 1,000 nodes deep. Production traversals keep their own stack.
  • "BFS finds the shortest path." It finds the shortest in hops. Once edges carry latency or distance, the path with the fewest hops can be far longer, and the right algorithm is Dijkstra's, in the Shortest Paths topic.
  • "A traversal costs time in proportion to the number of nodes." It costs nodes plus edges, and in a dense graph or around a high-degree node the edges are almost all of it.
Why It Matters
  • Keep a visited set in every traversal, keyed by a stable node identity. It turns infinite or exponential walks into one visit per node.
  • Use BFS for fewest-hops questions and DFS for reachability, cycles and ordering. Each order answers different questions.
  • Write deep traversals with an explicit stack instead of recursion. Depth is then limited by memory, not by the call stack.
  • Estimate a traversal's cost by its edges, not its nodes. The high-degree nodes are where the time goes.
RelatedIterative deepening DFS with a growing depth limit: fewest hops at DFS's memory costBidirectional BFS two frontiers that meet in the middleDijkstra BFS with a priority queue in place of the queue

Knowledge Check

A support tool must show the fewest hops between two routers in an unweighted network. Which traversal returns that directly?

  • Depth-first search, stopping as soon as it reaches the target
  • Breadth-first search, reading the path when the target first appears
  • A recursive depth-first search that explores all neighbours of each node
  • Either one, since both visit every reachable node exactly once

A traversal walks a graph that is very wide and shallow: a start node with a million neighbours, each with a few of its own. Which statement about memory holds?

  • BFS holds about a million nodes in its queue; DFS holds a short path
  • DFS holds about a million nodes on its stack; BFS holds a short path
  • Both hold a constant amount, since each node is visited only once
  • Both hold the same amount, since they visit the same set of nodes

A dependency walker without a visited set runs over a chain of 20 diamonds, each pair of paths rejoining at one node. What happens?

  • It never finishes, because the diamonds form a cycle
  • It visits each node about twice, a harmless overhead
  • It reaches the last node about a million times over
  • It raises RecursionError after the tenth diamond

How does a depth-first search on a directed graph recognize a cycle?

  • It reaches a node that it has already finished
  • It reaches a node that is still on the current path
  • It finds a node with more than one incoming edge
  • It finds that the visit count exceeds the node count

A social-graph job walks the followers of 1,000 accounts. One of them has 2 million followers; the rest average 200. Where does the time go?

  • Evenly across all 1,000 accounts, since each one is visited once
  • Mostly on the 999 ordinary accounts, since there are more of them
  • Mostly on the one large account, whose 2 million edges dominate
  • Mostly on the visited set lookups, which grow with the square of n

You got correct