Historical Context & Motivation
Long before computers existed, mathematicians were already thinking about connections between things. In 1736, the Swiss mathematician Leonhard Euler tackled the famous Königsberg Bridge Problem: could a person walk through a city crossing each of its seven bridges exactly once? To solve it, Euler stripped away the map's details and focused only on the landmasses (nodes) and the bridges (edges) connecting them. This was the birth of graph theory — a branch of mathematics that studies how objects relate to one another.
Today, graphs are everywhere in computing. Social networks like Instagram model users as nodes and follow relationships as edges. Google Maps uses graphs to find the shortest driving route. Even the internet itself is a massive graph of interconnected web pages. The question that drives this lesson is: how do we represent a graph in code so that a computer can work with it efficiently?
Throughout this history, one pattern remains constant: to do anything useful with a graph, you first need to choose a data structure that stores nodes and edges. The adjacency list, especially when built with Python dictionaries, is the most common and practical choice for most real-world applications. Let's learn why.
Core Principles & Definitions
Before diving into code, you need a solid understanding of the vocabulary that surrounds graphs. A graph is a collection of vertices (also called nodes) connected by edges (also called links). An adjacency list is a way to store which nodes are connected to which other nodes. In Python, the most natural way to build one is with a dict where each key is a node and each value is a list or set of that node's neighbors.
Vertex (Node)
Edge (Link)
Undirected vs. Directed
Adjacency List
dict[node] = list/set of neighbors. Memory-efficient for graphs where most nodes are NOT connected to each other.List vs. Set for Neighbors
list allows duplicate edges and preserves insertion order. Using a set prevents duplicates and gives O(1) membership checking.Visualizing an Adjacency List
The diagram below shows a small undirected graph on the left and its corresponding adjacency list representation on the right. Each node in the graph (A through E) appears as a key in the dictionary. The connections you see drawn between nodes are captured in each key's list of neighbors.
Notice in the diagram that node B has three neighbors (A, D, E), so its list contains three elements. Node A has only two neighbors (B, C). In an undirected graph, every edge is recorded twice — once for each endpoint. This is why "A" appears in B's list and "B" appears in A's list. If this were a directed graph, you would only add the neighbor in the direction the edge points.
How It Works — Building the Dictionary
Building an adjacency list in Python comes down to three operations: creating the dictionary, adding nodes, and adding edges. Let's look at the patterns for both list-based and set-based representations.
Pattern 1 — Dict of Lists
graph["A"].append("B") and graph["B"].append("A"). For a directed edge A → B, you only append to A's list.Pattern 2 — Dict of Sets
graph["A"].add("B") and graph["B"].add("A"). Using .add() on a set automatically prevents duplicate edges.Time Complexity at a Glance
Directed vs. Undirected Adjacency Lists
The difference between directed and undirected graphs directly affects how you build the adjacency list. The diagram below compares the same set of connections stored as an undirected graph (mutual friendships) and a directed graph (one-way follows). Pay close attention to which lists each node appears in.
In the undirected graph, there are 4 edges, but the adjacency list stores 8 total entries (each edge counted twice). In the directed graph with the same 4 edges, only 4 entries are stored. This is an important distinction when you think about memory usage. For undirected graphs, the total number of items across all neighbor lists equals 2 × E, while for directed graphs it equals exactly E.
| Operation | Undirected | Directed |
|---|---|---|
| Add edge (u, v) | Append v to graph[u] AND u to graph[v] | Append v to graph[u] only |
| Remove edge (u, v) | Remove from both lists | Remove from graph[u] only |
| Total list entries | 2 × E | E |
| Degree of node u | len(graph[u]) | out-degree: len(graph[u]); in-degree requires scanning all lists |
Worked Example — Building a Social Network Graph
Let's walk through a complete example. Suppose you are building a simple social network where users can be friends with each other (an undirected graph). The users are Alice, Bob, Charlie, and Diana. The friendships are: Alice–Bob, Alice–Charlie, Bob–Diana, and Charlie–Diana.
graph = {"Alice": [], "Bob": [], "Charlie": [], "Diana": []}graph["Alice"].append("Bob")
graph["Bob"].append("Alice").append() calls.graph["Alice"].append("Charlie")
graph["Charlie"].append("Alice")
graph["Bob"].append("Diana")
graph["Diana"].append("Bob")
graph["Charlie"].append("Diana")
graph["Diana"].append("Charlie"){
"Alice": ["Bob", "Charlie"],
"Bob": ["Alice", "Diana"],
"Charlie": ["Alice", "Diana"],
"Diana": ["Bob", "Charlie"]
}print(graph["Alice"]) # ['Bob', 'Charlie']
print("Charlie" in graph["Bob"]) # False — they are not directly connecteddef add_edge(graph, u, v): graph[u].append(v); graph[v].append(u). This keeps your code clean and reduces the chance of forgetting one direction.Adjacency List vs. Adjacency Matrix
The adjacency list is not the only way to represent a graph. The other major approach is an adjacency matrix — a 2D grid (V × V) where cell [i][j] equals 1 if there's an edge from node i to node j, and 0 otherwise. Each representation has its strengths, and choosing between them depends on the properties of your graph.
| Feature | Adjacency List | Adjacency Matrix |
|---|---|---|
| Space complexity | O(V + E) — efficient for sparse graphs | O(V²) — wastes space when few edges exist |
| Check if edge exists | O(degree) with list, O(1) with set | O(1) — just look up matrix[i][j] |
| List all neighbors of a node | O(degree) — iterate through the list | O(V) — must scan entire row |
| Add an edge | O(1) — append/add to the list/set | O(1) — set matrix[i][j] = 1 |
| Best for | Sparse graphs (social networks, road maps) | Dense graphs or when frequent edge-existence checks are needed |
Connection to Advanced Topics
Once you can represent a graph with an adjacency list, you unlock a whole family of powerful algorithms. These algorithms solve real-world problems like finding the shortest route on a map, detecting cycles in dependencies, and determining if a network is fully connected.
| This Lesson (Intro) | Next Steps (Advanced) |
|---|---|
| Store neighbors in a list or set | Store edge weights as tuples: graph[u] = [(v, weight)] |
| Manually add edges one at a time | Build graphs from files, APIs, or edge lists automatically |
| Check who a node's neighbors are | Traverse the entire graph with BFS (breadth-first search) or DFS (depth-first search) |
| Simple undirected/directed graphs | Weighted graphs, DAGs (directed acyclic graphs), multigraphs |
| Count neighbors with len(graph[u]) | Find shortest paths (Dijkstra), detect cycles, topological sort |
The adjacency list you learn today is the exact same data structure used inside graph libraries like networkx in Python. Mastering this foundation means you'll be ready to implement BFS, DFS, Dijkstra's algorithm, and more — all of which expect the graph to be provided as an adjacency list. Think of this lesson as building the stage that every future graph algorithm will perform on.
from collections import defaultdict followed by graph = defaultdict(list). This eliminates the need to manually initialize each key — if a key doesn't exist, it's automatically created with an empty list. This is a common Python pattern you'll encounter in coding interviews and competitive programming.Practice Problems
edges = [("X","Y"), ("Y","Z"), ("X","Z"), ("Z","W")]. Write a Python function build_undirected_graph(edges) that takes this list and returns a dict-of-sets adjacency list. What does the resulting dictionary look like?Lesson Summary
A graph is a data structure made of vertices (nodes) connected by edges. The most practical way to represent a graph in Python is with an adjacency list — a dictionary where each key is a node and each value is a list or set of that node's neighbors. For undirected graphs, each edge is stored in both endpoints' lists. For directed graphs, each edge is stored only in the source node's list.
The adjacency list uses O(V + E) space, making it far more memory-efficient than an adjacency matrix for sparse graphs. Using a dict of sets gives O(1) edge-existence checks and prevents duplicate edges, while a dict of lists preserves insertion order and allows parallel edges. This foundational representation is the starting point for powerful algorithms like BFS, DFS, and Dijkstra's algorithm.