← All structures

Matrix

Linear · view source on GitHub

Read this in: English · Português · Español

Category: Linear

The problem

Java's native T[][] isn't really one 2D structure — it's an array of references to independently-allocated 1D row arrays. Nothing guarantees those rows sit next to each other in memory, each row is its own heap object with its own header, and nothing stops rows from having different lengths (a "jagged" array), which is sometimes wanted but often just a footgun when what's actually needed is a fixed-shape grid with predictable memory layout and predictable access cost.

The solution

Back the whole grid with a single flat 1D array, and compute the flat index for (row, col) with row-major arithmetic: index = row * cols + col. That single allocation guarantees the entire matrix is one contiguous memory block, which turns get/set into direct arithmetic — and, just as importantly, makes traversal order a real, measurable cost: scanning the array in the same order it's laid out (row-major) stays cache-friendly, while scanning it in the "wrong" order (column-major, jumping cols slots every step) is not, even though both visit the exact same cells the exact same number of times.

flowchart LR
    subgraph "logical grid (2 rows x 3 cols)"
        direction LR
        A00["(0,0)"] --- A01["(0,1)"] --- A02["(0,2)"]
        A10["(1,0)"] --- A11["(1,1)"] --- A12["(1,2)"]
    end
    subgraph "flat backing array, row-major"
        direction LR
        F0["[0]=(0,0)"] --- F1["[1]=(0,1)"] --- F2["[2]=(0,2)"] --- F3["[3]=(1,0)"] --- F4["[4]=(1,1)"] --- F5["[5]=(1,2)"]
    end
Operation Cost Why
get(row, col) / set(row, col, value) O(1) direct arithmetic into the flat backing array
full traversal, row-major (matches storage order) O(rows·cols), cache-friendly sequential scan of one contiguous array
full traversal, column-major O(rows·cols), cache-unfriendly same element count, but jumps cols slots every step

Classic example

classic/Matrix is backed by a single Object[] sized rows * cols — no Java T[][]. get/set both compute the flat index with the same row-major formula, and both bounds-check row and column independently before touching the array. MatrixTest covers get/set round-tripping, that cells stay independent across rows and columns (the direct check that the index arithmetic isn't accidentally transposed or overlapping), every out-of-bounds row/column combination for both get and set, and both non-positive-dimension constructor guards.

Applied example: insurance premium-rating grid

applied/PremiumRatingGrid models an actuarial rating table exactly the shape it's already published in: rows are age brackets, columns are risk zones, and each cell holds the rate multiplier underwriting applies for that combination. Resolving a quote's multiplier is then a single O(1) indexed lookup — multiplierFor(ageBracket, riskZone) — instead of a chain of range checks or a linearly-scanned rule list. Querying a cell that was never registered fails loudly (IllegalStateException) rather than silently returning a default multiplier that could under-price a policy. PremiumRatingGridTest covers round-tripping a multiplier, independence across cells, the unset-cell failure case, a rejected non-positive multiplier, and an out-of-range lookup propagating the underlying bounds check.

Benchmark

./gradlew :linear:matrix:jmh

Real run on this machine (JMH 1.37, JDK 26.0.2, 2 warmup + 3 measurement iterations, 1 fork). Full traversal (sum every cell) of a square matrix, row-major order (matches the backing array's storage order) vs. column-major order (identical element count, jumps dimension slots per step):

Full-traversal cost dimension=100 (10K cells) dimension=500 (250K cells) dimension=1000 (1M cells)
row-major 9.06 µs 292.60 µs 1,711.45 µs
column-major 16.17 µs 1,494.50 µs 26,936.84 µs

Same element count, same operation, both orders — the gap is purely a memory-access-pattern effect. At the smallest size (10K cells, small enough to sit comfortably in cache regardless of order) column-major is only ~1.8x slower. That gap widens sharply as the matrix grows: ~5.1x slower at 250K cells, ~15.7x slower at 1M cells — exactly the shape a cache-locality effect produces once the working set stops fitting in cache and column-major's dimension-slot jumps start missing cache lines that row-major's sequential scan never does. The confidence interval at dimension=500 is wide (single-digit-millisecond JVM/GC noise at this iteration count) — the widening trend across all three sizes is the reliable signal here, not any one number in isolation.

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :linear:matrix:jacocoTestReport

Report at linear/matrix/build/reports/jacoco/test/html/index.html.

Unit tests

src/test/java/com/datastructures/linear/matrix/classic/MatrixTest.java
package com.datastructures.linear.matrix.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class MatrixTest {

    @Test
    void reportsTheDimensionsItWasConstructedWith() {
        Matrix<String> matrix = new Matrix<>(3, 5);

        assertThat(matrix.rows()).isEqualTo(3);
        assertThat(matrix.cols()).isEqualTo(5);
    }

    @Test
    void everyCellStartsUnset() {
        Matrix<String> matrix = new Matrix<>(2, 2);

        assertThat(matrix.get(0, 0)).isNull();
        assertThat(matrix.get(1, 1)).isNull();
    }

    @Test
    void setThenGetReturnsTheStoredValue() {
        Matrix<String> matrix = new Matrix<>(2, 2);

        matrix.set(1, 0, "value");

        assertThat(matrix.get(1, 0)).isEqualTo("value");
    }

    @Test
    void setReturnsThePreviousValueAtThatCell() {
        Matrix<String> matrix = new Matrix<>(2, 2);
        matrix.set(0, 0, "original");

        String previous = matrix.set(0, 0, "replaced");

        assertThat(previous).isEqualTo("original");
        assertThat(matrix.get(0, 0)).isEqualTo("replaced");
    }

    @Test
    void cellsAreIndependentAcrossRowsAndColumns() {
        Matrix<Integer> matrix = new Matrix<>(3, 3);

        for (int row = 0; row < 3; row++) {
            for (int col = 0; col < 3; col++) {
                matrix.set(row, col, row * 10 + col);
            }
        }

        for (int row = 0; row < 3; row++) {
            for (int col = 0; col < 3; col++) {
                assertThat(matrix.get(row, col)).isEqualTo(row * 10 + col);
            }
        }
    }

    @Test
    void getRejectsAnOutOfBoundsRowOrColumn() {
        Matrix<String> matrix = new Matrix<>(2, 2);

        assertThatThrownBy(() -> matrix.get(-1, 0)).isInstanceOf(IndexOutOfBoundsException.class);
        assertThatThrownBy(() -> matrix.get(2, 0)).isInstanceOf(IndexOutOfBoundsException.class);
        assertThatThrownBy(() -> matrix.get(0, -1)).isInstanceOf(IndexOutOfBoundsException.class);
        assertThatThrownBy(() -> matrix.get(0, 2)).isInstanceOf(IndexOutOfBoundsException.class);
    }

    @Test
    void setRejectsAnOutOfBoundsRowOrColumn() {
        Matrix<String> matrix = new Matrix<>(2, 2);

        assertThatThrownBy(() -> matrix.set(-1, 0, "x")).isInstanceOf(IndexOutOfBoundsException.class);
        assertThatThrownBy(() -> matrix.set(0, 2, "x")).isInstanceOf(IndexOutOfBoundsException.class);
    }

    @Test
    void constructorRejectsNonPositiveRows() {
        assertThatThrownBy(() -> new Matrix<String>(0, 3)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> new Matrix<String>(-1, 3)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void constructorRejectsNonPositiveCols() {
        assertThatThrownBy(() -> new Matrix<String>(3, 0)).isInstanceOf(IllegalArgumentException.class);
    }
}
src/test/java/com/datastructures/linear/matrix/applied/PremiumRatingGridTest.java
package com.datastructures.linear.matrix.applied;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PremiumRatingGridTest {

    @Test
    void setMultiplierThenMultiplierForReturnsIt() {
        PremiumRatingGrid grid = new PremiumRatingGrid(3, 4);

        grid.setMultiplier(1, 2, 1.35);

        assertThat(grid.multiplierFor(1, 2)).isEqualTo(1.35);
    }

    @Test
    void differentCellsHoldIndependentMultipliers() {
        PremiumRatingGrid grid = new PremiumRatingGrid(2, 2);

        grid.setMultiplier(0, 0, 1.0);
        grid.setMultiplier(0, 1, 1.2);
        grid.setMultiplier(1, 0, 1.5);
        grid.setMultiplier(1, 1, 2.0);

        assertThat(grid.multiplierFor(0, 0)).isEqualTo(1.0);
        assertThat(grid.multiplierFor(0, 1)).isEqualTo(1.2);
        assertThat(grid.multiplierFor(1, 0)).isEqualTo(1.5);
        assertThat(grid.multiplierFor(1, 1)).isEqualTo(2.0);
    }

    @Test
    void multiplierForOnAnUnsetCellThrows() {
        PremiumRatingGrid grid = new PremiumRatingGrid(2, 2);

        assertThatThrownBy(() -> grid.multiplierFor(0, 0)).isInstanceOf(IllegalStateException.class);
    }

    @Test
    void setMultiplierRejectsAZeroOrNegativeMultiplier() {
        PremiumRatingGrid grid = new PremiumRatingGrid(2, 2);

        assertThatThrownBy(() -> grid.setMultiplier(0, 0, 0.0)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> grid.setMultiplier(0, 0, -1.0)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void multiplierForOnAnOutOfRangeCellPropagatesTheUnderlyingBoundsCheck() {
        PremiumRatingGrid grid = new PremiumRatingGrid(2, 2);

        assertThatThrownBy(() -> grid.multiplierFor(5, 0)).isInstanceOf(IndexOutOfBoundsException.class);
    }
}

View full JaCoCo coverage report →