It is usually best to allocate an array of pointers, and then initialize each pointer to a dynamically allocated "row." The resulting "ragged" array can save space, although it is not necessarily contiguous in memory as a real array would be. Here is a two-dimensional example:
int **array = (int **)malloc(nrows * sizeof(int *));
for(i = 0; i < nrows; i++)
array[i] = (int *)malloc(ncolumns * sizeof(int));
(In "real" code, of course, malloc should be declared correctly, and each return value checked.)
You can keep the array's contents contiguous, while making later reallocation of individual rows difficult, with a bit of explicit pointer arithmetic:
int **array = (int **)malloc(nrows * sizeof(int *));
array[0] = (int *)malloc(nrows * ncolumns * sizeof(int));
for(i = 1; i < nrows; i++)
array[i] = array[0] + i * ncolumns;
In either case, the elements of the dynamic array can be accessed with normal-looking array subscripts: array[i][j].
If the double indirection implied by the above schemes is for some reason unacceptable, you can simulate a two-dimensional array with a single, dynamically allocated one-dimensional array:
int *array = (int *)malloc(nrows * ncolumns * sizeof(int));
However, you must now perform subscript calculations manually, accessing the i,jth element with array[i * ncolumns + j]. (A macro can hide the explicit calculation, but invoking it then requires parentheses and commas, which don't look exactly like multidimensional array subscripts.)