Graphs/Graph Representations and Traversal

Lesson 6.14,172 words

Graph Representations and Traversal

A graph captures relationships — who connects to whom. We fix the vocabulary, weigh the two standard representations (adjacency list versus matrix), then meet the single search skeleton behind everything that follows: Whatever-First-Search, and its breadth-first reading, which finds shortest paths by number of edges in O(V+E)O(V + E).

╌╌╌╌

Almost every interesting structure (a road map, a social network, the dependencies between tasks, the states of a puzzle) is a set of things and the connections between them. A graph is the mathematical object that captures exactly this and nothing more. A handful of graph algorithms covers a wide range of problems; Skiena's advice is that the hardest part is usually recognizing that a problem is a graph problem.1

What is a graph?

If edges have no direction, so that an edge connects and symmetrically, the graph is undirected. If each edge is an ordered pair pointing from to , the graph is directed (a digraph). We write for the number of vertices and for the number of edges; inside asymptotic notation we abbreviate these to and , writing bounds like .

The definition is fussy for a reason: every clause rules out a pathology. A graph is finite (otherwise we cannot index vertices), edges are unordered pairs (ordered pairs would give a digraph), there are no parallel edges (since is a set, not a multiset), and no self-loops (since for every ). Relaxing any one clause yields a richer object: a multigraph, a digraph, and so on.

A few terms recur constantly:

  • Vertices and are adjacent if an edge joins them; that edge is incident on both, which are its endpoints.
  • The degree counts the edges touching . In a digraph leaves (its tail) and arrives at (its head), and we split degree into and .
  • The handshake lemma falls straight out of counting incidences both ways: in a graph, and in a digraph.
  • A walk is an alternating sequence respecting incidence, of length . A walk with is closed. A path is a walk with no repeated vertices; a cycle is a closed walk whose vertices are distinct except for the shared endpoint (length in a graph, in a digraph). Beware: many texts overload path to mean walk, but we keep them separate.
  • An undirected graph is connected if a path joins every pair of vertices; a digraph is strongly connected if a directed path runs both ways between every pair. A connected component is a maximal connected subgraph.
  • The distance is the length of the shortest path from to (directed, for digraphs), or if is unreachable from .
  • A graph may carry a weight on each edge (a length, cost, or capacity) that later lessons will exploit.

Here is a small undirected graph on five vertices that we will use throughout this lesson:

A small undirected graph on five vertices used throughout the lesson.

A graph is bounded in size: every simple graph has at most edges, so . A graph is sparse when is close to and dense when is close to . This single distinction governs which representation, and sometimes which algorithm, to choose.

Two ways to store a graph

We need a concrete data structure before we can compute anything. The two standard choices trade space against the speed of one key query: is there an edge from to ?

Adjacency list. Keep an array indexed by vertex; entry holds a list of 's neighbors. Total space is , one slot per vertex plus one list node per edge (two, in an undirected graph, since each edge appears on both endpoints' lists). Listing a vertex's neighbors is immediate — just what traversals need.

Adjacency matrix. Keep an matrix with when edge exists and otherwise (or the weight, for a weighted graph). Testing a specific edge is , but the matrix always occupies space regardless of how few edges there are, and listing a vertex's neighbors costs because we must scan a whole row.

OperationAdjacency listAdjacency matrix
Space
Test edge ?
List neighbors of
Add an edge
Iterate over all edges
Best whengraph is sparsegraph is dense

Concretely, here is the five-vertex graph above stored both ways. The list keeps one short neighbor-list per vertex; the matrix spends a full grid of bits, symmetric across the diagonal because the graph is undirected:

The five-vertex graph stored two ways: adjacency lists (left) and the symmetric adjacency matrix (right).

The row-scan cost is the decisive one. Every traversal below spends its time asking give me the neighbors of , once per vertex. With lists, the total work is by the handshake lemma — that is where the bound comes from. With a matrix, the same sweep costs no matter how few edges exist. On a sparse graph with vertices and edges, the list stores about entries, while the matrix stores cells; a single BFS does units of work versus . The break-even point sits around : only when most possible edges are present does the matrix's edge test and cache-friendly layout pay for its quadratic footprint.

CLRS, Skiena, and Erickson all reach the same verdict: the adjacency list is the default.2 Real graphs are usually sparse, and the linear-space, fast-to-iterate list is what makes the traversals below possible. Reach for the matrix only when the graph is dense, when you need constant-time edge tests, or when an algorithm is naturally phrased in linear-algebra terms (powers of count walks; spectral methods want the matrix by definition).

adjacency_matrix.pypython
from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar

Label = TypeVar("Label", bound=Hashable)

class AdjacencyMatrix(Generic[Label]):
  """
    A graph stored as a dense n*n weight matrix.\n
    Pass `directed=True` for a digraph; otherwise each edge is mirrored\n
    across the diagonal so the matrix stays symmetric.\n
  """

  def __init__(self, directed: bool = False) -> None:
    self.directed: bool = directed
    self._index: dict[Label, int] = {}
    self._labels: list[Label] = []
    self._matrix: list[list[float]] = []

  def add_vertex(self, label: Label) -> int:
    """
      Return the row/column index for `label`, creating it if absent.\n
      Growing the matrix appends a fresh zero row and a zero column.\n
    """
    # reuse the existing index when the label is already present.
    existing: Optional[int] = self._index.get(label)
    if existing is not None:
      return existing

    # register the new label at the next index.
    position: int = len(self._labels)
    self._index[label] = position
    self._labels.append(label)

    # widen every existing row, then append the new zero row.
    for row in self._matrix:
      row.append(0.0)
    self._matrix.append([0.0 for _ in range(position + 1)])
    return position

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    weight: float = 1.0,
  ) -> None:
    """
      Connect two labels (creating either vertex as needed).\n
      Stores the weight at the cell; mirrors it when undirected.\n
    """
    # resolve both endpoints, creating them if missing.
    source: int = self.add_vertex(source_label)
    target: int = self.add_vertex(target_label)

    # store the weight, mirroring across the diagonal when undirected.
    self._matrix[source][target] = weight
    if not self.directed:
      self._matrix[target][source] = weight

  def has_edge(self, source_label: Label, target_label: Label) -> bool:
    """
      Whether an edge runs from `source_label` to `target_label` — O(1).\n
    """
    # missing endpoints mean no edge.
    source: Optional[int] = self._index.get(source_label)
    target: Optional[int] = self._index.get(target_label)
    if source is None or target is None:
      return False

    return self._matrix[source][target] != 0.0

  def weight(self, source_label: Label, target_label: Label) -> float:
    """
      The stored weight of the edge, or 0 when no edge exists.\n
    """
    # missing endpoints carry no weight.
    source: Optional[int] = self._index.get(source_label)
    target: Optional[int] = self._index.get(target_label)
    if source is None or target is None:
      return 0.0

    return self._matrix[source][target]

  def neighbors(self, label: Label) -> list[Label]:
    """
      Every vertex reachable from `label` by one edge — scans a row, O(V).\n
    """
    # scan the row and collect every column holding a non-zero weight.
    source: int = self._index[label]
    return [
      self._labels[target]
      for target, cell in enumerate(self._matrix[source])
      if cell != 0.0
    ]

  @property
  def labels(self) -> list[Label]:
    """
      Every vertex label, in insertion order.\n
    """
    return list(self._labels)

  def matrix(self) -> list[list[float]]:
    """
      A copy of the underlying n*n weight grid.\n
    """
    return [row[:] for row in self._matrix]

  def __contains__(self, label: Label) -> bool:
    return label in self._index

  def __iter__(self) -> Iterator[Label]:
    return iter(self._labels)

  def __len__(self) -> int:
    return len(self._labels)

One traversal to rule them all

Before specializing, it pays to see that BFS and DFS are the same algorithm. This is made explicit with a deliberately generic skeleton called Whatever-First Search: grow a frontier of discovered-but-unprocessed vertices, repeatedly pull one out, and push each of its undiscovered neighbors in. The only freedom is which vertex you pull next, and that is decided entirely by the data structure holding the frontier.

Algorithm 1:Whatever-First-Search(G,s)\textsc{Whatever-First-Search}(G, s) — the generic skeleton
  1. 1
    foreach vertex vVv \in V do
  2. 2
    v.visitedfalsev.visited \gets \text{false}
  3. 3
    v.πnilv.\pi \gets \text{nil}
  4. 4
    s.visitedtrues.visited \gets \text{true}
  5. 5
    put ss into the bag BB
  6. 6
    while BB \neq \emptyset do
  7. 7
    take a vertex uu out of BB
    bag decides who
  8. 8
    foreach vv adjacent to uu do
  9. 9
    if not v.visitedv.visited then
  10. 10
    v.visitedtruev.visited \gets \text{true}
  11. 11
    v.πuv.\pi \gets u
  12. 12
    put vv into the bag BB

The pointers always carve out a tree (or forest) rooted at , the search tree, because each vertex is discovered exactly once, from exactly one parent. What changes is the shape of that tree, and it is fixed by one choice:

Bag Order of removalSpecialization
Queue (FIFO)oldest first — explores in rings
Stack (LIFO)newest first — plunges and backtracks
Priority queuecheapest firstDijkstra / Prim (later lessons)

This is the unifying idea to carry forward: BFS is the queue instantiation, DFS is the stack instantiation, and the weighted shortest-path and minimum-spanning-tree algorithms of later lessons are just Whatever-First-Search with a priority queue. Everything below specializes this one skeleton.

The choice of bag shows up as the shape of the search tree. Run both on the same little graph from (ties broken alphabetically): the queue grows a short, bushy tree that hugs at every depth, while the stack grows one long descending spine, diving as far as it can before backing up.

Same graph, same source: the queue (BFS) builds a shallow bushy tree, the stack (DFS) a deep spine.

The most basic question we can ask is: starting from a source , which vertices can I reach, and how far away is each? Breadth-first search (BFS) is Whatever-First-Search with a queue. It explores in rings of increasing distance: first itself, then all neighbors of , then everything new one step beyond them, and so on. The first-in-first-out discipline is what enforces this level-by-level order; the oldest-discovered vertex always sits at the shallowest depth still unfinished.

As it runs, BFS computes for each vertex a distance , the fewest edges (hops) on any path from to , and a predecessor , the vertex from which was discovered. The predecessors form the breadth-first tree (or shortest-path tree) . We refine the single visited flag of the skeleton into three colors: white vertices are undiscovered, gray ones are discovered but still in the queue, and black ones are finished.

Algorithm 2:BFS(G,s)\textsc{BFS}(G, s) — shortest distances in hops from ss
  1. 1
    foreach vertex uV{s}u \in V \setminus \set{s} do
  2. 2
    u.colorwhiteu.color \gets \text{white}
  3. 3
    u.du.d \gets \infty
  4. 4
    u.πnilu.\pi \gets \text{nil}
  5. 5
    s.colorgrays.color \gets \text{gray}
    discover source
  6. 6
    s.d0s.d \gets 0
  7. 7
    s.πnils.\pi \gets \text{nil}
  8. 8
    QQ \gets \emptyset
  9. 9
    enqueue(Q,s)(Q, s)
  10. 10
    while QQ \neq \emptyset do
  11. 11
    uu \gets dequeue(Q)(Q)
  12. 12
    foreach vv adjacent to uu do
  13. 13
    if v.color=whitev.color = \text{white} then
    first time reaching v
  14. 14
    v.colorgrayv.color \gets \text{gray}
  15. 15
    v.du.d+1v.d \gets u.d + 1
  16. 16
    v.πuv.\pi \gets u
  17. 17
    enqueue(Q,v)(Q, v)
  18. 18
    u.colorblacku.color \gets \text{black}
  19. 19
    return dd and π\pi

Write for the true distance from to , the length of the shortest directed path. BFS computes it exactly.

Running time. Initialization touches every vertex once: . Each vertex is enqueued and dequeued exactly once (only white vertices are enqueued, and they are immediately grayed), and when we dequeue we scan its adjacency list once. The scans together examine every edge a constant number of times, for total. Hence BFS runs in , linear in the size of the graph.3

A worked run. Take the digraph with vertices and directed edges

run BFS from , and scan each adjacency list alphabetically. Every row below is one iteration of the while loop: dequeue , scan 's list, enqueue each white neighbor with distance .

Dequeue 's listNewly discovered ()Queue after
— (init)
, , , all
none ( gray)
,
, ( gray)
,
none
none

Two things to watch in the table. The -values leaving the queue never decrease (), the sortedness the proof leaned on. And each non-tree edge is examined but discovers nothing: arrives while is gray, while is gray. The resulting -values sort the vertices into layers by distance from , which let us read off shortest BFS distances directly:

BFS tree from with vertices sorted into distance layers to .

Thick edges are tree edges ; dashed edges point at vertices already discovered, so BFS skips them. Reading off : ; ; ; . Vertex has no path from , so , and it never enters the queue — BFS computes distances from the source, and unreachable vertices simply stay white. The tree path from down to any vertex spells out a shortest route in hops.

The same run, viewed as snapshots between layers, shows the queue acting as a moving ring: at any instant it holds the frontier, the gray vertices whose edges have not been scanned yet, and each pass pushes the ring one hop outward:

Four snapshots of the BFS from . Blue-ringed vertices are the frontier (gray, in the queue), shaded vertices are finished (black), plain vertices are undiscovered (white). Thick edges discovered the current frontier.

Vertex stays white in every panel: no ring ever reaches it. The frontier never holds vertices from more than two adjacent layers, and once a layer is fully dequeued the next layer is fully discovered — this is the queue invariant from the correctness proof, drawn.

Reachability and components, for free. The skeleton already solves more than distances. To list the connected components of an undirected graph, loop over all vertices and start a fresh search from each still-unvisited one, tagging every vertex it reaches with the current component number:

Algorithm 3:Connected-Components(G)\textsc{Connected-Components}(G) — label every vertex's component
  1. 1
    c0c \gets 0
  2. 2
    foreach vertex vVv \in V do
  3. 3
    if not v.visitedv.visited then
  4. 4
    cc+1c \gets c + 1
  5. 5
    run BFS(G,v)\textsc{BFS}(G, v), marking each newly visited vertex with cc

Each search marks exactly one component, and every vertex is visited once, so the whole sweep is still . Because we only used the visited flag, any instantiation works here — swap in DFS and nothing changes. This is the payoff of the unifying view: connectivity is a Whatever-First-Search property, not a BFS one.

BFS reads the search skeleton with a queue and exposes shortest hop-distances. Swap the queue for a stack and the same skeleton plunges instead of fanning out, exposing a graph's recursive structure — the timestamps and edge classification that the rest of this module is built on. This continues in Depth-First Search.

Footnotes

  1. Skiena, §5 — Graph Traversal — the hardest part is recognizing a problem as a graph problem.
  2. CLRS, Ch. 22 — Elementary Graph Algorithms — adjacency list versus adjacency matrix and when each is preferred.
  3. CLRS, Ch. 22 — Elementary Graph Algorithms — BFS computes shortest hop-distances in .
Practice

╌╌ END ╌╌