Part 3 · Data Structures · Module 26

Graphs

Trees have one parent per node. Remove that restriction and you get a graph: the shape of road maps, social networks, package dependencies, web links, and course prerequisites. Two traversals, breadth-first and depth-first, answer a surprising number of questions about all of them.

⏱ ~3 hoursPrerequisites: M22, M23, M24

Graph vocabulary

A graph G = (V, E) is a set of vertices (nodes) V and a set of edges E, each connecting two vertices. We write n = |V| and m = |E|; graph algorithm costs are stated in both.

undirecteddirected (a DAG)weighted ABCD abcd XYZ 723 deg(B) = 3; A-B-C-A is a cycle no directed cyclesX→Z→Y costs 5 < 7
Three kinds of graph. In the weighted graph the cheapest route from X to Y has more edges than the direct one.
TermMeaning
Undirected / directedEdges are two-way (friendship) or one-way arrows (follows, depends on, links to). A directed edge u → v is also called an arc.
WeightedEach edge carries a number: distance, cost, capacity, latency.
Neighbors, degreeVertices joined to v by an edge; degree is how many. Directed graphs split it into in-degree and out-degree. The degrees sum to 2m (each edge counted twice).
Path, simple pathA sequence of vertices joined by edges; simple if no vertex repeats. Its length is the number of edges (or total weight).
CycleA path that returns to its start. A graph with none is acyclic.
Connected, componentUndirected: every vertex reaches every other. A connected component is a maximal connected piece. (Directed graphs distinguish weakly and strongly connected.)
DAGDirected acyclic graph: arrows but no directed cycles. Dependencies, schedules, spreadsheets, and git history are DAGs.
Sparse / densem close to n (roads, most real networks) versus m close to n² (complete graphs).

A tree is a connected, acyclic, undirected graph; it always has exactly n − 1 edges. Everything from M24 is a special case of what follows.

Representations and their trade-offs

Just the edges: [("A", "B"), ("A", "C"), ...]. This is how graphs arrive from files and databases. Compact, but "who are A's neighbors?" means scanning every edge, O(m).

A dict from each vertex to the collection of its neighbors: dict[str, set[str]]. Neighbors of v in O(deg v), edge test in O(1) average with sets, and O(n + m) memory. This is the default for almost everything, because real graphs are sparse.

An n × n grid where M[i][j] is 1 (or the weight) if there is an edge i → j. Edge test in O(1) with no hashing, but O(n²) memory even for a sparse graph and O(n) to list neighbors. Good for small dense graphs and for linear algebra (NumPy, graph spectra, counting paths with matrix powers).

OperationEdge listAdjacency list (sets)Adjacency matrix
MemoryO(m)O(n + m)O(n²)
Is there an edge u–v?O(m)O(1) averageO(1)
List neighbors of vO(m)O(deg v)O(n)
Add an edgeO(1)O(1) averageO(1)
Add a vertexO(1)O(1)O(n²) (rebuild)
Visit every edge (BFS/DFS)O(m) per vertexO(n + m)O(n²)

Breadth-first search and shortest paths

BFS explores in rings: the start vertex, then everything one edge away, then everything two edges away. A FIFO queue makes this happen, because vertices discovered earlier are processed earlier. Mark a vertex as visited when you enqueue it (not when you dequeue it), or the same vertex can be queued many times.

The key property: the first time BFS reaches a vertex, it has found a path with the fewest edges. So BFS solves shortest paths in unweighted graphs. Record each vertex's parent (the vertex it was discovered from) and you can rebuild the path by walking parents back from the target. Each vertex is enqueued once and each edge examined at most twice, so BFS costs O(n + m).

Depth-first search: recursive and iterative

DFS goes as deep as it can along one path, then backtracks. It is the graph version of pre-order tree traversal, with a visited set to avoid going around cycles forever. DFS does not find shortest paths, but its structure (when each vertex is entered and finished) is what cycle detection, topological sorting, and many other algorithms build on. Also O(n + m).

Notice the iterative version marks a vertex visited when it is popped, unlike BFS. That is what makes its order match the recursive one. It also means a vertex can sit on the stack several times; the continue skips the duplicates.

Connected components, cycles, and bipartite graphs

Connected components

Run a traversal from every vertex not yet visited; each run discovers exactly one component. Total cost is still O(n + m) because every vertex and edge is processed once across all runs. Uses: clustering, finding isolated groups in a network, checking whether a road network is fully connected.

Cycle detection

In an undirected graph, a cycle exists if DFS meets an already-visited vertex that is not the vertex it just came from (its parent). In a directed graph that test is wrong: reaching a finished vertex through a different route is fine (a diamond A→B→D, A→C→D has no cycle). Instead use three colors: white (unvisited), gray (on the current DFS path), black (finished). An edge into a gray vertex is a back edge, and a back edge means a cycle.

Bipartite check

A graph is bipartite if its vertices split into two groups with every edge going between the groups: students and courses, job applicants and jobs, or "can these meetings be split across two rooms so no conflicting pair shares one?" Test it by BFS 2-coloring: color the start 0, its neighbors 1, their neighbors 0, and so on. A conflict (an edge between same colors) means an odd cycle, and the graph is not bipartite.

Topological sort

A topological order of a DAG lists every vertex before all the vertices it points to. If edges mean "must come before" (u → v: do u before v), it is a valid schedule. Build systems, package installers, spreadsheet recalculation, and course planners all need one. A topological order exists if and only if the graph has no directed cycle, and there are usually many valid orders.

Kahn's algorithm (BFS-style)

Compute every vertex's in-degree. Queue the vertices with in-degree 0 (nothing must precede them). Repeatedly pop one, output it, and decrement its successors' in-degrees; any that reach 0 join the queue. If fewer than n vertices are output, the rest are on a cycle. O(n + m).

DFS finishing order

Run DFS; when a vertex finishes (all its descendants are done), prepend it to the output. A vertex finishes only after everything it points to, so the reversed finishing order is topological. Combine with three colors to detect cycles at the same time. O(n + m).

The standard library has this built in. graphlib.TopologicalSorter takes a mapping from each node to its predecessors (the things it depends on, which is the opposite direction from the dict above), raises graphlib.CycleError on cycles, and supports parallel scheduling: get_ready() returns every node whose dependencies are done, so independent tasks can run at the same time.

Grids as implicit graphs

Many problems hide a graph without ever building one. In a grid (a maze, an image, a game board), each cell is a vertex and its up/down/left/right neighbors are its edges. You compute neighbors on the fly instead of storing an adjacency list: an implicit graph. BFS on a maze finds the shortest route; DFS or BFS from a pixel is the paint-bucket flood fill; counting how many traversals you start is the number of islands.

Graphs in the real world

Social networks

Users are vertices and friendships undirected edges (follows are directed). "People you may know" is friends-of-friends, a BFS to depth 2. "Six degrees of separation" is the BFS distance. Communities are dense components.

Dependency graphs

Packages, build targets, and database migrations form DAGs. Pixi's solver resolves a dependency graph with version constraints; make and CI pipelines topologically sort tasks and run independent ones in parallel; a cycle is an error.

Road and route networks

Intersections are vertices, roads weighted directed edges (one-way streets, travel times). Shortest routes need Dijkstra or A* (M31); cheapest wiring needs a minimum spanning tree.

The web and knowledge graphs

Pages link to pages: a directed graph with billions of vertices. Crawlers do BFS (you will build one in Project 5), and PageRank scores vertices by the structure of links.

QuestionToolCost
Reachable from s? Fewest-edges path?BFSO(n + m)
Explore everything, backtracking structureDFSO(n + m)
Connected componentsBFS/DFS from each unvisited vertex (or union-find, M27)O(n + m)
Cycle in a directed graph?DFS with three colors, or Kahn leftoverO(n + m)
Two-colorable?BFS 2-coloringO(n + m)
Valid order for dependenciesKahn, DFS, graphlibO(n + m)
Cheapest weighted pathDijkstra (M31)O((n + m) log n)

Pitfalls

Practice

Write build_graph(vertices, edges, directed=False) returning a plain dict[str, set[str]] that includes every vertex (even isolated ones), and degrees(graph) returning a dict of out-degrees.

Hint

Start with {v: set() for v in vertices}, then add each edge once (directed) or in both directions (undirected).

Show solution
def build_graph(vertices, edges, directed=False):
    graph = {v: set() for v in vertices}
    for u, v in edges:
        graph[u].add(v)
        if not directed:
            graph[v].add(u)
    return graph

def degrees(graph):
    return {v: len(ns) for v, ns in graph.items()}

Write shortest_path(graph, start, goal) returning a list of vertices from start to goal with the fewest edges, or None if unreachable. shortest_path(g, s, s) is [s].

Hint

Keep a parent dict that doubles as the visited set. When you dequeue the goal, follow parents back and reverse.

Show solution
from collections import deque

def shortest_path(graph, start, goal):
    parent = {start: None}
    queue = deque([start])
    while queue:
        u = queue.popleft()
        if u == goal:
            path = []
            while u is not None:
                path.append(u)
                u = parent[u]
            return path[::-1]
        for v in graph[u]:
            if v not in parent:
                parent[v] = u
                queue.append(v)
    return None

Write count_components(graph) for an undirected adjacency-list graph. It must work on a 10,000-vertex path graph, so don't use recursion.

Hint

Loop over vertices; each unvisited vertex starts a new traversal (stack or deque) and increments the count.

Show solution
def count_components(graph):
    seen, count = set(), 0
    for s in graph:
        if s in seen:
            continue
        count += 1
        seen.add(s)
        stack = [s]
        while stack:
            u = stack.pop()
            for v in graph[u]:
                if v not in seen:
                    seen.add(v)
                    stack.append(v)
    return count

Write has_cycle(graph) for a directed graph given as dict[str, list[str]] (every vertex is a key). Diamonds are not cycles; self-loops are.

Hint

Three colors: gray while a vertex is on the current path, black when finished. An edge to a gray vertex is a cycle. Kahn's algorithm also works: a cycle leaves vertices unprocessed.

Show solution
def has_cycle(graph):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {v: WHITE for v in graph}

    def visit(u):
        color[u] = GRAY
        for v in graph[u]:
            if color[v] == GRAY:
                return True
            if color[v] == WHITE and visit(v):
                return True
        color[u] = BLACK
        return False

    return any(color[v] == WHITE and visit(v) for v in graph)

Write topo_sort(deps) where deps maps each task to the set of tasks it depends on (like graphlib). Return a list in which every task appears after its dependencies; when several tasks are ready, take them in alphabetical order. Raise ValueError on a cycle. Tasks that appear only as dependencies must be included. Don't use graphlib.

Hint

Build successor lists and in-degrees (in-degree = number of dependencies). Use a heap instead of a deque so the ready set always yields the alphabetically smallest task.

Show solution
import heapq

def topo_sort(deps):
    nodes = set(deps)
    for ds in deps.values():
        nodes |= set(ds)
    indegree = {n: 0 for n in nodes}
    successors = {n: [] for n in nodes}
    for task, ds in deps.items():
        for d in ds:
            successors[d].append(task)
            indegree[task] += 1
    ready = [n for n in nodes if indegree[n] == 0]
    heapq.heapify(ready)
    order = []
    while ready:
        n = heapq.heappop(ready)
        order.append(n)
        for s in successors[n]:
            indegree[s] -= 1
            if indegree[s] == 0:
                heapq.heappush(ready, s)
    if len(order) != len(nodes):
        raise ValueError("dependency cycle")
    return order

Write num_islands(grid) where grid is a list of strings of "1" (land) and "0" (water). Islands connect horizontally and vertically only. Return 0 for an empty grid. A test uses a 300 × 300 all-land grid, so avoid recursion.

Hint

Scan every cell; each unseen land cell starts a BFS that marks its whole island seen.

Show solution
from collections import deque

def num_islands(grid):
    if not grid:
        return 0
    rows, cols = len(grid), len(grid[0])
    seen, count = set(), 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] != "1" or (r, c) in seen:
                continue
            count += 1
            seen.add((r, c))
            queue = deque([(r, c)])
            while queue:
                cr, cc = queue.popleft()
                for nr, nc in ((cr + 1, cc), (cr - 1, cc), (cr, cc + 1), (cr, cc - 1)):
                    if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1" and (nr, nc) not in seen:
                        seen.add((nr, nc))
                        queue.append((nr, nc))
    return count

Mini-project: a course-prerequisite planner

Mini-projectSemester planner

A university publishes its catalog as a TOML file mapping each course to its prerequisites. Build planner.py that turns it into a semester-by-semester plan.

pixi init planner && cd planner
pixi add python=3.14 pytest
pixi run python planner.py catalog.toml --max-per-term 3
[courses]
"CS101" = []
"CS102" = ["CS101"]
"MATH120" = []
"CS201" = ["CS102", "MATH120"]
"CS250" = ["CS201"]
  • Load the catalog with tomllib; build the graph; report unknown prerequisites as errors.
  • Detect cycles and print the offending cycle (for example CS201 → CS250 → CS201).
  • Produce a plan of terms where each course's prerequisites were completed in an earlier term, with at most --max-per-term courses per term (Kahn's algorithm by levels, or graphlib.TopologicalSorter with get_ready/done).
  • --target CS250 plans only the courses needed for one target (DFS over prerequisites first).
  • --taken CS101,MATH120 skips courses already completed.
  • The plan is valid: a test checks every prerequisite sits in an earlier term
  • The term limit is respected and the number of terms is minimal for unlimited capacity (the longest prerequisite chain)
  • Cycles and unknown courses produce clear error messages and a non-zero exit code
  • --target and --taken work together
  • Tests cover a diamond, a chain, a cycle, and an empty catalog

Stretch goals: courses offered only in fall or spring; credit-hour limits instead of course counts; render the prerequisite graph as Mermaid text for the README; compute each course's "unlocks" count to prioritize bottleneck courses.

Quiz

Recap

Going further