Loading content...
We've explored the adjacency list representation in depth: its structure, O(V + E) space complexity, and O(degree) edge lookup time. Now comes the practical question every engineer faces: When should I use an adjacency list instead of an adjacency matrix?
This isn't a theoretical exercise—the choice profoundly affects memory usage, algorithm speed, and even whether your solution is feasible at all. A wrong choice might mean the difference between a solution that runs in milliseconds and one that crashes with out-of-memory errors, or between an elegant implementation and a convoluted workaround.
Let's synthesize everything we've learned into actionable decision criteria.
By the end of this page, you will be able to confidently choose between adjacency lists and matrices for any graph problem. You'll understand the key decision factors, recognize common scenarios favoring each representation, and know how to adapt when requirements change.
When choosing a graph representation, evaluate these factors in order of importance:
Factor 1: Graph Density
This is often the deciding factor. Density = |E| / |E_max| where |E_max| = V(V-1)/2 for undirected graphs.
Factor 2: Dominant Operations
What will your algorithm do most?
Factor 3: Memory Constraints
What can your system handle?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
def recommend_representation( num_vertices: int, num_edges: int, available_memory_mb: int, dominant_op: str # 'traversal', 'edge_query', 'modification') -> str: """ Recommend graph representation based on problem characteristics. Returns: 'adjacency_list', 'hash_set_list', or 'adjacency_matrix' """ max_edges = num_vertices * (num_vertices - 1) // 2 density = num_edges / max_edges if max_edges > 0 else 0 # Calculate memory requirements (rough estimates) list_memory_mb = (num_vertices * 24 + num_edges * 2 * 8) / 1e6 matrix_memory_mb = (num_vertices ** 2) / 1e6 # 1 byte per cell # Memory check first - if matrix doesn't fit, must use list if matrix_memory_mb > available_memory_mb: if dominant_op == 'edge_query': return 'hash_set_list' # O(1) lookup with list space return 'adjacency_list' # Both fit in memory - choose by density and operations if density > 0.5: # Dense graph - matrix likely better if dominant_op == 'traversal': # Even for dense graphs, list traversal is O(E) vs O(V²) return 'adjacency_list' if density < 0.7 else 'adjacency_matrix' return 'adjacency_matrix' # Sparse graph if dominant_op == 'edge_query': return 'hash_set_list' elif dominant_op == 'modification': return 'adjacency_list' # Easy to add/remove edges else: return 'adjacency_list' # Default for sparse + traversal # Examplesprint(recommend_representation(1000, 5000, 500, 'traversal'))# → 'adjacency_list' (sparse, traversal-heavy) print(recommend_representation(1000, 400000, 500, 'edge_query'))# → 'adjacency_matrix' (dense, query-heavy) print(recommend_representation(100000, 500000, 500, 'traversal'))# → 'adjacency_list' (matrix would need 10GB!) print(recommend_representation(1000, 50000, 500, 'edge_query'))# → 'hash_set_list' (moderate density, query-heavy)Let's examine specific scenarios where adjacency lists are the clear winner:
Scenario 1: Large Sparse Graphs
The canonical use case. When V is large and E is proportional to V (not V²), adjacency lists are the only viable option.
| Domain | Vertices (V) | Edges (E) | Avg Degree | Matrix Memory | List Memory |
|---|---|---|---|---|---|
| Facebook social | 3 billion | ~200 billion | ~130 | ~72 exabytes | ~1.6 TB |
| US road network | 24 million | ~58 million | ~4.8 | ~576 TB | ~700 MB |
| Wikipedia links | ~60 million | ~500 million | ~16 | ~3.6 PB | ~6 GB |
| Web graph (sample) | 100 million | ~1 billion | ~20 | ~10 PB | ~12 GB |
| Protein interactions | ~20,000 | ~200,000 | ~20 | ~400 MB | ~3 MB |
Scenario 2: Traversal-Heavy Algorithms
Algorithms that iterate through neighbors benefit from O(degree) iteration rather than O(V) scanning:
Scenario 3: Dynamic Graphs
When edges are frequently added or removed, adjacency lists handle modifications gracefully:
Matrices also support O(1) modification, but if the graph is already sparse, you're paying the O(V²) space penalty for nothing.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
class DynamicGraph: """ Graph supporting efficient edge modifications. Uses hash sets for O(1) edge operations while maintaining O(V + E) space. """ def __init__(self): self.adj = {} # vertex → set of neighbors def add_vertex(self, v): if v not in self.adj: self.adj[v] = set() def add_edge(self, u, v): """O(1) average time.""" self.add_vertex(u) self.add_vertex(v) self.adj[u].add(v) self.adj[v].add(u) def remove_edge(self, u, v): """O(1) average time (vs O(degree) with lists!).""" if u in self.adj: self.adj[u].discard(v) if v in self.adj: self.adj[v].discard(u) def remove_vertex(self, v): """O(degree(v)) time to clean up edges.""" if v not in self.adj: return # Remove v from all neighbors' lists for neighbor in self.adj[v]: self.adj[neighbor].discard(v) # Remove v's entry del self.adj[v] def contract_edge(self, u, v): """ Merge vertex v into u, removing edge (u,v). Common in graph algorithms like Karger's min-cut. """ if u not in self.adj or v not in self.adj: return # Move all v's edges to u for neighbor in self.adj[v]: if neighbor != u: self.adj[u].add(neighbor) self.adj[neighbor].discard(v) self.adj[neighbor].add(u) # Remove v del self.adj[v] self.adj[u].discard(v) # Dynamic graphs appear in:# - Social network updates (friend/unfriend)# - Network routing (link up/down)# - Incremental algorithms (streaming graphs)# - Game AI (navigable regions changing)To fully appreciate when lists are appropriate, we must understand when they're NOT. Here are scenarios where adjacency matrices excel:
Scenario 1: Dense Graphs
When most possible edges exist, the matrix wastes little space and provides O(1) lookups:
1234567891011121314151617181920212223242526
def create_complete_bipartite(m: int, n: int): """ Complete bipartite graph K_{m,n}: Every vertex in set A (size m) connects to every vertex in set B (size n). Edges: m × n Density: m × n / (m + n - 1)² → approaches 1 as m, n grow together Matrix makes sense here: - Space: (m + n)² vs (m + n) + 2(m × n) for list - Edge queries are frequent in bipartite matching algorithms """ V = m + n matrix = [[0] * V for _ in range(V)] for i in range(m): for j in range(m, V): matrix[i][j] = 1 matrix[j][i] = 1 return matrix # For K_{100,100}:# Matrix: 40,000 cells = 40 KB# List: 200 vertices + 20,000 edges = similar space# But matrix gives O(1) edge lookup!Scenario 2: Small Graphs
When V is small (say, < 1000), the O(V²) space is negligible and the constant-time operations are convenient:
For these sizes, programmer convenience often outweighs asymptotic concerns.
Scenario 3: Matrix-Based Algorithms
Some algorithms are naturally expressed in matrix form:
Floyd-Warshall finds all-pairs shortest paths in O(V³) time. For this algorithm, the matrix representation is natural and may even be faster than list-based alternatives due to cache-friendly sequential access patterns. If you're running Floyd-Warshall, use a matrix.
Sometimes neither pure lists nor pure matrices are optimal. Consider these hybrid approaches:
1. Hash Set Adjacency List (Already discussed)
Combines O(V + E) space with O(1) average edge lookup. The go-to when you need both.
2. Compressed Sparse Row (CSR)
Stores all neighbors in a single contiguous array with an index array marking boundaries. Better cache performance for static graphs:
Index array: [0, 2, 5, 7, 9, 10] ← where each vertex's neighbors start
Neighbors array: [1,4, 0,2,3, 1,3, 1,2,4, 3] ← all neighbors contiguous
3. Adjacency List + Matrix for Specific Subgraph
For graphs with a small dense core and large sparse periphery, use a matrix for the core and lists for the rest.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
class HybridPowerLawGraph: """ Specialized for power-law graphs (social networks, web graphs). Observation: Few 'hub' vertices have very high degree. Strategy: Use different representations for hubs vs regular vertices. Hubs: Store neighbors as sorted array for binary search O(log d) Regular: Store neighbors as unsorted list (small d ≈ O(1)) """ def __init__(self, num_vertices: int, hub_threshold: int = 1000): self.V = num_vertices self.hub_threshold = hub_threshold self.regular = [[] for _ in range(num_vertices)] # Unsorted self.hubs = {} # vertex → sorted list of neighbors self.is_hub = [False] * num_vertices def _promote_to_hub(self, v: int): """Convert regular vertex to hub representation.""" if not self.is_hub[v]: self.hubs[v] = sorted(self.regular[v]) self.regular[v] = None # Free memory self.is_hub[v] = True def add_edge(self, u: int, v: int): """Add edge, promoting to hub if threshold exceeded.""" for x, y in [(u, v), (v, u)]: if self.is_hub[x]: # Insert into sorted array import bisect bisect.insort(self.hubs[x], y) else: self.regular[x].append(y) if len(self.regular[x]) > self.hub_threshold: self._promote_to_hub(x) def has_edge(self, u: int, v: int) -> bool: """O(log d) for hubs, O(d) for regular.""" if self.is_hub[u]: import bisect neighbors = self.hubs[u] pos = bisect.bisect_left(neighbors, v) return pos < len(neighbors) and neighbors[pos] == v else: return v in self.regular[u] def neighbors(self, v: int): """Get neighbors of v.""" if self.is_hub[v]: return self.hubs[v] return self.regular[v] # This hybrid approach gives:# - O(log d) lookup for high-degree hubs (instead of O(d))# - O(d) lookup for regular vertices (where d is small)# - Same asymptotic space as regular adjacency listHybrid representations add complexity. Use them only when: (1) Profiling shows clear bottlenecks in standard representations, (2) The graph structure is known to be pathological (e.g., extreme power-law), or (3) You're building production infrastructure where micro-optimizations matter.
Here's a flowchart-style decision process you can follow for any graph problem:
Step 1: Check Memory Feasibility
Calculate V². If V² × cell_size exceeds available memory, use adjacency list. End of discussion.
Step 2: Assess Graph Density
Step 3: Identify Dominant Operations
| Graph Size | Density | Primary Operation | Recommendation |
|---|---|---|---|
| Small (V < 500) | Any | Any | Either works; prefer lists |
| Medium (V < 5000) | < 10% | Traversal | Adjacency List |
| Medium (V < 5000) | < 10% | Edge queries | Hash Set List |
| Medium (V < 5000) | 50% | Edge queries | Adjacency Matrix |
| Large (V > 10000) | < 1% | Any | Adjacency List (only option) |
| Large (V > 10000) | High | N/A | Matrix won't fit; use list |
| Any | Any | Floyd-Warshall | Adjacency Matrix |
| Any | Any | BFS/DFS/Dijkstra | Adjacency List |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
def choose_graph_representation( V: int, E: int, available_memory_bytes: int, operations: list[str] # e.g., ['bfs', 'edge_query', 'add_edge']) -> dict: """ Comprehensive graph representation recommendation. Returns a dict with: - 'recommendation': str - 'reasoning': list of factors that influenced the decision - 'alternatives': other viable options """ result = { 'recommendation': None, 'reasoning': [], 'alternatives': [] } # Calculate metrics max_edges = V * (V - 1) // 2 density = E / max_edges if max_edges > 0 else 0 matrix_size = V * V # cells (at least 1 byte each) list_size = V * 8 + E * 2 * 8 # rough estimate in bytes # Step 1: Memory check if matrix_size > available_memory_bytes: result['recommendation'] = 'adjacency_list' result['reasoning'].append( f'Matrix would require {matrix_size/1e9:.1f}GB, exceeds available memory' ) # Refine list type based on operations if 'edge_query' in operations: result['recommendation'] = 'hash_set_list' result['reasoning'].append('Edge queries benefit from O(1) hash set lookup') return result result['reasoning'].append(f'Both representations fit in memory') # Step 2: Density analysis if density < 0.10: result['recommendation'] = 'adjacency_list' result['reasoning'].append(f'Graph is sparse ({density:.1%} density)') elif density > 0.50: result['recommendation'] = 'adjacency_matrix' result['reasoning'].append(f'Graph is dense ({density:.1%} density)') else: result['reasoning'].append( f'Medium density ({density:.1%}), considering operations' ) # Step 3: Operation-based refinement op_scores = {'list': 0, 'matrix': 0, 'hash_set': 0} for op in operations: if op in ['bfs', 'dfs', 'dijkstra', 'bellman_ford', 'topological_sort']: op_scores['list'] += 2 elif op in ['edge_query', 'has_edge']: op_scores['matrix'] += 1 op_scores['hash_set'] += 2 elif op in ['add_edge', 'remove_edge', 'dynamic']: op_scores['list'] += 1 op_scores['hash_set'] += 1 elif op in ['floyd_warshall', 'matrix_multiply', 'spectral']: op_scores['matrix'] += 3 # Final decision best = max(op_scores, key=op_scores.get) if best == 'hash_set': result['recommendation'] = 'hash_set_list' elif best == 'matrix': result['recommendation'] = 'adjacency_matrix' else: result['recommendation'] = 'adjacency_list' result['reasoning'].append(f'Operations favor {best} (score: {op_scores[best]})') # Document alternatives for rep, score in sorted(op_scores.items(), key=lambda x: -x[1]): if rep != best: result['alternatives'].append(f'{rep} (score: {score})') return result # Example usagerec = choose_graph_representation( V=10000, E=50000, available_memory_bytes=100_000_000, operations=['bfs', 'dfs', 'edge_query'])print(f"Recommendation: {rec['recommendation']}")for reason in rec['reasoning']: print(f" - {reason}")In competitive programming, constraints often dictate the representation choice. Let's see how to read contest constraints:
Typical Constraint Patterns:
| Constraint | Interpretation | Representation |
|---|---|---|
| V ≤ 1000, E ≤ 10⁵ | Sparse | Adjacency List |
| V ≤ 500, E ≤ V² | Possibly dense | Either works |
| V ≤ 2000, all-pairs query | Floyd-Warshall | Matrix |
| V ≤ 10⁵, BFS/DFS | Standard traversal | Adjacency List |
| V ≤ 10⁶, Dijkstra | Large sparse | Adjacency List |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
#include <bits/stdc++.h>using namespace std; // Standard adjacency list (unweighted)vector<int> adj[100005]; // Static array of vectors // Weighted adjacency listvector<pair<int, int>> wAdj[100005]; // (neighbor, weight) // Reading graph from inputvoid readGraph(int n, int m, bool weighted = false, bool directed = false) { for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; if (weighted) { int w; cin >> w; wAdj[u].push_back({v, w}); if (!directed) wAdj[v].push_back({u, w}); } else { adj[u].push_back(v); if (!directed) adj[v].push_back(u); } }} // BFS with adjacency listvector<int> bfs(int start, int n) { vector<int> dist(n + 1, -1); queue<int> q; q.push(start); dist[start] = 0; while (!q.empty()) { int u = q.front(); q.pop(); for (int v : adj[u]) { // O(degree) iteration if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); } } } return dist;} // Dijkstra with adjacency listvector<long long> dijkstra(int start, int n) { vector<long long> dist(n + 1, LLONG_MAX); priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<pair<long long, int>>> pq; dist[start] = 0; pq.push({0, start}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d > dist[u]) continue; for (auto [v, w] : wAdj[u]) { // O(degree) iteration if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } } return dist;}In contests, default to adjacency lists unless: (1) The problem explicitly requires O(1) edge lookup (rare), (2) V ≤ 500 and you're doing all-pairs operations, or (3) The problem involves matrix operations. Lists work for 95%+ of graph problems.
We've completed our deep dive into the adjacency list representation. Let's consolidate everything into a final set of principles:
Module Complete:
You've now mastered the adjacency list representation:
The next module compares both representations head-to-head, providing a comprehensive analysis of their trade-offs for different scenarios.
You now have complete mastery of the adjacency list representation. You understand its structure, space and time characteristics, and can confidently choose between lists and matrices for any graph problem. This knowledge is foundational for all graph algorithms you'll encounter.