5. 多維數組

就像結構體可以嵌套一樣,數組也可以嵌套,一個數組的元素可以是另外一個數組,這樣就構成了多維數組(Multi-dimensional Array)。例如定義並初始化一個二維數組:

int a[3][2] = { 1, 2, 3, 4, 5 };

數組a有3個元素,a[0]a[1]a[2]。每個元素也是一個數組,例如a[0]是一個數組,它有兩個元素a[0][0]a[0][1],這兩個元素的類型是int,值分別是1、2,同理,數組a[1]的兩個元素是3、4,數組a[2]的兩個元素是5、0。如下圖所示:

圖 8.3. 多維數組

多維數組

從概念模型上看,這個二維數組是三行兩列的表格,元素的兩個下標分別是行號和列號。從物理模型上看,這六個元素在存儲器中仍然是連續存儲的,就像一維數組一樣,相當於把概念模型的表格一行一行接起來拼成一串,C語言的這種存儲方式稱為Row-major方式,而有些編程語言(例如FORTRAN)是把概念模型的表格一列一列接起來拼成一串存儲的,稱為Column-major方式。

多維數組也可以像嵌套結構體一樣用嵌套Initializer初始化,例如上面的二維數組也可以這樣初始化:

int a[][2] = { { 1, 2 },
		{ 3, 4 },
		{ 5, } };

注意,除了第一維的長度可以由編譯器自動計算而不需要指定,其餘各維都必須明確指定長度。利用C99的新特性也可以做Memberwise Initialization,例如:

int a[3][2] = { [0][1] = 9, [2][1] = 8 };

結構體和數組嵌套的情況也可以做Memberwise Initialization,例如:

struct complex_struct {
	double x, y;
} a[4] = { [0].x = 8.0 };

struct {
	double x, y;
	int count[4];
} s = { .count[2] = 9 };

如果是多維字元數組,也可以嵌套使用字元串字面值做Initializer,例如:

例 8.4. 多維字元數組

#include <stdio.h>

void print_day(int day)
{
	char days[8][10] = { "", "Monday", "Tuesday",
			     "Wednesday", "Thursday", "Friday",
			     "Saturday", "Sunday" };

	if (day < 1 || day > 7)
		printf("Illegal day number!\n");
	printf("%s\n", days[day]);
}

int main(void)
{
	print_day(2);
	return 0;
}

圖 8.4. 多維字元數組

多維字元數組

這個程序中定義了一個多維字元數組char days[8][10];,為了使1~7剛好映射到days[1]~days[7],我們把days[0]空出來不用,所以第一維的長度是8,為了使最長的字元串"Wednesday"能夠保存到一行,末尾還能多出一個Null字元的位置,所以第二維的長度是10。

這個程序和例 4.1 “switch語句”的功能其實是一樣的,但是代碼簡潔多了。簡潔的代碼不僅可讀性強,而且維護成本也低,像例 4.1 “switch語句”那樣一堆caseprintfbreak,如果漏寫一個break就要出Bug。這個程序之所以簡潔,是因為用數據代替了代碼。具體來說,通過下標訪問字元串組成的數組可以代替一堆case分支判斷,這樣就可以把每個case裡重複的代碼(printf調用)提取出來,從而又一次達到了“提取公因式”的效果。這種方法稱為數據驅動的編程(Data-driven Programming),寫代碼最重要的是選擇正確的資料結構來組織信息,設計控制流程和算法尚在其次,只要資料結構選擇得正確,其它代碼自然而然就變得容易理解和維護了,就像這裡的printf自然而然就被提取出來了。[人月神話]中說過:“Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious.

最後,綜合本章的知識,我們來寫一個最簡單的小遊戲--剪刀石頭布:

例 8.5. 剪刀石頭布

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
	char gesture[3][10] = { "scissor", "stone", "cloth" };
	int man, computer, result, ret;

	srand(time(NULL));
	while (1) {
		computer = rand() % 3;
	  	printf("\nInput your gesture (0-scissor 1-stone 2-cloth):\n");
		ret = scanf("%d", &man);
	  	if (ret != 1 || man < 0 || man > 2) {
			printf("Invalid input! Please input 0, 1 or 2.\n");
			continue;
		}
		printf("Your gesture: %s\tComputer's gesture: %s\n", 
			gesture[man], gesture[computer]);

		result = (man - computer + 4) % 3 - 1;
		if (result > 0)
			printf("You win!\n");
		else if (result == 0)
			printf("Draw!\n");
		else
			printf("You lose!\n");
	}
	return 0;
}

0、1、2三個整數分別是剪刀石頭布在程序中的內部表示,用戶也要求輸入0、1或2,然後和計算機隨機生成的0、1或2比勝負。這個程序的主體是一個死循環,需要按Ctrl-C退出程序。以往我們寫的程序都只有打印輸出,在這個程序中我們第一次碰到處理用戶輸入的情況。我們簡單介紹一下scanf函數的用法,到第 2.9 節 “格式化I/O函數”再詳細解釋。scanf("%d", &man)這個調用的功能是等待用戶輸入一個整數並回車,這個整數會被scanf函數保存在man這個整型變數裡。如果用戶輸入合法(輸入的確實是數字而不是別的字元),則scanf函數返回1,表示成功讀入一個數據。但即使用戶輸入的是整數,我們還需要進一步檢查是不是在0~2的範圍內,寫程序時對用戶輸入要格外小心,用戶有可能輸入任何數據,他才不管遊戲規則是什麼。

printf類似,scanf也可以用%c%f%s等轉換說明。如果在傳給scanf的第一個參數中用%d%f%c表示讀入一個整數、浮點數或字元,則第二個參數的形式應該是&運算符加相應類型的變數名,表示讀進來的數保存到這個變數中,&運算符的作用是得到一個指針類型,到第 1 節 “指針的基本概念”再詳細解釋;如果在第一個參數中用%s讀入一個字元串,則第二個參數應該是數組名,數組名前面不加&,因為數組類型做右值時自動轉換成指針類型,在第 2 節 “斷點”scanf讀入字元串的例子。

留給讀者思考的問題是:(man - computer + 4) % 3 - 1這個神奇的表達式是如何比較出0、1、2這三個數字在“剪刀石頭布”意義上的大小的?