书接上回,我们已经让玩家输入了扫雷棋盘的大小和雷的个数,接下来只需要实现扫雷的核心逻辑,即 MineSweeper 函数。希望大家享受编程的过程,并且有所收获。
void MineSweeper(size_t row, size_t col, size_t mineCount);
摆在我们面前的一个难题是:我可以直接创建一个二维数组,比如 int arr[3][5]; 就是一个三行五列的数组,但是创建数组时是不能用变量指定大小的!
说个题外话,C99 支持变长数组,可以使用变量来定义数组,但不能初始化。如:
int num = 10;
char ch[num];
但是绝大多数的编译器都不支持呀!所以我们要另辟蹊径。这里卖个关子,本篇博客会教会你如何使用动态内存管理的知识来管理二维数组,这个技巧会在很多 online judge (在线编程)题目中见到,还是很重要的。
复习动态内存管理。
使用动态内存管理的知识来模拟二维数组。
对这块模拟出来的二维数组进行初始化。
打印这个模拟出来的二维数组。
回收这块动态开辟出来的空间。
如果我们想一次性存储很多数据,可以使用数组。但是数组的大小在创建的时候就已经确定了,而且必须使用常量来初始化(C99 标准前不支持变长数组)。我们想实现扫雷,就必须用 row, col 这样的变量来初始化,这该怎么办呢?这就需要动态内存管理派上用场了!
C语言的动态内存管理有四大天王四个函数,分别是 malloc, calloc, realloc 和 free。这四个函数都非常重要,分别扮演了重要的角色,使用它们都得包含头文件 stdlib.h 。不过我们今天只会用到 malloc 和 free ,所以就先复习下这两个函数。
malloc 函数是用来开辟空间的。比如,我想开辟 10 个字节的空间,就只需要写 malloc(10) 就行了,malloc 函数会帮你开辟好 10 个字节的连续空间,并且把这块空间的起始位置返回给你。注意,返回的地址的类型是 void* 。void* 类型的特点是,不能解引用,不能加减整数,啥都不能干。所以,你如果想要使用这块空间,就必须使用非 void* 类型的指针来接收这个地址。
char* pch = (char*)malloc(10);
由于 malloc 是在堆上申请空间,需要程序员手动释放。当我们不需要这块空间时,必须手动 free 掉这块空间,否则会引起内存泄漏。
free(pch);
pch = NULL;
接下来是重头戏!malloc 可以帮我们申请到一块空间,具体原理如下图。
既然 ptr 可以指向一块空间,这块空间里如果不存 char ,而是存 char* 呢?就像这样:
要是每个 char* 又指向一块 malloc 开辟的空间呢,就像这样:
如何访问用颜色标识出来的空间呢?是不是 ptr[0][1] 就行了?这样表示和二维数组访问元素是完全一样的!
回到扫雷小游戏。我们想要创建一个 row * col 大小的盘面来存储雷的信息(mine),同时还需要另一个数组来存储排查出来的雷的信息(show)。对于 show ,假设对于没有排查的位置都存储 * ,那么 show 应该是一个存储 char 类型元素的二维数组,为了方便管理,我们假设 show 也是存储 char 类型的二维数组。
但是,如果只开 row * col 大小的数组,对于边上或者角上位置,就很难统计周围 8 个位置雷的个数,因为直接访问会越界。我一开始想的是,可以判断一下,但后来想到了个更简单的办法,把 mine 和 show 都开大一圈,也就是说,开 (row+2)*(col+2) 大小的数组!
而只使用中间的 row*col 部分不就行了吗!其余位置就默认当没有雷,这样统计某个位置周围 8 个位置雷的个数时,就不需要考虑边角上的问题了。
阶段性总结一下:我们需要开两个 char 类型,大小是 (row+2)*(col+2) 的数组,而数组不能在初始化时用变量指定大小,所以可以使用 malloc 两层的方法,先 malloc 出一个存储元素类型是 char* 的指针数组(这个指针数组有 row+2 个空间),然后再让每一个 char* 指针指向一个存储元素类型是 char 的字符数组(这个字符数组有 col+2 个空间)。而具体访问每一个 char 的时候,就可以通过下标的方式,既 mine[i][j] 的方式来访问了,完美!
void MineSweeper(size_t row, size_t col, size_t mineCount)
{assert(row < 100 && col < 100);assert(mineCount < row * col);char** mine = NULL;char** show = NULL;InitBoard(&mine, row, col, '0');InitBoard(&show, row, col, '*');DisplayBoard(mine, row, col);DisplayBoard(show, row, col);DestroyBoard(mine, row, col);DestroyBoard(show, row, col);
}
解释下,assert 函数是用来断言的,也就是说,assert 后面跟的括号里的表达式如果为真,就什么都不会发生,如果为假,就会直接终止掉程序。assert 函数的使用需要包含头文件 assert.h 。这里判断的是上期讲解的,row 和 col 不能超过两位数且雷的个数不能多于总数。
mine 数组中,默认用字符 0 表示该位置不是雷,字符 1 表示该位置是雷。show 数组中,用 * 表示该位置未排查,数字字符(字符 1 ~ 字符 8 )表示该位置周围 8 个位置雷的个数。当然还有其他的设计,比如空格和 ? 字符,这些留着后面讲解。一开始的初始化函数 InitBoard 负责把 mine 和 show 模拟的二维数组分别初始化成全 0 和全 * 。由于改变了 mine 和 show 两个指针变量,所以需要传它们的地址,而 char** 的地址是 char*** 。接下来是 InitBoard 函数的实现,采取前面说的两层 malloc 的方式。
static void InitBoard(char*** pBoard, size_t row, size_t col, char set)
{assert(pBoard != NULL);*pBoard = (char**)malloc((row + 2) * sizeof(char*));if (*pBoard == NULL){perror("InitBoard: 开辟空间失败");return;}for (size_t i = 0; i < row + 2; ++i){(*pBoard)[i] = (char*)malloc((col + 2) * sizeof(char));if ((*pBoard)[i] == NULL){perror("InitBoard: 开辟空间失败");for (size_t j = 0; j < i; ++j){free((*pBoard)[j]);(*pBoard)[j] = NULL;free(*pBoard);*pBoard = NULL;}return;}else{memset((*pBoard)[i], set, col + 2);}}
}
函数前用 static 关键字修饰,函数成为静态的函数,只能在本源文件内部使用,其他源文件内不能使用,这是一个很好的封装。
每次 malloc 之后,都必须检查是否开辟空间成功,如果失败,malloc 会返回空指针 NULL 。
如果空间都开辟成功,使用 memset 函数把这块空间都初始化成 set 。memset 函数对应的头文件是 string.h ,可以初始化一块内存,三个参数分别是这块空间的起始地址,要设置的值,这块空间的大小(单位是字节)。
空间开辟完,使用完后需要使用 free 函数回收。写个循环挨个挨个回收就行了。
static void DestroyBoard(char** board, size_t row, size_t col)
{assert(board != NULL);for (size_t i = 0; i < row + 2; ++i){free(board[i]);board[i] = NULL;}free(board);board = NULL;
}
最后聊聊如何打印。其实这非常简单,因为两层 malloc 出来的数组的访问方式和二维数组一模一样,所以直接当成二维数组来打印就行了。为了打印的好看,我把行标和列标也打印上去了。
需要注意的是,这个数组的有效区间不包括最外面这一圈,所以打印时需要控制范围是 [1, row] * [1, col] 。
static void DisplayBoard(char** board, size_t row, size_t col)
{assert(board != NULL);// 有效区间 [1, row] * [1, col]for (size_t i = 0; i <= col; ++i){printf("%2zd ", i);}printf("\n\n");for (size_t i = 1; i <= row; ++i){printf("%2zd ", i);for (size_t j = 1; j <= col; ++j){printf("%2c ", board[i][j]);}printf("\n\n");}
}
打印出来的效果如下:
现在我们就把框架搭好了,接下来就要写如何布置雷和排查雷的逻辑了。欲知后事如何,且听下回分解。