CrackedRuby CrackedRuby

Overview

Graph representations define how graph topology is stored in memory. A graph consists of vertices (nodes) and edges (connections between vertices). The choice of representation affects memory consumption, operation speed, and implementation complexity for graph algorithms.

Three primary representations dominate software development: adjacency matrices store connections in a two-dimensional array, adjacency lists maintain a collection of neighbors for each vertex, and edge lists enumerate all connections as pairs. Each representation optimizes different operations at the cost of others.

# Adjacency list representation
graph = {
  'A' => ['B', 'C'],
  'B' => ['A', 'D'],
  'C' => ['A', 'D'],
  'D' => ['B', 'C']
}

# Adjacency matrix representation
matrix = [
  [0, 1, 1, 0],  # A connects to B, C
  [1, 0, 0, 1],  # B connects to A, D
  [1, 0, 0, 1],  # C connects to A, D
  [0, 1, 1, 0]   # D connects to B, C
]

Graph representations serve as the foundation for implementing shortest path algorithms, network flow, social network analysis, dependency resolution, and route planning. The representation choice impacts both the asymptotic complexity and constant factors in algorithm performance.

Directed graphs require asymmetric edge storage where an edge from vertex u to vertex v does not imply an edge from v to u. Undirected graphs store bidirectional connections. Weighted graphs associate numerical values with edges, requiring additional storage beyond simple connectivity information.

Key Principles

Graph representations encode two fundamental pieces of information: vertex existence and edge connectivity. The vertex set V contains all nodes in the graph. The edge set E contains all connections, where each edge links two vertices. A representation must support querying both vertex properties and edge relationships.

Adjacency Matrix uses a |V| × |V| two-dimensional array where entry (i, j) indicates whether an edge exists from vertex i to vertex j. For unweighted graphs, entries contain boolean values or 0/1 integers. For weighted graphs, entries store edge weights with a sentinel value (often infinity or nil) representing absent edges.

# Weighted adjacency matrix
INFINITY = Float::INFINITY
matrix = [
  [0,      5,      INFINITY, 10],
  [INFINITY, 0,    3,        INFINITY],
  [INFINITY, INFINITY, 0,    1],
  [INFINITY, INFINITY, INFINITY, 0]
]
# matrix[0][1] = 5 means edge from vertex 0 to vertex 1 with weight 5

The matrix representation provides O(1) edge existence queries by direct array indexing. Adding or removing edges requires O(1) time. The space complexity remains Θ(|V|²) regardless of edge density, making matrices inefficient for sparse graphs but acceptable for dense graphs where |E| approaches |V|².

Adjacency List maintains a collection of neighbors for each vertex. Common implementations use hash tables mapping vertices to arrays, sets, or linked lists of adjacent vertices. This representation scales storage with the number of edges rather than vertices.

# Adjacency list with arrays
adj_list = {
  0 => [1, 3],
  1 => [2],
  2 => [3],
  3 => []
}

# With weights as tuples
weighted_adj_list = {
  0 => [[1, 5], [3, 10]],
  1 => [[2, 3]],
  2 => [[3, 1]],
  3 => []
}

Adjacency lists consume Θ(|V| + |E|) space, optimal for sparse graphs. Edge existence queries require O(degree(v)) time where degree(v) represents the number of edges from vertex v. Iterating over all neighbors of a vertex takes O(degree(v)) time, proportional to actual neighbor count rather than total vertex count.

Edge List stores edges as a flat collection of vertex pairs. This representation works well for algorithms that process edges sequentially rather than vertex-by-vertex.

edges = [
  [0, 1],
  [0, 3],
  [1, 2],
  [2, 3]
]

# Weighted edges
weighted_edges = [
  [0, 1, 5],
  [0, 3, 10],
  [1, 2, 3],
  [2, 3, 1]
]

Edge lists use Θ(|E|) space, minimal among all representations. Edge existence queries require O(|E|) time as they necessitate scanning the entire list. This representation suits algorithms like Kruskal's minimum spanning tree that sort or iterate through all edges.

Incidence Matrix uses a |V| × |E| array where rows represent vertices and columns represent edges. Entry (v, e) indicates whether vertex v is incident to edge e. For directed graphs, values distinguish between source and target vertices.

The incidence matrix representation appears less frequently in practice due to its space complexity and limited performance benefits. It finds applications in theoretical graph analysis and specific mathematical computations involving graph Laplacians.

Implementation Approaches

Graph representation selection depends on graph density, expected operations, and memory constraints. Sparse graphs with |E| << |V|² benefit from adjacency lists. Dense graphs with |E| ≈ |V|² show less difference between representations. Directed and undirected graphs share the same representation strategies with different edge storage semantics.

Adjacency Matrix Strategy suits dense graphs or scenarios requiring frequent edge existence queries. The matrix provides constant-time access to any edge but wastes space on absent edges in sparse graphs. Matrix implementations align well with linear algebra operations on graphs.

The matrix approach stores vertices implicitly through array indices. Adding a vertex requires allocating a new matrix with dimension (|V| + 1) × (|V| + 1) and copying existing data, an O(|V|²) operation. This makes adjacency matrices inflexible for dynamic graphs with frequent vertex additions.

For undirected graphs, the adjacency matrix becomes symmetric with entry (i, j) equal to entry (j, i). Storing only the upper or lower triangle reduces space consumption by half but complicates access patterns.

Adjacency List Strategy excels for sparse graphs and algorithms that iterate through neighbors. The list representation stores only existing edges, minimizing memory usage. Hash table implementations provide O(1) average-case vertex lookup.

# Using Set for constant-time neighbor checks
require 'set'

adj_list = Hash.new { |h, k| h[k] = Set.new }
adj_list[0].add(1)
adj_list[0].add(3)
adj_list[1].add(2)

# Check edge existence
adj_list[0].include?(1)  # => true

The adjacency list grows dynamically as edges are added. Vertex addition requires O(1) time to insert a new empty neighbor collection. Edge removal requires searching the neighbor collection, taking O(degree(v)) time for array-based lists or O(1) average-case time for set-based lists.

Adjacency lists handle both directed and undirected graphs naturally. For undirected graphs, each edge appears in two adjacency lists. For directed graphs, each edge appears only in the source vertex's list. Storing reverse edges in a separate structure accelerates algorithms that traverse graphs backward.

Edge List Strategy provides a minimal representation suitable for specific algorithms. Edge lists work well when processing all edges uniformly without focusing on particular vertices. The representation supports efficient sorting by weight or other edge attributes.

Edge list implementations use arrays, linked lists, or priority queues depending on access patterns. Adding edges takes O(1) time by appending to the list. Removing specific edges requires O(|E|) time to locate and delete entries.

Hybrid Strategies combine multiple representations to optimize different operations. Storing both an adjacency list and an edge list provides fast neighbor iteration and efficient edge enumeration. The dual representation doubles space consumption but eliminates conversion overhead when both access patterns occur.

# Hybrid representation
class Graph
  attr_reader :adj_list, :edges
  
  def initialize
    @adj_list = Hash.new { |h, k| h[k] = [] }
    @edges = []
  end
  
  def add_edge(u, v, weight = 1)
    @adj_list[u] << [v, weight]
    @edges << [u, v, weight]
  end
end

Ruby Implementation

Ruby provides flexible data structures for implementing graph representations through hashes, arrays, and sets. Hash-based adjacency lists offer the most idiomatic approach, combining dynamic growth with fast lookups.

Basic Adjacency List

class Graph
  def initialize(directed: false)
    @adj_list = Hash.new { |h, k| h[k] = [] }
    @directed = directed
  end
  
  def add_vertex(vertex)
    @adj_list[vertex] ||= []
  end
  
  def add_edge(from, to, weight: 1)
    @adj_list[from] << { vertex: to, weight: weight }
    @adj_list[to] << { vertex: from, weight: weight } unless @directed
  end
  
  def neighbors(vertex)
    @adj_list[vertex].map { |edge| edge[:vertex] }
  end
  
  def edge_weight(from, to)
    edge = @adj_list[from].find { |e| e[:vertex] == to }
    edge ? edge[:weight] : nil
  end
  
  def vertices
    @adj_list.keys
  end
  
  def edges
    result = []
    @adj_list.each do |from, neighbors|
      neighbors.each do |edge|
        result << [from, edge[:vertex], edge[:weight]]
      end
    end
    @directed ? result : result.uniq { |u, v, w| [u, v].sort }
  end
end

graph = Graph.new(directed: true)
graph.add_edge('A', 'B', weight: 5)
graph.add_edge('A', 'C', weight: 3)
graph.add_edge('B', 'C', weight: 2)

graph.neighbors('A')  # => ['B', 'C']
graph.edge_weight('A', 'B')  # => 5

The implementation uses hashes with default block initialization to create empty arrays for new vertices automatically. Edge data structures use hashes to store target vertices and weights together, providing clear attribute access.

Adjacency Matrix Implementation

class MatrixGraph
  def initialize(size)
    @matrix = Array.new(size) { Array.new(size, nil) }
    @vertex_map = {}
    @next_index = 0
  end
  
  def add_vertex(vertex)
    return if @vertex_map.key?(vertex)
    
    if @next_index >= @matrix.size
      expand_matrix
    end
    
    @vertex_map[vertex] = @next_index
    @next_index += 1
  end
  
  def add_edge(from, to, weight: 1)
    add_vertex(from)
    add_vertex(to)
    
    from_idx = @vertex_map[from]
    to_idx = @vertex_map[to]
    @matrix[from_idx][to_idx] = weight
  end
  
  def edge_weight(from, to)
    from_idx = @vertex_map[from]
    to_idx = @vertex_map[to]
    return nil unless from_idx && to_idx
    
    @matrix[from_idx][to_idx]
  end
  
  def neighbors(vertex)
    idx = @vertex_map[vertex]
    return [] unless idx
    
    neighbors = []
    @matrix[idx].each_with_index do |weight, to_idx|
      neighbors << vertex_at(to_idx) if weight
    end
    neighbors
  end
  
  private
  
  def vertex_at(index)
    @vertex_map.key(index)
  end
  
  def expand_matrix
    new_size = @matrix.size * 2
    new_matrix = Array.new(new_size) { Array.new(new_size, nil) }
    
    @matrix.each_with_index do |row, i|
      row.each_with_index do |val, j|
        new_matrix[i][j] = val
      end
    end
    
    @matrix = new_matrix
  end
end

graph = MatrixGraph.new(10)
graph.add_edge('A', 'B', weight: 5)
graph.add_edge('A', 'C', weight: 3)

graph.edge_weight('A', 'B')  # => 5
graph.neighbors('A')  # => ['B', 'C']

The matrix implementation maintains a separate vertex-to-index mapping since Ruby does not enforce integer vertex labels. The expand_matrix method handles dynamic growth, though this operation remains expensive at O(|V|²).

Optimized Adjacency List with Sets

require 'set'

class FastGraph
  def initialize(directed: false)
    @adj_list = Hash.new { |h, k| h[k] = Set.new }
    @weights = {}
    @directed = directed
  end
  
  def add_edge(from, to, weight: 1)
    @adj_list[from].add(to)
    @weights[[from, to]] = weight
    
    unless @directed
      @adj_list[to].add(from)
      @weights[[to, from]] = weight
    end
  end
  
  def remove_edge(from, to)
    @adj_list[from].delete(to)
    @weights.delete([from, to])
    
    unless @directed
      @adj_list[to].delete(from)
      @weights.delete([to, from])
    end
  end
  
  def edge?(from, to)
    @adj_list[from].include?(to)
  end
  
  def edge_weight(from, to)
    @weights[[from, to]]
  end
  
  def degree(vertex)
    @adj_list[vertex].size
  end
  
  def neighbors(vertex)
    @adj_list[vertex].to_a
  end
end

graph = FastGraph.new(directed: true)
graph.add_edge('A', 'B', weight: 5)
graph.add_edge('A', 'C', weight: 3)

graph.edge?('A', 'B')  # => true
graph.degree('A')  # => 2
graph.remove_edge('A', 'B')
graph.edge?('A', 'B')  # => false

Using Set for neighbor storage reduces edge existence queries from O(degree(v)) to O(1) average case. The separate weights hash stores edge weights indexed by [from, to] tuples, decoupling connectivity from weight information.

Edge List Implementation

class EdgeListGraph
  Edge = Struct.new(:from, :to, :weight)
  
  def initialize(directed: false)
    @edges = []
    @vertices = Set.new
    @directed = directed
  end
  
  def add_vertex(vertex)
    @vertices.add(vertex)
  end
  
  def add_edge(from, to, weight: 1)
    @vertices.add(from)
    @vertices.add(to)
    @edges << Edge.new(from, to, weight)
  end
  
  def edges
    @edges
  end
  
  def vertices
    @vertices.to_a
  end
  
  def edges_from(vertex)
    @edges.select { |e| e.from == vertex }
  end
  
  def sort_by_weight!
    @edges.sort_by!(&:weight)
  end
  
  def edge_count
    @directed ? @edges.size : @edges.size / 2
  end
end

graph = EdgeListGraph.new(directed: true)
graph.add_edge('A', 'B', weight: 10)
graph.add_edge('B', 'C', weight: 5)
graph.add_edge('A', 'C', weight: 15)

graph.sort_by_weight!
graph.edges.first  # => #<struct Edge from="B", to="C", weight=5>

The edge list uses a Struct for type-safe edge representation. This implementation suits algorithms that iterate through all edges or require edges sorted by weight. Filtering edges by source vertex takes O(|E|) time but remains acceptable when this operation occurs infrequently.

Performance Considerations

Space and time complexity vary significantly across graph representations. The choice impacts algorithm efficiency, especially for graphs with millions of vertices or edges.

Space Complexity Analysis

Adjacency matrices consume Θ(|V|²) space regardless of edge count. A graph with 10,000 vertices requires 100 million entries, typically 400MB for 32-bit integers. This becomes prohibitive for sparse graphs where most entries remain empty.

Adjacency lists require Θ(|V| + |E|) space. Each vertex stores a collection reference, and each edge appears once (directed) or twice (undirected). For a sparse graph with 10,000 vertices and 50,000 edges, an adjacency list consumes approximately 10,000 + 100,000 = 110,000 entries, about 440KB with 32-bit integers and pointers.

Edge lists use Θ(|E|) space, storing only edge information. This provides the minimal space requirement but lacks vertex metadata storage. For 50,000 edges with weights, the representation needs roughly 600KB (12 bytes per edge).

Operation Time Complexity

Operation Adjacency Matrix Adjacency List Edge List
Add vertex O(V²) O(1) O(1)
Add edge O(1) O(1) O(1)
Remove edge O(1) O(degree) O(E)
Edge existence O(1) O(degree) O(E)
Find neighbors O(V) O(degree) O(E)
Space O(V²) O(V + E) O(E)

The degree term in adjacency list operations represents the number of edges from a vertex. For sparse graphs, degree << |V|, making adjacency list operations much faster than matrix operations that must scan entire rows.

Practical Performance Characteristics

Ruby's hash implementation provides O(1) average-case lookup through open addressing. Set operations inherit this performance. For adjacency lists using hashes and sets, edge existence queries complete in microseconds even for vertices with thousands of neighbors.

require 'benchmark'
require 'set'

# Compare edge existence query performance
def benchmark_edge_query(vertex_count, edge_count)
  # Build adjacency list
  adj_list = Hash.new { |h, k| h[k] = Set.new }
  edge_count.times do
    u = rand(vertex_count)
    v = rand(vertex_count)
    adj_list[u].add(v)
  end
  
  # Build adjacency matrix
  matrix = Array.new(vertex_count) { Array.new(vertex_count, false) }
  adj_list.each do |u, neighbors|
    neighbors.each { |v| matrix[u][v] = true }
  end
  
  # Benchmark queries
  queries = 10000
  Benchmark.bm(15) do |x|
    x.report("adj_list:") do
      queries.times do
        u = rand(vertex_count)
        v = rand(vertex_count)
        adj_list[u].include?(v)
      end
    end
    
    x.report("matrix:") do
      queries.times do
        u = rand(vertex_count)
        v = rand(vertex_count)
        matrix[u][v]
      end
    end
  end
end

benchmark_edge_query(1000, 5000)
# adj_list:        0.003s
# matrix:          0.002s

For edge existence queries, matrices slightly outperform adjacency lists due to direct array indexing versus hash lookup. The difference remains small in absolute terms but becomes relevant in tight loops processing millions of queries.

Neighbor iteration shows dramatic differences. Adjacency lists iterate only over actual neighbors in O(degree) time. Matrices must scan entire rows, taking O(|V|) time regardless of actual neighbor count.

# Neighbor iteration comparison
def benchmark_neighbor_iteration(vertex_count, avg_degree)
  edge_count = vertex_count * avg_degree
  
  adj_list = Hash.new { |h, k| h[k] = [] }
  edge_count.times do
    u = rand(vertex_count)
    v = rand(vertex_count)
    adj_list[u] << v
  end
  
  matrix = Array.new(vertex_count) { Array.new(vertex_count, false) }
  adj_list.each do |u, neighbors|
    neighbors.each { |v| matrix[u][v] = true }
  end
  
  iterations = 1000
  Benchmark.bm(15) do |x|
    x.report("adj_list:") do
      iterations.times do
        vertex = rand(vertex_count)
        adj_list[vertex].each { |n| n }
      end
    end
    
    x.report("matrix:") do
      iterations.times do
        vertex = rand(vertex_count)
        matrix[vertex].each_with_index { |exists, n| n if exists }
      end
    end
  end
end

benchmark_neighbor_iteration(1000, 10)
# avg_degree = 10, sparse graph
# adj_list:        0.001s
# matrix:          0.156s

For sparse graphs, adjacency list neighbor iteration runs 100x faster than matrix scanning. This performance gap drives the preference for adjacency lists in graph algorithm implementations.

Cache Locality Considerations

Adjacency matrices benefit from sequential memory access patterns. Modern CPUs prefetch array elements, making matrix row scans cache-efficient. Adjacency lists scatter edge data across memory through pointer-based structures, causing more cache misses.

For dense graphs processed with full matrix traversal, the cache advantage can offset adjacency list algorithmic benefits. Graph algorithms that repeatedly scan all edges from all vertices may run faster with matrix representations despite higher asymptotic complexity.

Practical Examples

Social Network Graph

Social networks represent users as vertices and friendships as edges. The graph remains sparse since each user connects to a small fraction of total users.

class SocialNetwork
  def initialize
    @friendships = Hash.new { |h, k| h[k] = Set.new }
    @users = {}
  end
  
  def add_user(id, name)
    @users[id] = name
    @friendships[id] ||= Set.new
  end
  
  def add_friendship(user1, user2)
    @friendships[user1].add(user2)
    @friendships[user2].add(user1)
  end
  
  def friends(user_id)
    @friendships[user_id].map { |id| @users[id] }
  end
  
  def mutual_friends(user1, user2)
    common = @friendships[user1] & @friendships[user2]
    common.map { |id| @users[id] }
  end
  
  def friend_of_friend_count(user_id)
    fof = Set.new
    @friendships[user_id].each do |friend|
      @friendships[friend].each do |fof_candidate|
        fof.add(fof_candidate) unless fof_candidate == user_id
      end
    end
    fof.size - @friendships[user_id].size
  end
end

network = SocialNetwork.new
network.add_user(1, "Alice")
network.add_user(2, "Bob")
network.add_user(3, "Carol")
network.add_user(4, "Dave")

network.add_friendship(1, 2)
network.add_friendship(1, 3)
network.add_friendship(2, 3)
network.add_friendship(3, 4)

network.friends(1)  # => ["Bob", "Carol"]
network.mutual_friends(1, 2)  # => ["Carol"]
network.friend_of_friend_count(1)  # => 1 (Dave)

The social network uses an adjacency list with Set for efficient friendship queries. Set intersection provides fast mutual friend computation. The friend-of-friend calculation demonstrates graph traversal patterns common in social network analysis.

Dependency Graph

Package managers and build systems represent dependencies as directed acyclic graphs (DAGs). Each package depends on other packages, forming edges from dependent to dependency.

class DependencyGraph
  def initialize
    @dependencies = Hash.new { |h, k| h[k] = Set.new }
    @reverse_deps = Hash.new { |h, k| h[k] = Set.new }
  end
  
  def add_dependency(package, depends_on)
    @dependencies[package].add(depends_on)
    @reverse_deps[depends_on].add(package)
  end
  
  def direct_dependencies(package)
    @dependencies[package].to_a
  end
  
  def dependents(package)
    @reverse_deps[package].to_a
  end
  
  def topological_sort
    in_degree = Hash.new(0)
    @dependencies.each do |pkg, deps|
      in_degree[pkg] ||= 0
      deps.each { |dep| in_degree[dep] += 1 }
    end
    
    queue = in_degree.select { |_, deg| deg == 0 }.map(&:first)
    result = []
    
    while !queue.empty?
      pkg = queue.shift
      result << pkg
      
      @reverse_deps[pkg].each do |dependent|
        in_degree[dependent] -= 1
        queue << dependent if in_degree[dependent] == 0
      end
    end
    
    result
  end
end

deps = DependencyGraph.new
deps.add_dependency('app', 'database')
deps.add_dependency('app', 'auth')
deps.add_dependency('database', 'config')
deps.add_dependency('auth', 'config')

deps.topological_sort  # => ["config", "database", "auth", "app"]

This implementation maintains both forward and reverse edges for efficient queries in both directions. The topological sort determines build order, ensuring dependencies are processed before dependents.

Weighted Road Network

Transportation networks model intersections as vertices and roads as weighted edges where weights represent distance or travel time.

class RoadNetwork
  def initialize
    @roads = Hash.new { |h, k| h[k] = {} }
    @locations = {}
  end
  
  def add_location(id, name, lat, lon)
    @locations[id] = { name: name, lat: lat, lon: lon }
  end
  
  def add_road(from, to, distance, bidirectional: true)
    @roads[from][to] = distance
    @roads[to][from] = distance if bidirectional
  end
  
  def shortest_path_dijkstra(start, goal)
    distances = Hash.new(Float::INFINITY)
    distances[start] = 0
    previous = {}
    unvisited = @roads.keys.to_set
    
    while !unvisited.empty?
      current = unvisited.min_by { |v| distances[v] }
      break if current == goal || distances[current] == Float::INFINITY
      
      unvisited.delete(current)
      
      @roads[current].each do |neighbor, distance|
        next unless unvisited.include?(neighbor)
        
        alt_distance = distances[current] + distance
        if alt_distance < distances[neighbor]
          distances[neighbor] = alt_distance
          previous[neighbor] = current
        end
      end
    end
    
    # Reconstruct path
    path = []
    current = goal
    while current
      path.unshift(current)
      current = previous[current]
    end
    
    { path: path, distance: distances[goal] }
  end
end

network = RoadNetwork.new
network.add_location(1, "Downtown", 40.7128, -74.0060)
network.add_location(2, "Midtown", 40.7489, -73.9680)
network.add_location(3, "Uptown", 40.7829, -73.9654)
network.add_location(4, "Airport", 40.6413, -73.7781)

network.add_road(1, 2, 5.2)
network.add_road(2, 3, 3.8)
network.add_road(1, 4, 15.6)
network.add_road(2, 4, 12.3)

result = network.shortest_path_dijkstra(1, 4)
# => { path: [1, 2, 4], distance: 17.5 }

The road network stores weights in nested hashes, providing O(1) average-case edge weight lookup. Dijkstra's algorithm demonstrates how adjacency list representations facilitate graph traversal algorithms that process neighbors repeatedly.

Design Considerations

Graph representation selection requires analyzing graph characteristics and expected operations. No single representation dominates across all scenarios.

Density Analysis

Graph density ρ = |E| / (|V| × (|V| - 1)) measures edge count relative to maximum possible edges. Sparse graphs have ρ << 1, dense graphs have ρ ≈ 1.

For sparse graphs (ρ < 0.1), adjacency lists provide substantial space savings. A graph with 1,000 vertices and 5,000 edges achieves density ρ = 5,000 / (1,000 × 999) ≈ 0.005. An adjacency list uses approximately 1,000 + 10,000 = 11,000 entries (undirected graph stores edges twice). A matrix requires 1,000,000 entries, wasting 99% of storage on absent edges.

For dense graphs (ρ > 0.5), matrices become competitive. A graph with 1,000 vertices and 400,000 edges requires 800,000 adjacency list entries versus 1,000,000 matrix entries, a smaller difference.

Operation Frequency Profile

Algorithms dominated by edge existence queries favor matrices. Graph coloring algorithms repeatedly check whether vertices share edges, making O(1) matrix queries attractive despite O(|V|²) space cost.

Algorithms dominated by neighbor iteration favor adjacency lists. Breadth-first search processes each vertex's neighbors sequentially, wasting time scanning matrix rows filled with absent edges.

Algorithms processing edges uniformly favor edge lists. Minimum spanning tree algorithms using Kruskal's method sort all edges by weight, an operation natural for edge lists but requiring extraction from adjacency matrices or lists.

Dynamic Graph Requirements

Graphs with frequent vertex additions favor adjacency lists. Adding a vertex to an adjacency list takes O(1) time. Adding a vertex to an adjacency matrix requires allocating a larger matrix and copying existing data, an O(|V|²) operation that becomes prohibitive for large graphs.

Graphs with frequent edge additions and deletions favor structures supporting efficient modification. Adjacency lists using sets allow O(1) average-case edge addition and deletion. Edge lists support O(1) addition but O(|E|) deletion.

Memory Access Patterns

Algorithms with sequential access patterns benefit from matrix cache locality. Iterating through matrix rows accesses contiguous memory, enabling CPU prefetching. Traversing adjacency lists follows pointers scattered across memory, causing cache misses.

Algorithms with random access patterns benefit from adjacency list space efficiency. Small working sets fit in cache despite pointer indirection. Large matrices thrash cache even with sequential access.

Trade-off Decision Framework

Select adjacency lists when:

  • Graph remains sparse (ρ < 0.1)
  • Neighbor iteration dominates operations
  • Dynamic vertex additions occur frequently
  • Memory constraints limit available space

Select adjacency matrices when:

  • Graph approaches density (ρ > 0.5)
  • Edge existence queries dominate operations
  • Graph size stays fixed and small (|V| < 1000)
  • Cache locality benefits offset space overhead

Select edge lists when:

  • Algorithm processes edges uniformly
  • Edge sorting by weight required
  • Minimal space consumption critical
  • Neighbor iteration unnecessary

Select hybrid representations when:

  • Multiple access patterns occur frequently
  • Space permits redundant storage
  • Conversion overhead exceeds storage cost
  • Algorithm phases require different representations

Reference

Representation Comparison

Aspect Adjacency Matrix Adjacency List Edge List
Space Complexity O(V²) O(V + E) O(E)
Add Vertex O(V²) O(1) O(1)
Add Edge O(1) O(1) O(1)
Remove Edge O(1) O(degree) O(E)
Edge Exists O(1) O(degree) O(E)
Get Neighbors O(V) O(degree) O(E)
Iterate All Edges O(V²) O(V + E) O(E)
Best For Dense graphs, edge queries Sparse graphs, neighbor iteration Edge processing, minimal space

Ruby Implementation Patterns

Pattern Code Structure Use Case
Hash of Arrays Hash.new with default block returning array Basic adjacency list, ordered neighbors
Hash of Sets Hash.new with default block returning Set Fast edge existence checks
Hash of Hashes Hash.new with default block returning hash Weighted edges with fast lookup
Nested Arrays Array.new(n) with nested Array.new(n) Fixed-size adjacency matrix
Array of Structs Array with Edge Struct elements Edge list with metadata
Separate Weight Storage Hash for neighbors, Hash for weights Decouple topology and weights

Operation Selection Guide

Required Operation Optimal Representation Alternative
Check if edge exists Adjacency Matrix Adjacency List with Set
Find all neighbors Adjacency List Edge List with filter
Iterate all edges Edge List Adjacency List
Add vertex dynamically Adjacency List Edge List
Sort edges by weight Edge List Extract from Adjacency List
Check vertex degree Adjacency List Count in Matrix row
Find incoming edges (directed) Reverse Adjacency List Scan Edge List
Compute graph Laplacian Adjacency Matrix Convert from List

Graph Types and Default Representations

Graph Type Characteristics Recommended Representation
Social Network Sparse, undirected, dynamic Adjacency List with Set
Road Network Sparse, weighted, mostly static Adjacency List with Hash
Dependency DAG Sparse, directed, topological order Adjacency List with reverse edges
State Machine Dense, directed, fixed size Adjacency Matrix
Communication Network Sparse, weighted, high query rate Adjacency List with separate weights
Complete Graph Dense by definition Adjacency Matrix
Tree Sparse, hierarchical Parent/children pointers or Adjacency List
Bipartite Graph Sparse, partitioned vertices Two separate Adjacency Lists

Memory Usage Estimates

Vertex Count Edge Count Density Matrix Size List Size List Advantage
100 500 0.05 40 KB 4 KB 10x
1,000 5,000 0.005 4 MB 40 KB 100x
10,000 50,000 0.0005 400 MB 400 KB 1000x
10,000 5,000,000 0.5 400 MB 40 MB 10x
1,000 500,000 0.5 4 MB 4 MB 1x

Memory calculations assume 32-bit integers, 64-bit pointers where applicable, and undirected edges stored bidirectionally in lists.

Common Graph Algorithms and Representation Preference

Algorithm Preferred Representation Reason
Breadth-First Search Adjacency List Iterates neighbors repeatedly
Depth-First Search Adjacency List Iterates neighbors recursively
Dijkstra Shortest Path Adjacency List Extracts minimum distances, iterates neighbors
Bellman-Ford Edge List Relaxes all edges repeatedly
Floyd-Warshall Adjacency Matrix All-pairs operations, triple nested loop
Kruskal MST Edge List Sorts edges by weight
Prim MST Adjacency List Grows tree by examining neighbors
Topological Sort Adjacency List Processes vertices by in-degree
Strongly Connected Components Adjacency List DFS-based, needs reverse edges
Network Flow Adjacency List Residual graph updates

Ruby Gems for Graph Operations

Gem Purpose Representation
rgl Ruby Graph Library Multiple internal representations
graphviz Graph visualization DOT format export
algorithms Common graph algorithms Works with various representations
plexus Graph theory library Flexible representation