10.1 Introduction
The vocabulary
The shape of the space
A graph has up to possible edges (undirected) or (directed). The real world rarely fills that space:
-
Dense graph: . Every vertex is connected to a constant fraction of the others. Complete graphs, small road networks within a city block.
-
Sparse graph: or . 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 algorithm on a sparse graph is wasteful; an 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 has degree --- neighbours . Path ——— has length (unweighted).
Right (directed): vertex has in-degree (edges from and ) and out-degree (edge to ). Vertex is a source (in-degree ); vertex is a sink (out-degree ). The directed path has length .
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,
#includeedges 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: . Each vertex’s list contributes one header; each edge contributes two list entries (one in each endpoint’s list for undirected graphs).
-
“Is adjacent to ?”: . Scan ‘s list. Worst case if is connected to everything.
-
“List all neighbors of ”: . Perfect — you just hand over the list.
-
Add/remove edge: to add. Removal is 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: . Forgiving for small dense graphs, catastrophic for large sparse ones.
-
“Is adjacent to ?”: . Just read the matrix cell.
-
“List all neighbors of ”: . Scan row even if there are only two neighbors.
-
Add/remove edge: .
-
Matrix math: is the number of length- walks from to . 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 Edge query List neighbors of Iterate all edges Good for… sparse, large dense, small :::
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
. 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 has exactly one parent, namely the vertex from which was first discovered. The tree’s edges are exactly the ” first discovered from ” edges, and the tree’s root-to- path is a shortest path in the original graph (length ).
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, thenstd::reverse). per query, no extra preprocessing. -
“Component” callers ignore the tree and just iterate over
dist[]for “everyone reachable from .”
::: center :::
BFS tree on a 6-vertex undirected graph, source at the left. Layer , , , . Solid arrows are the parent-pointer edges BFS keeps; dotted edges and 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
, 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) queue 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 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: first, then , then (each picked when the prior tree finishes). Tree edges (solid black): , , , --- the parent-pointer DFS tree. Back edge (red dashed): closes the cycle . Forward edge (blue solid): skips ahead to a descendant via a non-tree edge. Cross edge (green dotted): jumps between separately rooted DFS trees and is neither ancestor nor descendant of .
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. "" is the back-edge definition: back edge means is an ancestor of , so is a cycle. "" is harder: any cycle in produces a back edge during DFS, because among the cycle’s vertices the one with smallest discovery time 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 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 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 handles disconnected directed graphs --- any vertex that has not yet been discovered launches a fresh DFS tree. Cost is the same as plain DFS: .
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 ( topological sort).
-
Build systems ( incremental rebuild).
-
Spreadsheet formulas ( evaluation order).
-
Git commit history ( 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 : number of edges ending at .
-
Out-degree of : number of edges starting at .
-
Successor / predecessor: is a successor of if ; then is a predecessor of .
-
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 means ” depends on , so must be built before .” 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 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 C versus A B 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 . Shortest path is undefined.
Relaxation: the universal SSSP primitive
Relaxation is monotonic: each call either decreases 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; . Correctness: when we process , any predecessor on the shortest path has already been processed (it appears earlier in topological order), so is already final, and relaxing propagates the correct distance to .
-
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 -monotonic processing.
-
Bellman-Ford (§10.10): relax every edge 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 , maintain:
-
dist[v]: best-known shortest distance from the source (initially ). -
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 , 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 : , , (via at total ).
What Dijkstra does, however: pop (), relax both edges so . Pop next (smallest tentative ), declare final, finish its empty out-list. Pop , relax with weight finding --- but is already settled and we never re-expand it. Dijkstra terminates with , which is wrong by .
The greedy-correctness proof above breaks at exactly the line “adds a non-negative edge weight on top of .” With a negative edge, a later path can undercut a “finalised” distance. Bellman-Ford (§10.10) survives by relaxing every edge 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 .
Cost
::: center Priority-queue implementation Runtime
Unsorted array / linear scan
Binary heap (std::priority_queue)
Fibonacci heap
:::
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 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 passes, every shortest path has been fully propagated, as long as no negative cycles exist.
If an edge is still relaxable on a -th iteration, then some path keeps getting shorter — a negative cycle is the only way that can happen.
Cost
. Roughly 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 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 .
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 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
(since , the two are equivalent up to a constant factor). Dominated by the sort; union-find operations are near- 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 where is the connected component of 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 . 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 . With an array-based priority queue and extract-min the cost is --- and on a dense graph () array-Prim’s beats Kruskal’s 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 ), thin grey edges are the non-MST edges . Cut-property sanity check on the cut : crossing edges are , with min --- which is in the MST. By the cycle property, the cycle (weights ) has heaviest edge , 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 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 to . Initialize:
-
dist[i][i] = 0. -
dist[i][j] = w(i,j)if the edge exists. -
dist[i][j] =otherwise.
Then iterate: for each intermediate vertex , for each pair , ask “does going through 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
time, 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 up to a few thousand.
Floyd-Warshall vs. running Dijkstra V times
::: center Dijkstra from every source Floyd-Warshall
Total cost (binary heap) 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 (every edge reversed). Two passes:
-
Run DFS on and push vertices to a stack in finish-time order (the latest-finishing vertex on top).
-
Pop vertices off the stack one at a time. Each pop runs a fresh DFS in 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 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 : 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. via the pair; via the cycle ; singleton. Inter-SCC edges and collapse to a 3-vertex condensation DAG . :::
Applications
SCCs are the workhorse for two production-relevant problems:
-
2-SAT solving in linear time. Encode a 2-SAT formula as the implication graph on literals: each clause contributes two edges, and . The formula is satisfiable iff no variable has and in the same SCC --- and a satisfying assignment is recovered from the SCC topological order. Total cost . Compare 3-SAT, which is NP-complete --- the SCC decomposition is what makes 2-SAT tractable.
-
Dependency-cycle detection in package managers.
cargo,npm,pipall 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 favour adjacency-list-based algorithms (Dijkstra heap form, Bellman-Ford, Kruskal); dense 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.