二維陣列和「指標陣列」長得很像(都能寫 x[i][j]),底層卻是完全不同的兩種結構。 本單元釐清它們的記憶體佈局、傳入函式的正確寫法,並重現 K&R 的月份名稱經典範例。

學習目標

  • 理解二維陣列的 row-major(列優先) 記憶體佈局
  • 會把二維陣列傳入函式,並理解「為什麼欄數不能省略」
  • 掌握指標陣列(如 char *lines[])的結構與用途
  • 理解「指標的指標」(char **)以及它與指標陣列的關係
  • 能清楚比較「指標陣列」與「二維陣列」的差異與適用場景
  • 看懂並能默寫 K&R 的 month_name 範例

對照 K&R 章節

本單元主題 K&R 2nd Edition
多維陣列(日期轉換範例) §5.7 Multi-dimensional Arrays
指標陣列與指標的指標 §5.6 Pointer Arrays; Pointers to Pointers
指標陣列的初始化(month_name) §5.8 Initialization of Pointer Arrays
指標 vs 多維陣列 §5.9 Pointers vs. Multi-dimensional Arrays

觀念講解

1. 二維陣列的記憶體佈局(row-major)

int m[3][4] 是「3 個元素的陣列,每個元素是 4 個 int 的陣列」。 記憶體中一整列接著一整列連續排列:

m[0][0] m[0][1] m[0][2] m[0][3] m[1][0] m[1][1] ... m[2][3]
+------+------+------+------+------+------+-----+------+
| 第 0 列(row 0)            | 第 1 列 ...        ...   |
+------+------+------+------+------+------+-----+------+
 低位址  ------------------------------------------> 高位址

因此 m[i][j] 的位址 = 起點 + (i * 4 + j) * sizeof(int)。 這也解釋了為什麼按列走訪比按欄走訪快(連續記憶體對快取友善)。

初始化可以用巢狀大括號,較易讀:

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

2. 二維陣列傳入函式

編譯器要計算 m[i][j] 的位址,必須知道每列有幾欄;列數反而可以省略:

void print_matrix(int m[][4], int rows);     /* 欄數 4 不可省略 */
void print_matrix(int m[3][4], int rows);    /* 3 會被編譯器忽略 */
void print_matrix(int (*m)[4], int rows);    /* 與上面完全等價 */

int (*m)[4] 讀作「m 是指向『4 個 int 的陣列』的指標」。 注意 int *m[4](指標陣列)與 int (*m)[4](陣列指標)完全不同——括號決定一切。

3. 指標陣列(Array of Pointers)

處理「多個長度不一的字串」時,指標陣列遠比二維字元陣列合適:

char *lines[] = { "hello", "hi", "greetings" };
lines[0] --+--> "hello\0"
lines[1] --+--> "hi\0"
lines[2] --+--> "greetings\0"
(指標本身連續排列;字串散落各處、長度不一)

好處: - 每個字串只占實際需要的空間 - 交換兩列只需交換兩個指標,不必搬動字串本身(K&R 排序文字行的核心技巧)

4. 指標的指標(char **)

指標陣列傳入函式時退化為「指向指標的指標」:

void print_all(char **lines, int n)   /* 等同 char *lines[] */
{
    for (int i = 0; i < n; i++)
        printf("%s\n", lines[i]);     /* lines[i] 是第 i 個 char * */
}

argv 就是最著名的例子:int main(int argc, char *argv[]), 其中 argv 是「指向字串指標陣列」的 char **。

5. 指標陣列 vs 二維陣列

char a[10][20] char *a[10]
記憶體 固定 200 bytes,連續 10 個指標 + 各字串實際長度
每列長度 一律 20(浪費或不足) 可各自不同
交換兩列 要搬 20 bytes 只換兩個指標
初始化後 內容可修改 指向字面值時內容唯讀
a[i] 是什麼 第 i 列的(固定位置)位址 第 i 個指標變數的值

6. K&R 月份名稱範例(§5.8)

指標陣列初始化的經典示範——用非法月份的守衛值處理錯誤輸入:

char *month_name(int n)
{
    static char *name[] = {
        "Illegal month",
        "January", "February", "March",
        "April",   "May",      "June",
        "July",    "August",   "September",
        "October", "November", "December"
    };
    return (n < 1 || n > 12) ? name[0] : name[n];
}

static 讓陣列在函式返回後仍然存在,回傳其中的指標才安全。

範例程式

檔案 內容
01_matrix_layout.c 二維陣列宣告/初始化/走訪、row-major 佈局驗證、傳入函式(含 int (*)[N] 寫法)、矩陣轉置
02_month_name.c K&R 月份名稱範例 + day_of_year/month_day 日期轉換(§5.7 的閏年表)
03_pointer_array_vs_2d.c 指標陣列 vs 二維字元陣列的對比、交換指標排序字串、char ** 走訪

編譯與執行(以第一個為例):

cc -std=c11 -Wall -Wextra -o /tmp/demo 01_matrix_layout.c
/tmp/demo

常見錯誤與陷阱

1. 函式參數省略欄數

void f(int m[][], int r, int c);   /* 編譯錯誤:欄數必須給 */

修正:void f(int m[][4], int r);。若欄數要能變動, 可用 C99 的變長陣列參數 void f(int r, int c, int m[r][c]); 或改傳一維陣列自己算索引。

2. 把 int ** 當成二維陣列傳(或反過來)

void f(int **p);
int m[3][4];
f(m);            /* 錯!m 退化成 int (*)[4],不是 int ** */

二維陣列記憶體中沒有指標表,int ** 期望的是「指標的陣列」。兩者不可混用。

3. 修改指標陣列所指的字串字面值

char *names[] = { "alice", "bob" };
names[0][0] = 'A';    /* 未定義行為:字面值唯讀 */

修正:宣告為 const char *names[] 讓編譯器擋下,或改用二維陣列存放可修改副本。

4. 以為 m[1] 是指標變數

int m[3][4] 中,m[1] 是「第 1 列陣列」退化成的位址值,不是可指派的變數; m[1] = m[2]; 不合法。指標陣列的 a[1] = a[2]; 則完全合法。

練習題

  1. 寫 void identity(int m[][4], int n):把方陣設為單位矩陣,印出驗證。
  2. 寫 int trace(int m[][4], int n):回傳方陣對角線元素之和。
  3. 寫 char *weekday_name(int d):仿照 month_name,回傳星期名稱(含非法輸入守衛)。
  4. 寫 void sort_strings(const char *a[], int n): 用 strcmp 比較、只交換指標,把字串陣列按字典序排序。
  5. 實作 K&R §5.7 的 day_of_year 的反函式 month_day(int year, int yearday, int *pmonth, int *pday), 用指標參數回傳月與日。
  6. (進階)寫一個矩陣乘法 void matmul(const int a[][3], const int b[][3], int out[][3]), 3×3 矩陣相乘並印出結果。

延伸閱讀

  • K&R 2nd Edition,§5.6–5.9(本單元主軸)
  • K&R §5.6 的排序程式:指標陣列在 qsort 文字行的完整應用
  • comp.lang.c FAQ 6.18–6.20:二維陣列傳遞的各種疑難
  • 下一單元 04-strings-advanced:string.h 完整導覽與字串安全