SECTION 1
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?
1736 • Euler & the Königsberg Bridges
1956 • Dijkstra's Shortest Path
1970s • Adjacency Lists Gain Popularity
2004 • Facebook's Social Graph
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.
SECTION 2
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.KEY TAKEAWAY
SECTION 3
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.
SECTION 4
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
CREATING AN UNDIRECTED GRAPH (LIST)
Start with an empty list for each node. Then to add an edge between A and B: 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
CREATING AN UNDIRECTED GRAPH (SET)
To add an edge between A and B: graph["A"].add("B") and graph["B"].add("A"). Using .add() on a set automatically prevents duplicate edges.
Time Complexity at a Glance
SPACE COMPLEXITY
V = number of vertices (dictionary keys). E = number of edges (total items across all neighbor lists). This is much better than an adjacency matrix which uses O(V²) space.
EDGE LOOKUP TIME
Checking if edge (A, B) exists requires searching A's neighbor collection. With a list you scan through each element — O(degree of A). With a set you get constant-time O(1) lookup.
When to use list vs. set?
SECTION 5
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 |
Key differences between undirected and directed adjacency list operations
SECTION 6
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.
Building an Undirected Social Network Graph
- 1
Step 1 — Initialize the dictionary
Create a dictionary with each user as a key and an empty list as the value. This sets up the structure before any edges are added.graph = {"Alice": [], "Bob": [], "Charlie": [], "Diana": []} - 2
Step 2 — Add edge Alice–Bob
Since this is an undirected graph, you add Bob to Alice's list and Alice to Bob's list.graph["Alice"].append("Bob") graph["Bob"].append("Alice") - 3
Step 3 — Add remaining edges
Repeat the same process for Alice–Charlie, Bob–Diana, and Charlie–Diana. Each edge requires two.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") - 4
Step 4 — Inspect the final adjacency list
Print the dictionary to verify it matches the friendships you intended.{ "Alice": ["Bob", "Charlie"], "Bob": ["Alice", "Diana"], "Charlie": ["Alice", "Diana"], "Diana": ["Bob", "Charlie"] } - 5
Step 5 — Query the graph
You can now answer questions like 'Who are Alice's friends?' or 'Are Bob and Charlie friends?' using simple dictionary and list operations.print(graph["Alice"]) # ['Bob', 'Charlie'] print("Charlie" in graph["Bob"]) # False — they are not directly connected
Helper Function Tip
def 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.SECTION 7
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 |
Comparison of adjacency list and adjacency matrix representations
KEY TAKEAWAY
SECTION 8
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 |
How today's foundational concepts connect to more advanced graph algorithms
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.
Using defaultdict for cleaner code
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.SECTION 9
Practice Problems
Question 1: CONCEPTUAL
Show answer
Question 2: BASIC CALCULATION
Show answer
graph = {1: [2, 3], 2: [4], 3: [4], 4: []}. You initialize each node as a key, then place only the target of each directed edge in the source node's list. Since this is directed, edge 1→2 only adds 2 to node 1's list, not the reverse. The out-degree of node 1 is len(graph[1]) which equals 2, because node 1 has outgoing edges to nodes 2 and 3.Question 3: INTERMEDIATE
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?Show answer
def build_undirected_graph(edges):
graph = {}
for u, v in edges:
graph.setdefault(u, set()).add(v)
graph.setdefault(v, set()).add(u)
return graph. The setdefault method creates the key with an empty set if it doesn't exist, then adds the neighbor. The result is: {"X": {"Y", "Z"}, "Y": {"X", "Z"}, "Z": {"Y", "X", "W"}, "W": {"Z"}}. Using sets prevents any accidental duplicate edges.Question 4: APPLIED
Show answer
graph = {"Room1": ["Room2","Room3"], "Room2": ["Room1","Room4"], "Room3": ["Room1","Room4"], "Room4": ["Room2","Room3","Room5"], "Room5": ["Room4","Room6"], "Room6": ["Room5"]}. To find the room with the highest degree: max(graph, key=lambda node: len(graph[node])). The answer is Room4 with a degree of 3, because it connects to Room2, Room3, and Room5. This makes Room4 the most central hallway hub in the school.Question 5: CRITICAL THINKING
Show answer
SUMMARY
Lesson Summary
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.