Menu

Mảng 2 chiều trong C: mảng nhiều chiều, bộ nhớ và ma trận

Cách khai báo, khởi tạo và duyệt mảng 2 chiều trong C, bố cục row-major thực sự có nghĩa gì trong bộ nhớ, vì sao truyền mảng 2 chiều vào hàm cần số cột, và một ví dụ ma trận chi tiết.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Mảng nhiều chiều là mảng mà mỗi phần tử của nó lại là một mảng. int grid[3][4]; không phải một kiểu lưới đặc biệt nào cả: đó là ba phần tử, mỗi phần tử là một mảng bốn số int, nằm nối tiếp nhau. Khi đã hiểu điều đó, phần còn lại tự nhiên theo sau: cách bố trí trong bộ nhớ, phép tính chỉ số, và quy tắc tưởng như khó hiểu khi truyền chúng vào hàm.

Hai chiều đã đủ cho gần như mọi nhu cầu thực tế: lưới, bảng, ma trận, bàn cờ game, hình ảnh. Vì vậy bài này tập trung vào mảng 2 chiều.

Khai báo và khởi tạo

int grid[3][4];        // 3 rows, 4 columns - 12 ints

Số thứ nhất là số hàng, số thứ hai là số cột. Có thể viết giá trị khởi tạo phẳng hoặc dùng cặp ngoặc nhọn bên trong; nên dùng ngoặc nhọn vì chúng cho thấy hình dạng của mảng.

Cách viết int e[][3] rất quan trọng: bạn có thể bỏ trống số hàng để giá trị khởi tạo tự quyết định, nhưng số cột thì không bao giờ được bỏ. Phần tiếp theo giải thích lý do.

Bố cục row-major

C lưu mảng 2 chiều theo thứ tự row-major: toàn bộ hàng 0, rồi toàn bộ hàng 1, cứ thế tiếp tục, trong một khối nhớ liền mạch. Phía sau không hề có một mảng các con trỏ tới từng hàng.

int grid[3][4] = {{ 1, 2, 3, 4},
                  { 5, 6, 7, 8},
                  { 9,10,11,12}};

how you picture it            how it actually sits in memory
+----+----+----+----+
|  1 |  2 |  3 |  4 |   row 0   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+           | 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
|  5 |  6 |  7 |  8 |   row 1   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+            \__ row 0 __/\__ row 1 __/\_ row 2 _/
|  9 | 10 | 11 | 12 |   row 2
+----+----+----+----+           grid[i][j] is at element index i*4 + j

Công thức i * columns + j chính là toàn bộ cơ chế, và cũng là lý do trình biên dịch phải biết số cột thì mới truy cập được bất kỳ phần tử nào. Số hàng không bao giờ tham gia vào phép tính.

Bạn có thể thấy trực tiếp cách bố trí này bằng cách in ra địa chỉ:

Các địa chỉ tăng dần đúng bằng sizeof(int) mà không có khoảng trống nào, kể cả ở chỗ một hàng kết thúc và hàng tiếp theo bắt đầu. Vòng lặp duyệt phẳng chứng minh điều đó: flat[k] đi qua cả mười hai phần tử như một dãy liền.

Cách bố trí này còn ảnh hưởng tới hiệu năng: duyệt theo hàng rồi tới cột sẽ chạm vào bộ nhớ theo đúng thứ tự, điều mà bộ nhớ đệm CPU rất ưa. Đảo thứ tự lồng vòng lặp để vòng trong đi xuống theo cột thì mỗi bước sẽ nhảy qua cả một hàng, và trên mảng lớn có thể chạy chậm hơn vài lần.

Vòng lặp lồng nhau

Hai chiều thì cần hai vòng lặp for: vòng ngoài chọn hàng, vòng trong quét các cột của hàng đó.

Đặt tên biến đếm theo ý nghĩa của nó (i/row cho hàng, j/col cho cột) và giữ thứ tự nhất quán: grid[row][col] ở mọi nơi. Một nửa số bug với mảng 2 chiều là do đảo ngược cặp chỉ số.

Các kích thước tạo bằng #define cũng không chỉ để trang trí: nhờ chúng, giới hạn vòng lặp và khai báo mảng không thể lệch nhau khi bạn đổi hình dạng mảng.

Truyền mảng 2 chiều vào hàm

Đây là quy tắc khiến ai cũng vấp: tham số của hàm phải khai báo số cột.

Lý do nằm ở sự suy biến (decay). Khi truyền grid, nó được chuyển thành con trỏ tới phần tử đầu tiên, mà phần tử của nó là các hàng, nên kiểu là int (*)[4]: con trỏ tới một mảng 4 số int. Để grid[i][j] có nghĩa, trình biên dịch phải biết một hàng dài bao nhiêu, và đó chính là số 4. Số hàng thực sự không có trong kiểu, nên nó phải được truyền như một đối số riêng.

Lưu ý int (*grid)[COLS] và int grid[][COLS] là cùng một tham số được viết theo hai cách. Cặp ngoặc tròn là bắt buộc, vì int *grid[COLS] lại là một mảng các con trỏ. Sự khác biệt này được giải thích trong bài con trỏ và mảng.

Nếu số cột chỉ biết được lúc chạy, tham số có kích thước thay đổi (variably-modified) của C99 cho phép bạn truyền nó vào trước:

void print_any(int rows, int cols, int grid[rows][cols]);

rows và cols phải được khai báo trước tham số mảng dùng chúng. Ở nơi không hỗ trợ cách này, giải pháp thường gặp là một mảng 1 chiều phẳng cộng với tự tính chỉ số:

data[i * cols + j] chính xác là thứ trình biên dịch viết hộ bạn trong trường hợp kích thước cố định. Tự làm chỉ tốn một dòng và dùng được cho mọi hình dạng được quyết định lúc chạy.

Ví dụ về ma trận

Nhân ma trận gom toàn bộ nội dung bài này lại: ba vòng lặp lồng nhau trên vùng nhớ row-major.

Có hai chi tiết đáng học theo. Vòng lặp k bên trong ghép a[i][k] với b[k][j]: một chỉ số đi dọc một hàng, chỉ số kia đi dọc một cột. Và phép chuyển vị bắt đầu vòng trong ở j = i + 1: nếu bắt đầu từ 0, mỗi cặp sẽ bị hoán đổi hai lần và ma trận giữ nguyên như cũ.

Ba chiều trở lên

Quy luật vẫn mở rộng như vậy, và quy tắc về tham số hàm cũng thế: mọi chiều trừ chiều đầu tiên đều phải được khai báo.

Trong thực tế, đến ba chiều thì mảng kích thước cố định bắt đầu trở nên cồng kềnh, và phần lớn code chuyển sang một khối phẳng với chỉ số tự tính, hoặc một mảng các struct đặt tên rõ ý nghĩa của từng trục.

Lỗi thường gặp

  • Viết grid[i, j]. Toán tử dấu phẩy tính i, bỏ nó đi, rồi truy cập bằng j. Code vẫn biên dịch được. Nhưng sai. Hãy dùng grid[i][j].
  • Đảo ngược chỉ số. grid[col][row] đọc một phần tử có thật nhưng ở sai chỗ, nên không có lỗi nào để bắt. Giữ thứ tự [row][col] ở mọi nơi.
  • Bỏ số cột trong tham số. void f(int grid[][]) không biên dịch được, và đó là trình biên dịch đang cứu bạn.
  • Truy cập ngoài phạm vi. Như mọi mảng khác, không có kiểm tra giới hạn. grid[0][5] trên một lưới [3][4] sẽ âm thầm đọc grid[1][1], vì bố cục liền mạch và phép tính chẳng quan tâm.

Câu hỏi thường gặp

Khai báo mảng 2 chiều trong C như thế nào?

Ghi hai kích thước trong ngoặc vuông: int grid[3][4]; khai báo 3 hàng, mỗi hàng 4 cột, tổng cộng 12 số int. Hãy đọc nó là "một mảng gồm 3 phần tử, mỗi phần tử là một mảng 4 số int", vì C lưu nó đúng theo cách đó.

Mảng 2 chiều được lưu trong bộ nhớ như thế nào trong C?

Theo thứ tự row-major: toàn bộ phần tử của hàng 0, rồi toàn bộ hàng 1, cứ thế tiếp tục, trong một khối nhớ liền mạch. grid[i][j] nằm ở vị trí cách đầu mảng i * columns + j phần tử, và đó là lý do số cột là con số mà trình biên dịch cần biết.

Truyền mảng 2 chiều vào hàm trong C như thế nào?

Tham số phải khai báo số cột: void print(int grid[][4], int rows) hoặc tương đương void print(int (*grid)[4], int rows). Có thể bỏ số hàng vì mảng suy biến thành con trỏ tới một hàng, nhưng nếu thiếu số cột, trình biên dịch không tính được một hàng bắt đầu ở đâu.

Có thể khởi tạo toàn bộ mảng 2 chiều bằng 0 không?

Có: int grid[3][4] = {0}; đặt mọi phần tử bằng 0, vì phần tử nào bạn không liệt kê đều được khởi tạo bằng 0. int grid[3][4] = {{1, 2}}; gán hai phần tử đầu của hàng 0 và để mười phần tử còn lại bằng 0.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu