DSA DSA In Depth
0%
lesson graphscsrcache-locality

Graph Representations: Adjacency Lists, Bit Matrices & Compressed Sparse Row (CSR)

7 min read Module 5.1: Graph Representations, Traversals, Connectivity & Topological Order Updated Oct 23, 2026
📄 Cheat Sheet
Compressed Sparse Row (CSR) Memory Layout for High-Performance Graph & Matrix Kernels
Dayne Guy Compressed Sparse Row (CSR) Memory Layout for High-Performance Graph & Matrix Kernels Watch on YouTube ↗

Erik Demaine and Jason Ku establish the foundational memory models for sparse and dense graphs before contrasting pointer-heavy adjacency lists with flat array encodings.

0:00 / --:--

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.

Formal Invariant — Compressed Sparse Row (CSR) Slice Equivalence

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:

  1. 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)$.
  2. 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$.
CSR Flat Memory Layout (V = 4, E = 6) head[0..4] (Offsets) 0 u=0 2 u=1 4 u=2 5 u=3 6 end to[0..5] (Targets) 1 2 2 3 0 3 0 Single 64-byte cache line holds 16 consecutive 32-bit target vertex IDs
flowchart LR A["Raw Edge List (u, v)"] --> B["Count Degrees: head[u+1]++"] B --> C["Prefix Sum: head[i] += head[i-1]"] C --> D["Scatter Edges into Contiguous to[0..E-1]"]
CSR (offsets/edges) Memory Layout & Hierholzer Eulerian Circuit Visualizer
O(V + E) Contiguous CSR · O(E) Hierholzer

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.

Step 1 / 11
Custom Input:
Timeline

Invariant & State

CSR Slice Invariant: N_out(u0) = edges[offsets[0] .. offsets[1]) = edges[0..3) (3 contiguous neighbors).
e[0] e[1] e[2] e[3] e[4] e[5] e[6] e[7] u0 [0..3) u1 [3..5) u2 [5..6) u3 [6..8) u4 [8..8)
offsets[0..5] (V+1 = 6 entries; slice for u0 is [offsets[0]=0, offsets[1]=3)):
[0]0start=0
[1]3end=3
[2]5
[3]6
[4]8
[5]8
edges[0..7] (Contiguous E = 8 target IDs in single cache-line allocation):
[0]u1u0→u1
[1]u2u0→u2
[2]u3u0→u3
[3]u2
[4]u4
[5]u4
[6]u1
[7]u4
Inspected Vertex
u0
CSR Slice [L, R)
[0, 3)
Out-Degree
offsets[1] - offsets[0] = 3
Cache Locality
Stride-1 (0 heap ptrs)
[Step 1] Inspecting u0: offsets[0]=0, offsets[1]=3 -> neighbors [u1, u2, u3].

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 POPCNT instruction.
  • Bitset BFS / Unvisited Neighbor Discovery: Maintaining a bitset unvisited of remaining vertices allows BFS from $u$ to find and clear all unvisited neighbors in $O(V / 64)$ via mask = 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)$.
Hardware Reality — L1D Prefetcher & TLB Page Walks

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;
}
Failure Mode — Undirected Edge Doubling & Reverse-Edge Indexing

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.

Check yourself Question 1 of 4
Concept Check CSR Out-Degree Computation recall
Section: CSR Memory Layout

In a Compressed Sparse Row (CSR) representation of a directed graph with $V$ vertices and $E$ edges using arrays `head[0..V]` and `to[0..E-1]`, how is the out-degree of vertex $u$ ($0 \le u < V$) computed in $O(1)$ time?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

In a BFS benchmark on a sparse graph with $V = 2 \times 10^6$ and $E = 8 \times 10^6$, CSR runs $6.8\times$ faster than `std::vector>`. Which hardware mechanism explains the largest share of this speedup?

Key Takeaways

Compressed Sparse Row (CSR) stores all outgoing edges in a single contiguous array to[0..E-1] indexed by prefix-sum offsets head[0..V], eliminating per-vertex heap allocations and pointer chasing.
Constructing a CSR graph from an unsorted edge list takes O(V + E) time and O(V + E) auxiliary space via counting sort (degree histogram + prefix sum + scatter).
For mutable sparse graphs, the array-backed Forward Star (head[V], to[E], next[E]) supports O(1) worst-case edge insertion without std::vector reallocation spikes.
Word-packed 64-bit bitset adjacency matrices compress dense graphs to V^2/8 bytes and accelerate neighbor intersection and dense BFS by a factor of w = 64 using hardware POPCNT and TZCNT instructions.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue