The Three Canonical Graph Layouts
Given a directed or undirected graph $G = (V, E)$ with $|V| = n$ vertices and $|E| = m$ edges, an algorithm interacts with the graph through three primary operations: testing edge existence $\text{hasEdge}(u, v)$, enumerating incident edges $\text{neighbors}(u)$, and mutating topology $\text{addEdge}(u, v)$. No single memory layout optimizes all three simultaneously.
A static directed graph $G = (V, E)$ with vertices indexed $0 \dots n-1$ is uniquely encoded by two flat arrays: an offset array $\text{head}[0 \dots n]$ of length $n + 1$ and a target array $\text{to}[0 \dots m-1]$ of length $m$ (plus optional $\text{weight}[0 \dots m-1]$). For every vertex $u \in [0, n-1]$, its outgoing neighbors occupy the contiguous slice:
$$\text{neighbors}(u) = \text{to}\big[\text{head}[u] \;..\; \text{head}[u+1] - 1\big]$$Consequently, $\text{out-degree}(u) = \text{head}[u+1] - \text{head}[u]$ in $O(1)$ arithmetic without storing explicit length fields, and $\text{head}[n] = m$.
1. Vector-of-Vectors Adjacency List vs. Linked-Star Forward Star
In naive C++ or Java implementations, developers represent graphs as vector<vector<Edge>> adj(n). While convenient for incremental push_back mutations, this layout incurs two levels of indirection: first indexing the outer spine array to load a 24-byte std::vector control block (pointer, size, capacity), and second dereferencing its heap pointer into a fragmented allocator arena. When $n = 10^6$ and average degree is 4, allocator metadata and geometric overallocation waste over $60\%$ of RAM.
When dynamic edge insertion is required without sorting, competitive programmers and high-performance solvers use the Adjacency Forward Star (array-backed intrusive singly linked list): three flat arrays head[V], to[E], and next[E]. Adding edge $e = (u, v)$ sets to[e] = v; next[e] = head[u]; head[u] = e; in $O(1)$ worst-case time with zero heap allocations after initial reservation.
2. Compressed Sparse Row (CSR) Construction via Prefix Sums
When the edge list is known upfront (or ingested in batches), CSR eliminates even the next[E] pointer array of the forward star. Construction proceeds in two linear passes over the raw edge buffer:
- Degree Histogram & Exclusive Prefix Sum: Count the out-degree of each vertex $u$ in $\text{head}[u+1]$, then compute the cumulative sum $\text{head}[i] = \sum_{k=0}^{i-1} \text{deg}(k)$.
- Scatter Pass: Copy $\text{head}$ into a temporary cursor array $\text{cur}[0 \dots n-1]$. For each edge $(u, v)$, write $\text{to}[\text{cur}[u]++] = v$.
Inspect how Compressed Sparse Row (CSR) packs all adjacency lists into two flat arrays offsets[V+1] and edges[E] so vertex u’s neighbors live in slice edges[offsets[u]..offsets[u+1]), and trace Hierholzer’s O(E) stack splice for Eulerian circuits.
Invariant & State
3. Word-Packed Bitset Adjacency Matrices
For dense graphs ($E = \Theta(V^2)$) or transitive closure / triangle counting on medium-sized graphs ($V \le 5000$), a boolean byte matrix wastes $7/8$ of every byte. Packing 64 edges into each uint64_t word reduces memory to $V^2 / 8$ bytes (just $3.1\text{ MB}$ for $V = 5000$) and unlocks word-level parallelism ($O(V / w)$ where $w = 64$):
- Common Neighbors / Triangle Counting: The number of common neighbors of $u$ and $v$ is $\sum_{k=0}^{\lceil V/64 \rceil - 1} \text{popcount}(\text{row}[u][k] \,\&\, \text{row}[v][k])$, executing 64 vertex intersections per single
POPCNTinstruction. - Bitset BFS / Unvisited Neighbor Discovery: Maintaining a bitset
unvisitedof remaining vertices allows BFS from $u$ to find and clear all unvisited neighbors in $O(V / 64)$ viamask = row[u][k] & unvisited[k]and iterating set bits with__builtin_ctzll(mask), reducing dense BFS from $O(V^2)$ to $O(V^2 / 64)$.
A 64-byte CPU cache line holds sixteen 32-bit vertex IDs. When iterating over to[head[u] .. head[u+1]-1] in CSR, the Intel/AMD L1 data stream prefetcher detects sequential memory access within 2 cache lines and prefetches subsequent edges into L1D ($\approx 4$ cycles latency). In contrast, std::vector<std::vector<int>> scatters small heap allocations across disparate 4 KB virtual memory pages, thrashing the L1 dTLB (typically 64 entries) and stalling the out-of-order execution engine on $\approx 200$-cycle DRAM fetches.
Asymptotic & Hardware Complexity Matrix
| Representation | Space Overhead | hasEdge(u, v) |
Iterate neighbors(u) |
Dynamic addEdge(u, v) |
|---|---|---|---|---|
| CSR (Flat Arrays) | 4V + 4E bytes | O(log deg(u)) (sorted) | O(deg(u)) (L1 contiguous) | O(V + E) (rebuild) |
| Forward Star (Array Linked) | 4V + 8E bytes | O(deg(u)) | O(deg(u)) | O(1) worst-case |
| Vector of Vectors | 24V + ~8E bytes | O(deg(u)) | O(deg(u)) (pointer chase) | O(1) amortized |
| 64-Bit Bitset Matrix | V² / 8 bytes | O(1) bitwise test | O(V / 64 + deg(u)) | O(1) bitwise OR |
Production Implementation: CSR Graph & Bitset Intersection
#include <vector>
#include <cstdint>
#include <utility>
struct CSRGraph {
int n, m;
std::vector<int> head;
std::vector<int> to;
CSRGraph(int vertices, const std::vector<std::pair<int, int>>& edges)
: n(vertices), m(static_cast<int>(edges.size())), head(vertices + 1, 0), to(edges.size()) {
for (const auto& [u, v] : edges) {
++head[u + 1];
}
for (int i = 1; i <= n; ++i) {
head[i] += head[i - 1];
}
std::vector<int> cursor(head.begin(), head.end() - 1);
for (const auto& [u, v] : edges) {
to[cursor[u]++] = v;
}
}
struct NeighborRange {
const int* b;
const int* e;
const int* begin() const noexcept { return b; }
const int* end() const noexcept { return e; }
};
NeighborRange neighbors(int u) const noexcept {
return {to.data() + head[u], to.data() + head[u + 1]};
}
};
// Triangle counting on a 64-bit word-packed bitset adjacency matrix in O(V^3 / 64)
uint64_t countTrianglesBitset(int n, const std::vector<std::vector<uint64_t>>& adjBits) {
const int words = (n + 63) / 64;
uint64_t total = 0;
for (int u = 0; u < n; ++u) {
for (int v = u + 1; v < n; ++v) {
if ((adjBits[u][v >> 6] >> (v & 63)) & 1ULL) {
for (int w = 0; w < words; ++w) {
total += __builtin_popcountll(adjBits[u][w] & adjBits[v][w]);
}
}
}
}
return total / 3;
}
When encoding an undirected graph in CSR or Forward Star, every undirected edge ${u, v}$ must be inserted twice: $(u, v)$ and $(v, u)$, so $m_{\text{array}} = 2|E|$. However, note that CSR scatters edges sorted by source vertex $u$, which breaks the paired index trick e ^ 1 used in residual flow networks or bridge-finding unless paired edge indices are explicitly stored in a parallel array.