7.12. Two-Dimensional And Higher Arrays

Time: 00:08:21 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

As suggested by the most recent version of the multtab demonstration and the illustrations appearing in Visualizing Arrays, we can easily create a multi-dimensional array as an automatic or local variable if we know the size of each dimension at compile time. However, if we want to specify the size of the array at runtime, we can only easily create a one-dimensional array: double* scores = new double[size];. It's difficult for us and the compiler to create a multi-dimensional array whose dimension sizes are determined at runtime as the seemingly natural way of doing so, double* scores = new double[nrows][ncols];, doesn't work.

To help us better understand the problems of creating and using multi-dimensional arrays, let's explore how the compiler maps a two-dimensional array to the linear address space of main memory. Said another way, to locate an element in a two-dimensional array, the compiler must convert two address variables, row, and column, into the single address value used to locate the element in main memory. Once we understand how the compiler does the mapping or conversion, we can use our understanding to develop two ways to create multi-dimensional arrays.

row-major Ordering

An illustration in chapter 4 suggests that we can view a computer's main memory as an array of bytes. In this view, each byte of main memory corresponds to one array element, and the address of each byte is analogous to the index location of the corresponding element. This arrangement suggests that when we create and use a one-dimensional array in a program, it's quite easy for the compiler to convert or map the location of an array element to a memory address. Although it takes multiple bytes of memory to store most C++ data types (see part (b) of the illustration), this fact does not significantly increase the complexity of the mapping operation. Recalling that the name of an array is the address of the array, the compiler generates the mapping operation array + i * sizeof(T), where T is some unspecified data type, array is the name of an array, and i is a valid index into the array. (This expression is similar to, but not the same as, the code a programmer writes to solve the sample problem. Test your understanding: Review Arithmetic with multi-byte data and try to write the correct C++ code. The answer is at the end of the page)

Historically, two similar techniques were used to map multi-dimensional arrays into linear memory: row-major and column-major order. (However, the only well-known computer maker using column-major ordering long ago converted to row-major.) The row-major order maps the elements of a two-dimensional array to a one-dimensional array by rows: reading left-to-right, top-to-bottom, the first row, then the second row, etc. Alternatively, column-major order maps the elements by columns: reading top-to-to-bottom, left-to-right, the first column, then the second column, etc.

A picture illustrating row- and column-major ordering. A two-dimensional array, four rows by three columns, is filled with the characters A through L. The letter H at [2][1] is mapped to a linear array. Using row-major ordering, H is mapped to [7], and use column-major ordering, H is mappted to [6].
array[i][j] = array[2][1] =
RM[i * ncols + j] =
RM[2 * 3 + 1] =
RM[7]
(b)
array + sizeof(T) * (i * ncols + j);
(c)
array[i * ncols + j] ≡ array + (i * ncols + j)
(a)(d)
Mapping a two-dimensional array to one dimension. Programs store two-dimensional arrays in memory as one-dimensional arrays, usually using a row-major ordering. When the program accesses an array element with two indexes, one for the row and one for the column, the compiler translates or maps the two indexes into the single index needed to access the element in the one-dimensional array.
  1. For this example, we begin with a 4×3 array: nrows = 4 and ncols = 3. If we name the array array and let T be some unspecified type, then we can define the array as T array[4][3]. The array may be arranged in main memory either in row-major or column-major order as illustrated, but row-major is the most common ordering.
  2. array is the real, two-dimensional array appearing in a program. RM is an invented name for the one-dimensional array; it's introduced here to illustrate the mapping from two indexes to one. To illustrate the mapping from array[i][j] to RM[n], let i = 2, j = 1, and ncols = 3, which refers to the array element highlighted in yellow in both arrays. Notice that the the calculation does not use nrows, which is why that value is not needed when we pass a two-dimensional array as a function argument.
  3. Running programs always locate data in memory by address. The compiler locates each array element based on a simple memory calculation. (The compiler generates this code; (d) illustrates programmer written code.)
  4. To solve a problem, a programmer might find it convenient to think of an array as being two-dimensional but to implement as one-dimensional. In this case, the programmer has two options for mapping two indexes to a single index. The first version of multtab below demonstrates this technique.
If needed, we can extend these results to higher array dimensions.
#include <iostream>
using namespace std;

void print(char array[][3], int i, int j)			// (a)
{
	cout << array[i][j] << endl;
}

void print(char array[][2], int i, int j)			// (b)
{
	cout << array[i][j] << endl;
}

void print(char* array, int i, int j, int ncols)		// (c)
{
	cout << array[i * ncols + j] << endl;
}

int main()
{
	char	a1[4][3] = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L' };
	char	a2[3][2] = { 'u', 'v', 'w', 'x', 'y', 'z' };

	print(a1, 2, 1);					// calls (a)
	print(a2, 1, 0);					// calls (b)
	print((char *)a1, 2, 1, 3);				// calls (c)
	print((char *)a2, 1, 0, 2);				// calls (c)

	return 0;
}
General array functions with row-major ordering. Although it's easy to pass a two-dimensional array in a function call, it's difficult to create a general function that has a parameter that can receive the array. Using the array parameter notation introduced previously, the arrays a1 and a2 cannot be passed to the same function. All the print functions print the [i][j] element of their array arguments.
  1. A character array with any number of rows may be passed to this function, but the array must have exactly 3 columns.
  2. A character array with any number of rows may be passed to this function, but the array must have exactly 2 columns.
  3. This version of print accepts a one-dimensional character array but treats it as a two-dimensional array. It converts the two-dimensional array indexes, i and j, into a single linear index using the row-major ordering function. We can use this print function for any two-dimensional character array.
Passing a two-dimensional array to the (c) print function requires a type cast to convert the array from char** to char*. Fortunately, pointers (i.e., addresses) are malleable, and the cast completes without error. The (c) version is more flexible and maintainable because programmers can use it with two-dimensional character arrays whose dimensions may be any size. The alternative to (c) requires us to add a new function tailored for each array with a different number of columns.

Although understanding how a program stores and accesses a two-dimensional array in memory explains why functions require the second and subsequent dimension sizes, it doesn't help programmers create two-dimensional arrays whose sizes are determined at runtime. After all, the program in the previous example still specifies the array sizes with compile-time constants: a1[4][3] and a2[3][2]. But understanding how row major-ordering works does suggest how we can synthesize a two-dimensional array from a one-dimensional array.

Creating Two-Dimensional Arrays with row-major Ordering

Now that we have an idea of how row-major ordering works - how it converts two indexes into one index - we can use it to create a one-dimensional array that behaves like a two-dimensional array. The "trick" is a simple expression that consistently combines the row and column to produce the same, unique single array index value. For clarity, the following example evaluates the expression in an inline function.

#include <iostream>
#include <iomanip>
using namespace std;


inline int index(int row, int col, int ncols) { return row * ncols + col; }	// (a)

int main()
{
	int	nrows;
	int	ncols;

	cout << "Number of rows: ";
	cin >> nrows;
	cout << "Number of columns: ";
	cin >> ncols;

	int*	table = new int[nrows * ncols];					// (b)

	for (int row = 1; row <= nrows; row++)
		for (int col = 1; col <= ncols; col++)
			table[index(row - 1, col - 1, ncols)] = row * col;	// (c)


	for (int row = 0; row < nrows; row++)
	{
		for (int col = 0; col < ncols; col++)
			cout << setw(4) << table[index(row, col, ncols)];	// (c)
		cout << endl;
	}

	return 0;
}
multtab based on a row-major mapping. This example uses row-major mapping to simulate a two-dimensional array implemented with a single dimension. Replace the highlighted "int" with another type name to create arrays of different types.
  1. The index function evaluates the row-major expression to map a row × column address into a single array index. I believe that wrapping the row-major calculation in a function accrues two advantages: First, the function is less-error prone than the expression alone when the indexes are complex expressions, and the advantage increases proportionally with the expression complexity. Second, it provides a general solution in programs with multiple simulated two-dimensional arrays. index is an ideal example of a function that benefits from being inlined: An authentic program will call it frequently, and because it avoids the overhead of a call and return, the generated machine code is smaller than a "regular" or non-inline function.
  2. Creates a one-dimensional array large enough to hold all the rows and columns of the simulated two-dimensional array.
  3. Whenever an element of the simulated two-dimensional array is needed, the index function maps the row and column indexes into the single value needed to index into the actual one-dimensional array. However, the index function is not necessary - it just reflects my personal preference. We can easily embed the mapping operation directly in each array access:
    table[row * ncols + col]
Ideally, we would like our code to "hide" the row × column ⇒ index mapping, (c), from the client code. We can easily implement the desired hiding by wrapping the array logic in a class.

Although creating a simulated two-dimensional array is easy, and we don't need to destroy it later (because the program makes it on the stack), using the array is a little awkward and unnatural. Whenever we want to access an array element, we must translate the row×column address into a single array index using the row-major ordering. Another two-dimensional technique is available.

Creating Two-Dimensional Arrays As An Array Of Arrays

Java takes a different approach to creating multi-dimensional arrays: it creates an array of arrays. Specifically, creating a two-dimensional array creates a one-dimensional array whose elements are arrays. We can do the same in C++. This technique reverses the advantages and disadvantages of the row-major solution. It takes more effort to create the array, and we must destroy it later, but using the array is less awkward because it only uses the array indexing operator.

An arrow from 'table' to a vertical array of of pointers suggests that 'table' is a pointer to an array of pointers. There is one pointer in the array for each row (i.e., nrows) in the table. Each row pointer points to a data array (i.e., to an array of data elements) 'ncols' long.
#include <iostream>
#include <iomanip>
using namespace std;

int main()
{
    int    nrows;
    int    ncols;

    cout << "Number of rows: ";
    cin >> nrows;
    cout << "Number of columns: ";
    cin >> ncols;

    int**    table = new int* [nrows];		// (i)

    for (int i = 0; i < nrows; i++)		// (ii)
        table[i] = new int[ncols];

    for (int row = 1; row <= nrows; row++)
        for (int col = 1; col <= ncols; col++)
            table[row-1][col-1] = row * col;	// (iii)


    for (int row = 0; row < nrows; row++)
    {
        for (int col = 0; col < ncols; col++)
            cout << setw(4) << table[row][col];	// (iii)
        cout << endl;
    }
    
    for (int i = 0; i < nrows; i++)		// (iv)
        delete[] table[i];
    delete[] table;

    return 0;
}
(a)(b)
multtab based on an array of arrays.
  1. table is a pointer to a pointer (i.e., it's a pointer with two levels of indirection as illustrated in b.i). table points to an array of row pointers. Each row pointer points to an array that serves as one table row.
    1. The program defines table as int**, which means that it is a pointer to a pointer. The data type int* means that each element of table is a pointer to an integer, that is, an array of integers.
    2. The program must create each row of the table one at a time.
    3. One of the advantages of creating a two-dimensional array as an array or arrays is that the "client" or main-logic code can continue to use the two-index notation: table[row][col].
    4. The program must delete each row one at a time. When the rows are delete, then the program deletes the array of pointers. The square brackets, [], indicate that delete is operating on an array.


Answer

array + i
Why is the code that the compiler generates, array + i * sizeof(T), different from the code that a programmer writes? When the compiler translates the programmer's C++ code to machine code, it automatically multiples i by the size of each array element, that is, by sizeof(T), making it unnecessary for programmers to perform the operation.