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).

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.

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