Chapter 10 · Lectures

Graphs: BFS, DFS, shortest paths

10.1 Introduction

The vocabulary

The shape of the space

A graph has up to (V2)=Θ(V2)\binom{V}{2} = \Theta(V^2) possible edges (undirected) or V(V1)=Θ(V2)V(V-1) = \Theta(V^2) (directed). The real world rarely fills that space:

  • Dense graph: E=Θ(V2)|E| = \Theta(V^2). Every vertex is connected to a constant fraction of the others. Complete graphs, small road networks within a city block.

  • Sparse graph: E=O(V)|E| = O(V) or O(VlogV)O(V \log V). Each vertex has only a few neighbors. Most real graphs: social networks, road networks, computer networks, the web.

Dense vs. sparse matters enormously for algorithm choice. An O(V2)O(V^2) algorithm on a sparse graph is wasteful; an O(ElogV)O(E \log V) algorithm is brilliant on sparse graphs and mediocre on dense ones. The first question to ask about any graph algorithm is: “what does this cost on a sparse input?”

The picture

Vocabulary is easier to keep straight if you can see it. The same five vertices below carry an undirected and a directed edge set; note how “degree” splits into “in-degree” and “out-degree” once edges acquire direction.

::: center :::

Left (undirected): vertex bb has degree 33 --- neighbours {a,c,e}\{a, c, e\}. Path aaddeebb has length 33 (unweighted).

Right (directed): vertex bb has in-degree 22 (edges from aa and ee) and out-degree 11 (edge to cc). Vertex aa is a source (in-degree 00); vertex cc is a sink (out-degree 00). The directed path adebca \to d \to e \to b \to c has length 44.

10.2 Applications

The textbook section runs through three examples; the interesting claim is the same in each:

Navigation (geography)

Cities/intersections are vertices; roads are edges. Edge weights are distances or travel times. Shortest-path queries give driving directions. This is what powers Google Maps, Waze, and every GPS unit made since the 90s.

The interesting twist: edge weights aren’t static. Real-time traffic turns the same road into a different edge every few minutes. The right algorithm has to be fast enough that you can re-solve every time conditions change, not just once.

Product recommendations (e-commerce)

Products are vertices; “bought together” or “viewed together” is an edge. A customer’s basket gives you a starting set; walk one step out along the edges and you have recommendations.

Social and professional networks

People are vertices; relationships are edges. “Friends of friends” is a two-step walk. PageRank, centrality, community detection all live here.

Other common models you’ll encounter

  • Compiler dependencies. Source files are vertices, #include edges drive rebuild order — a topological sort of the dependency DAG.

  • Spreadsheet formula dependencies. Cells are vertices, references are edges. Evaluation order is a topological sort; circular references are a cycle detection problem.

  • Package managers. Libraries are vertices, dependency constraints are edges. Resolving versions is a constraint problem on the graph.

  • Build systems (make, bazel, cmake). Targets are vertices, prerequisites are edges. Determining what to rebuild after a change is reachability.

  • State machines. States are vertices, transitions are edges. Model checking is graph reachability with labels.

  • Game AI and pathfinding. Tiles or waypoints are vertices, valid moves are edges. A* (shortest path with a heuristic) is the industry standard.

  • Neural networks. The topology is literally a weighted DAG.

10.3 Adjacency Lists

We have the vocabulary. Now: how do we store a graph in memory?

C++ representations

Several idioms are common; pick based on your data model.

// 1. Vertices as integer IDs 0..V-1, parallel array of neighbor vectors.
//    The fastest and most common choice in competitive programming.
std::vector<std::vector<int>> adj(V);
adj[u].push_back(v);   // add edge u--v
adj[v].push_back(u);   // undirected => add to both lists

// 2. Weighted edges: store (neighbor, weight) pairs.
std::vector<std::vector<std::pair<int,int>>> adj(V);
adj[u].emplace_back(v, weight);

// 3. Vertex objects with owned adjacency data (OOP style).
struct Vertex {
    std::string label;
    std::vector<Vertex*> neighbors;
};

Cost analysis

  • Space: O(V+E)O(V + E). Each vertex’s list contributes one header; each edge contributes two list entries (one in each endpoint’s list for undirected graphs).

  • “Is uu adjacent to vv?”: O(deg(u))O(\deg(u)). Scan uu‘s list. Worst case O(V)O(V) if uu is connected to everything.

  • “List all neighbors of uu: O(deg(u))O(\deg(u)). Perfect — you just hand over the list.

  • Add/remove edge: O(1)O(1) to add. Removal is O(deg(u))O(\deg(u)) because you have to find the entry.

10.4 Adjacency Matrices

std::vector<std::vector<int>> m(V, std::vector<int>(V, 0));
m[u][v] = 1;
m[v][u] = 1;   // undirected

// Weighted version; INT_MAX = "no edge"
std::vector<std::vector<int>> w(V, std::vector<int>(V, INT_MAX));
w[i][i] = 0;   // zero distance to self
w[u][v] = weight;

Cost analysis

  • Space: Θ(V2)\Theta(V^2). Forgiving for small dense graphs, catastrophic for large sparse ones.

  • “Is uu adjacent to vv?”: O(1)O(1). Just read the matrix cell.

  • “List all neighbors of uu: O(V)O(V). Scan row uu even if there are only two neighbors.

  • Add/remove edge: O(1)O(1).

  • Matrix math: Mk[i][j]M^k[i][j] is the number of length-kk walks from ii to jj. This is the basis of matrix-multiplication shortest-path algorithms and a ton of algebraic graph theory.

When to pick which

::: center Adjacency list Adjacency matrix


Space O(V+E)O(V+E) Θ(V2)\Theta(V^2) Edge query (u,v)?(u,v)? O(degu)O(\deg u) O(1)O(1) List neighbors of uu O(degu)O(\deg u) O(V)O(V) Iterate all edges O(V+E)O(V+E) O(V2)O(V^2) Good for… sparse, large VV dense, small VV :::

10.5 Breadth-First Search

Algorithm

void bfs(const std::vector<std::vector<int>>& adj, int start) {
    std::vector<bool> discovered(adj.size(), false);
    std::queue<int> frontier;

    frontier.push(start);
    discovered[start] = true;

    while (!frontier.empty()) {
        int u = frontier.front(); frontier.pop();
        visit(u);
        for (int v : adj[u]) {
            if (!discovered[v]) {
                discovered[v] = true;
                frontier.push(v);
            }
        }
    }
}

Distance and parent tracking

BFS’s real power isn’t just “visit everything.” It computes shortest paths in unweighted graphs for free. Augment each visited vertex with a distance and a parent pointer:

void bfs(const std::vector<std::vector<int>>& adj, int start,
         std::vector<int>& dist, std::vector<int>& parent) {
    int n = adj.size();
    dist.assign(n, -1);
    parent.assign(n, -1);
    std::queue<int> q;
    q.push(start);
    dist[start] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                parent[v] = u;
                q.push(v);
            }
        }
    }
}

After BFS, dist[v] is the minimum number of edges from start to v; following parent pointers back from v reconstructs the path in reverse.

Cost

O(V+E)O(V + E). Each vertex is dequeued once; each edge is scanned at most twice (once from each endpoint, for undirected graphs). Can’t be beat for unweighted shortest paths.

Why BFS works – the invariant

Parent pointers and the BFS tree

The parent[] array isn’t just bookkeeping --- it builds a BFS tree rooted at the source: every reachable vertex vv has exactly one parent, namely the vertex from which vv was first discovered. The tree’s edges are exactly the ”vv first discovered from uu” edges, and the tree’s root-to-vv path is a shortest svs \to v path in the original graph (length =dist[v]= \texttt{dist}[v]).

This is the BFS analogue of ch. 9’s parent-pointer trees --- the parent pointers are the data structure BFS produces. Two callers of BFS use it differently:

  • “Shortest path” callers walk parent pointers backward from the target (path.push_back(prev[v]) in a loop, then std::reverse). O(path length)O(\text{path length}) per query, no extra preprocessing.

  • “Component” callers ignore the tree and just iterate over dist[] for “everyone reachable from ss.”

::: center :::

BFS tree on a 6-vertex undirected graph, source ss at the left. Layer L0={s}L_0 = \{s\}, L1={a,b}L_1 = \{a, b\}, L2={c,d}L_2 = \{c, d\}, L3={e}L_3 = \{e\}. Solid arrows are the parent-pointer edges BFS keeps; dotted edges (a,b)(a, b) and (c,d)(c, d) are present in the input graph but are not added to the BFS tree because their endpoints were already in the same or adjacent layer when the second endpoint was first discovered.

Typical applications

  • Shortest path in unweighted graph — unbeatable.

  • “Friend of a friend” recommendations — explore distance-2 and distance-3 neighborhoods.

  • Connected components — run BFS from every unvisited vertex; each run finds one component.

  • Bipartite check — 2-color BFS layers; if an edge connects two same-colored vertices, the graph isn’t bipartite.

  • Crawling / scraping — explore a website or peer-to-peer network level-by-level.

  • Closest-of-many search — as in the peer-to-peer example above, find the first node satisfying a condition (fewest hops).

10.6 Depth-First Search

Iterative DFS

void dfs(const std::vector<std::vector<int>>& adj, int start) {
    std::vector<bool> visited(adj.size(), false);
    std::stack<int> s;
    s.push(start);

    while (!s.empty()) {
        int u = s.top(); s.pop();
        if (visited[u]) continue;
        visited[u] = true;
        visit(u);
        for (int v : adj[u])
            if (!visited[v]) s.push(v);
    }
}

Recursive DFS

Shorter and usually what you want:

void dfs(int u, const std::vector<std::vector<int>>& adj,
         std::vector<bool>& visited) {
    if (visited[u]) return;
    visited[u] = true;
    visit(u);
    for (int v : adj[u]) dfs(v, adj, visited);
}

Cost

O(V+E)O(V + E), same as BFS — each vertex is visited once, each edge examined at most twice (undirected).

BFS vs. DFS: when to pick which

::: center BFS DFS


Auxiliary structure queue (FIFO) stack (LIFO) Shortest path in unweighted graphs yes no Memory (worst case) O(V)O(V) queue O(V)O(V) stack Explores breadth-first depth-first Natural for distance, levels reachability, ordering Solves shortest path, connectivity topological sort, cycle detection, SCCs :::

Edge classification

DFS isn’t just a way to visit every vertex --- the order in which it visits matters, and that order classifies every edge of the graph into one of four kinds. Each kind has a precise meaning in terms of the DFS tree (the parent-pointer tree DFS produces) and the discovery / finish times d[u],f[u]d[u], f[u] DFS records when it enters and leaves each vertex. Edge classification is the foundation for cycle detection, topological sort, strongly connected components (§10.14), and articulation-point analysis.

::: center :::

DFS edge classification on a 7-vertex directed graph. DFS roots: aa first, then ee, then gg (each picked when the prior tree finishes). Tree edges (solid black): aba{\to}b, aca{\to}c, bdb{\to}d, efe{\to}f --- the parent-pointer DFS tree. Back edge (red dashed): dad{\to}a closes the cycle abdaa{\to}b{\to}d{\to}a. Forward edge (blue solid): ada{\to}d skips ahead to a descendant via a non-tree edge. Cross edge (green dotted): geg{\to}e jumps between separately rooted DFS trees and is neither ancestor nor descendant of gg.

In an undirected graph, only tree edges and back edges exist --- forward edges and cross edges collapse to back edges under undirected DFS because every non-tree edge is examined twice (once from each endpoint).

Cycle detection on directed graphs

The classification gives a one-line directed-graph cycle test: a directed graph has a cycle if and only if directed-DFS produces a back edge. "\Leftarrow" is the back-edge definition: (uv)(u \to v) back edge means vv is an ancestor of uu, so vuvv \to \ldots \to u \to v is a cycle. "\Rightarrow" is harder: any cycle in GG produces a back edge during DFS, because among the cycle’s vertices the one with smallest discovery time d[]d[\cdot] ends up an ancestor of the rest, and the incoming-cycle-edge to that ancestor is a back edge.

In code, you don’t need to maintain d[]/f[]d[\cdot] / f[\cdot] explicitly --- a tri-state colour[] array does the job. A vertex is [white]{.smallcaps} (undiscovered), [gray]{.smallcaps} (on the current DFS recursion stack, i.e. “in progress”), or [black]{.smallcaps} (finished). An edge (u,v)(u, v) to a [gray]{.smallcaps} vertex is a back edge.

enum Colour { WHITE, GRAY, BLACK };

bool hasCycleDFS(int u, const std::vector<std::vector<int>>& adj,
                 std::vector<Colour>& colour) {
    colour[u] = GRAY;
    for (int v : adj[u]) {
        if (colour[v] == GRAY) return true;          // back edge => cycle
        if (colour[v] == WHITE && hasCycleDFS(v, adj, colour))
            return true;
    }
    colour[u] = BLACK;
    return false;
}

bool hasCycle(const std::vector<std::vector<int>>& adj) {
    int n = adj.size();
    std::vector<Colour> colour(n, WHITE);
    for (int u = 0; u < n; ++u)
        if (colour[u] == WHITE && hasCycleDFS(u, adj, colour))
            return true;
    return false;
}

The outer loop over uu handles disconnected directed graphs --- any vertex that has not yet been discovered launches a fresh DFS tree. Cost is the same as plain DFS: O(V+E)O(V + E).

10.7 Directed Graphs

Why DAGs matter

A DAG captures “A must come before B” relationships without contradicting itself. Anything that orders things is a DAG:

  • Course prerequisites (\to topological sort).

  • Build systems (\to incremental rebuild).

  • Spreadsheet formulas (\to evaluation order).

  • Git commit history (\to merges and ancestors).

  • Neural net forward passes.

  • Dataflow programs.

If your directed graph has a cycle, you have a contradiction: A needs B first, but B also needs A first. Cycle detection on directed graphs is DFS plus a “currently in recursion” flag.

Representation

Same adjacency list or matrix, but only one direction per edge:

// Directed: only add u -> v, not v -> u
adj[u].push_back(v);

Terminology that bites

  • In-degree of vv: number of edges ending at vv.

  • Out-degree of vv: number of edges starting at vv.

  • Successor / predecessor: vv is a successor of uu if (uv)E(u \to v) \in E; then uu is a predecessor of vv.

  • Source: vertex with in-degree 0 (no one points to it).

  • Sink: vertex with out-degree 0 (it points to no one).

The build-system DAG, drawn

Every modern build system --- make, Bazel, Cargo, Ninja, cmake –build --- is a topological sort of a dependency DAG with a re-execution rule layered on top. Vertices are build targets; an edge uvu \to v means ”vv depends on uu, so uu must be built before vv.” The topological order is a valid build schedule, and any two vertices that aren’t related by the ancestor relation can be built in parallel. That last sentence is exactly why make -j8 works: independent subtrees of the DAG are independent jobs.

::: center :::

Six-target dependency DAG. A valid topological order is util, cfg, log, core, api, cli --- but cfg, log, util, api, core, cli works equally well. The first three targets share no edges between them, so a parallel build invokes their compilers concurrently; cli sits at the bottom and must wait for both core and api to finish. Add a cycle to this graph (say, cli \to cfg from a stray #include) and no build order exists --- which is exactly the error modern build systems print.

10.8 Weighted Graphs

Why weights complicate things

In an unweighted graph, “shortest” means “fewest hops.” BFS handles it. In a weighted graph, “shortest” means “smallest total weight” — and a path with more edges can easily be shorter than one with fewer:

::: center A 10\xrightarrow{10} C versus A 2\xrightarrow{2} B 3\xrightarrow{3} C (length 10 vs. 5) :::

BFS can’t see this. You need an algorithm that explores in order of weighted distance, not edge count — enter Dijkstra (§10.9).

Representation

// List form: (neighbor, weight) pairs.
std::vector<std::vector<std::pair<int,int>>> adj(V);
adj[u].emplace_back(v, w);

// Matrix form: INT_MAX (or INFINITY) marks "no edge".
std::vector<std::vector<int>> w(V, std::vector<int>(V, INT_MAX));
w[i][i] = 0;
w[u][v] = weight;

Negative weights

Negative edge weights are allowed, but negative cycles break the shortest-path problem entirely: you can loop around the negative cycle forever, driving the total cost to -\infty. Shortest path is undefined.

Relaxation: the universal SSSP primitive

Relaxation is monotonic: each call either decreases dist[v]\texttt{dist}[v] or leaves it alone. Every single-source shortest-path algorithm in this chapter --- DAG-relax, Dijkstra, Bellman-Ford --- is just “call [relax]{.smallcaps} on every edge in some order; correctness comes from the order.” The three differ only in order:

  • DAG-relax (the easy case): topologically sort the DAG, then relax every outgoing edge of each vertex in topological order. One pass; O(V+E)O(V + E). Correctness: when we process uu, any predecessor pp on the shortest svs \to v path has already been processed (it appears earlier in topological order), so dist[u]\texttt{dist}[u] is already final, and relaxing (u,v,w)(u, v, w) propagates the correct distance to vv.

  • Dijkstra (§10.9): pick the unvisited vertex with smallest tentative distance and relax all its outgoing edges --- a greedy frontier expansion that works because non-negative edges guarantee dist\texttt{dist}-monotonic processing.

  • Bellman-Ford (§10.10): relax every edge V1|V| - 1 times. Slower but order-agnostic, so it tolerates negative weights and detects negative cycles.

Once you see the unification, picking the right algorithm collapses to “what’s special about my edge weights / graph structure?“

10.9 Dijkstra’s Shortest Path

Algorithm

For every vertex vv, maintain:

  • dist[v]: best-known shortest distance from the source (initially \infty).

  • prev[v]: previous vertex along that best-known path (initially null).

At each step, pull the vertex with smallest tentative dist out of the priority queue (it won’t improve), and relax all its outgoing edges: for each edge (u,v,w)(u, v, w), if dist[u] + w < dist[v], update dist[v] and prev[v].

#include <queue>
#include <vector>
#include <limits>

using Edge = std::pair<int,int>;   // (neighbor, weight)
const int INF = std::numeric_limits<int>::max();

void dijkstra(const std::vector<std::vector<Edge>>& adj, int src,
              std::vector<int>& dist, std::vector<int>& prev) {
    int n = adj.size();
    dist.assign(n, INF);
    prev.assign(n, -1);
    dist[src] = 0;

    // Min-heap of (distance, vertex). std::greater flips the default max-heap.
    using PQE = std::pair<int,int>;
    std::priority_queue<PQE, std::vector<PQE>, std::greater<PQE>> pq;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;          // stale entry -- already improved
        for (auto [v, w] : adj[u]) {
            int alt = d + w;
            if (alt < dist[v]) {
                dist[v] = alt;
                prev[v] = u;
                pq.push({alt, v});
            }
        }
    }
}

Why it works – greedy correctness

Negative-edge counterexample, drawn

The cleanest illustration that Dijkstra can break on negative edges is a 3-node graph:

::: center :::

True shortest distances from ss: dist[s]=0\texttt{dist}[s] = 0, dist[b]=3\texttt{dist}[b] = 3, dist[a]=0\texttt{dist}[a] = 0 (via sbas \to b \to a at total 3+(3)=03 + (-3) = 0).

What Dijkstra does, however: pop ss (d=0d = 0), relax both edges so dist[a]=1,dist[b]=3\texttt{dist}[a] = 1, \texttt{dist}[b] = 3. Pop aa next (smallest tentative =1= 1), declare dist[a]=1\texttt{dist}[a] = 1 final, finish its empty out-list. Pop bb, relax bab \to a with weight 3-3 finding 0<10 < 1 --- but aa is already settled and we never re-expand it. Dijkstra terminates with dist[a]=1\texttt{dist}[a] = 1, which is wrong by 11.

The greedy-correctness proof above breaks at exactly the line “adds a non-negative edge weight on top of dist[v]\texttt{dist}[v].” With a negative edge, a later path can undercut a “finalised” distance. Bellman-Ford (§10.10) survives by relaxing every edge V1|V| - 1 times instead of trusting a greedy ordering.

A worked Dijkstra trace

::: center :::

5-node weighted directed graph. Edge weights are all non-negative, so Dijkstra is the right tool. Source is ss.

Cost

::: center Priority-queue implementation Runtime


Unsorted array / linear scan O(V2+E)O(V^2 + E) Binary heap (std::priority_queue) O((V+E)logV)O((V + E) \log V) Fibonacci heap O(E+VlogV)O(E + V \log V) :::

For sparse graphs, the binary-heap version is the sweet spot: simple, fast, what you’d reach for in real code. Fibonacci heaps have better theoretical bounds but rarely win in practice because of their hideous constant factors.

Reconstructing the path

After Dijkstra, walk prev backwards from the destination:

std::vector<int> pathTo(int target, const std::vector<int>& prev) {
    std::vector<int> path;
    for (int v = target; v != -1; v = prev[v]) path.push_back(v);
    std::reverse(path.begin(), path.end());
    return path;
}

If prev[target] == -1 and target != src, the target is unreachable.

10.10 Bellman-Ford Shortest Path

Algorithm

struct Edge { int u, v, w; };

bool bellmanFord(int n, const std::vector<Edge>& edges, int src,
                 std::vector<int>& dist, std::vector<int>& prev) {
    dist.assign(n, INF);
    prev.assign(n, -1);
    dist[src] = 0;

    // Main loop: relax every edge V-1 times.
    for (int i = 0; i < n - 1; ++i) {
        for (const auto& e : edges) {
            if (dist[e.u] != INF && dist[e.u] + e.w < dist[e.v]) {
                dist[e.v] = dist[e.u] + e.w;
                prev[e.v] = e.u;
            }
        }
    }

    // Negative-cycle check: one more pass -- if anything still relaxes,
    // the graph has a reachable negative cycle.
    for (const auto& e : edges) {
        if (dist[e.u] != INF && dist[e.u] + e.w < dist[e.v])
            return false;
    }
    return true;
}

Why V-1 iterations?

A shortest path has at most V1V-1 edges (any more would revisit a vertex, wasting weight if weights are non-negative — and if weights are negative, see below). Each iteration propagates “correct” distances one edge farther along. After V1V-1 passes, every shortest path has been fully propagated, as long as no negative cycles exist.

If an edge is still relaxable on a VV-th iteration, then some path keeps getting shorter — a negative cycle is the only way that can happen.

Cost

O(VE)O(VE). Roughly VV times slower than Dijkstra in the worst case, but:

  • Handles negative weights.

  • Detects negative cycles.

  • Works on any graph representation — just give it a list of edges.

  • Easy to parallelize (independent edge relaxations).

Dijkstra vs. Bellman-Ford, decision flowchart

::: center Situation Use


All edges non-negative, sparse Dijkstra + binary heap
Negative edges, no negative cycles Bellman-Ford
Need to detect negative cycles Bellman-Ford
All-pairs shortest paths Floyd-Warshall (§10.13)
Unweighted graph BFS (not either of these)
:::

10.11 Topological Sort

Kahn’s algorithm (in-degree method)

Repeatedly peel off vertices with no incoming edges — they’re safe to do first.

std::vector<int> topoSortKahn(int n,
                              const std::vector<std::vector<int>>& adj) {
    std::vector<int> indeg(n, 0);
    for (int u = 0; u < n; ++u)
        for (int v : adj[u]) ++indeg[v];

    std::queue<int> q;
    for (int u = 0; u < n; ++u)
        if (indeg[u] == 0) q.push(u);

    std::vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : adj[u])
            if (--indeg[v] == 0) q.push(v);
    }

    if ((int)order.size() != n) return {};  // cycle detected
    return order;
}

If the final order doesn’t have all nn vertices, the remaining vertices form a cycle — automatic cycle detection, no extra work.

DFS-based topological sort

Alternative: DFS, and push each vertex onto a result stack when it finishes (after its recursion returns). Reverse the stack.

void dfsTopo(int u, const std::vector<std::vector<int>>& adj,
             std::vector<bool>& visited, std::vector<int>& out) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v]) dfsTopo(v, adj, visited, out);
    out.push_back(u);   // post-order: u is "done" now
}

std::vector<int> topoSortDfs(int n,
                             const std::vector<std::vector<int>>& adj) {
    std::vector<bool> visited(n, false);
    std::vector<int> out;
    for (int u = 0; u < n; ++u)
        if (!visited[u]) dfsTopo(u, adj, visited, out);
    std::reverse(out.begin(), out.end());
    return out;
}

Why the reversal? DFS finishes deeper-in-the-dependency-chain vertices first. In a topological ordering, those should come last (they depend on earlier stuff). Reversing puts them in the right order.

Cost

Both are O(V+E)O(V + E).

Kahn vs. DFS

  • Kahn is iterative and natural for streaming/parallel processing — multiple vertices can be in the “ready” queue simultaneously.

  • DFS is shorter to write and fits naturally with other DFS-based algorithms (SCC, cycle detection).

Pick based on context. Most textbooks teach DFS; most real-world schedulers (like Tarjan’s, or make’s job dispatcher) use Kahn-style because they can work on “what’s ready now” incrementally.

10.12 Minimum Spanning Tree

Kruskal’s algorithm (greedy edges)

Sort all edges by weight. Add each edge to the MST unless it would create a cycle. Stop when you have V1V-1 edges.

“Would it create a cycle?” is answered with a union-find (disjoint-set) data structure: two vertices are in the same component iff their roots are equal.

struct DSU {
    std::vector<int> parent, rank_;
    DSU(int n) : parent(n), rank_(n, 0) {
        std::iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];   // path compression
            x = parent[x];
        }
        return x;
    }
    bool unite(int a, int b) {
        a = find(a); b = find(b);
        if (a == b) return false;
        if (rank_[a] < rank_[b]) std::swap(a, b);
        parent[b] = a;
        if (rank_[a] == rank_[b]) ++rank_[a];
        return true;
    }
};

struct Edge { int u, v, w; };

std::vector<Edge> kruskal(int n, std::vector<Edge> edges) {
    std::sort(edges.begin(), edges.end(),
              [](const Edge& a, const Edge& b){ return a.w < b.w; });

    DSU dsu(n);
    std::vector<Edge> mst;
    for (const auto& e : edges) {
        if (dsu.unite(e.u, e.v)) {
            mst.push_back(e);
            if ((int)mst.size() == n - 1) break;
        }
    }
    return mst;
}

Cost

O(ElogE)=O(ElogV)O(E \log E) = O(E \log V) (since EV2E \leq V^2, the two are equivalent up to a constant factor). Dominated by the sort; union-find operations are near-O(1)O(1) amortized.

Why the greedy works

These two lemmas justify both Kruskal and Prim:

  • Kruskal. Each unite-accepting edge is the minimum-weight edge across the cut (S,VS)(S, V \setminus S) where SS is the connected component of uu in the partial MST. So by the cut property the edge is safe to take.

  • Prim. Each “cheapest edge from the inside set to an outside vertex” is the minimum-weight edge across the cut (inside,outside)(\text{inside}, \text{outside}). Same cut property, different cut.

The cycle property is the anti-MST companion: it tells you which edges to never bother with --- whenever a cycle’s heaviest edge can be replaced by a lighter cycle alternative, the heavy edge is dead weight.

Prim’s algorithm (greedy vertex frontier)

Alternative MST algorithm: start from any vertex, repeatedly add the cheapest edge that connects an “inside” vertex to an “outside” vertex. Structurally it’s Dijkstra with a different relaxation rule --- compare edge weight directly, not cumulative distance. The min-heap holds (edge weight, outside vertex, inside parent) tuples; we pop the cheapest crossing edge, add the outside vertex to the inside set, and push the newly-uncovered edges.

struct PrimEdge { int w, v, parent; };
struct PrimCmp {
    bool operator()(const PrimEdge& a, const PrimEdge& b) const {
        return a.w > b.w;   // min-heap on weight
    }
};

// adj[u] = vector of (neighbour, weight) pairs.
std::vector<Edge> prim(int n,
                       const std::vector<std::vector<std::pair<int,int>>>& adj) {
    std::vector<bool> inMST(n, false);
    std::priority_queue<PrimEdge, std::vector<PrimEdge>, PrimCmp> pq;
    pq.push({0, 0, -1});                          // start at vertex 0

    std::vector<Edge> mst;
    while (!pq.empty() && (int)mst.size() < n - 1) {
        auto [w, v, parent] = pq.top(); pq.pop();
        if (inMST[v]) continue;                   // stale entry, skip
        inMST[v] = true;
        if (parent != -1)
            mst.push_back({parent, v, w});
        for (auto [nbr, weight] : adj[v])
            if (!inMST[nbr])
                pq.push({weight, nbr, v});
    }
    return mst;                                   // empty if graph disconnected
}

The same stale-entry / lazy-deletion idiom from §10.9 Dijkstra applies: we push duplicates instead of decrease-key, and filter by inMST[v] on pop. With a binary heap the cost is O((V+E)logV)O((V + E) \log V). With an array-based priority queue and O(V)O(V) extract-min the cost is O(V2)O(V^2) --- and on a dense graph (E=Θ(V2)E = \Theta(V^2)) array-Prim’s O(V2)O(V^2) beats Kruskal’s O(ElogE)=O(V2logV)O(E \log E) = O(V^2 \log V) because we skip the sort and the union-find both. Production graph libraries pick by density.

::: center :::

6-vertex weighted graph and its MST. Heavy tan edges form the MST (total weight 1+2+3+4+2=121{+}2{+}3{+}4{+}2 = 12), thin grey edges are the non-MST edges (b,e,6),(e,f,8),(a,c,7)(b,e,6), (e,f,8), (a,c,7). Cut-property sanity check on the cut S={a},VS={b,c,d,e,f}S = \{a\}, V \setminus S = \{b, c, d, e, f\}: crossing edges are (a,b,1),(a,d,3),(a,c,7)(a,b,1), (a,d,3), (a,c,7), with min (a,b,1)(a,b,1) --- which is in the MST. By the cycle property, the cycle abcaa{-}b{-}c{-}a (weights 1,2,71, 2, 7) has heaviest edge (a,c,7)(a,c,7), which is correctly excluded.

::: center Kruskal Prim


Approach greedy on edges greedy on vertex frontier Needs sorted edge list, union-find adjacency list, priority queue Cost O(ElogE)O(E \log E) O(ElogV)O(E \log V) w/ binary heap Good when edges are easy to enumerate graph is dense, adjacency is fast :::

10.13 All-Pairs Shortest Path (Floyd-Warshall)

Algorithm

Maintain dist[i][j] = current best-known distance from ii to jj. Initialize:

  • dist[i][i] = 0.

  • dist[i][j] = w(i,j) if the edge exists.

  • dist[i][j] = \infty otherwise.

Then iterate: for each intermediate vertex kk, for each pair (i,j)(i, j), ask “does going through kk beat the current best?”

void floydWarshall(std::vector<std::vector<int>>& dist) {
    int n = dist.size();
    for (int k = 0; k < n; ++k)
        for (int i = 0; i < n; ++i)
            for (int j = 0; j < n; ++j)
                if (dist[i][k] != INF && dist[k][j] != INF
                    && dist[i][k] + dist[k][j] < dist[i][j])
                    dist[i][j] = dist[i][k] + dist[k][j];
}

Why it works – the k-loop invariant

Cost

O(V3)O(V^3) time, O(V2)O(V^2) space. That’s cubic, but with tiny constants — three nested for loops of integer additions. On modern hardware it’s vectorizable and absolutely flies for VV up to a few thousand.

Floyd-Warshall vs. running Dijkstra V times

::: center Dijkstra from every source Floyd-Warshall


Total cost (binary heap) O(V(V+E)logV)O(V (V+E) \log V) O(V3)O(V^3) Handles negative edges? no yes Detects negative cycles? no yes (check diagonal) Code complexity moderate very simple Ideal on sparse, non-negative dense, or need negative-weight support :::

Path reconstruction

Floyd-Warshall only computes distances. To recover paths, track the intermediate vertex alongside:

std::vector<std::vector<int>> next;   // next[i][j] = first hop from i toward j

// During init: next[i][j] = j if edge exists, else -1; next[i][i] = i.
// During update:
if (dist[i][k] + dist[k][j] < dist[i][j]) {
    dist[i][j] = dist[i][k] + dist[k][j];
    next[i][j] = next[i][k];
}

// Reconstruct path i -> j:
std::vector<int> path = {i};
while (path.back() != j) path.push_back(next[path.back()][j]);

Algebraic view

Floyd-Warshall is min-plus matrix multiplication in disguise. Replace “product + sum” with “sum + min,” and the adjacency matrix raised to high enough a power (in this semiring) is exactly the all-pairs-shortest-path matrix. This connects shortest paths to a whole field of tropical algebra, used in everything from compiler optimization to network calculus.

10.14 Strongly Connected Components

Kosaraju’s algorithm (DFS twice)

The trick is to combine §10.6’s DFS finish-time ordering with the transpose graph GTG^T (every edge reversed). Two passes:

  1. Run DFS on GG and push vertices to a stack in finish-time order (the latest-finishing vertex on top).

  2. Pop vertices off the stack one at a time. Each pop runs a fresh DFS in GTG^T starting at the popped vertex, restricted to so-far-unvisited vertices. The set of vertices each restricted DFS reaches is one SCC.

Why it works: the latest-finishing vertex of pass 1 is in a “source SCC” of the SCC-DAG (no incoming inter-SCC edges). DFS from that vertex in GTG^T reverses the inter-SCC edges, so it cannot escape its own SCC --- giving exactly the SCC’s vertex set. Removing those, the next latest-finishing unvisited vertex is in the next source SCC of the remaining DAG, and so on. CLRS Theorem 22.16 carries the proof.

// Pass 1: DFS on G, pushing vertices in finish-time order.
void dfsForward(int u, const std::vector<std::vector<int>>& adj,
                std::vector<bool>& visited, std::vector<int>& order) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v]) dfsForward(v, adj, visited, order);
    order.push_back(u);   // post-order: latest finishes go on the back
}

// Pass 2: DFS on G^T, collecting one SCC per call.
void dfsBackward(int u, const std::vector<std::vector<int>>& adjT,
                 std::vector<bool>& assigned, std::vector<int>& comp) {
    assigned[u] = true;
    comp.push_back(u);
    for (int v : adjT[u])
        if (!assigned[v]) dfsBackward(v, adjT, assigned, comp);
}

std::vector<std::vector<int>> kosaraju(
        int n, const std::vector<std::vector<int>>& adj) {
    // Build transpose G^T.
    std::vector<std::vector<int>> adjT(n);
    for (int u = 0; u < n; ++u)
        for (int v : adj[u]) adjT[v].push_back(u);

    // Pass 1.
    std::vector<bool> visited(n, false);
    std::vector<int> order;
    for (int u = 0; u < n; ++u)
        if (!visited[u]) dfsForward(u, adj, visited, order);

    // Pass 2: pop vertices in reverse finish order, DFS on G^T.
    std::vector<bool> assigned(n, false);
    std::vector<std::vector<int>> sccs;
    for (int i = (int)order.size() - 1; i >= 0; --i) {
        int u = order[i];
        if (!assigned[u]) {
            std::vector<int> comp;
            dfsBackward(u, adjT, assigned, comp);
            sccs.push_back(std::move(comp));
        }
    }
    return sccs;
}

Cost is O(V+E)O(V + E): two linear-time DFS traversals plus one transpose construction. Output sccs is a list of vertex sets, one per SCC, emitted in topological order on the SCC-DAG (the first SCC popped is a source).

::: center Three SCCs in this 6-vertex digraph. {a,b}\{a, b\} via the aba \leftrightarrow b pair; {c,d,e}\{c, d, e\} via the cycle cdecc \to d \to e \to c; {f}\{f\} singleton. Inter-SCC edges bcb \to c and dfd \to f collapse to a 3-vertex condensation DAG {a,b}{c,d,e}{f}\{a,b\} \to \{c,d,e\} \to \{f\}. :::

Applications

SCCs are the workhorse for two production-relevant problems:

  • 2-SAT solving in linear time. Encode a 2-SAT formula i(ii)\bigwedge_i (\ell_i \vee \ell'_i) as the implication graph on 2n2n literals: each clause ()(\ell \vee \ell') contributes two edges, ¬\neg \ell \to \ell' and ¬\neg \ell' \to \ell. The formula is satisfiable iff no variable xx has xx and ¬x\neg x in the same SCC --- and a satisfying assignment is recovered from the SCC topological order. Total cost O(n+m)O(n + m). Compare 3-SAT, which is NP-complete --- the SCC decomposition is what makes 2-SAT tractable.

  • Dependency-cycle detection in package managers. cargo, npm, pip all need to detect when a set of packages forms a mutual-dependency cycle (which prevents a clean install order). SCC computation surfaces every such cycle as a non-singleton SCC; the package manager either errors out or treats the SCC as a single “compile-everyone- together” unit, depending on the language’s circular- import rules.

10.15 Picking the right algorithm: master decision table

By now you’ve seen seven (sometimes eight) graph algorithms with overlapping use-cases. The right tool depends on which problem (single-pair / single-source / all-pairs / MST / topological / cycle-detect / SCC / connectivity) and which graph properties (unweighted / non-negative / negative-no-cycle / negative-with-cycle / DAG / sparse / dense). The following master chooser collapses every chapter algorithm into one place. Cell entries name the algorithm, complexity, and chapter section.

::: center :::

The columns also encode the sparse vs. dense dimension: sparse \Rightarrow favour adjacency-list-based algorithms (Dijkstra heap form, Bellman-Ford, Kruskal); dense \Rightarrow favour matrix-based or array-PQ algorithms (Floyd-Warshall, Prim-with-array-PQ). The chapter map and the mastery checklist both forward-ref this table; if you internalise just one artefact from the chapter, this is the one.

10.16 Forward direction: A*, Johnson, network flow, planarity

ch. 10 covers the canonical undergraduate graph toolkit. The following are the algorithms a senior-level / graduate course would build on top --- not implemented here, but pointed at so the curious reader knows what to look for.

interactive mode active