Overview
Graphs consist of vertices (nodes) connected by edges (links), forming a mathematical structure that models pairwise relationships between objects. The distinction between directed and undirected graphs determines whether edges have directionality. In directed graphs (digraphs), each edge has a specific source and destination vertex, representing asymmetric relationships. In undirected graphs, edges connect vertices bidirectionally, representing symmetric relationships.
Graphs appear throughout software development: social networks map connections between users, file systems represent directory hierarchies, compilers analyze code dependencies, routing algorithms find optimal paths through networks, and schedulers manage task execution order. The choice between directed and undirected graphs depends on whether relationships have inherent direction.
A directed graph G = (V, E) contains a set of vertices V and a set of ordered pairs E, where each edge (u, v) goes from vertex u to vertex v but not necessarily from v to u. An undirected graph treats each edge as an unordered pair, where (u, v) and (v, u) represent the same connection.
# Simple directed graph representation
directed = {
'A' => ['B', 'C'], # A points to B and C
'B' => ['D'], # B points to D
'C' => ['D'], # C points to D
'D' => [] # D points to nothing
}
# Simple undirected graph representation
undirected = {
'A' => ['B', 'C'], # A connects to B and C
'B' => ['A', 'D'], # B connects to A and D
'C' => ['A', 'D'], # C connects to A and D
'D' => ['B', 'C'] # D connects to B and C
}
Graph density describes the ratio of actual edges to possible edges. Dense graphs have many edges relative to vertices, while sparse graphs have few edges. This characteristic affects implementation choices, as different representations optimize for different density levels.
Weighted graphs assign values to edges, representing costs, distances, capacities, or other metrics. Unweighted graphs treat all edges equally. Many algorithms operate on both types, with unweighted graphs treated as weighted graphs where all edges have weight 1.
Key Principles
Graphs model relationships through two fundamental components: vertices represent entities, and edges represent connections between those entities. Each vertex typically contains data or an identifier, while edges may contain weights or labels describing the relationship strength or type.
Directed edges create asymmetric relationships where traversal follows a specific direction. In a web page link graph, an edge from page A to page B indicates A links to B, but B might not link back to A. In dependency graphs, an edge from module X to module Y means X depends on Y, establishing a one-way relationship that affects build order and module loading.
Undirected edges create symmetric relationships where traversal works in both directions. In a friendship network, if person A is friends with person B, then person B is friends with person A. In a road network where roads allow bidirectional travel, an edge between city X and city Y allows travel in either direction.
Graph connectivity describes whether paths exist between vertices. A connected undirected graph contains paths between all vertex pairs. A directed graph has two connectivity types: strongly connected graphs contain directed paths between all vertex pairs, while weakly connected graphs become connected when treating all edges as undirected.
# Checking connectivity in an undirected graph
def connected?(graph, start, target, visited = Set.new)
return true if start == target
return false if visited.include?(start)
visited.add(start)
graph[start].any? { |neighbor| connected?(graph, neighbor, target, visited) }
end
graph = {
'A' => ['B', 'C'],
'B' => ['A', 'D'],
'C' => ['A'],
'D' => ['B']
}
connected?(graph, 'A', 'D')
# => true
Cycles occur when a path exists from a vertex back to itself. Directed acyclic graphs (DAGs) contain no cycles and support topological ordering, making them useful for dependency resolution, task scheduling, and version control histories. Undirected graphs containing cycles indicate alternative paths between vertices, affecting routing and redundancy strategies.
The degree of a vertex measures its connectivity. In undirected graphs, degree counts incident edges. In directed graphs, in-degree counts incoming edges while out-degree counts outgoing edges. Vertices with high degrees represent hubs or central nodes in the network structure.
Subgraphs form when selecting a subset of vertices and their connecting edges from a larger graph. Trees represent connected acyclic undirected graphs, forming a special subgraph class. Spanning trees include all vertices from the original graph while maintaining the tree property, with minimum spanning trees minimizing total edge weight.
Path properties define graph traversal characteristics. Path length measures the number of edges traversed. Shortest paths minimize edge count in unweighted graphs or total weight in weighted graphs. Multiple paths between vertices offer redundancy but increase complexity for algorithms that must consider all possibilities.
Implementation Approaches
Graph representation choices affect memory usage, operation performance, and implementation complexity. Three primary approaches trade off between space efficiency and operation speed: adjacency lists, adjacency matrices, and edge lists.
Adjacency lists store each vertex with a list of its neighbors. For vertex v, the adjacency list contains all vertices u where edge (v, u) exists. This representation works well for sparse graphs where the number of edges remains much smaller than the square of vertices. Adding edges requires appending to a list, while checking edge existence requires scanning the neighbor list.
For graph with edges: (A,B), (A,C), (B,D), (C,D)
Adjacency list representation:
A: [B, C]
B: [D]
C: [D]
D: []
Adjacency matrices use a two-dimensional array where entry [i][j] indicates whether an edge exists from vertex i to vertex j. For weighted graphs, the entry stores the edge weight, with a sentinel value representing no edge. This representation excels at dense graphs and provides constant-time edge existence checks but consumes quadratic space regardless of actual edge count.
For the same graph:
Adjacency matrix (1 = edge exists, 0 = no edge):
A B C D
A 0 1 1 0
B 0 0 0 1
C 0 0 0 1
D 0 0 0 0
Edge lists store graphs as collections of edge pairs, with each entry containing source and destination vertices plus optional weight. This representation minimizes space for very sparse graphs and simplifies iteration over all edges. However, checking whether a specific edge exists requires scanning the entire list, and finding a vertex's neighbors requires filtering all edges.
For undirected graphs, implementation approaches must handle bidirectionality. Adjacency lists typically store each edge twice, once in each vertex's neighbor list. Adjacency matrices ensure symmetry by setting both [i][j] and [j][i] when adding edges. Edge lists may store each undirected edge once or twice depending on whether algorithms need explicit bidirectional entries.
Directed graphs avoid the duplication requirement since edges have explicit direction. Adjacency lists store neighbors only for outgoing edges. Accessing incoming edges requires either maintaining a separate reverse adjacency list or scanning all vertices to find edges pointing to a target vertex.
Hybrid approaches combine representations for different operation patterns. Storing both forward and reverse adjacency lists doubles space usage but provides fast access to both outgoing and incoming edges. Some implementations maintain an adjacency list for the primary structure with a hash table for fast edge existence checks.
Ruby Implementation
Ruby implements graphs through custom classes built on native data structures. Hash objects serve as adjacency lists, arrays store neighbor collections, and Set objects eliminate duplicate edges while providing fast membership testing.
class DirectedGraph
def initialize
@adjacency_list = Hash.new { |hash, key| hash[key] = [] }
end
def add_vertex(vertex)
@adjacency_list[vertex] ||= []
end
def add_edge(source, destination)
@adjacency_list[source] << destination
end
def neighbors(vertex)
@adjacency_list[vertex]
end
def vertices
@adjacency_list.keys
end
def edges
@adjacency_list.flat_map { |source, destinations|
destinations.map { |dest| [source, dest] }
}
end
end
graph = DirectedGraph.new
graph.add_vertex('A')
graph.add_vertex('B')
graph.add_edge('A', 'B')
graph.add_edge('A', 'C')
graph.neighbors('A')
# => ["B", "C"]
Undirected graphs require adding edges bidirectionally to maintain symmetry. Using Set objects for neighbor collections prevents duplicate edges when the same edge gets added from both directions.
require 'set'
class UndirectedGraph
def initialize
@adjacency_list = Hash.new { |hash, key| hash[key] = Set.new }
end
def add_vertex(vertex)
@adjacency_list[vertex] ||= Set.new
end
def add_edge(vertex1, vertex2)
@adjacency_list[vertex1].add(vertex2)
@adjacency_list[vertex2].add(vertex1)
end
def remove_edge(vertex1, vertex2)
@adjacency_list[vertex1].delete(vertex2)
@adjacency_list[vertex2].delete(vertex1)
end
def neighbors(vertex)
@adjacency_list[vertex].to_a
end
def degree(vertex)
@adjacency_list[vertex].size
end
end
graph = UndirectedGraph.new
graph.add_edge('A', 'B')
graph.add_edge('B', 'C')
graph.degree('B')
# => 2
Weighted graphs extend basic implementations by storing neighbor information as hashes mapping destination vertices to weights rather than simple lists of neighbors.
class WeightedDirectedGraph
def initialize
@adjacency_list = Hash.new { |hash, key| hash[key] = {} }
end
def add_edge(source, destination, weight)
@adjacency_list[source][destination] = weight
end
def weight(source, destination)
@adjacency_list[source][destination]
end
def neighbors(vertex)
@adjacency_list[vertex].keys
end
def neighbors_with_weights(vertex)
@adjacency_list[vertex]
end
end
graph = WeightedDirectedGraph.new
graph.add_edge('A', 'B', 5)
graph.add_edge('A', 'C', 3)
graph.neighbors_with_weights('A')
# => {"B"=>5, "C"=>3}
Matrix representations use nested arrays with numeric indices for vertices. This approach suits dense graphs and algorithms requiring frequent edge existence checks.
class GraphMatrix
attr_reader :vertices
def initialize(vertices)
@vertices = vertices
@vertex_indices = vertices.each_with_index.to_h
@matrix = Array.new(vertices.size) { Array.new(vertices.size, 0) }
end
def add_edge(source, destination, weight = 1)
source_idx = @vertex_indices[source]
dest_idx = @vertex_indices[destination]
@matrix[source_idx][dest_idx] = weight
end
def edge?(source, destination)
source_idx = @vertex_indices[source]
dest_idx = @vertex_indices[destination]
@matrix[source_idx][dest_idx] != 0
end
def weight(source, destination)
source_idx = @vertex_indices[source]
dest_idx = @vertex_indices[destination]
@matrix[source_idx][dest_idx]
end
end
graph = GraphMatrix.new(['A', 'B', 'C'])
graph.add_edge('A', 'B', 5)
graph.edge?('A', 'B')
# => true
Traversal algorithms form the foundation for graph analysis. Depth-first search explores as far as possible along each branch before backtracking, while breadth-first search explores all neighbors before moving to the next level.
def depth_first_search(graph, start, visited = Set.new, &block)
return if visited.include?(start)
visited.add(start)
yield start if block_given?
graph.neighbors(start).each do |neighbor|
depth_first_search(graph, neighbor, visited, &block)
end
end
def breadth_first_search(graph, start)
visited = Set.new([start])
queue = [start]
while !queue.empty?
vertex = queue.shift
yield vertex if block_given?
graph.neighbors(vertex).each do |neighbor|
unless visited.include?(neighbor)
visited.add(neighbor)
queue.push(neighbor)
end
end
end
end
# Usage
graph = DirectedGraph.new
graph.add_edge('A', 'B')
graph.add_edge('A', 'C')
graph.add_edge('B', 'D')
depth_first_search(graph, 'A') { |v| puts v }
# Prints: A, B, D, C (one possible order)
Practical Examples
Social networks model relationships between users as graphs. Directed graphs represent asymmetric relationships like following on platforms where users can follow others without reciprocation. Undirected graphs represent symmetric relationships like friendship where connections work both ways.
class SocialNetwork
def initialize
@followers = Hash.new { |h, k| h[k] = Set.new }
end
def follow(follower, followee)
@followers[followee].add(follower)
end
def followers(user)
@followers[user].to_a
end
def following(user)
@followers.select { |followee, followers|
followers.include?(user)
}.keys
end
def mutual_followers(user1, user2)
followers(user1) & followers(user2)
end
def friend_recommendations(user)
# Find friends of friends who aren't already followed
following(user).flat_map { |friend|
following(friend)
}.uniq - following(user) - [user]
end
end
network = SocialNetwork.new
network.follow('Alice', 'Bob')
network.follow('Charlie', 'Bob')
network.follow('Alice', 'Charlie')
network.mutual_followers('Bob', 'Charlie')
# => ["Alice"]
Dependency resolution uses directed acyclic graphs to determine execution order for tasks with prerequisites. Topological sorting produces a valid ordering where all dependencies appear before dependent items.
class DependencyGraph
def initialize
@dependencies = Hash.new { |h, k| h[k] = Set.new }
@dependents = Hash.new { |h, k| h[k] = Set.new }
end
def add_dependency(task, dependency)
@dependencies[task].add(dependency)
@dependents[dependency].add(task)
end
def topological_sort
in_degree = Hash.new(0)
@dependencies.each { |task, deps| in_degree[task] = deps.size }
queue = @dependencies.keys.select { |task| in_degree[task] == 0 }
result = []
while !queue.empty?
task = queue.shift
result << task
@dependents[task].each do |dependent|
in_degree[dependent] -= 1
queue << dependent if in_degree[dependent] == 0
end
end
result.size == @dependencies.size ? result : nil
end
end
deps = DependencyGraph.new
deps.add_dependency('deploy', 'test')
deps.add_dependency('test', 'build')
deps.add_dependency('build', 'compile')
deps.topological_sort
# => ["compile", "build", "test", "deploy"]
Navigation systems use weighted graphs where vertices represent locations and edges represent paths with distances or travel times. Shortest path algorithms find optimal routes between locations.
require 'set'
class NavigationGraph
def initialize
@edges = Hash.new { |h, k| h[k] = {} }
end
def add_road(location1, location2, distance)
@edges[location1][location2] = distance
@edges[location2][location1] = distance
end
def dijkstra(start, finish)
distances = Hash.new(Float::INFINITY)
distances[start] = 0
previous = {}
unvisited = Set.new(@edges.keys)
while !unvisited.empty?
current = unvisited.min_by { |vertex| distances[vertex] }
break if current == finish || distances[current] == Float::INFINITY
unvisited.delete(current)
@edges[current].each do |neighbor, weight|
next unless unvisited.include?(neighbor)
alt_distance = distances[current] + weight
if alt_distance < distances[neighbor]
distances[neighbor] = alt_distance
previous[neighbor] = current
end
end
end
path = []
current = finish
while current
path.unshift(current)
current = previous[current]
end
{ distance: distances[finish], path: path }
end
end
map = NavigationGraph.new
map.add_road('A', 'B', 5)
map.add_road('A', 'C', 2)
map.add_road('B', 'D', 1)
map.add_road('C', 'D', 6)
map.dijkstra('A', 'D')
# => {:distance=>6, :path=>["A", "B", "D"]}
State machines model system behavior as directed graphs where vertices represent states and edges represent transitions. Each edge may have conditions determining whether the transition fires.
class StateMachine
def initialize(initial_state)
@current_state = initial_state
@transitions = Hash.new { |h, k| h[k] = {} }
end
def add_transition(from_state, to_state, event)
@transitions[from_state][event] = to_state
end
def trigger(event)
next_state = @transitions[@current_state][event]
if next_state
@current_state = next_state
true
else
false
end
end
def state
@current_state
end
end
order_flow = StateMachine.new(:pending)
order_flow.add_transition(:pending, :processing, :pay)
order_flow.add_transition(:processing, :shipped, :ship)
order_flow.add_transition(:shipped, :delivered, :deliver)
order_flow.trigger(:pay)
order_flow.state
# => :processing
Common Patterns
Graph traversal patterns determine the order for visiting vertices during exploration. Depth-first search follows edges as deeply as possible before backtracking, creating a path that explores one branch completely before moving to siblings. This pattern suits cycle detection, topological sorting, and path finding in mazes.
def find_path_dfs(graph, start, target, path = [], visited = Set.new)
return nil if visited.include?(start)
path = path + [start]
return path if start == target
visited.add(start)
graph.neighbors(start).each do |neighbor|
result = find_path_dfs(graph, neighbor, target, path, visited)
return result if result
end
nil
end
Breadth-first search explores all vertices at distance k before visiting vertices at distance k+1, expanding outward in levels from the starting vertex. This pattern finds shortest paths in unweighted graphs and determines connectivity within a specific distance.
def shortest_path_bfs(graph, start, target)
return [start] if start == target
visited = Set.new([start])
queue = [[start, [start]]]
until queue.empty?
vertex, path = queue.shift
graph.neighbors(vertex).each do |neighbor|
next if visited.include?(neighbor)
new_path = path + [neighbor]
return new_path if neighbor == target
visited.add(neighbor)
queue.push([neighbor, new_path])
end
end
nil
end
Cycle detection identifies whether a graph contains loops. In directed graphs, tracking the current recursion stack during depth-first search detects back edges that close cycles. In undirected graphs, finding an edge to a visited vertex other than the parent indicates a cycle.
def has_cycle_directed?(graph)
visited = Set.new
rec_stack = Set.new
detect_cycle = lambda do |vertex|
visited.add(vertex)
rec_stack.add(vertex)
graph.neighbors(vertex).each do |neighbor|
return true if rec_stack.include?(neighbor)
return true if !visited.include?(neighbor) && detect_cycle.call(neighbor)
end
rec_stack.delete(vertex)
false
end
graph.vertices.any? { |v| !visited.include?(v) && detect_cycle.call(v) }
end
def has_cycle_undirected?(graph)
visited = Set.new
detect_cycle = lambda do |vertex, parent|
visited.add(vertex)
graph.neighbors(vertex).each do |neighbor|
next if neighbor == parent
return true if visited.include?(neighbor)
return true if detect_cycle.call(neighbor, vertex)
end
false
end
graph.vertices.any? { |v| !visited.include?(v) && detect_cycle.call(v, nil) }
end
Connected components identify groups of vertices where paths exist between any pair within the group but not between groups. Running depth-first or breadth-first search from unvisited vertices partitions the graph into components.
def connected_components(graph)
visited = Set.new
components = []
graph.vertices.each do |vertex|
next if visited.include?(vertex)
component = []
queue = [vertex]
visited.add(vertex)
until queue.empty?
current = queue.shift
component << current
graph.neighbors(current).each do |neighbor|
unless visited.include?(neighbor)
visited.add(neighbor)
queue.push(neighbor)
end
end
end
components << component
end
components
end
Minimum spanning trees connect all vertices in a weighted undirected graph while minimizing total edge weight. Prim's algorithm grows a single tree by repeatedly adding the minimum weight edge connecting a tree vertex to a non-tree vertex.
def prims_mst(graph)
vertices = graph.vertices
start = vertices.first
in_tree = Set.new([start])
edges = []
while in_tree.size < vertices.size
min_edge = nil
min_weight = Float::INFINITY
in_tree.each do |vertex|
graph.neighbors_with_weights(vertex).each do |neighbor, weight|
next if in_tree.include?(neighbor)
if weight < min_weight
min_weight = weight
min_edge = [vertex, neighbor, weight]
end
end
end
break unless min_edge
edges << min_edge
in_tree.add(min_edge[1])
end
edges
end
Performance Considerations
Operation complexity varies significantly based on graph representation. Adjacency lists provide O(1) edge addition but O(degree) edge existence checks, where degree represents the number of neighbors for a vertex. Adjacency matrices offer O(1) edge checks but consume O(V²) space regardless of edge count, where V represents vertex count.
# Adjacency list performance
def adjacency_list_operations(vertices, edges)
list = Hash.new { |h, k| h[k] = [] }
# Edge addition: O(1)
edges.each { |u, v| list[u] << v }
# Edge check: O(degree of u)
edge_exists = list['u'].include?('v')
# Get neighbors: O(1) - returns reference
neighbors = list['u']
# Space: O(V + E)
end
# Adjacency matrix performance
def adjacency_matrix_operations(vertices)
size = vertices.size
matrix = Array.new(size) { Array.new(size, 0) }
# Edge addition: O(1)
matrix[0][1] = 1
# Edge check: O(1)
edge_exists = matrix[0][1] == 1
# Get neighbors: O(V) - must scan row
neighbors = []
matrix[0].each_with_index { |val, i| neighbors << i if val == 1 }
# Space: O(V²)
end
Graph traversal algorithms have distinct complexity profiles. Depth-first search visits each vertex once and explores each edge once, resulting in O(V + E) time complexity. Breadth-first search exhibits identical asymptotic complexity but maintains a queue that grows up to O(V) in space.
Shortest path algorithms scale differently based on graph properties. Dijkstra's algorithm requires O((V + E) log V) time using a binary heap priority queue. Graphs with negative edge weights need Bellman-Ford at O(VE) time. Dense graphs where E approaches V² make Floyd-Warshall's O(V³) all-pairs approach competitive.
Sparse graphs benefit from adjacency list representations where E remains much smaller than V². Social networks typically exhibit sparsity since users connect to a tiny fraction of total users. Web graphs also show sparsity as pages link to relatively few other pages compared to total page count.
Dense graphs approach E ≈ V² edges, making matrix representations more efficient despite quadratic space cost. Complete graphs where every vertex connects to every other vertex, and some proximity graphs in geometric problems, exhibit density that favors matrices.
require 'benchmark'
def benchmark_representations(vertex_count, edge_density)
vertices = (0...vertex_count).to_a
edge_count = (vertex_count * (vertex_count - 1) * edge_density / 2).to_i
# Generate random edges
edges = edge_count.times.map {
[rand(vertex_count), rand(vertex_count)]
}.uniq
# Adjacency list
list_time = Benchmark.measure {
list = Hash.new { |h, k| h[k] = [] }
edges.each { |u, v| list[u] << v }
1000.times { list[rand(vertex_count)].include?(rand(vertex_count)) }
}
# Adjacency matrix
matrix_time = Benchmark.measure {
matrix = Array.new(vertex_count) { Array.new(vertex_count, 0) }
edges.each { |u, v| matrix[u][v] = 1 }
1000.times { matrix[rand(vertex_count)][rand(vertex_count)] == 1 }
}
{ list: list_time.real, matrix: matrix_time.real }
end
# Sparse graph (10% density)
benchmark_representations(100, 0.1)
# List faster for edge checks due to small neighbor lists
# Dense graph (90% density)
benchmark_representations(100, 0.9)
# Matrix faster for edge checks despite larger space
Cache performance affects graph algorithm efficiency in practice despite identical big-O complexity. Adjacency lists with random access patterns cause more cache misses than matrix representations with sequential access patterns. However, memory consumption often dominates since matrices exceed cache size for large graphs.
Parallel graph algorithms partition vertices across processors to exploit multiple cores. Degree distribution affects load balancing since high-degree vertices require more work than low-degree vertices. Dynamic work stealing mitigates imbalance by redistributing work from busy processors to idle ones.
Reference
Graph Terminology
| Term | Definition | Directed Graph | Undirected Graph |
|---|---|---|---|
| Vertex/Node | Basic unit representing an entity | Same in both | Same in both |
| Edge/Link | Connection between two vertices | Has direction | No direction |
| Adjacent | Two vertices connected by an edge | u adjacent to v if edge (u,v) exists | u and v adjacent if edge exists |
| Degree | Number of edges incident to a vertex | Split into in-degree and out-degree | Count of incident edges |
| In-degree | Number of edges pointing to vertex | Relevant | Not applicable |
| Out-degree | Number of edges pointing from vertex | Relevant | Not applicable |
| Path | Sequence of vertices connected by edges | Follows edge direction | Edges traversable either way |
| Cycle | Path that starts and ends at same vertex | Directed cycle | Undirected cycle |
| Connected | Path exists between any two vertices | Weakly or strongly connected | Single definition |
| Component | Maximal connected subgraph | Can be disconnected overall | Partition of vertices |
Representation Comparison
| Representation | Space Complexity | Add Edge | Check Edge | Get Neighbors | Best For |
|---|---|---|---|---|---|
| Adjacency List | O(V + E) | O(1) | O(degree) | O(1) | Sparse graphs, traversal |
| Adjacency Matrix | O(V²) | O(1) | O(1) | O(V) | Dense graphs, edge queries |
| Edge List | O(E) | O(1) | O(E) | O(E) | Very sparse, edge iteration |
| Incidence Matrix | O(V × E) | O(1) | O(E) | O(E) | Multigraphs, hypergraphs |
Common Algorithms
| Algorithm | Purpose | Time Complexity | Space Complexity | Works On |
|---|---|---|---|---|
| DFS | Graph traversal | O(V + E) | O(V) | Both types |
| BFS | Graph traversal, shortest path | O(V + E) | O(V) | Both types |
| Dijkstra | Shortest path, single source | O((V + E) log V) | O(V) | Weighted, non-negative |
| Bellman-Ford | Shortest path, negative edges | O(VE) | O(V) | Weighted, any edges |
| Floyd-Warshall | All-pairs shortest paths | O(V³) | O(V²) | Weighted |
| Topological Sort | Dependency ordering | O(V + E) | O(V) | Directed acyclic |
| Prim/Kruskal | Minimum spanning tree | O(E log V) | O(V) | Undirected, weighted |
| Tarjan | Strong components | O(V + E) | O(V) | Directed |
| Kosaraju | Strong components | O(V + E) | O(V) | Directed |
Implementation Patterns
| Pattern | Use Case | Implementation |
|---|---|---|
| Adjacency list with Hash | Dynamic vertex addition | Hash mapping vertices to arrays |
| Adjacency list with Array | Fixed vertex set | Array of arrays indexed by vertex ID |
| Matrix with nested Array | Small dense graphs | Two-dimensional array |
| Edge list with Array | Minimal operations needed | Array of [source, destination] pairs |
| Weighted edges as Hash | Path costs needed | Hash mapping destinations to weights |
| Bidirectional search | Large graph path finding | BFS from both ends simultaneously |
| Memoized traversal | Repeated path queries | Cache visited sets between calls |
Traversal Patterns
| Pattern | Characteristic | Structure | Typical Use |
|---|---|---|---|
| DFS Preorder | Visit before children | Stack, recursion | Expression evaluation |
| DFS Postorder | Visit after children | Stack, recursion | Topological sort |
| BFS Level-order | Visit by distance | Queue | Shortest paths |
| Iterative deepening | Memory-limited DFS | Stack with depth limit | Large spaces |
| Bidirectional | Meet in middle | Two queues | Fast path finding |
Edge Classification (DFS)
| Edge Type | Definition | Directed Graph | Undirected Graph |
|---|---|---|---|
| Tree Edge | Edge in DFS tree | Part of traversal path | Part of traversal path |
| Back Edge | Points to ancestor | Creates cycle | Creates cycle |
| Forward Edge | Points to descendant | Shortcut in tree | Does not exist |
| Cross Edge | Between different subtrees | Connects subtrees | Does not exist |
Graph Properties
| Property | Directed | Undirected | Implication |
|---|---|---|---|
| Strongly connected | All pairs have directed paths | Not applicable | Single component |
| Weakly connected | Connected if edges undirected | Not applicable | May have unreachable vertices |
| Connected | Not applicable | All pairs have paths | Single component |
| Acyclic | No directed cycles (DAG) | No cycles (forest/tree) | Topological order exists |
| Bipartite | Vertices split into two sets | Same definition | Two-colorable |
| Complete | All possible edges exist | All possible edges exist | Maximum density |