What Are Multidimensional C-Style Arrays?

A multidimensional C-style array is an array whose elements are themselves arrays. Each nested layer adds a subscript, so you reach a single element with several indices instead of one.

An array's dimension is how many indices it takes to pick out a single element. An array that needs one index is a one-dimensional array (often abbreviated 1d array), an array that needs two is a two-dimensional array (2d array), and so on.

Take the seating plan of a small cinema screen: four tiers with six seats in each. You could store the whole plan as one flat run of 24 ints:

int flatPlan[24]{}; // 24 seats, reached with one index

That holds every seat, but the index says nothing about the shape of the room. Seat 4 of tier 2 lands at flatPlan[16] only because you agreed to do the arithmetic in your head. Since the element type of an array may be any object type, including another array, C++ can carry that shape for you instead.

Reading the declaration

int hall[4][6]{}; // 4 tiers, 6 seats per tier

Read the subscripts left to right: hall is an array of 4 elements, and each of those elements is an array of 6 ints. Adding a bound on the right makes the element type bigger; adding one on the left makes the array longer.

Declaration Reads as Indices to reach an int Dimension
int flatPlan[24]; 24 ints 1 one-dimensional
int hall[4][6]; 4 arrays of 6 ints 2 two-dimensional
int venue[3][4][6]; 3 arrays of 4 arrays of 6 ints 3 three-dimensional
Key Concept
Dimension counts subscripts, not elements. `int hall[4][6]` and `int flatPlan[24]` both store 24 ints, but only the first one is two-dimensional.

Selecting an element

Apply one subscript per dimension, left to right:

hall[1][4] = 2; // tier 1, seat 4

The first subscript picks a whole inner array: hall[1] names an int[6]. The second subscript then picks an int out of that. This is also why std::size(hall) reports 4 while std::size(hall[0]) reports 6.

By convention the left subscript is read as the row and the right one as the column, so hall[1][4] sits at row 1, column 4. Nothing in the language enforces that reading, but everyone follows it so that code and diagrams agree.

More layers means more subscripts. A three-screen multiplex where every screen has the same seating:

int venue[3][4][6]{}; // 3 screens of 4 tiers of 6 seats
venue[2][1][4] = 1;   // screen 2, tier 1, seat 4

Arrays beyond three dimensions are legal, but they get hard to reason about and are rare in practice.

Row-major order: how the grid is flattened

Memory is one-dimensional, so a 2d array has to be stored as a single run of ints. Two orders are possible, and a language has to commit to one:

Order Index that varies fastest Layout of a 2x3 array Used by
Row-major the rightmost [0][0] [0][1] [0][2] [1][0] [1][1] [1][2] C++, C
Column-major the leftmost [0][0] [1][0] [0][1] [1][1] [0][2] [1][2] Fortran, MATLAB

C++ uses row-major order: an entire row is stored, then the next row begins. That one fact drives both of the sections below, because it fixes the order initializers are consumed in and the order that is cheapest to traverse.

You can watch the layout directly. Hand a 2d array a flat list of values with no inner braces and they land in memory order:

#include <iostream>
#include <iterator>

int main()
{
    int hall[3][4]{ 41, 42, 43, 44, 51, 52, 53, 54, 61, 62, 63, 64 };

    for (std::size_t tier{ 0 }; tier < std::size(hall); ++tier)
    {
        for (std::size_t seat{ 0 }; seat < std::size(hall[tier]); ++seat)
            std::cout << hall[tier][seat] << ' ';

        std::cout << '\n';
    }

    return 0;
}
41 42 43 44 
51 52 53 54 
61 62 63 64 

The first four values filled row 0, the next four filled row 1, and the last four filled row 2. Nothing reshuffled them on the way in; that is the storage order itself.

Choosing an initializer form

Flat lists prove a point about layout, but they are a poor way to write a table. Nested braces let the source code look like the grid it describes.

What you write What you get
Nested braces, one inner brace per row Clearest form, and the source lines up with the grid
An inner brace with fewer values than the row holds The missing trailing elements are value-initialized to 0
int hall[][4] with initializers The compiler counts the rows for you
int hall[3][4] {} Every element is value-initialized to 0
int hall[][] with initializers Compile error: only the leftmost bound may be dropped

Written out, the first four rows of that table look like this:

int nested[3][4]
{
    { 12, 14, 16, 18 }, // row 0
    { 22, 24, 26, 28 }, // row 1
    { 32, 34, 36, 38 }  // row 2
};

int shortRows[3][4]
{
    { 12, 14 },     // row 0 becomes 12, 14, 0, 0
    { 22, 24, 26 }, // row 1 becomes 22, 24, 26, 0
    { 32 }          // row 2 becomes 32, 0, 0, 0
};

int deduced[][4]   // the compiler works out that this is 3 rows
{
    { 12, 14, 16, 18 },
    { 22, 24, 26, 28 },
    { 32, 34, 36, 38 }
};

int zeroed[3][4]{}; // all 12 elements are 0

Some compilers accept the inner braces being left out, as the row-major demonstration above relied on. Do not lean on that in real code: without the inner braces a single miscounted value silently shifts every element after it into the wrong row.

Best Practice
Initialize a multidimensional array with one set of inner braces per row. The braces document the shape, and they stop a missing value from sliding the rest of the data into neighbouring rows.

The leftmost bound is the only one the compiler can supply

The last row of the table is a hard rule, not a style preference. This does not compile:

int hall[][]
{
    { 12, 14, 16 },
    { 22, 24, 26 }
};
error: declaration of 'hall' as multidimensional array must have bounds for all dimensions except the first

The reason follows from the type. The element type of hall is int[4] (or int[6], or whatever the row length is), and the compiler cannot lay out or index an array until it knows how big one element is. The row length is part of that element type, so it must be spelled out. The number of rows is merely how many of those elements exist, which counting the initializers can answer.

The same asymmetry appears when a 2d array is passed to a function. The array decays to a pointer to its first element, and that first element is a whole row, so the row length stays in the parameter type while the row count has to travel separately:

#include <iostream>
#include <iterator>

// The seats-per-tier bound is part of the element type, so it must stay
void showHall(const int hall[][6], std::size_t tierCount)
{
    for (std::size_t tier{ 0 }; tier < tierCount; ++tier)
    {
        for (std::size_t seat{ 0 }; seat < std::size(hall[tier]); ++seat)
            std::cout << hall[tier][seat] << ' ';

        std::cout << '\n';
    }
}

int main()
{
    int hall[2][6]
    {
        { 41, 42, 43, 44, 45, 46 },
        { 51, 52, 53, 54, 55, 56 }
    };

    showHall(hall, std::size(hall));

    return 0;
}
41 42 43 44 45 46 
51 52 53 54 55 56 

Inside showHall, std::size(hall[tier]) still works because the row type survived the decay, but std::size(hall) would not compile: the outer length is gone, which is why tierCount is a separate parameter.

Traversing in memory order

One subscript needs one loop, so two subscripts need two nested loops. Which loop goes on the outside is a real decision.

#include <iostream>
#include <iterator>

int main()
{
    int hall[3][4]
    {
        { 41, 42, 43, 44 },
        { 51, 52, 53, 54 },
        { 61, 62, 63, 64 }
    };

    // std::size(hall) is the number of tiers, std::size(hall[0]) the seats per tier
    for (std::size_t tier{ 0 }; tier < std::size(hall); ++tier)
    {
        for (std::size_t seat{ 0 }; seat < std::size(hall[tier]); ++seat)
            std::cout << hall[tier][seat] << ' ';

        std::cout << '\n';
    }

    std::cout << '\n';

    // The outer loop hands you one whole tier, which is itself an array
    for (const auto& tierSeats : hall)
    {
        for (int label : tierSeats)
            std::cout << label << ' ';

        std::cout << '\n';
    }

    return 0;
}
41 42 43 44 
51 52 53 54 
61 62 63 64 

41 42 43 44 
51 52 53 54 
61 62 63 64 

Both loop styles work. The range-based version reads better because the outer loop variable is naturally a whole row: tierSeats binds to an int[4], and the inner loop then walks that row. Take the outer row by reference (const auto&), otherwise the loop tries to copy each row on every iteration.

In both versions the row selector is the outer loop and the column selector is the inner loop. That ordering walks the array straight through memory in row-major order, which is exactly how the hardware wants to read it.

Warning
Putting the column selector on the outside still visits every element and still prints correct values, but each step then jumps a whole row forward in memory instead of moving to the neighbouring int. Every access lands in a different cache line, and on a large array that can cost several times the run time. Keep the leftmost subscript on the outer loop.

Worked example: reading a seat map

This program stores a sold-or-available flag per seat, draws the hall as a picture, and tallies each tier as it goes:

#include <iostream>
#include <iterator>

int main()
{
    // 0 = still available, 1 = sold
    constexpr int hall[4][6]
    {
        { 1, 1, 0, 0, 1, 0 },
        { 0, 1, 1, 1, 0, 0 },
        { 1, 1, 1, 1, 1, 0 },
        { 0, 0, 0, 1, 0, 0 }
    };

    std::size_t soldTotal{ 0 };

    for (std::size_t tier{ 0 }; tier < std::size(hall); ++tier)
    {
        std::size_t tierSold{ 0 };

        std::cout << "Tier " << tier << "  ";

        for (std::size_t seat{ 0 }; seat < std::size(hall[tier]); ++seat)
        {
            std::cout << (hall[tier][seat] == 1 ? 'X' : '.');
            tierSold += static_cast<std::size_t>(hall[tier][seat]);
        }

        std::cout << "  " << tierSold << " sold\n";
        soldTotal += tierSold;
    }

    std::cout << "Hall total: " << soldTotal << " of "
              << std::size(hall) * std::size(hall[0]) << " seats\n";

    return 0;
}
Tier 0  XX..X.  3 sold
Tier 1  .XXX..  3 sold
Tier 2  XXXXX.  5 sold
Tier 3  ...X..  1 sold
Hall total: 12 of 24 seats

Notice how the two loops divide the work. Anything printed once per tier (the label, the running count, the newline) belongs in the outer loop; anything printed once per seat belongs in the inner one. The total number of elements is just the two lengths multiplied, std::size(hall) * std::size(hall[0]).

Plotting {x, y} on a [row][column] grid

Grid data often arrives as Cartesian coordinates. In two dimensions those are written as a { x, y } pair, where x measures horizontally and y measures vertically. Array subscripts run the other way round, and mixing the two up is one of the most common bugs in grid code.

Cartesian name What it measures Which array subscript
x horizontal position, across a row second subscript (the column)
y vertical position, down the rows first subscript (the row)

So a Cartesian { x, y } becomes canvas[y][x], reversed from the alphabetical order most people expect:

#include <iostream>
#include <iterator>

int main()
{
    char canvas[3][8]{};

    for (auto& stripe : canvas)
        for (char& cell : stripe)
            cell = '.';

    // Three points measured as { x, y }: x steps right, y steps down
    constexpr std::size_t plotX[]{ 3, 6, 0 };
    constexpr std::size_t plotY[]{ 0, 1, 2 };

    for (std::size_t point{ 0 }; point < std::size(plotX); ++point)
    {
        const std::size_t pixelX{ plotX[point] };
        const std::size_t pixelY{ plotY[point] };

        canvas[pixelY][pixelX] = '*'; // y picks the row, x picks the column
    }

    for (const auto& stripe : canvas)
    {
        for (char cell : stripe)
            std::cout << cell;

        std::cout << '\n';
    }

    return 0;
}
...*....
......*.
*.......

The point { 3, 0 } appears three characters along the top line, { 6, 1 } six characters along the middle line, and { 0, 2 } at the start of the bottom line, which is what the coordinates say should happen.

Warning
Writing `canvas[pixelX][pixelY]` compiles without complaint, because both subscripts are just integers. On a non-square grid it reads past the end of the array, which is undefined behaviour. On a square grid it quietly transposes the picture and produces no diagnostic at all. Check the subscript order every time you index a grid with coordinates.

Summary

Array dimension: How many indices it takes to pick out one element. One index means a one-dimensional array, two means a two-dimensional array, and so on. Dimension counts subscripts, not elements.

An array of arrays: int hall[4][6] declares 4 elements whose type is int[6]. The left subscript picks an inner array, the right one picks an int out of it. By convention the left subscript is the row and the right one is the column.

Multidimensional arrays: Any number of dimensions is allowed, so int venue[3][4][6] is valid, but arrays beyond three dimensions are rare.

Row-major order: C++ stores multidimensional arrays one complete row after another, so the rightmost index varies fastest and [0][0], [0][1], [0][2] all come before [1][0]. Column-major languages such as Fortran do the opposite.

Initialization: Use one set of inner braces per row. A short inner brace leaves the rest of that row value-initialized to 0, and {} zeroes the whole array. Only the leftmost bound may be omitted (int hall[][4]), because every other bound is part of the element type; int hall[][] is a compile error, and a function parameter must keep the row length for the same reason.

Traversal: Nested loops, with the row selector outside and the column selector inside. That order matches the memory layout and keeps the access pattern cache-friendly. Range-based loops work too, as long as the outer row is taken by reference.

Cartesian coordinates: x is the column and y is the row, so a point { x, y } is stored at array[y][x], not array[x][y].