2. 數組應用實例:統計隨機數

本節通過一個實例介紹使用數組的一些基本模式。問題是這樣的:首先生成一列0~9的隨機數保存在數組中,然後統計其中每個數字出現的次數並打印,檢查這些數字的隨機性如何。隨機數在某些場合(例如遊戲程序)是非常有用的,但是用計算機生成完全隨機的數卻不是那麼容易。計算機執行每一條指令的結果都是確定的,沒有一條指令產生的是隨機數,調用C標準庫得到的隨機數其實是偽隨機(Pseudorandom)數,是用數學公式算出來的確定的數,只不過這些數看起來很隨機,並且從統計意義上也很接近均勻分佈(Uniform Distribution)的隨機數。

C標準庫中生成偽隨機數的是rand函數,使用這個函數需要包含標頭檔stdlib.h,它沒有參數,返回值是一個介於0和RAND_MAX之間的接近均勻分佈的整數。RAND_MAX是該標頭檔中定義的一個常量,在不同的平台上有不同的取值,但可以肯定它是一個非常大的整數。通常我們用到的隨機數是限定在某個範圍之中的,例如0~9,而不是0~RAND_MAX,我們可以用%運算符將rand函數的返回值處理一下:

int x = rand() % 10;

完整的程序如下:

例 8.2. 生成並打印隨機數

#include <stdio.h>
#include <stdlib.h>
#define N 20

int a[N];

void gen_random(int upper_bound)
{
	int i;
	for (i = 0; i < N; i++)
		a[i] = rand() % upper_bound;
}

void print_random()
{
	int i;
	for (i = 0; i < N; i++)
		printf("%d ", a[i]);
	printf("\n");
}

int main(void)
{
	gen_random(10);
	print_random();
	return 0;
}

這裡介紹一種新的語法:用#define定義一個常量。實際上編譯器的工作分為兩個階段,先是預處理(Preprocess)階段,然後才是編譯階段,用gcc-E選項可以看到預處理之後、編譯之前的程序,例如:

$ gcc -E main.c
...(這裡省略了很多行stdio.h和stdlib.h的代碼)
int a[20];

void gen_random(int upper_bound)
{
 int i;
 for (i = 0; i < 20; i++)
  a[i] = rand() % upper_bound;
}

void print_random()
{
 int i;
 for (i = 0; i < 20; i++)
  printf("%d ", a[i]);
 printf("\n");
}

int main(void)
{
 gen_random(10);
 print_random();
 return 0;
}

可見在這裡預處理器做了兩件事情,一是把標頭檔stdio.hstdlib.h在代碼中展開,二是把#define定義的標識符N替換成它的定義20(在代碼中做了三處替換,分別位於數組的定義中和兩個函數中)。像#include#define這種以#號開頭的行稱為預處理指示(Preprocessing Directive),我們將在第 21 章 預處理學習其它預處理指示。此外,用cpp main.c命令也可以達到同樣的效果,只做預處理而不編譯,cpp表示C preprocessor。

那麼用#define定義的常量和第 3 節 “數據類型標誌”講的枚舉常量有什麼區別呢?首先,define不僅用於定義常量,也可以定義更複雜的語法結構,稱為宏(Macro)定義。其次,define定義是在預處理階段處理的,而枚舉是在編譯階段處理的。試試看把第 3 節 “數據類型標誌”習題2的程序改成下面這樣是什麼結果。

#include <stdio.h>
#define RECTANGULAR 1
#define POLAR 2

int main(void)
{
	int RECTANGULAR;
	printf("%d %d\n", RECTANGULAR, POLAR);
	return 0;
}

注意,雖然includedefine在預處理指示中有特殊含義,但它們並不是C語言的關鍵字,換句話說,它們也可以用作標識符,例如聲明int include;或者void define(int);。在預處理階段,如果一行以#號開頭,後面跟includedefine,預處理器就認為這是一條預處理指示,除此之外出現在其它地方的includedefine預處理器並不關心,只是當成普通標識符交給編譯階段去處理。

回到隨機數這個程序繼續討論,一開始為了便于分析和調試,我們取小一點的數組長度,只生成20個隨機數,這個程序的運行結果為:

3 6 7 5 3 5 6 2 9 1 2 7 0 9 3 6 0 6 2 6

看起來很隨機了。但隨機性如何呢?分佈得均勻嗎?所謂均勻分佈,應該每個數出現的概率是一樣的。在上面的20個結果中,6出現了5次,而4和8一次也沒出現過。但這說明不了什麼問題,畢竟我們的樣本太少了,才20個數,如果樣本足夠多,比如說100000個數,統計一下其中每個數字出現的次數也許能說明問題。但總不能把100000個數都打印出來然後挨個去數吧?我們需要寫一個函數統計每個數字出現的次數。完整的程序如下:

例 8.3. 統計隨機數的分佈

#include <stdio.h>
#include <stdlib.h>
#define N 100000

int a[N];

void gen_random(int upper_bound)
{
	int i;
	for (i = 0; i < N; i++)
		a[i] = rand() % upper_bound;
}

int howmany(int value)
{
	int count = 0, i;
	for (i = 0; i < N; i++)
		if (a[i] == value)
			++count;
	return count;
}

int main(void)
{
	int i;

	gen_random(10);
	printf("value\thow many\n");
	for (i = 0; i < 10; i++)
		printf("%d\t%d\n", i, howmany(i));

	return 0;
}

我們只要把#define N的值改為100000,就相當於把整個程序中所有用到N的地方都改為100000了。如果我們不這麼寫,而是在定義數組時直接寫成int a[20];,在每個循環中也直接使用20這個值,這稱為硬編碼(Hard coding)。如果原來的代碼是硬編碼的,那麼一旦需要把20改成100000就非常麻煩,你需要找遍整個代碼,判斷哪些20表示這個數組的長度就改為100000,哪些20表示別的數量則不做改動,如果代碼很長,這是很容易出錯的。所以,寫代碼時應儘可能避免硬編碼,這其實也是一個“提取公因式”的過程,和第 2 節 “數據抽象”講的抽象具有相同的作用,就是避免一個地方的改動波及到大的範圍。這個程序的運行結果如下:

$ ./a.out
value	how many
0	10130
1	10072
2	9990
3	9842
4	10174
5	9930
6	10059
7	9954
8	9891
9	9958

各數字出現的次數都在10000次左右,可見是比較均勻的。

習題

1、用rand函數生成[10, 20]之間的隨機整數,表達式應該怎麼寫?