第二部分、线性表详解:数据结构线性表10分钟入门

线性表,数据结构中最简单的一种存储结构,专门用于存储逻辑关系为”一对一”的数据。

线性表,基于数据在实际物理空间中的存储状态,又可细分为顺序表(顺序存储结构)和链表(链式存储结构)。

本章还会讲解顺序表和链表的结合体——静态链表,不仅如此,还会涉及循环链表、双向链表、双向循环链表等链式存储结构。

十五、怎样用双向链表实现贪吃蛇游戏?

前面章节中,给读者详细介绍了双向链表及其基本操作。在此基础上,本节教大家:如何利用双向链表实现一个简易的 C 语言版贪吃蛇游戏(如图 1 所示)。

图 1 贪吃蛇小游戏的实现效果

其中,黄色框代表贪吃蛇,红色代表食物!

使用双向链表实现此游戏,有以下几点需要做重点分析。

1) 我们知道,双向链表中各个节点的标准构成是一个数据域和 2 个指针域,但对于实现贪吃蛇游戏来说,由于各个节点的位置是随贪吃蛇的移动而变化的,因此链表中的各节点还需要随时进行定位。

在一个二维画面中,定义一个节点的位置,至少需要所在的行号和列号这 2 个数据。由此,我们可以得出构成贪吃蛇的双向链表中各节点的构成:

//创建表示蛇各个节点的结构体

typedef struct SnakeNode {

int x, y;//记录节点所在的行和列

struct SnakeNode *pre;//指向前驱节点的指针

struct SnakeNode *next;//指向后续节点的指针

}Node, *pNode;

2) 贪吃蛇的移动,本质上就是对链表中各个节点的重新定位。换句话说,除非贪吃蛇吃到食物,否则无论怎样移动,都不会对双向链表的整个结构(节点数)产生影响,唯一受影响的就只是各个节点中 (x,y) 这对定位数据。

由此,我们可以试着设计出实现贪吃蛇移动的功能函数,本节所用的实现思想分为 2 步:

  1. 从蛇尾(双向链表尾节点)开始,移动向前遍历,过程中依次将当前节点的 (x,y) 修改为前驱节点的 (x,y),由此可实现整个蛇身(除首元节点外的其它所有节点)的向前移动;
  2. 接收用户输入的移动指令,根据用户指示贪吃蛇向左、向右、向上还是向下移动,首元节点中的 (x,y) 分别做 x-1、x+1、y-1 和 y+1 运算。

如下所示,move() 函数就实现了贪吃蛇的移动:

//贪吃蛇移动的过程,即链表中所有节点从尾结点开始逐个向前移动一个位置

bool Move(pNode pHead, char key) {

bool game_over = false;

pNode pt = pTail;

while (pt != pHead) { // 每个节点依次向前完成蛇的移动

pt->x = pt->pre->x;

pt->y = pt->pre->y;

pt = pt->pre;

}

switch (key) {

case’d’: {

pHead->x += 1;

if (pHead->x >= ROW)

game_over = true;

break;

}

case’a’: {

pHead->x -= 1;

if (pHead->x < 0)

game_over = true;

break;

}

case’s’: {

pHead->y += 1;

if (pHead->y >= COL)

game_over = true;

break;

}

case’w’: {

pHead->y -= 1;

if (pHead->y < 0)

game_over = true;;

break;

}

}

if (SnakeDeath(pHead))

game_over = true;

return game_over;

}

注意,此段代码中还调用了 SnakeDeath() 函数,此函数用于判断贪吃蛇移动时是否撞墙、撞自身,如果是则游戏结束。

3) 当贪吃蛇吃到食物时,贪吃蛇需要增加一截,其本质也就是双向链表增加一个节点。前面章节中,已经详细介绍了如何在双向链表中增加一个节点,因此实现这个功能唯一的难点在于:如何为该节点初始化 (x,y)?

本节所设计的贪吃蛇游戏,针对此问题,提供了最简单的解决方案,就是不对新节点 x 和 y 做初始化。要知道,贪吃蛇是时刻移动的,而在上面的 move() 函数中,会时刻修正贪吃蛇每个节点的位置,因此当为双向链表添加新节点后,只要贪吃蛇移动一步,新节点的位置就会自行更正。

当然,读者也可发散思维,设计其他的解决方案。

也就是说,贪吃蛇吃到食物的实现,就仅是给双向链表添加一个新节点。如下即为实现此功能的代码:

//创建表示食物的结构体,其中只需要记录其所在的行和列

typedef struct Food {

int x;

int y;

}Food, *pFood;

//吃食物,等同于链表中新增一个节点

pNode EatFood(pNode pHead, pFood pFood) {

pNode p_add = NULL, pt = NULL;

if (pFood->x == pHead->x&&pFood->y == pHead->y) {

p_add = (pNode)malloc(sizeof(Node));

score++;

pTail->next = p_add;

p_add->pre = pTail;

p_add->next = NULL;

pTail = p_add;

// 检查食物是否出现在蛇身的位置上

do {

*pFood = CreateFood();

} while (FoodInSnake(pHead, pFood));

}

return pHead;

}

其中,Food 结构体用来表示食物,其内部仅包含能够定位食物位置的 (x,y) 即可。另外,此段代码中,还调用了 FoodeInSnake() 函数,由于食物的位置是随机的,因此极有可能会和贪吃蛇重合,所以此函数的功能就是:如果重合,就重新生成食物。

FoodInSnake() 函数的实现很简单,这里不再赘述:

//判断食物的出现位置是否和蛇身重合

bool FoodInSnake(pNode pHead, pFood pFood) {

pNode pt = NULL;

for (pt = pHead; pt != NULL; pt = pt->next) {

if (pFood->x == pt->x&&pFood->y == pt->y)

return true;

}

return false;

}

4) 贪吃蛇游戏界面的显示,最简单的制作方法就是:贪吃蛇每移动一次,都清除屏幕并重新生成一次。这样实现的问题在于,如果贪吃蛇的移动速度过快,则整个界面在渲染的同时,会掺杂着光标,并且屏幕界面会频繁闪动。

因此,在渲染界面时,有必要将光标隐藏起来,这需要用到头文件,实现代码如下:

// 隐藏光标

void gotoxy(int x, int y) {

HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);

COORD pos;

pos.X = x;

pos.Y = y;

SetConsoleCursorPosition(handle, pos);

}

void HideCursor() {

CONSOLE_CURSOR_INFO cursor_info = { 1, 0 };

SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cursor_info);

}

同时,为了给整个界面渲染上颜色,也需要引入头文件,并使用如下函数:

void color(int m) {

HANDLE consolehend;

consolehend = GetStdHandle(STD_OUTPUT_HANDLE);

SetConsoleTextAttribute(consolehend, m);

}

5) 需要注意的一点是,由此结束后,一定要手动释放双向链表占用的堆空间

//退出游戏前,手动销毁链表中各个节点

void ExitGame(pNode *pHead)

{

pNode p_delete = NULL, p_head = NULL;

while (*pHead != NULL) {

p_head = (*pHead)->next;

if (p_head != NULL)

p_head->pre = NULL;

p_delete = *pHead;

free(p_delete);

p_delete = NULL;

*pHead = p_head;

}

}

解决以上问题之后,用双向链表实现贪吃蛇,基本上就没有难点了。读者可根据本节提供的实现思想,尝试独立实现。

本节设计实现的贪吃蛇游戏,源码文件有 3 个,分别为 snake.h、snake.c 和 main.c,读者可直接点击贪吃蛇游戏下载。


十六、循环链表(约瑟夫环)的建立及C语言实现

无论是静态链表还是动态链表,有时在解决具体问题时,需要我们对其结构进行稍微地调整。比如,可以把链表的两头连接,使其成为了一个环状链表,通常称为循环链表

和它名字的表意一样,只需要将表中最后一个结点的指针指向头结点,链表就能成环儿,如图 1 所示。

图1 循环链表

需要注意的是,虽然循环链表成环状,但本质上还是链表,因此在循环链表中,依然能够找到头指针和首元节点等。循环链表和普通链表相比,唯一的不同就是循环链表首尾相连,其他都完全一样。

一、循环链表实现约瑟夫环

约瑟夫环问题,是一个经典的循环链表问题,题意是:已知 n 个人(分别用编号 1,2,3,…,n 表示)围坐在一张圆桌周围,从编号为 k 的人开始顺时针报数,数到 m 的那个人出列;他的下一个人又从 1 开始,还是顺时针开始报数,数到 m 的那个人又出列;依次重复下去,直到圆桌上剩余一个人。

如图 2 所示,假设此时圆周周围有 5 个人,要求从编号为 3 的人开始顺时针数数,数到 2 的那个人出列:

图 2 循环链表实现约瑟夫环

出列顺序依次为:

  • 编号为 3 的人开始数 1,然后 4 数 2,所以 4 先出列;
  • 4 出列后,从 5 开始数 1,1 数 2,所以 1 出列;
  • 1 出列后,从 2 开始数 1,3 数 2,所以 3 出列;
  • 3 出列后,从 5 开始数 1,2 数 2,所以 2 出列;
  • 最后只剩下 5 自己,所以 5 胜出。

约瑟夫环问题有多种变形,比如顺时针转改为逆时针等,虽然问题的细节有多种变数,但解决问题的中心思想是一样的,即使用循环链表。

通过以上的分析,我们可以尝试编写 C 语言代码,完整代码如下所示:

#include

#include

typedef struct node{

int number;

struct node * next;

}person;

person * initLink(int n){

person * head=(person*)malloc(sizeof(person));

head->number=1;

head->next=NULL;

person * cyclic=head;

for (int i=2; i<=n; i++) {

person * body=(person*)malloc(sizeof(person));

body->number=i;

body->next=NULL;

cyclic->next=body;

cyclic=cyclic->next;

}

cyclic->next=head;//首尾相连

return head;

}

void findAndKillK(person * head,int k,int m){

person * tail=head;

//找到链表第一个结点的上一个结点,为删除操作做准备

while (tail->next!=head) {

tail=tail->next;

}

person * p=head;

//找到编号为k的人

while (p->number!=k) {

tail=p;

p=p->next;

}

//从编号为k的人开始,只有符合p->next==p时,说明链表中除了p结点,所有编号都出列了,

while (p->next!=p) { /

/找到从p报数1开始,报m的人,并且还要知道数m-1de人的位置tail,方便做删除操作。

for (int i=1; i<m; i++) {

tail=p;

p=p->next;

}

tail->next=p->next;//从链表上将p结点摘下来

printf(“出列人的编号为:%d\n”,p->number);

free(p);

p=tail->next;//继续使用p指针指向出列编号的下一个编号,游戏继续

}

printf(“出列人的编号为:%d\n”,p->number);

free(p);

}

int main() {

printf(“输入圆桌上的人数n:”);

int n; scanf(“%d”,&n);

person * head=initLink(n);

printf(“从第k人开始报数(k>1且k<%d):",n);

int k;

scanf(“%d”,&k);

printf(“数到m的人出列:”);

int m; scanf(“%d”,&m);

findAndKillK(head, k, m);

return 0;

}

输出结果:

输入圆桌上的人数n:5
从第k人开始报数(k>1且k<5):3
数到m的人出列:2
出列人的编号为:4
出列人的编号为:1
出列人的编号为:3
出列人的编号为:2
出列人的编号为:5

最后出列的人,即为胜利者。当然,你也可以改进程序,令查找出最后一个人时,输出此人胜利的信息。

二、总结

循环链表和动态链表唯一不同在于它的首尾连接,这也注定了在使用循环链表时,附带最多的操作就是遍历链表。

在遍历的过程中,尤其要注意循环链表虽然首尾相连,但并不表示该链表没有第一个节点和最后一个结点。所以,不要随意改变头指针的指向。