Why This Is the Most Important Chapter
Google’s email says: "Know the three basic representations (objects and pointers, matrix, adjacency list) and their tradeoffs. Master BFS and DFS traversal algorithms."
This is the chapter where everything comes together. Trees are just special graphs. The "Number of Islands" problem you struggled with? It’s a graph problem where the grid IS the graph. The "build a bridge" follow-up? BFS on a graph.
What IS a Graph?
A graph is a collection of nodes (vertices) connected by edges. Unlike trees:
-
Graphs can have cycles (a path that returns to the starting node)
-
Nodes can have any number of connections
-
Edges can be directed (one-way) or undirected (two-way)
-
Edges can have weights (costs/distances)
A tree is just a connected acyclic graph with a designated root.
The Three Representations (Google’s Exact Question)
This is what Google specifically asked you to know. Let’s represent the same graph three ways:
Graph example (undirected):
A --- B
| / |
| / |
C --- D
Representation 1: Adjacency List
The most common representation in interviews. Each node maps to a list of its neighbors.
# Using a dictionary of lists
adjacency_list = {
"A": ["B", "C"],
"B": ["A", "C", "D"],
"C": ["A", "B", "D"],
"D": ["B", "C"],
}
# Using defaultdict (avoids KeyError for missing nodes)
from collections import defaultdict
graph = defaultdict(list)
edges = [("A", "B"), ("A", "C"), ("B", "C"), ("B", "D"), ("C", "D")]
for source, destination in edges:
graph[source].append(destination)
graph[destination].append(source) # Undirected → add both directions
Complexity:
| Operation | Complexity | Why |
|---|---|---|
Store graph |
O(V + E) space |
V entries + total E neighbor references |
Check if edge exists |
O(degree) = O(V) worst |
Must scan the neighbor list |
Get all neighbors |
O(1) for the list, O(degree) to iterate |
Direct dict access |
Add vertex |
O(1) |
Add a new key |
Add edge |
O(1) |
Append to list |
When to use:
-
Most interview problems — it’s the default choice
-
Sparse graphs (E << V²) — saves memory over matrix
-
When you primarily need to iterate over neighbors (BFS, DFS)
Representation 2: Adjacency Matrix
A 2D matrix where matrix[i][j] = 1 if there’s an edge from node i to node j.
# For nodes A=0, B=1, C=2, D=3
# matrix[i][j] = 1 means edge from i to j
adjacency_matrix = [
# A B C D
[0, 1, 1, 0], # A: connected to B, C
[1, 0, 1, 1], # B: connected to A, C, D
[1, 1, 0, 1], # C: connected to A, B, D
[0, 1, 1, 0], # D: connected to B, C
]
# Check if A and B are connected:
# adjacency_matrix[0][1] → 1 (yes)
# For WEIGHTED graphs, store the weight instead of 1:
weighted_matrix = [
[0, 5, 3, 0], # A→B costs 5, A→C costs 3
[5, 0, 2, 7], # B→A costs 5, B→C costs 2, B→D costs 7
[3, 2, 0, 4],
[0, 7, 4, 0],
]
Complexity:
| Operation | Complexity | Why |
|---|---|---|
Store graph |
O(V²) space |
2D array regardless of edges |
Check if edge exists |
O(1) |
Direct array access matrix[i][j] |
Get all neighbors |
O(V) |
Must scan entire row |
Add vertex |
O(V²) |
Must resize the matrix |
Add edge |
O(1) |
Set matrix[i][j] = 1 |
When to use:
-
Dense graphs where E ≈ V² (most nodes connected to most others)
-
When you need O(1) edge existence checks
-
Grid problems (like Number of Islands!) — the grid IS the matrix
-
Small graphs where V² memory is acceptable
|
The Matrix Realization 🧠: When you see a 2D grid in an interview (like the islands problem), you’re already looking at an adjacency matrix! Each cell is a node, and neighbors are the adjacent cells (up, down, left, right). You don’t need to build a separate graph — the grid IS the graph. This is the key insight many tutorials skip: a grid/matrix IS a graph representation where adjacency is defined by position (neighboring cells), not by explicit edge lists. |
Representation 3: Objects and Pointers (Node Classes)
Each node is an object with a list of references to its neighbor nodes.
class GraphNode:
def __init__(self, value):
self.value = value
self.neighbors = [] # List of GraphNode references
# Build the graph
node_a = GraphNode("A")
node_b = GraphNode("B")
node_c = GraphNode("C")
node_d = GraphNode("D")
# Add edges (undirected = both directions)
node_a.neighbors.extend([node_b, node_c])
node_b.neighbors.extend([node_a, node_c, node_d])
node_c.neighbors.extend([node_a, node_b, node_d])
node_d.neighbors.extend([node_b, node_c])
Complexity:
| Operation | Complexity | Why |
|---|---|---|
Store graph |
O(V + E) space |
Same as adjacency list |
Check if edge exists |
O(degree) |
Must scan neighbors list |
Get all neighbors |
O(1) for the list |
Direct attribute access |
Clone/deep copy |
Tricky! |
Must handle cycles (see Clone Graph problem) |
When to use:
-
When the problem gives you node objects (Clone Graph, graph serialization)
-
When nodes carry complex metadata
-
OOP-heavy codebases
When NOT to use:
-
Most interview problems — adjacency list (dict) is simpler and more flexible
-
When you need to iterate over ALL nodes (objects don’t naturally give you that)
Comparison Table
| Adjacency List | Adjacency Matrix | Objects/Pointers | |
|---|---|---|---|
Space |
O(V + E) |
O(V²) |
O(V + E) |
Edge check |
O(degree) |
O(1) ⭐ |
O(degree) |
Iterate neighbors |
O(degree) ⭐ |
O(V) |
O(degree) ⭐ |
Add edge |
O(1) ⭐ |
O(1) ⭐ |
O(1) ⭐ |
Best for |
Most problems |
Dense/grid |
OOP style |
Interview default |
⭐ Yes |
Grid problems |
When given nodes |
BFS and DFS on Graphs
You already know BFS and DFS from trees. On graphs, there’s one critical difference: you must track visited nodes to avoid infinite loops (because graphs can have cycles).
BFS (Breadth-First Search)
from collections import deque
def bfs(graph: dict, start: str) -> list[str]:
"""
BFS on a graph using adjacency list representation.
Visits nodes level by level (closest first).
Uses a QUEUE (deque) — FIFO order.
Time: O(V + E) — visit each vertex once, examine each edge once
Space: O(V) — visited set + queue can hold up to V nodes
"""
visited = set() # Track visited nodes to avoid cycles. O(1) lookup.
queue = deque([start]) # BFS queue — FIFO order
visited.add(start) # Mark start as visited BEFORE entering loop
result = []
while queue:
node = queue.popleft() # O(1) — deque advantage!
result.append(node)
# Explore all neighbors
for neighbor in graph[node]: # O(degree of node)
if neighbor not in visited: # O(1) — set lookup
visited.add(neighbor) # O(1) — mark visited
queue.append(neighbor) # O(1) — enqueue
return result
|
Why we mark visited WHEN ENQUEUEING, not when dequeuing: If you mark visited when dequeuing instead, a node can be enqueued multiple times by different neighbors before it’s dequeued. This wastes memory and time. Always mark visited at the moment of enqueueing. |
DFS (Depth-First Search)
def dfs_recursive(graph: dict, start: str,
visited: set | None = None) -> list[str]:
"""
DFS on a graph — recursive version.
Explores as deep as possible before backtracking.
Uses the CALL STACK as an implicit stack.
Time: O(V + E)
Space: O(V) — visited set + recursion stack depth (up to V for linear graph)
"""
if visited is None:
visited = set()
visited.add(start) # Mark visited before diving deeper
result = [start]
for neighbor in graph[start]:
if neighbor not in visited:
result.extend(dfs_recursive(graph, neighbor, visited))
return result
def dfs_iterative(graph: dict, start: str) -> list[str]:
"""
DFS on a graph — iterative version with explicit stack.
Time: O(V + E)
Space: O(V)
"""
visited = set()
stack = [start] # DFS uses a STACK — LIFO order
result = []
while stack:
node = stack.pop() # O(1) — list pop from end
if node in visited:
continue
visited.add(node)
result.append(node)
# Push neighbors onto stack
# Note: order may differ from recursive DFS (reversed neighbor order)
for neighbor in graph[node]:
if neighbor not in visited:
stack.append(neighbor)
return result
BFS vs DFS: When to Use Which
| BFS | DFS | |
|---|---|---|
Explores |
Level by level (closest first) |
As deep as possible first |
Data structure |
Queue (deque) |
Stack (explicit or call stack) |
Shortest path |
⭐ Yes (unweighted graphs) |
❌ Not guaranteed |
Memory |
O(width of graph) |
O(depth of graph) |
Use for |
Shortest path, level-order, minimum steps |
Cycle detection, topological sort, connectivity, path finding |
Grid problems |
Finding shortest bridge 🌉 |
Marking islands 🏝️ |
The Grid as a Graph: Mental Model
This is the KEY insight for grid problems like "Number of Islands":
Grid: Graph interpretation:
1 1 0 (0,0)-(0,1) (0,2)
1 0 0 |
0 0 1 (1,0) (1,1) (1,2)
(2,0) (2,1) (2,2)
Edges exist between adjacent cells with value "1".
(0,0) is connected to (0,1) and (1,0) — both are "1".
(2,2) is isolated — it's its own "island".
You don’t need to explicitly build an adjacency list or graph object. The grid IS the graph. Neighbors are the 4 (or 8) adjacent cells.
# The DIRECTIONS pattern — memorize this for grid problems
DIRECTIONS = [(0, 1), (0, -1), (1, 0), (-1, 0)] # right, left, down, up
def get_neighbors(grid, row, col):
"""Get valid land neighbors for a cell. O(1) time (at most 4 checks)."""
rows, cols = len(grid), len(grid[0])
neighbors = []
for delta_row, delta_col in DIRECTIONS:
new_row = row + delta_row
new_col = col + delta_col
# Check bounds AND check that neighbor is land ("1")
if 0 <= new_row < rows and 0 <= new_col < cols and grid[new_row][new_col] == "1":
neighbors.append((new_row, new_col))
return neighbors
Exercises
-
Graph Representations — Build + convert between all 3 representations
-
BFS and DFS — Recursive, iterative, and shortest path on adjacency list graphs
-
Number of Islands ⭐ — DFS, BFS, and Union-Find solutions
-
Shortest Bridge ⭐ — The "build a bridge" follow-up (DFS + BFS)
-
Course Schedule — Topological sort / cycle detection (DFS 3-color + BFS Kahn’s)
-
Dijkstra’s Algorithm — Weighted shortest path with min-heap + Network Delay Time
-
Clone Graph — Graph traversal with parallel structure building (BFS + DFS)
-
Word Ladder ⭐ — BFS on an implicit graph + bidirectional BFS optimization