03 · Multi-dimensional Arrays & String Manipulation¶
Grids, matrices, game boards, tables of records — the moment your data has two
axes you need a multi-dimensional array. And because C strings are just arrays
of char, "an array of strings" is a two-dimensional problem too, with a
choice of layouts that has real consequences.
This module covers how C actually lays 2-D arrays out in memory, how to pass them to functions (the part that trips everyone up), and the string library functions you'll use constantly — including which ones are dangerous and what to use instead.
A 2-D array is one flat block¶
int grid[3][4] is not "three pointers to rows". It is twelve contiguous
ints, stored row by row — C is row-major:
#include <stdio.h>
int main(void) {
int grid[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
printf("sizeof(grid) = %zu\n", sizeof grid); // 12 ints
printf("sizeof(grid[0]) = %zu\n", sizeof grid[0]); // one row: 4 ints
// Proof that it is one flat block:
int *flat = &grid[0][0];
printf("flat[6] = %d (same as grid[1][2] = %d)\n", flat[6], grid[1][2]);
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 4; c++) {
printf("%3d", grid[r][c]);
}
printf("\n");
}
return 0;
}
// Output:
// sizeof(grid) = 48
// sizeof(grid[0]) = 16
// flat[6] = 7 (same as grid[1][2] = 7)
// 1 2 3 4
// 5 6 7 8
// 9 10 11 12
grid[r][c] compiles to *(&grid[0][0] + r * 4 + c). The compiler needs the
column count to do that multiplication — which is why the next section
exists.
Row-major order also has a performance consequence you'll meet again in Level 4: iterating rows-then-columns walks memory sequentially and is cache-friendly; iterating columns-then-rows jumps 16 bytes at a time and can be several times slower on large matrices.
Passing a 2-D array to a function¶
A 2-D array decays to a pointer to its first row, i.e. int (*)[4]. The
column count is part of the type and cannot be omitted:
#include <stdio.h>
#define COLS 4
// Any of these three parameter spellings mean the same thing:
// int grid[3][COLS] int grid[][COLS] int (*grid)[COLS]
void print_grid(int rows, int grid[][COLS]) {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < COLS; c++) printf("%3d", grid[r][c]);
printf("\n");
}
}
// Alternative: take it flat, and do the index maths yourself.
// This works for ANY dimensions decided at run time.
void print_flat(const int *data, int rows, int cols) {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) printf("%3d", data[r * cols + c]);
printf("\n");
}
}
int main(void) {
int grid[2][COLS] = {{1,2,3,4},{5,6,7,8}};
print_grid(2, grid);
printf("--\n");
print_flat(&grid[0][0], 2, COLS);
return 0;
}
// Output:
// 1 2 3 4
// 5 6 7 8
// --
// 1 2 3 4
// 5 6 7 8
Note int (*grid)[COLS] — pointer to an array of COLS ints. Drop the
parentheses and int *grid[COLS] means an array of COLS pointers, an
entirely different layout. This is the same parenthesis rule you saw with
function pointers in Module 1.
Two layouts for "array of strings"¶
#include <stdio.h>
int main(void) {
// (a) 2-D char array: fixed-width rows, all mutable, one block
char names[3][16] = {"Ada", "Grace", "Linus"};
names[0][0] = 'a'; // legal -- it's your own memory
// (b) Array of pointers: ragged, each points at a string literal
const char *words[] = {"short", "considerably-longer", "mid"};
// words[0][0] = 's'; // UNDEFINED BEHAVIOR: literals are read-only
for (int i = 0; i < 3; i++) printf("%s ", names[i]);
printf("\n");
for (int i = 0; i < 3; i++) printf("%s ", words[i]);
printf("\n");
printf("sizeof(names) = %zu, sizeof(words) = %zu\n",
sizeof names, sizeof words);
return 0;
}
// Output:
// ada Grace Linus
// short considerably-longer mid
// sizeof(names) = 48, sizeof(words) = 24
char names[3][16] |
const char *words[3] |
|
|---|---|---|
| Memory | one 48-byte block | 3 pointers + the literals |
| Row length | all padded to 16 | exactly as long as needed |
| Mutable? | yes | no — literals are read-only |
| Swapping two entries | copies 16 bytes | swaps two pointers (cheap) |
| Good for | records you edit in place | fixed word lists, argv-style data |
Declaring string literals as char * instead of const char * compiles but
lets you write code that crashes at run time. Always const-qualify pointers
to literals.
The string library¶
Every function here assumes a terminating '\0'. Forget it and they run off
the end of your buffer.
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[32];
strcpy(buf, "Hello"); // copy in
strcat(buf, ", world"); // append
printf("%s (len %zu)\n", buf, strlen(buf));
printf("cmp equal: %d\n", strcmp("abc", "abc")); // 0
printf("cmp a < b: %d\n", strcmp("abc", "abd") < 0);
char *found = strchr(buf, 'w'); // first 'w'
if (found) printf("found at index %td\n", found - buf);
char *sub = strstr(buf, "world"); // substring search
if (sub) printf("substring: %s\n", sub);
return 0;
}
// Output:
// Hello, world (len 12)
// cmp equal: 0
// cmp a < b: 1
// found at index 7
// substring: world
| Function | Does | Watch out for |
|---|---|---|
strlen(s) |
length, excluding '\0' |
O(n) — don't call it inside a loop condition |
strcpy(d, s) |
copy s into d |
no bounds check — overflows silently |
strncpy(d, s, n) |
copy at most n bytes |
may not terminate d; terminate manually |
strcat(d, s) |
append | walks d to find its end first; no bounds check |
strcmp(a, b) |
<0, 0, >0 |
it is not a boolean — if (strcmp(a,b)) means not equal |
strchr(s, c) |
pointer to first c, or NULL |
check for NULL |
strstr(h, n) |
pointer to substring, or NULL |
check for NULL |
snprintf(d, n, ...) |
formatted, always terminates | the safe general-purpose builder |
The strncpy trap, and the fix¶
#include <stdio.h>
#include <string.h>
int main(void) {
char small[6];
// strncpy does NOT add '\0' if the source fills the buffer exactly.
strncpy(small, "abcdef", sizeof small);
small[sizeof small - 1] = '\0'; // you must do this yourself
printf("strncpy: %s\n", small);
// snprintf always terminates, and tells you what it WANTED to write.
char dest[10];
int needed = snprintf(dest, sizeof dest, "%s-%d", "value", 12345);
printf("snprintf: %s\n", dest);
if (needed >= (int)sizeof dest) {
printf("truncated: needed %d bytes\n", needed + 1);
}
return 0;
}
// Output:
// strncpy: abcde
// snprintf: value-123
// truncated: needed 12 bytes
snprintf is the workhorse: it never overflows, it always writes a '\0', and
its return value tells you whether truncation happened. When in doubt, reach
for it instead of strcpy/strcat.
Splitting a string with strtok¶
#include <stdio.h>
#include <string.h>
int main(void) {
// strtok MODIFIES its input, so it cannot be a string literal.
char line[] = "widget,42,9.99";
char *field = strtok(line, ",");
while (field != NULL) {
printf("[%s]\n", field);
field = strtok(NULL, ","); // NULL = "continue the same string"
}
printf("line is now: %s\n", line); // shows the damage strtok did
return 0;
}
// Output:
// [widget]
// [42]
// [9.99]
// line is now: widget
strtok replaces each delimiter with '\0' in place — which is why line
looks truncated afterwards, and why passing a literal (char *line = "a,b";)
is undefined behavior. It also keeps state in a hidden static variable, so you
cannot interleave two strtok loops or use it from threads.
A worked example: word frequency in a fixed table¶
#include <stdio.h>
#include <string.h>
#define MAX_WORDS 32
#define WORD_LEN 20
int main(void) {
char text[] = "the cat sat on the mat the end";
char words[MAX_WORDS][WORD_LEN];
int counts[MAX_WORDS] = {0};
int n = 0;
for (char *tok = strtok(text, " "); tok != NULL; tok = strtok(NULL, " ")) {
int found = -1;
for (int i = 0; i < n; i++) {
if (strcmp(words[i], tok) == 0) { found = i; break; }
}
if (found >= 0) {
counts[found]++;
} else if (n < MAX_WORDS) {
snprintf(words[n], WORD_LEN, "%s", tok); // safe bounded copy
counts[n] = 1;
n++;
}
}
for (int i = 0; i < n; i++) printf("%-6s %d\n", words[i], counts[i]);
return 0;
}
// Output:
// the 3
// cat 1
// sat 1
// on 1
// mat 1
// end 1
Note strcmp(...) == 0 for "equal". Writing if (strcmp(a, b)) reads like
"if equal" but actually means "if different" — a bug that survives code
review depressingly often.
How It Actually Works¶
The compiler needs the column count encoded in the type (int (*grid)[COLS])
because address computation for a 2-D array is a genuine multiplication
baked into the generated instructions at compile time, not something
resolved dynamically. grid[r][c] compiles to loading grid's base
address, computing r * COLS * sizeof(int) (often using a shift-and-add
sequence rather than an actual multiply instruction, since COLS is a
compile-time constant), adding c * sizeof(int), and dereferencing the
result — four or five instructions total, none of which involve a runtime
lookup of "how wide is this array." Without COLS known at compile time
inside the function, the compiler has no way to generate that multiply at
all, which is exactly why int grid[][COLS] accepts a variable row count
but never a variable column count in the classic (non-VLA) form.
The row-major cache-friendliness point is a real hardware effect worth
naming precisely: the CPU doesn't fetch memory one int at a time — it
pulls in a fixed-size cache line (commonly 64 bytes, 16 ints) from
RAM into the L1 cache on every miss. Iterating grid[r][c] with c as the
inner loop touches 16 consecutive ints per cache line before the next miss;
iterating with r as the inner loop jumps COLS * sizeof(int) bytes
between every single access, likely missing the cache on nearly every
iteration and forcing a slow trip to main memory each time — the same
matrix, same total accesses, but potentially an order of magnitude slower
purely because of access order relative to physical memory layout.
The two "array of strings" layouts differ in a way that maps directly onto
where their bytes physically live: char names[3][16] is 48 bytes on the
stack (or wherever it's declared) that your code fully owns and can
overwrite freely, while const char *words[] is 24 bytes of pointers
whose targets are string literals baked into the binary's read-only data
segment at compile time — the same kind of memory region discussed in
Level 1, Module 1. Writing through words[0][0]
attempts to modify that read-only segment, which the OS's memory protection
will reject with a segmentation fault — a different, harder failure than
the silent corruption an unchecked array write produces, because the
hardware's page tables mark that segment non-writable at the process level,
not just by convention.
Exercise¶
Write a program that stores a 5×5 int matrix and implements three functions:
void transpose(int m[][5], int n) (swap in place), int row_sum(const int
m[][5], int row), and void print_matrix(const int m[][5], int n). Then add a
string half: given char csv[] = "name,qty,price";, use strtok to split it
and store the fields into a char fields[8][16] table with snprintf, printing
each field with its length. Confirm with sizeof that your matrix really is
100 contiguous bytes.