← Back to DevBytes

Graphs: Implementation and Time Complexity Analysis

Graphs: Implementation and Time Complexity Analysis

Graphs are one of the most versatile and powerful data structures in computer science. They model relationships between objects, making them essential for solving problems in networking, social networks, recommendation systems, pathfinding, and more. Understanding how to implement graphs and analyzing the time complexity of common operations is crucial for writing efficient software. This tutorial covers graph fundamentals, concrete implementations in code, and detailed time complexity breakdowns to help you make informed design choices.

What is a Graph?

A graph is a collection of vertices (also called nodes) and edges that connect pairs of vertices. Edges can be directed (one-way) or undirected (two-way). Graphs may also contain weights on edges, representing cost, distance, or capacity. Formally, a graph G = (V, E) where V is the set of vertices and E is the set of edges. Each edge is a tuple (u, v) optionally accompanied by a weight w.

Common variants include:

Why Graphs Matter

Graphs appear everywhere. Social networks (Facebook friends, Twitter followers), web link structures, road networks, electrical circuits, dependency graphs in build systems, and state machines are all naturally modeled as graphs. Efficient graph algorithms power GPS navigation (Dijkstra, A*), network routing protocols, compiler optimizations, and machine learning pipelines. Choosing the right graph representation directly affects memory usage and runtime performance. For large-scale systems (e.g., Google Maps, LinkedIn’s graph of millions of users), these choices become critical.

Graph Representations

There are three primary ways to represent a graph in memory. Each offers different trade-offs in terms of space and time complexity for common operations like adding an edge, checking adjacency, and iterating over neighbors.

1. Adjacency Matrix

A 2D array (or matrix) of size V × V where matrix[i][j] indicates an edge from vertex i to vertex j. In an unweighted graph, entries are boolean (0/1); in a weighted graph, they store the weight or a sentinel (e.g., Infinity) for no edge. Undirected graphs are represented symmetrically: matrix[i][j] = matrix[j][i].

Adjacency matrices excel with dense graphs where E ≈ V², or when fast edge existence queries are paramount (e.g., dynamic connectivity checks).

2. Adjacency List

An array of lists (or hash sets) where each entry corresponds to a vertex and stores its outgoing edges (neighbors). For weighted graphs, each neighbor is paired with the edge weight (often as a tuple or a lightweight object). This is the most common representation due to its efficiency for sparse graphs.

Adjacency lists shine for sparse graphs (most real-world graphs are sparse) and are the go‑to for algorithms like BFS, DFS, Dijkstra, and Prim’s.

3. Edge List

A simple list of all edges, each stored as a triple (u, v, weight) or a pair for unweighted. Vertices may be stored separately or inferred from edge endpoints. This representation is minimal but lacks direct neighbor access.

Edge lists are useful for algorithms that process edges globally, like Kruskal’s Minimum Spanning Tree or Bellman-Ford, where adjacency iteration is not needed.

Time Complexity Analysis of Core Operations

When choosing a representation, consider the operations your algorithm performs most frequently. The table below summarizes the asymptotic complexities:


Operation              | Adjacency Matrix | Adjacency List  | Edge List
-----------------------|------------------|-----------------|--------------
Space                  | O(V²)            | O(V + E)        | O(E)
Add Vertex             | O(V²) (resize)   | O(1) amortized  | O(1) (or V if storing vertices)
Add Edge               | O(1)             | O(1)            | O(1)
Remove Edge            | O(1)             | O(deg(v))       | O(E) (find + remove)
Query Edge Exists      | O(1)             | O(deg(v))       | O(E)
Iterate Neighbors (v)  | O(V)             | O(deg(v))       | O(E)

Note: For adjacency list using hash sets, edge query and removal improve to O(1) average, but iteration remains O(deg(v)) (hash set iteration order undefined).

Implementing a Graph in Code

We'll build a generic, unweighted, directed graph using adjacency lists in Python and Java. Both implementations support adding vertices, adding edges, querying adjacency, and iterating neighbors. We'll also provide an adjacency matrix variant for comparison.

Python Implementation: Adjacency List


class Graph:
    def __init__(self):
        self.adj_list = {}  # vertex -> list of neighbors

    def add_vertex(self, vertex):
        if vertex not in self.adj_list:
            self.adj_list[vertex] = []

    def add_edge(self, u, v):
        self.add_vertex(u)
        self.add_vertex(v)
        if v not in self.adj_list[u]:
            self.adj_list[u].append(v)

    def has_edge(self, u, v):
        return u in self.adj_list and v in self.adj_list[u]

    def neighbors(self, u):
        return self.adj_list.get(u, [])

    def vertices(self):
        return list(self.adj_list.keys())

    def __str__(self):
        return "\n".join(f"{v}: {neighbors}" for v, neighbors in self.adj_list.items())

This implementation provides O(1) edge addition and O(deg(u)) edge existence check. For frequent edge queries, replace the list with a set:


class FastLookupGraph:
    def __init__(self):
        self.adj_list = {}  # vertex -> set of neighbors

    def add_vertex(self, vertex):
        if vertex not in self.adj_list:
            self.adj_list[vertex] = set()

    def add_edge(self, u, v):
        self.add_vertex(u)
        self.add_vertex(v)
        self.adj_list[u].add(v)

    def has_edge(self, u, v):
        return u in self.adj_list and v in self.adj_list[u]

    def neighbors(self, u):
        return list(self.adj_list.get(u, set()))

Java Implementation: Adjacency List


import java.util.*;

public class Graph {
    private Map<Integer, List<Integer>> adjList;

    public Graph() {
        adjList = new HashMap<>();
    }

    public void addVertex(int vertex) {
        adjList.putIfAbsent(vertex, new ArrayList<>());
    }

    public void addEdge(int u, int v) {
        addVertex(u);
        addVertex(v);
        List<Integer> neighbors = adjList.get(u);
        if (!neighbors.contains(v)) {
            neighbors.add(v);
        }
    }

    public boolean hasEdge(int u, int v) {
        List<Integer> neighbors = adjList.get(u);
        return neighbors != null && neighbors.contains(v);
    }

    public List<Integer> neighbors(int u) {
        return adjList.getOrDefault(u, Collections.emptyList());
    }

    public Set<Integer> vertices() {
        return adjList.keySet();
    }
}

For faster edge existence, use HashSet<Integer> instead of ArrayList.

Adjacency Matrix Implementation (Python)


class AdjMatrixGraph:
    def __init__(self, max_vertices):
        self.V = max_vertices
        self.matrix = [[0] * self.V for _ in range(self.V)]
        self.vertex_map = {}  # label -> index
        self.index_map = {}   # index -> label
        self.next_idx = 0

    def add_vertex(self, label):
        if label not in self.vertex_map and self.next_idx < self.V:
            self.vertex_map[label] = self.next_idx
            self.index_map[self.next_idx] = label
            self.next_idx += 1

    def add_edge(self, u, v):
        i = self.vertex_map[u]
        j = self.vertex_map[v]
        self.matrix[i][j] = 1  # unweighted

    def has_edge(self, u, v):
        i = self.vertex_map[u]
        j = self.vertex_map[v]
        return self.matrix[i][j] == 1

    def neighbors(self, u):
        i = self.vertex_map[u]
        return [self.index_map[j] for j in range(self.V) if self.matrix[i][j] == 1]

Traversal Algorithms: BFS and DFS

Two fundamental graph traversal strategies are Breadth‑First Search (BFS) and Depth‑First Search (DFS). Both run in O(V + E) when using an adjacency list representation, visiting each vertex and exploring each edge once.

BFS (Breadth‑First Search)

BFS explores neighbors level by level, typically using a queue. It’s ideal for shortest‑path in unweighted graphs, level‑order traversal, and finding connected components.


from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    traversal_order = []

    while queue:
        vertex = queue.popleft()
        traversal_order.append(vertex)
        for neighbor in graph.neighbors(vertex):
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return traversal_order

Time Complexity: O(V + E) with adjacency list, because each vertex is dequeued once and each edge is examined once. With adjacency matrix, it becomes O(V²) because each vertex’s row of length V must be scanned.

DFS (Depth‑First Search)

DFS dives deep along a branch before backtracking. It can be implemented recursively or with an explicit stack. DFS is used for topological sorting, cycle detection, maze solving, and strongly connected components.


def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    traversal_order = []

    while stack:
        vertex = stack.pop()
        if vertex not in visited:
            visited.add(vertex)
            traversal_order.append(vertex)
            # Add neighbors in reverse to simulate typical recursive order
            for neighbor in reversed(graph.neighbors(vertex)):
                if neighbor not in visited:
                    stack.append(neighbor)
    return traversal_order

Recursive DFS (elegant but may hit recursion limits on deep graphs):


def dfs_recursive(graph, vertex, visited=None, traversal_order=None):
    if visited is None:
        visited = set()
    if traversal_order is None:
        traversal_order = []
    visited.add(vertex)
    traversal_order.append(vertex)
    for neighbor in graph.neighbors(vertex):
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited, traversal_order)
    return traversal_order

Time Complexity: Both iterative and recursive DFS are O(V + E) with adjacency lists, as each vertex is visited once and each edge explored once. With adjacency matrix, complexity degrades to O(V²).

Time Complexity Analysis in Practice

Let's analyze some common graph algorithms and their complexities under different representations.

The key takeaway: adjacency lists are almost always the best choice for general‑purpose graphs unless the graph is extremely dense (E ≈ V²) and edge‑existence queries dominate.

Best Practices

Conclusion

Graphs are an indispensable tool in every developer’s toolkit. Understanding the nuances of adjacency matrices, adjacency lists, and edge lists empowers you to select the right representation and anticipate the performance of graph algorithms. By analyzing time complexity upfront, you can avoid expensive pitfalls and build scalable systems. Practice implementing graphs from scratch, run BFS/DFS on real datasets, and always match the representation to the problem’s demands. With this foundation, you’re well‑equipped to tackle everything from simple connectivity checks to complex network flow problems.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles