7.10. Row-Major Ordering

Time: 00:03:12 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

Computer memory occupies a linear address space, meaning that programs treat it as a huge, one-dimensional array. Therefore, if a program has an array with two or more dimensions, the compiler maps the array's indexes to a single memory index. Historically, compilers used two schemes to map multi-dimensional arrays to linear computer memory: row-major or column-major order. (However, the only well-known computer maker using column-major ordering switched to row-major long ago.) Row-major order maps the indexes of a two-dimensional array to a single, linear address by rows: left-to-right and top-to-bottom. Conceptually, the mapping process extends to higher dimensions, but the implementation becomes more complex.

A picture illustrating row- and column-major ordering. A two-dimensional array, named 'array,' consisting of four rows and three columns, is filled with the characters A through L. The letter H at array[2][1] is mapped to a linear array using row-major ordering to index location 7 and using column-major ordering to index location 6.
array[i,j] = array + sizeof(T) * (i * ncols + j);
(b)
array[i][j] = array[2][1] = 'H'

memory[rm(i,j)] = memory[rm(2,1)] =
memory[i * ncols + j] = memory[2 * 3 + 1] =
memory[7] = 'H'
(a)(c)
Mapping a two-dimensional array to one dimension. Accessing a single element in a two-dimensional array requires two indexes, one for the row and one for the column. The compiler translates or maps the two index values to the single index needed to access the array element in the computer memory's linear or one-dimensional address space. The example names the two-dimensional array array, defines two variables maintaining the arrays' size (nrows and ncols), and two index variables (i and j) selecting a given element.
  1. The example defines the program array as T array[4][3], making nrows = 4 and ncols = 3. It fills the array elements with consecutive letters and labels the indexes to demonstrate the correspondence between the various representations. The example arbitrarily sets i and j (perhaps loop-control variables) to 2 and 1 and shows the elements' arrangement in computer memory for row- and column-major orderings. The mapping operation is independent of the array elements' type, so the example uses T as a general or unspecified type (i.e., a placeholder for a "real" type).
  2. An array's name is its address, so the compiler-generated mapping operation calculates an offset from the array's beginning to element array[i][j].
  3. memory is an invented array introduced to illustrate the row-major mapping operation, rm, a function mapping from two to one index: array[i]⁠[j] ⇒ memory[rm(i,j)]. For the example, let i = 2 and j = 1, which refers to the array element highlighted in yellow. Notice that the calculation does not use nrows, so it is unnecessary when we pass a two-dimensional array as a function argument.

Row-Major Demonstrations

Programmers must understand row-major ordering if they write programs initializing two-dimensional arrays with an initializer list, but we use two-dimensional arrays more often than initializer lists. Understanding row-major ordering also explains why C++ sometimes allows us to forgo the first dimension size. Although these reasons justify including row-major ordering in the text, we can also use them to solve some programming problems. The first example contrasts the "normal" array notation with a "trick" based on the row-major ordering operation.

Row-Major Indexing

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

int main()
{
    const int nrows = 4;
    const int ncols = 3;

    char  array[nrows][ncols] = {
        'A', 'B', 'C',
        'D', 'E', 'F',
        'G', 'H', 'I',
        'J', 'K', 'L'
    };

    for (int i = 0; i < nrows ; i++)
    {
        for (int j = 0; j < ncols; j++)
            cout << setw(2) << array[i][j];
        cout << endl;
    }

    return 0;
}
#include <iostream>
#include <iomanip>
using namespace std;

int main()
{
    const int nrows = 4;
    const int ncols = 3;

    char  array[nrows * ncols] = {
        'A', 'B', 'C',
        'D', 'E', 'F',
        'G', 'H', 'I',
        'J', 'K', 'L'
    };

    for (int i = 0; i < nrows ; i++)
    {
        for (int j = 0; j < ncols; j++)
            cout << setw(2) << array[i * ncols + j];
        cout << endl;
    }

    return 0;
}
(a)(b)
Two-dimensional array with row-major indexing. Two programs demonstrate the equivalence of a "true" two-dimensional array and a two-dimensional array synthesized from a one-dimension. The first program creates and accesses the elements of array with the typical two-index notation, while the second uses a one-index notation. The second program accesses individual array elements with an indexing expression derived from the row-major ordering operation. Both programs create array automatically on the stack, requiring programmers to use compile-time constants  to specify their dimension sizes.
  1. Creates a "standard" two-dimensional array (green) accessed with two index variables (blue).
  2. Creates a one-dimensional array (orange) - the compiler evaluates the constant product nrows*ncols. The program treats the array as two-dimensional by mapping the two indexes (i and j) to a single index (pink).
The output of both programs is:
 A B C
 D E F
 G H I
 J K L

Dynamic Two-Dimensional Arrays

A previous example (Figure 3(b)) demonstrated that programs can create one-dimensional arrays dynamically on the heap with the new operator. This technique allows programs to specify the array's size with a variable rather than a compile-time constant. Unfortunately, this flexibility doesn't extend to two or more dimensions. Fortunately, programmers can combine the row-major indexing "trick" demonstrated above with the new operator to achieve the desired flexibility. However, the technique requires more effort to create and destroy the array, and it requires a mapping operation, which the following example implements with an inline function for efficiency.
#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;
}
The multtab example with row-major indexing. This version of the multtab example synthesizes a two-dimensional array from a linear array by mapping two indexes to a single, one-dimensional index value. Replace "int" (highlighted in yellow) with another type name to create arrays of different types.
  1. The index function (blue) uses row-major ordering to translate or map from two to one dimension. Implementing the mapping as a function provides a general solution that is usable with arrays of any type.
  2. Dynamically creates (on the heap) a one-dimensional array large enough to hold nrows × ncols elements.
  3. Whenever an element of the synthesized 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 (pink).

Generalizing Functions With Array Parameters

Although multi-dimensioned array function arguments are independent of the dimension sizes, the corresponding function parameters are not (see Passing arrays as function arguments). Creating a general parameter, independent of the dimension sizes, is more challenging. Overloaded functions offer some relief but are practically limited to a small number of possible array configurations. Programmers can use the row-major ordering operation to implement a general function, albeit at the cost of a more complex and cumbersome function call.

#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;
}
Generalizing array functions with row-major ordering. Programs can't pass arrays a1 and a2 to the same function because their second dimensions have different sizes. The program can define two overloaded functions, (a) and (b), as a partial solution. However, this technique requires a different overloaded function for each column size. Function (c) is more flexible (its array parameter is independent of the number of rows and columns), but the function call must provide more information. The functions demonstrate how programmers define two-dimensional array parameters and access element [i][j] in the function's body.
  1. The array parameter is a two-dimensional character array with a variable number of rows but exactly 3 columns.
  2. The array parameter is a two-dimensional character array with a variable number of rows but exactly 2 columns.
  3. This version of print accepts a one-dimensional character array but treats it as two-dimensional. It converts the two-dimensional array indexes, i and j, into a single linear index using the row-major ordering operation (pink). This function can process any two-dimensional character array regardless of how many rows or columns it has. However, passing a two-dimensional array to this 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.

More Index Function Examples

Programmers can extend the row-major indexing function to handle both automatic (stack) and dynamic (heap) arrays. However, in the case of automatic arrays, the syntax can become complex and confusing.

Dynamic (Heap) Automatic (Stack)
#include <iostream>
using namespace std;

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

int main()
{
	int nrows = 4;
	int ncols = 3;

	char* array = new char[nrows * ncols] {
		'A', 'B', 'C',
		'D', 'E', 'F',
		'G', 'H', 'I',
		'J', 'K', 'L'
	};

	cout << array[index(2, 1, 3)] << endl;

	return 0;
}
#include <iostream>
using namespace std;

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

int main()
{
	const int nrows = 4;
	const int ncols = 3;

	char  array[4][3] = {
		'A', 'B', 'C',
		'D', 'E', 'F',
		'G', 'H', 'I',
		'J', 'K', 'L'
	}

	cout << *((char*)(array) + index(2, 1, 3)) << endl;

	return 0;
}
Index function: dynamic and automatic arrays. Generally, automatic variables are easier to use than dynamic ones, but these examples reverse the general case. When used in conjunction with a dynamic array, the index function is straightforward and well-demonstrated by the multtab example (Figure 3). In contrast, the automatic version involves several complex pointer operations:
(char*)(array)
Casts array, a two-dimensional character pointer, to a single dimension
+ index(2, 1, 3)
Calls the index function, calculating an offset from the beginning of the array, and adds the offset to array's address
*(...)
Dereferences the address expression to access the character it points to