A Graph \(G = (V, E)\) is a fundamental non-linear data structure consisting of a set of Vertices (nodes) \(V\) and a set of Edges \(E\) that connect pairs of vertices.
Formally, an edge \(e = (u, v)\) represents a relationship between node \(u\) and node \(v\). Graphs are the most versatile data structures because they can model any relationship: hierarchical (trees), linear (linked lists), or complex networks (social media).
In the graphic above, we can clearly see the components of the formal definition \(G = (V, E)\):
There are two primary ways to translate a visual graph into computer memory: Adjacency Lists and Adjacency Matrices.
The most idiomatic way to represent a graph in Python is a dictionary. Each key is a vertex, and its value is a list of all vertices it is connected to.
An adjacency matrix is a 2D array where both rows and columns represent vertices. A value of 1 indicates an edge exists, and 0 indicates no connection.
For our example graph (A-G), the matrix looks like this:
| A | B | C | D | E | F | G | |
|---|---|---|---|---|---|---|---|
| A | 0 | 1 | 1 | 0 | 0 | 0 | 0 |
| B | 1 | 0 | 1 | 1 | 0 | 0 | 1 |
| C | 1 | 1 | 0 | 0 | 1 | 0 | 0 |
| D | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| E | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
| F | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
| G | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
( Note: In many problems, including those below, we use ânodeâ instead of âvertexâ; the terms are interchangeable in graph theory.)
matrix_to_list(matrix, nodes) that takes a 2D adjacency matrix and a list of node names, and returns an adjacency list (dictionary). Test your function on the graph shown above.The choice of representation (Adjacency List vs. Adjacency Matrix) significantly impacts the complexity of common operations:
| Operation | Adjacency List | Adjacency Matrix |
|---|---|---|
| Store Graph | \(O(V + E)\) | \(O(V^2)\) |
| Add Vertex | \(O(1)\) | \(O(V^2)\) |
| Add Edge | \(O(1)\) | \(O(1)\) |
| Query (Are U, V adjacent?) | \(O(V)\) | \(O(1)\) |
Note: V = Number of Vertices, E = Number of Edges.
Graphs can be classified based on several criteria. Below are the visual comparisons for each type:
| Directed | Undirected |
|---|---|
![]() |
![]() |
| Connected | Disconnected |
|---|---|
![]() |
![]() |
| Cyclic | Acyclic |
|---|---|
![]() |
![]() |
| Weighted | Unweighted |
|---|---|
![]() |
![]() |
While a dictionary is a great "quick" way to represent a graph, using a class provides better structure for complex operations.
remove_edge(self, v1, v2) to the Graph class that safely removes a connection between two vertices.get_degree(self, vertex) that returns the number of neighbors a vertex has.is_valid_path(self, path) that takes a list of nodes (e.g., ['A', 'B', 'C']) and returns True if every consecutive pair in the list is connected by an edge.We will use the following graph in the visualizations and algorithm tests below:
Explores all neighbor nodes at the present depth prior to moving on to the nodes at the next depth level. It uses a queue (FIFO) and is ideal for finding the shortest path in unweighted graphs.
Explores as far as possible along each branch before backtracking. It uses a stack (LIFO) or recursion.
Breadth-First Search (BFS) explores a graph level by level, starting from a given source node. Here is a step-by-step breakdown of how the code above works:
Why use a Queue? By always adding new nodes to the back and taking nodes to explore from the front, we ensure that we visit all nodes at distance 1 before moving to nodes at distance 2, and so on. This property makes BFS ideal for finding the shortest path in unweighted graphs.
Below are two implementations of the DFS algorithm: iterative and recursive. The recursive version is described in detail under the code cells below.
Depth-First Search (DFS) explores a graph by going as deep as possible along each branch before backtracking. This implementation uses Recursion, which implicitly utilizes a stack (the call stack).
dfs(graph, start, visited=None):
graph and the start node.visited is initialized to None and then set to an empty list [] on the first call. This list serves two purposes: tracking visited nodes to avoid cycles and recording the order of traversal.visited.append(start): As soon as we enter the function for a node, we mark it as visited by adding it to our list.for neighbor in graph.get(start, []): We iterate through all neighbors of the current node.if neighbor not in visited: If a neighbor hasnât been visited yet, we immediately call dfs on that neighbor.dfs on the first neighbor before looking at the second neighbor, the algorithm plunges deep into the graph.Use Cases: DFS is excellent for tasks like finding connected components, solving puzzles (like mazes), and performing topological sorts in Directed Acyclic Graphs (DAGs).
bfs function to return a dictionary where keys are nodes and values are their distance (number of edges) from the start node.find_path_dfs(graph, start, end) that returns a list of nodes representing a path from start to end, or None if no path exists.has_cycle(graph) that uses either BFS or DFS to detect if there is at least one cycle in the graph.
Dijkstra's Algorithm:
Finds the shortest paths between nodes in a graph. It is a greedy algorithm that works for weighted graphs with non-negative edge weights.
To understand how Dijkstra's algorithm finds the shortest path, let's trace its execution on a sample graph. We want to find the shortest path from node A to node D.
Description:
We start by initializing the distance to the source node (A) as 0 and all other nodes as infinity (â). At this stage, no predecessors are known. Node A is our starting point.
Description:
We extract node A (the node with the smallest distance, 0). We examine its neighbors: B and C.
- The distance to B becomes 0 + 1 = 1 (Predecessor: A).
- The distance to C becomes 0 + 4 = 4 (Predecessor: A).
Node A is now marked as visited (fixed).
Description:
The next node with the smallest distance is B (dist=1). We explore its neighbors: C and D.
- Updating C: The path A -> B -> C has a length of 1 + 2 = 3. Since 3 is less than the current distance to C (4), we update C's distance to 3 and change its predecessor to B.
- Updating D: The path A -> B -> D has a length of 1 + 5 = 6. We update D's distance to 6 (Predecessor: B).
Node B is now marked as visited.
Description:
The next node is C (dist=3). Its neighbor is D.
- Updating D: The path A -> B -> C -> D has a length of 3 + 1 = 4. Since 4 is less than the current distance to D (6), we update D's distance to 4 and change its predecessor to C.
Node C is now marked as visited.
Description:
Finally, we extract node D (dist=4). All nodes have been visited. By following the predecessors backward from D, we find the shortest path: A -> B -> C -> D with a total weight of 4.
Dijkstra's algorithm finds the shortest path from a starting node to all other nodes in a weighted graph. Unlike BFS, which treats all edges as equal, Dijkstra's algorithm considers the specific weight (or "cost") of each edge.
distances = {vertex: float('infinity') for vertex in graph}: Initially, we don't know any paths, so we set the distance to all nodes to infinity.distances[start] = 0: The distance from the start node to itself is zero.priority_queue = [(0, start)]: We use a Min-Priority Queue (via heapq). It stores (distance, vertex) tuples.current_distance, current_vertex = heapq.heappop(priority_queue): We extract the "closest" node to the source.if current_distance > distances[current_vertex]: continue. If we have already found a better path to this node, we discard this entry.for neighbor, weight in graph.get(current_vertex, []).distance = current_distance + weight.if distance < distances[neighbor]: If this new path is shorter than the best one we knew before, we update the distance and push the new path into the priority queue.Key Insight: Dijkstra's algorithm is essentially a BFS that uses a Priority Queue instead of a regular Queue to handle varying edge weights. It is guaranteed to find the shortest path as long as all edge weights are non-negative.
Dijkstraâs algorithm is the most efficient in compute the shortest paths from a single source node to all others, but it requires all edge weights to be non-negative. In contrast, the BellmanâFord algorithm is more flexible: although slower, it can handle graphs with negative edge weights and can even detect the presence of negative cycles by repeatedly relaxing all edges. FloydâWarshall takes a different approach altogether, focusing on computing shortest paths between every pair of nodes rather than from a single source. It uses a dynamic programming technique that systematically considers whether passing through intermediate vertices can yield shorter paths. While this makes it very general and easy to implement, it is also the most computationally expensive, with cubic time complexity. In practice, Dijkstraâs algorithm is preferred for performance when its assumptions hold, BellmanâFord is chosen when negative weights must be supported, and FloydâWarshall is useful when a complete all-pairs distance matrix is required.
Graphs are ubiquitous in software engineering:
For production systems, NetworkX is the most popular choice for general-purpose graph analysis:
Sometimes a graph is overkill or inefficient. Consider these alternatives:
Dictionaries/Hash Maps: For simple 1:1 or 1:N lookups where you don't need traversal.
Sets: For testing membership and uniqueness without relationships.
NumPy Matrices: For dense, static connectivity where you want to use linear algebra (eigenvectors, etc.).
Machine Learning is increasingly moving toward non-Euclidean data via Graph Neural Networks (GNNs):
In ML, we often convert graphs into numerical data using Adjacency Matrices or Embeddings:
+48 790-430-860
analysislessons@gmail.com