Search…
DSA Foundations · Part 6

2D arrays and matrices

In this series (8 parts)
  1. How computers store data
  2. Big-O and algorithm analysis
  3. Bits, bytes, and bitwise tricks
  4. Arrays: fixed-size and indexing
  5. Dynamic arrays: how lists grow
  6. 2D arrays and matrices
  7. Strings and character encoding
  8. String manipulation patterns

After arrays taught you that indexing is really base + index × element_size, and dynamic arrays showed how that flat block can grow, the next step is the matrix. A spreadsheet, chessboard, image, or DP table feels like a grid — but memory still only understands one long 1D line of bytes. A “2D array” is just a rule for translating (row, col) into one flat offset.


A 2D array is really one flat block

Suppose we store this 3 × 4 matrix of integers:

[
  [5, 8, 1, 4],
  [7, 2, 9, 6],
  [3, 0, 11, 12]
]

If each integer is 4 bytes and the base address is 1000, the CPU does not reserve “three rows.” It reserves 3 × 4 × 4 = 48 bytes in one contiguous chunk.

Ready
Click Step to see how the computer accesses array elements using memory addresses.

That same idea powers matrices too: before the CPU can use base + offset × element_size, the language/runtime converts (row, col) into one offset.

For row-major storage, that offset is:

offset = row × num_cols + col
address = base + (row × num_cols + col) × element_size

For our matrix, matrix[2][1] means row 2, column 1:

offset = 2 × 4 + 1 = 9
address = 1000 + 9 × 4 = 1036
value = 0

The big mental model: 2D indexing is syntax; the storage is still 1D. Once you believe that, matrix problems become much less mysterious.


Row-major vs column-major layout

Two common flattening rules exist.

Layout Offset formula Address formula Common languages
Row-major row × num_cols + col base + (row × num_cols + col) × element_size C, C++, Python/NumPy default, Java
Column-major col × num_rows + row base + (col × num_rows + row) × element_size Fortran, MATLAB, Julia (default)

Same matrix, different flattening rule. The data is still contiguous either way; only the mapping changes.

Here is the same matrix flattened in row-major order — each row is laid end-to-end in memory:

Matrix (3×4):                Flat memory (row-major):

  col: 0   1   2   3        index: 0  1  2  3  4  5  6  7  8  9  10  11
      ┌───┬───┬───┬───┐           ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬───┬───┐
row 0 │ 5 │ 8 │ 1 │ 4 │  ──────►  │ 5│ 8│ 1│ 4│ 7│ 2│ 9│ 6│ 3│ 0│ 11│ 12│
      ├───┼───┼───┼───┤           └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴───┴───┘
row 1 │ 7 │ 2 │ 9 │ 6 │           ▲   row 0   ▲   row 1   ▲    row 2    ▲
      ├───┼───┼───┼───┤
row 2 │ 3 │ 0 │11 │12 │
      └───┴───┴───┴───┘

matrix[2][1] = flat[2×4 + 1] = flat[9] = 0

And here is the difference numerically for one cell, matrix[2][1]:

  • Row-major: offset = 2 × 4 + 1 = 9
  • Column-major: offset = 1 × 3 + 2 = 5

So with base 1000 and 4-byte ints:

  • Row-major address: 1000 + 9 × 4 = 1036
  • Column-major address: 1000 + 5 × 4 = 1020

A good shortcut: in row-major, moving right by one column usually means moving right by one element in memory. In column-major, moving down by one row usually does.


True 2D arrays vs jagged arrays

Many beginners hear “2D array” and assume every language stores matrices the same way. They don’t.

Structure Memory shape Rows same length? Example
C true 2D array One contiguous block Yes int a[3][4]
Python list of lists Outer list points to separate inner lists Not required [[1,2],[3,4,5]]
NumPy ndarray Usually one contiguous typed block Yes by shape np.array(...)

A grid-like API does not guarantee contiguous 2D storage.

C: true contiguous matrix

int a[3][4] = {
    {5, 8, 1, 4},
    {7, 2, 9, 6},
    {3, 0, 11, 12}
};

This is one block of 12 integers. a[1][2] is compiled into address arithmetic using the row length 4.

Python: usually a jagged structure

grid = [
    [5, 8, 1, 4],
    [7, 2, 9, 6],
    [3, 0, 11, 12],
]

This looks identical, but grid is an outer list pointing to three separate inner lists. They may live in different places in memory, and they do not have to be equal length:

jagged = [[1, 2], [3, 4, 5], [6]]

That is impossible for int a[3][4] in C, but normal in Python.

NumPy: closer to the “real matrix” model

A NumPy array stores a homogeneous typed buffer plus shape/stride metadata. By default, it is row-major contiguous, which is why NumPy can do matrix-heavy work far faster than plain Python lists.

Practical lesson: interview “matrix” usually means equal row lengths, but everyday Python “2D lists” may be jagged.


Core traversal patterns

Nearly every matrix question is some variation of “visit cells in a pattern.” Start with the simple ones.

1) Row by row and column by column

In row-major languages, row-by-row traversal matches the physical layout best. Step through each pattern below to see the visit order:

Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
matrix = [
    [5, 8, 1, 4],
    [7, 2, 9, 6],
    [3, 0, 11, 12],
]

rows = len(matrix)
cols = len(matrix[0])

# Row-major traversal
for r in range(rows):
    for c in range(cols):
        print(r, c, matrix[r][c])

# Column-wise traversal
for c in range(cols):
    for r in range(rows):
        print(r, c, matrix[r][c])
#include <stdio.h>

int main(void) {
    int matrix[3][4] = {
        {5, 8, 1, 4},
        {7, 2, 9, 6},
        {3, 0, 11, 12}
    };

    int rows = 3, cols = 4;

    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            printf("(%d,%d)=%d\n", r, c, matrix[r][c]);
        }
    }

    for (int c = 0; c < cols; c++) {
        for (int r = 0; r < rows; r++) {
            printf("(%d,%d)=%d\n", r, c, matrix[r][c]);
        }
    }
}

For our example matrix, row-major order visits:

5, 8, 1, 4, 7, 2, 9, 6, 3, 0, 11, 12

Column-wise order visits:

5, 7, 3, 8, 2, 0, 1, 9, 11, 4, 6, 12

Same cells. Very different access pattern.

2) Diagonal traversal

For the main diagonal, row and column move together: (0,0), (1,1), (2,2). The full diagonal traversal groups cells by their r + c sum:

Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
matrix = [
    [5, 8, 1, 4],
    [7, 2, 9, 6],
    [3, 0, 11, 12],
]

limit = min(len(matrix), len(matrix[0]))
main_diag = [matrix[i][i] for i in range(limit)]      # [5, 2, 11]

The anti-diagonal in a square n × n matrix uses (i, n - 1 - i). More advanced diagonal-traverse problems often group cells by r + c.

3) Spiral traversal

Spiral problems look scary until you realize they are just four shrinking boundaries: top, bottom, left, right. Watch the pattern form step by step:

Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
def spiral_order(matrix):
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    order = []

    while top <= bottom and left <= right:
        for c in range(left, right + 1):
            order.append(matrix[top][c])
        top += 1

        for r in range(top, bottom + 1):
            order.append(matrix[r][right])
        right -= 1

        if top <= bottom:
            for c in range(right, left - 1, -1):
                order.append(matrix[bottom][c])
            bottom -= 1

        if left <= right:
            for r in range(bottom, top - 1, -1):
                order.append(matrix[r][left])
            left += 1

    return order
void spiral_order(int rows, int cols, int matrix[rows][cols]) {
    int top = 0, bottom = rows - 1;
    int left = 0, right = cols - 1;

    while (top <= bottom && left <= right) {
        for (int c = left; c <= right; c++) printf("%d ", matrix[top][c]);
        top++;

        for (int r = top; r <= bottom; r++) printf("%d ", matrix[r][right]);
        right--;

        if (top <= bottom) {
            for (int c = right; c >= left; c--) printf("%d ", matrix[bottom][c]);
            bottom--;
        }

        if (left <= right) {
            for (int r = bottom; r >= top; r--) printf("%d ", matrix[r][left]);
            left++;
        }
    }
}

On our matrix, spiral order is:

5, 8, 1, 4, 6, 12, 11, 0, 3, 7, 2, 9

This is the core idea behind Spiral Matrix.


Transpose and rotate

Two matrix transformations show up constantly because they turn spatial problems into simple swaps.

Transpose

Transposing swaps rows and columns — watch cells move to their new positions:

Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
original            transpose
5   8   1   4       5   7   3
7   2   9   6   →   8   2   0
3   0  11  12       1   9  11
                    4   6  12

The rule is simple:

transposed[c][r] = original[r][c]

For a square matrix, you can often transpose in place by swapping across the main diagonal.

Rotate 90° clockwise

For a square matrix, a famous trick is:

  1. Transpose
  2. Reverse each row

Step through the two-phase rotation below:

Step 0 of 0
Matrix traversal ready.
Visit order:
Code
 
def transpose(matrix):
    n = len(matrix)
    for r in range(n):
        for c in range(r + 1, n):
            matrix[r][c], matrix[c][r] = matrix[c][r], matrix[r][c]


def rotate_90_clockwise(matrix):
    transpose(matrix)
    for row in matrix:
        row.reverse()
void transpose_square(int n, int matrix[n][n]) {
    for (int r = 0; r < n; r++) {
        for (int c = r + 1; c < n; c++) {
            int temp = matrix[r][c];
            matrix[r][c] = matrix[c][r];
            matrix[c][r] = temp;
        }
    }
}

void reverse_row(int n, int row[n]) {
    for (int l = 0, r = n - 1; l < r; l++, r--) {
        int temp = row[l];
        row[l] = row[r];
        row[r] = temp;
    }
}

void rotate_90_clockwise(int n, int matrix[n][n]) {
    transpose_square(n, matrix);
    for (int r = 0; r < n; r++) reverse_row(n, matrix[r]);
}

Example with a square matrix:

before            after rotate 90° clockwise
1 2 3             7 4 1
4 5 6      →      8 5 2
7 8 9             9 6 3

Why it works: transpose mirrors across the main diagonal, and reversing each row turns that mirror into a clockwise turn.


Performance: access order matters

All full traversals are O(rows × cols), but real machines care about which nearby elements get touched next.

In a row-major matrix, visiting matrix[r][0], matrix[r][1], matrix[r][2] walks through adjacent memory. Visiting matrix[0][c], matrix[1][c], matrix[2][c] jumps by an entire row and often causes more cache misses.

Pattern in a row-major matrix Big-O Practical behavior
Row-by-row O(rows × cols) Cache-friendly: adjacent memory
Column-by-column O(rows × cols) More strided jumps, often slower
Jagged rows of unequal length Varies Extra pointer chasing, less predictable locality

Same asymptotic complexity, different constant factors because caches care about physical layout.

This is why numerical code and scientific libraries obsess over layout and strides. The algorithm may be identical on paper, but memory order changes runtime.


When to use a 2D array — and when not to

A dense matrix is great when:

  • the grid has a known rectangular shape,
  • most cells actually exist,
  • you need fast O(1) access by (row, col),
  • and you often scan neighbors or whole rows.

A 2D array is not always the right answer.

Problem shape Best fit Why
Dense board/image/DP table 2D array or matrix Every cell matters; indexing is simple and O(1)
Sparse grid with very few occupied cells Hash map / dictionary Avoids allocating huge empty regions
Rows with naturally different lengths Jagged array / list of lists No need to waste space forcing a rectangle
Mostly append-only table of records 1D dynamic array of structs/objects You may not need 2D indexing at all

A chessboard is a natural 8 × 8 matrix. But a map storing only 20 occupied coordinates in a 10,000 × 10,000 world should probably use a hash map like {(x, y): value} instead of reserving 100 million cells.

Ask: is the data truly rectangular and dense, or only conceptually grid-like?


Key takeaways

  1. A 2D array is still stored in 1D memory. The runtime/compiler converts (row, col) into one flat offset.
  2. Row-major formula: row × num_cols + col. Column-major formula: col × num_rows + row.
  3. C-style true matrices are contiguous; Python lists of lists are usually jagged structures of references. NumPy is much closer to a real contiguous matrix.
  4. Traversal patterns are the real interview skill: row-wise, column-wise, diagonal, spiral, transpose, and rotation.
  5. Access order affects speed. In row-major layouts, row-by-row iteration is usually more cache-friendly than column-by-column.
  6. Use a 2D array for dense rectangular data, a hash map for sparse coordinates, and a jagged array when row lengths naturally differ.

Practice problems


What’s next

Next up: strings. They look very different from arrays, but under the hood many of the same questions come back: contiguous storage, indexing, slices, immutability, and why some operations are O(1) while others secretly copy data.

Start typing to search across all content
navigate Enter open Esc close