Loading learning content...
When we say an adjacency list uses O(V + E) space, we're making a profound statement about efficiency. This isn't just an abstract complexity class—it represents a fundamental alignment between the storage we use and the actual information content of the graph.
Unlike the adjacency matrix which reserves space for all possible edges, the adjacency list stores only what exists. This difference isn't merely linear—for sparse graphs, it can be the difference between gigabytes and petabytes, between feasibility and impossibility.
Let's rigorously understand where every byte goes and why this representation achieves optimal space efficiency for sparse graphs.
By the end of this page, you will understand exactly how O(V + E) space is distributed in an adjacency list, why this is asymptotically optimal for representing a graph, how constant factors affect real memory usage, and when this space profile makes adjacency lists the clear choice over matrices.
The O(V + E) notation combines two distinct components, each serving a specific purpose:
The O(V) Component: Vertex Infrastructure
For each vertex in the graph, we need:
This is O(V) because we have exactly one entry per vertex, regardless of how many edges exist.
123456789101112
Main Array (stores references to neighbor lists):┌───────┬───────┬───────┬───────┬───────┐│ ptr_0 │ ptr_1 │ ptr_2 │ ptr_3 │ ptr_4 │ ← 5 array slots (O(V))└───────┴───────┴───────┴───────┴───────┘ Each pointer references a dynamic array with its own overhead: - Array object header (language-dependent, typically 12-24 bytes) - Length field (4-8 bytes) - Capacity field (4-8 bytes in some implementations) - Pointer to actual data (8 bytes on 64-bit systems) Total vertex infrastructure: O(V) × constant per vertexThe O(E) Component: Edge Storage
For each edge in the graph, we need to store the endpoint(s):
Both cases are O(E), differing only by a constant factor of 2.
12345678910111213
Undirected Graph with 4 edges:Edges: (0,1), (0,2), (1,2), (2,3) Adjacency List:Vertex 0: [1, 2] ← 2 entriesVertex 1: [0, 2] ← 2 entriesVertex 2: [0, 1, 3] ← 3 entriesVertex 3: [2] ← 1 entry ────────────Total edge entries: 8 entries = 2 × |E| = 2 × 4 For weighted graphs, each entry is (neighbor, weight):Vertex 0: [(1, 5.0), (2, 3.0)] ← Still 2 entries, each largerTotal space = O(V) for vertex infrastructure + O(E) for edge entries = O(V + E). For sparse graphs where E << V², this is dramatically smaller than the O(V²) required by adjacency matrices.
The power of O(V + E) becomes apparent when we compare it to the O(V²) of adjacency matrices across different graph densities.
Graph Density Defined:
Density = |E| / |E_max| where |E_max| = V(V-1)/2 for undirected, V(V-1) for directed
| Vertices (V) | Edges (E) | Graph Type | Adj List O(V+E) | Matrix O(V²) | List Advantage |
|---|---|---|---|---|---|
| 1,000 | 999 | Tree | ~2K entries | 1M entries | 500× smaller |
| 1,000 | 5,000 | Sparse (5E/V) | ~6K entries | 1M entries | 167× smaller |
| 1,000 | 50,000 | Moderate (50E/V) | ~51K entries | 1M entries | 20× smaller |
| 1,000 | 499,500 | Complete | ~500K entries | 1M entries | 2× smaller |
| 1,000,000 | 2,000,000 | Sparse (2E/V) | ~3M entries | 1T entries | 333,333× smaller |
| 1,000,000 | 500B | Complete | ~500B entries | 1T entries | ~2× smaller |
The Critical Insight:
For sparse graphs (where |E| = O(V)), adjacency lists use O(V) space while matrices use O(V²). As V grows, the ratio becomes astronomical:
This is why real-world graph applications—social networks, web graphs, road networks—universally use adjacency lists or variations thereof.
Adjacency lists and matrices use roughly equal space when |E| ≈ V²/2. For denser graphs, matrices may actually be more space-efficient due to lower per-entry overhead. In practice, switch to matrices when density > 50% AND constant-time edge lookup is critical.
Big-O notation hides constant factors, but real systems have real memory limits. Let's examine the actual memory usage of adjacency list implementations:
Per-Vertex Overhead:
Each vertex's neighbor collection has associated overhead that varies by language and implementation:
| Language | Empty List/Vector Overhead | Notes |
|---|---|---|
| Python (list) | ~56 bytes | Object header + length + allocated capacity + pointer |
| Java (ArrayList) | ~48 bytes | Object header + size + modCount + array reference |
| C++ (vector) | ~24 bytes | 3 pointers: begin, end, capacity (on 64-bit) |
| Rust (Vec) | ~24 bytes | pointer + length + capacity |
| Go (slice) | ~24 bytes | pointer + length + capacity |
Per-Edge Memory:
Each neighbor entry costs memory based on the data stored:
123456789101112131415161718192021222324
Unweighted graph (store neighbor index only): - 32-bit integer index: 4 bytes per neighbor - 64-bit integer index: 8 bytes per neighbor Weighted graph (neighbor + weight): - (int32 neighbor, float32 weight): 8 bytes - (int64 neighbor, float64 weight): 16 bytes - Python tuple (neighbor, weight): ~64+ bytes (object overhead!) Edge with full metadata: - struct Edge { int dest; double weight; int capacity; int id; } - Typically 24-32 bytes per edge # Example: 1 million vertices, 10 million edges (sparse, 10 edges/vertex)# Using 32-bit indices for unweighted graph: Vertex overhead: 1,000,000 vertices × 24 bytes = 24 MBEdge storage: 10,000,000 edges × 2 × 4 bytes = 80 MB (×2 for undirected) ─────────────Total: ~104 MB # Adjacency matrix for same graph:1,000,000² × 1 bit = 125 GB (even with bit-packing!)1,000,000² × 1 byte = 1 TBIn Python, storing edges as tuple objects (neighbor, weight) can use 64+ bytes per edge due to object overhead. For memory-critical applications, use NumPy arrays or struct-based approaches. A graph with 100M edges could use 6.4 GB with tuples vs 800 MB with NumPy int32 arrays.
Space complexity isn't just about total memory—how that memory is organized affects performance through CPU cache behavior.
The Fragmentation Problem:
A naive adjacency list (array of separate arrays) scatters neighbor data across memory. When iterating through different vertices' neighbors, we suffer cache misses:
Main array: [ptr0, ptr1, ptr2, ptr3, ...]
↓ ↓ ↓
Memory: [neighbors0]....[neighbors1]....[neighbors2]
scattered across heap
Each vertex's neighbor traversal may start with a cache miss to load that vertex's neighbor array.
Compressed Sparse Row (CSR) Format:
For better cache performance, store all neighbors contiguously in a single array, with an index array marking where each vertex's neighbors begin:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
class CSRGraph: """ Compressed Sparse Row format for cache-efficient graph storage. All neighbors stored contiguously in one array. Index array marks where each vertex's neighbors start. Memory: Single allocation for all edges = better cache locality """ def __init__(self, num_vertices: int, edges: list[tuple[int, int]]): # First pass: count edges per vertex degrees = [0] * num_vertices for u, v in edges: degrees[u] += 1 degrees[v] += 1 # for undirected # Build index array: where does each vertex's neighbors start? # index[v] = starting position of vertex v's neighbors # index[V] = total number of neighbor entries (sentinel) self.index = [0] * (num_vertices + 1) for v in range(num_vertices): self.index[v + 1] = self.index[v] + degrees[v] # Build neighbors array with all neighbors contiguous self.neighbors = [0] * self.index[num_vertices] # Reset degrees to use as insertion pointers insert_pos = self.index[:-1].copy() for u, v in edges: self.neighbors[insert_pos[u]] = v insert_pos[u] += 1 self.neighbors[insert_pos[v]] = u # for undirected insert_pos[v] += 1 self.V = num_vertices def get_neighbors(self, v: int): """Return a view/slice of the neighbors array for vertex v.""" start = self.index[v] end = self.index[v + 1] return self.neighbors[start:end] def degree(self, v: int) -> int: """Degree can be computed from index array.""" return self.index[v + 1] - self.index[v] # Exampleedges = [(0,1), (0,2), (1,2), (2,3), (3,4)]g = CSRGraph(5, edges) print(f"Index array: {g.index}") # [0, 2, 4, 7, 9, 10]# Vertex 0 neighbors at positions 0-1, vertex 1 at 2-3, etc. print(f"Neighbors array: {g.neighbors}") # [1, 2, 0, 2, 0, 1, 3, 2, 4, 3]# All neighbors stored contiguously! print(f"Neighbors of vertex 2: {g.get_neighbors(2)}") # [0, 1, 3]| Format | Memory Allocations | Cache Behavior | Best For |
|---|---|---|---|
| Array of Arrays | V + 1 allocations | Cache misses per vertex | Dynamic graphs, frequent modifications |
| CSR (Compressed) | 2 allocations | Sequential, cache-friendly | Static graphs, repeated traversals |
| Array of Hash Sets | V + 1 allocations | Random access within sets | Frequent edge existence queries |
Let's formalize when each representation is more space-efficient, accounting for the storage size of different data types.
Adjacency List Space:
Adjacency Matrix Space:
Crossover Analysis:
List is better when: V × P + 2E × I < V² × C
Solving for E: E < (V² × C - V × P) / (2 × I)
12345678910111213141516171819202122232425262728293031
def crossover_density(V: int, pointer_size: int = 8, # bytes per vertex header index_size: int = 4, # bytes per neighbor index cell_size: float = 0.125 # bytes per matrix cell (1 bit = 0.125) ) -> float: """ Calculate the edge density at which list and matrix use equal space. Returns the density as a fraction of maximum possible edges. """ max_edges = V * (V - 1) // 2 # undirected complete graph # Space: V*P + 2*E*I = V² * C # Solving for E: E = (V² * C - V * P) / (2 * I) crossover_edges = (V**2 * cell_size - V * pointer_size) / (2 * index_size) if crossover_edges <= 0: return 0.0 # List always better for this configuration return crossover_edges / max_edges # Examples with different configurationsprint("Crossover density (list vs matrix equal space):")print(f" V=100, bit-matrix: {crossover_density(100):.2%}") # ~1.5%print(f" V=100, byte-matrix: {crossover_density(100, cell_size=1):.2%}") # ~12%print(f" V=1000, bit-matrix: {crossover_density(1000):.2%}") # ~0.15%print(f" V=1000, byte-matrix: {crossover_density(1000, cell_size=1):.2%}") # ~12.4% # Key insight: For bit-packed matrices, crossover is ~1/8 = 12.5% density# For byte matrices, crossover is ~50% density (accounting for overhead)# In practice, matrices win only for dense graphs or when weightedPractical Guidelines:
| Graph Density | Edges | Recommended Representation |
|---|---|---|
| < 1% | E < V²/100 | Adjacency List (no contest) |
| 1-10% | V² / 100 ≤ E < V² / 10 | Adjacency List |
| 10-50% | V² / 10 ≤ E < V² / 2 | Either; depends on access patterns |
| > 50% | E > V² / 2 | Consider Matrix |
Most real-world graphs fall in the < 1% category, which is why adjacency lists dominate in practice.
For weighted graphs, matrix cells need more space (4-8 bytes for weights), shifting the crossover toward lists. A weighted adjacency list stores (neighbor, weight) pairs—essentially the same information matrix cells would store—but only for edges that exist.
Real-world graphs often carry additional data beyond simple connectivity. Let's analyze space requirements for common variants:
Weighted Graphs:
Each edge entry grows to include weight data:
1234567891011121314151617181920212223242526272829
# Unweighted: neighbor index only# Space per edge entry: sizeof(int) = 4 bytes # Weighted with float weight:# Space per edge entry: sizeof(int) + sizeof(float) = 8 bytes # Weighted with metadata (distance, time, cost):# struct Edge { int dest; float distance; float time; float cost; }# Space per edge entry: 4 + 4 + 4 + 4 = 16 bytes # Total space for weighted undirected graph:# V × pointer_overhead + 2E × entry_size def weighted_graph_memory(V: int, E: int, vertex_overhead: int = 24, entry_bytes: int = 8) -> int: """Calculate memory in bytes for weighted adjacency list.""" vertex_memory = V * vertex_overhead edge_memory = 2 * E * entry_bytes # ×2 for undirected return vertex_memory + edge_memory # Example: Road network with 1M intersections, 2.5M road segmentsV, E = 1_000_000, 2_500_000mem = weighted_graph_memory(V, E, entry_bytes=16) # with distance+timeprint(f"Memory: {mem / 1e6:.1f} MB") # ~104 MB # Same as matrix?matrix_mem = V * V * 16 # 16 bytes per cell for weightsprint(f"Matrix would need: {matrix_mem / 1e12:.1f} TB") # 16 TB!Multigraphs (Multiple Edges Between Same Vertices):
Multigraphs allow multiple edges between the same pair of vertices (e.g., different flight routes between two cities). Adjacency lists naturally accommodate this—just add multiple entries:
123456789101112131415161718
# Multigraph: Multiple flights between same cities# NYC → LA: Flight 1 (morning, $200), Flight 2 (evening, $300) # Adjacency list naturally handles this:adj = { "NYC": [ ("LA", {"id": "F1", "time": "08:00", "price": 200}), ("LA", {"id": "F2", "time": "18:00", "price": 300}), ("CHI", {"id": "F3", "time": "10:00", "price": 100}), ], "LA": [ ("NYC", {"id": "F4", "time": "06:00", "price": 220}), ], # ...} # Space: O(V + E) where E counts ALL edges, including parallel ones# No special handling needed—parallel edges just add more entriesSelf-loops (edges from a vertex to itself) also store naturally in adjacency lists—vertex v's neighbor list simply includes v. The O(V + E) analysis remains unchanged; self-loops just count as edges. In a matrix, self-loops occupy diagonal cells.
We've thoroughly analyzed the O(V + E) space complexity of adjacency lists and understood when and why it matters. Let's consolidate the key insights:
What's Next:
Having understood space complexity, the next page analyzes time complexity for edge operations—specifically, why edge lookup in an adjacency list takes O(degree) time and what this means for algorithm design.
You now understand that O(V + E) space makes adjacency lists the go-to representation for sparse graphs. This proportional storage—using space only for actual edges—enables representation of graphs that would be impossible with O(V²) matrices.