9.3 Adjacency Matrices

You need a graph representation that can answer edge-existence queries quickly.

9.3 Adjacency Matrices

Problem

You need a graph representation that can answer edge-existence queries quickly.

An adjacency list is efficient for traversal, but checking whether an edge exists can require scanning a neighbor list. If your algorithm repeatedly asks questions such as:

Is there an edge from u to v?

then an adjacency matrix may be a better representation.

Solution

Represent the graph as a two-dimensional table.

For a graph with n vertices, create an n × n matrix. The entry at matrix[u][v] records whether the edge u -> v exists.

For an unweighted directed graph:

def build_matrix(n, edges):
    matrix = [[False] * n for _ in range(n)]

    for u, v in edges:
        matrix[u][v] = True

    return matrix

Example:

edges = [
    (0, 1),
    (0, 2),
    (1, 3),
]

matrix = build_matrix(4, edges)

Result:

[
  [False, True,  True,  False],
  [False, False, False, True ],
  [False, False, False, False],
  [False, False, False, False],
]

Now edge lookup is constant time:

if matrix[0][2]:
    print("0 can reach 2 directly")

Discussion

An adjacency matrix trades memory for lookup speed.

With an adjacency list, testing whether u -> v exists takes time proportional to the degree of u, unless the neighbor collection is a set. With an adjacency matrix, the same test is a direct table lookup.

This is useful when the graph is dense, when edge lookup is the dominant operation, or when the algorithm naturally operates over all vertex pairs.

Common examples include:

Use case Why a matrix helps
Dense graphs Most possible edges exist
Transitive closure Algorithms inspect vertex pairs
Floyd-Warshall Dynamic programming over all pairs
Graph complements Need to test missing edges quickly
Small graphs Simplicity matters more than memory
Bitset optimization Rows can be compressed into machine words

The cost is space. For n vertices, the matrix always uses O(n²) memory, regardless of how many edges exist.

A graph with one million vertices would require one trillion entries. Even if each entry used only one bit, that is still large. With ordinary boolean objects or integers, it becomes impractical.

Use an adjacency matrix deliberately. It is excellent for the right workload and wasteful for the wrong one.

Directed Graphs

For a directed graph, store only the directed relation.

def add_directed_edge(matrix, u, v):
    matrix[u][v] = True

The entry matrix[u][v] is independent of matrix[v][u].

Example:

matrix[0][1] = True
matrix[1][0] = False

This means 0 -> 1 exists, but 1 -> 0 does not.

Directed matrices are commonly used for dependency graphs, reachability relations, finite automata, and transition systems.

Undirected Graphs

For an undirected graph, the matrix must be symmetric.

def add_undirected_edge(matrix, u, v):
    matrix[u][v] = True
    matrix[v][u] = True

For every edge u -- v, both entries must be set.

A missing symmetric update is a frequent bug. It creates a graph that behaves directed even though the problem says undirected.

You can test the invariant directly:

def is_symmetric(matrix):
    n = len(matrix)

    for i in range(n):
        for j in range(n):
            if matrix[i][j] != matrix[j][i]:
                return False

    return True

This check costs O(n²), so it is usually used in tests, not in hot code.

Weighted Graphs

For weighted graphs, store a weight instead of a boolean.

Use a sentinel value to represent a missing edge.

For shortest-path algorithms, a common sentinel is infinity:

def build_weight_matrix(n, edges):
    inf = float("inf")
    matrix = [[inf] * n for _ in range(n)]

    for i in range(n):
        matrix[i][i] = 0

    for u, v, weight in edges:
        matrix[u][v] = weight

    return matrix

Example:

edges = [
    (0, 1, 5),
    (0, 2, 2),
    (1, 3, 7),
]

matrix = build_weight_matrix(4, edges)

Here:

matrix[0][1] == 5
matrix[0][3] == float("inf")
matrix[0][0] == 0

The diagonal is set to zero because the distance from a vertex to itself is usually zero.

This convention works well for Floyd-Warshall and other all-pairs shortest-path algorithms.

Recipe: Edge Existence Matrix

Use this version when edges are unweighted and vertex IDs are integers from 0 to n - 1.

class GraphMatrix:
    def __init__(self, n, directed=True):
        self.n = n
        self.directed = directed
        self.matrix = [[False] * n for _ in range(n)]

    def add_edge(self, u, v):
        self.matrix[u][v] = True

        if not self.directed:
            self.matrix[v][u] = True

    def has_edge(self, u, v):
        return self.matrix[u][v]

    def neighbors(self, u):
        for v, exists in enumerate(self.matrix[u]):
            if exists:
                yield v

Example:

g = GraphMatrix(4, directed=False)
g.add_edge(0, 1)
g.add_edge(0, 2)

print(g.has_edge(1, 0))
print(list(g.neighbors(0)))

Output:

True
[1, 2]

This class gives constant-time edge checks, but neighbors(u) scans the whole row. That scan costs O(V), even if u has only a few neighbors.

This is the central trade-off.

Recipe: Weighted Matrix

Use this version when edge weights matter.

class WeightedGraphMatrix:
    def __init__(self, n, directed=True):
        self.n = n
        self.directed = directed
        self.inf = float("inf")
        self.matrix = [[self.inf] * n for _ in range(n)]

        for i in range(n):
            self.matrix[i][i] = 0

    def add_edge(self, u, v, weight):
        self.matrix[u][v] = weight

        if not self.directed:
            self.matrix[v][u] = weight

    def weight(self, u, v):
        return self.matrix[u][v]

    def has_edge(self, u, v):
        return self.matrix[u][v] != self.inf

Example:

g = WeightedGraphMatrix(3, directed=True)
g.add_edge(0, 1, 8)
g.add_edge(1, 2, 4)

print(g.weight(0, 1))
print(g.weight(0, 2))

Output:

8
inf

Be careful with zero-weight edges. Do not use 0 as the sentinel for a missing edge if zero is a valid edge weight.

Complexity Characteristics

For V vertices, an adjacency matrix has predictable costs.

Operation Complexity
Memory usage O(V²)
Add edge O(1)
Remove edge O(1)
Check edge existence O(1)
Enumerate neighbors O(V)
Traverse graph O(V²)

The traversal cost differs from adjacency lists. With adjacency lists, traversal is O(V + E). With an adjacency matrix, traversal is typically O(V²) because each row scan checks every possible neighbor.

This matters for sparse graphs. If E is much smaller than , adjacency lists are usually better.

Matrix vs List

Use this comparison as a quick rule.

Requirement Prefer
Sparse graph Adjacency list
Dense graph Adjacency matrix
Fast edge lookup Adjacency matrix
Fast neighbor iteration Adjacency list
All-pairs algorithms Adjacency matrix
Memory efficiency Adjacency list
Simple small graph Either
Bitset acceleration Adjacency matrix

The choice is not permanent. Some algorithms convert between representations. For example, a graph may be loaded as an edge list, converted to an adjacency list for traversal, and converted to a matrix for all-pairs dynamic programming.

Common Pitfalls

Do not use an adjacency matrix for a huge sparse graph. It can fail before the algorithm begins because memory allocation alone is too large.

Do not forget the symmetric entry for undirected graphs.

Do not use a sentinel value that conflicts with valid weights.

Do not assume neighbor iteration is cheap. It scans an entire row.

Do not forget to initialize the diagonal for shortest-path matrices.

Do not mix external vertex labels with matrix indexes without a mapping layer. If vertices are strings, map them to integer IDs first.

labels = ["A", "B", "C"]
index = {label: i for i, label in enumerate(labels)}

Then store edges using indexes:

u = index["A"]
v = index["B"]
matrix[u][v] = True

Takeaway

An adjacency matrix is the right representation when constant-time edge lookup or all-pairs computation matters more than memory usage. It gives simple indexing and predictable operations, but it pays O(V²) space and often O(V²) traversal time. For dense graphs, small graphs, and matrix-oriented algorithms, that trade-off is acceptable. For large sparse graphs, use an adjacency list instead.