C语言贪吃蛇最短路径,AI贪吃蛇前瞻——基于Dijkstra算法的最短路径问题

在贪吃蛇流程结构优化之后,我又不满足于亲自操刀控制这条蠢蠢的蛇,干脆就让它升级成AI,我来看程序自己玩,哈哈。

一、Dijkstra算法原理

作为一种广为人知的单源最短路径算法,Dijkstra用于求解带权有向图的单源最短路径的问题。所谓单源,就是一个源头,也即一个起点。该算法的本质就是一个广度优先搜索,由中心向外层层层拓展,直到遇到终点或者遍历结束。该算法在搜索的过程中需要两个表S及Q,S用来存储已扫描过的节点,Q存储剩下的节点。起点s距离dist[s] = 0;其余点的值为无穷大(具体实现时,表示为某一不可能达到的数字即可)。开始时,从Q中选择一点u,放入S,以u为当前点,修改u周围点的距离。重复上述步骤,直到Q为空。

二、Dijkstra算法在AI贪吃蛇问题中的变化

2.1 地图的表示方法

与平时见到的各种连通图问题不同,贪吃蛇游戏中的地图可以看成是标准的矩形,也即,一个二维数组,图中各个相邻节点的权值为1。因此,我们可以用一个边长*边长的二维数组作为算法的主体数据结构,讲地图有关的数据都集成在数组里。既然选择了二维数组,就要考虑数组元素类型的问题,即我们的数组应该存储哪些信息。作为主要的数据结构,我们希望我们的数组能存储自身的坐标,起点到自身的最短路径,因此我们可以定义这样的一个结构体:typedef struct loca{

int x;

int y;

}Local;

typedef struct unit{

int value;

Local local;

}Unit;

又因为我们需要得到最短路径以求得贪吃蛇下一步的方向,所以在结构体里加一个指针,指向前一个节点的位置。typedef struct loca{

int x;

int y;

}Local;

typedef struct unit{

int value;

Local local;

struct unit *pre;

}Unit;

假设地图为一个正方形,因此创建一个边长*边长大小的二维数组:#define N 5

Unit mapUnit[N][N];

2.2 队列——待处理节点的集合

有了mapUnit之后,我们还需要一个数据结构来存储接下来需要处理的节点的信息。在此我选择了一个队列,由于C语言不提供标准的接口,就自己草草的写了一个。typedef struct queue{

int head,tail;

Local queue[N*N];

}Queue;

Queue que;

使用了一个定长的数组来作为队列结构,所以为了应对所有的结果,将其长度设为N*N。也正因为是定长数组,队列的进队与出队只需操作表示下标值的head与tail即可。这样虽然不节约空间,但胜在实现方便。void push(int x,int y)

{

que.tail++;

que.queue[que.tail].x = x;

que.queue[que.tail].y = y;

}

void pop()

{

que.head++;

}

由于push操作有一个自增操作,所以在初始化时需要将tail设为-1,这样在push第一个节点时可保证head与tail指向同一个位置。

2.3 console坐标——地图的初始化

在我的贪吃蛇链表实现中,前端展示时通过后台的计算逻辑+Pos函数来实现的,也就是现在后台计算结果,再推动前台的变化。因此Pos(),也就是使光标跳转到控制台某位置的函数就尤为重要,这也直接影响了整个项目各元素的坐标表示方法。

简单来说就是console的坐标表示类似于坐标轴中第四象限的表示方法,当然元素都为正值。

d4a6d3e3fd43ad727c4d0dcaa89185c2.png

所以对于一个N*N的数组,我们可以这样初始化:void InitializeMapUnit()

{

que.head = 0;

que.tail = -1;

for(int i = 0;i

for(int j = 0;j

{

mapUnit[i][j].local.x = i;

mapUnit[i][j].local.y = j;

mapUnit[i][j].pre = NULL;

mapUnit[i][j].value = N*N;

}

}

将队列的初始化放在这个函数里实属无奈,这两行语句,又不能在初始化时赋值,又不能在函数体外赋值,放main函数嫌它乱,单独一个函数嫌它慢….就放在地图初始化里了…

三、计算,BFS!

3.1 设置起点

基础的结构与初始化完成后,就需要开始计算了。在此之前,我们需要一个坐标,来作为路径问题的出发点。void setOrigin(int x,int y)

{

mapUnit[x][y].value = 0;

push(x,y);

}

将地图上该点位置的值设为0后,将其压入队列中。在第一轮的BFS中,它四周的点,将成为第二轮计算的原点。

3.2 BFS框架

在该地图的BFS中,我们将依托队列各个元素,来处理它们的邻接节点。两个循环,可以揭示大体的框架:void bfs(int end_x,int end_y)

{

//当前需要处理的节点

for(int i = head;i<=tail;i++)

{

//  四个方向

for(int j = 0;j<4;j++)

{

// 新节点

if(mapUnit[new_x][new_y].value == N*N)

{

//设置属性

}

//  处理过的节点,取小值

else

{

//属性更改Or不变

}

}

}

//下一轮

bfs();

}

3.3 变化的队列

BFS的主体循环依赖于队列的head与tail,但是对新节点的push操作改变了tail的值,所以我们需要在循环开始前将此时(上一轮BFS的结果)的队列状态保存下来,避免队列变化对BFS的影响。int head = que.head;

int tail = que.tail;

//当前队列

for(int i = head;i<=tail;i++)

{

// TODO...

}

3.4 节点的坐标

在原来写的BFS中,要获取一个节点的下标需要将一个结构体层层剥开,数组的下标是一个结构体某元素的某元素,绕来绕去,可读性早已被献祭了。

6468c3f7a88bb71aa30970745f333653.png

所以这次我吸取了教训,在内循环,也就是处理周围节点时,将其坐标先存储在变量中,用来确保程序的可读性。for(int i = head;i<=tail;i++)

{

int base_x = que.queue[i].x;

int base_y = que.queue[i].y;

//  四个方向

for(int j = 0;j<4;j++)

{

int new_x = base_x + direct[j][0];

int new_y = base_y + direct[j][1];

// TODO...

}

}

所以我们可以构建出这样一个移动的二维数组://          方向, 上       下       左   右

int direct[4][2] = {{0,-1},{0,1},{-1,0},{1,0}};

3.4.1 数组越界的处理

在得到了待处理节点的坐标后,需要对其进行判断,确保它在数组内部。if(stepRight(new_x,new_y) == false)

continue;

函数细节如下:bool stepRight(int x,int y)

{

if(x >= N || y >= N ||

return false;

return true;

}

3.5 新节点的处理

终于到了访问邻接坐标的时候。一个节点四周的节点,有可能没有被访问过,也可能以及被访问过。我们在初始化时就将所有节点的值设为了一个MAX,通过对值得判断,可以推断出其是否为新节点。if(mapUnit[new_x][new_y].value == N*N)

{

// ...

}

else    //取小值

{

// ...

}

3.5.1 未处理节点的处理

对于未处理的节点,对其的操作有两部。一是初始化,值的初始化与指针的初始化。由于两点间的距离为1,所以该节点的值为前一个节点的值+1,当然,他的pre指针也指向前一个节点。mapUnit[new_x][new_y].value = mapUnit[base_x][base_y].value +1;

mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];

push(new_x,new_y);

3.5.2 已处理节点的处理

对于已处理过的节点,需要先将其做一个判断,即寻找最短路径,将其自身的value与前一节点value+1比较,再处理。mapUnit[new_x][new_y].value = MIN(mapUnit[new_x][new_y].value,mapUnit[base_x][base_y].value +1);

if(mapUnit[new_x][new_y].value != mapUnit[new_x][new_y].value)

mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];

3.6 队列的刷新

在处理完一层节点后,新的节点导致了队列中tail的增加,但是head并没有减少,所以在新一轮BFS前,需要将队列的head移动到真正的头部去。for(int i = head;i<=tail;i++)

pop();

在这儿也需要当前BFS轮数前的队列数据。

3.7 最短路径

在地图的遍历完成之后,我们就可以任取一点,得到起点到该点的最短路径。void getStep(int x,int y)

{

Unit *scan = &mapUnit[x][y];

if(scan->pre!= NULL)

{

int x = scan->local.x;

int y = scan->local.y;

scan = scan->pre;

getStep(scan->local.x,scan->local.y);

printf(" -> ");

}

printf("(%d,%d)",x,y);

}

四、此路不通——障碍的引入

在贪吃蛇中,由于蛇身长度的存在,以及蛇头咬到自身就结束的特例,我们需要在算法中加入障碍的元素。

对于这个新加入的元素,我们设置一个坐标结构体的数组,来存储所有的障碍。#define WALL_CNT 3

Local Wall[WALL_CNT];

用一个函数来设置障碍:void setWall(void)

{

Wall[0].x = 1;

Wall[0].y = 1;

Wall[1].x = 1;

Wall[1].y = 2;

Wall[2].x = 2;

Wall[2].y = 1;

}

由于这个项目里数据用于模块测试的随机性,所以手动设置每一个坐标。在之后的贪吃蛇AI中,将接受一个数组——蛇身,来自动完成赋值。

如果将障碍与地图边界等同来看,就能将障碍的判断整合进stepRight()函数。bool stepRight(int x,int y)

{

//  out of map

if(x >= N || y >= N ||

return false;

//  wall

for(int i = 0;i

if(Wall[i].x == x && Wall[i].y == y)

return false;

return true;

}

五、简单版本的测试

完成了上诉的模块后,项目就可以无BUG但是低效的跑了。我们来试一试,在一个5*5的地图中,起点在中间,为(2,2),终点在起点的上上方,为(2,0),设置三面围墙,分别是(1,1),(2,1),(3,1)。如下:

5a464ec7be4fc057ba97b53ce0e93c8c.png

看看效果。

8a8a730ffe961c9fad87342baa1ead8a.png

图中二维数组打印了各个坐标点的value,即该点到起点的最短路径。25为墙,0为起点。可以看到到终点需要六步,路径是先往左,再往上,左后向右到终点。

任务完成,看似不错。把终点换近一些看看,就(1,4)吧。

29191f820969347c2b0f0941468ce696.png

喔,问题出来了。我取一个非常近的点,但是整张图都被遍历了,效率太低了,要改进。

还有一个问题,如果将起点周围四个点都设置为墙,结果应该是无法得到其余点的最短路径,但现阶段的结果还不尽如人意:

b2815a99bff8d168a9e41ba4eb4b5356.png

六、优化

6.1 遍历的半途结束

在BFS中,如果找到了终点,那就可以退出遍历,直接输出结果。不过这样的一个递归树,要随时终止可不容易。我一开始想到了"万恶之源"goto,不过goto不能跨函数跳转,随后又想到了非本地跳转setjmp与longjmp。

"与刺激的abort()和exit()相比,goto语句看起来是处理异常的更可行方案。不幸的是,goto是本地的:它只能跳到所在函数内部的标号上,而不能将控制权转移到所在程序的任意地点(当然,除非你的所有代码都在main体中)。

为了解决这个限制,C函数库提供了setjmp()和longjmp()函数,它们分别承担非局部标号和goto作用。头文件申明了这些函数及同时所需的jmp_buf数据类型。"

有了这随时能走的"闪现"功能,跳出复杂嵌套函数还是事儿嘛?#include 

jmp_buf jump_buffer;

int main (void)

{

//...

if(setjmp(jump_buffer) == 0)

bfs(finishing_x,finishing_y);

//...

}

由于跳转需要判断当前节点是否为终点,而终点又是一个局部变量,所以需要改变bfs函数,使其携带终点参数。

再在处理完一个节点后,判断其是否为终点,是则退出。

for(int i = head;i<=tail;i++)

{

//...

//  四个方向

for(int j = 0;j<4;j++)

{

//...

if(mapUnit[new_x][new_y].value == N*N)

{

//...

}

else    //取小值

{

//...

}

if(new_x == end_x && new_y == end_y)

{

longjmp(jump_buffer, 1);

}

}

}

6.2 无最短路径时的处理

在判断某一点的路径时,可先判断其是否存在最短路径,存在则输出,否则给出提示信息。void getStepNext(int x,int y)

{

Unit *scan = &mapUnit[x][y];

if(scan->pre!= NULL)

{

int x = scan->local.x;

int y = scan->local.y;

scan = scan->pre;

getStepNext(scan->local.x,scan->local.y);

printf(" -> ");

}

printf("(%d,%d)",x,y);

}

void getStep(int x,int y,int orgin_x,int orgin_y)

{

Unit *scan = &mapUnit[x][y];

Pos(0,10);

if(scan->pre == NULL)

{

printf("NO Path To Point (%d,%d) From Point (%d,%d)!\n",x,y,orgin_x,orgin_y);

}

else

{

getStepNext(x,y);

}

}

七、优化后效果

c221eadd0118bb67063b6b48dc801c41.png

八、写在后面

算法大体上完成了,将其改为贪吃蛇AI也只需做少量修改。尽量抽个时间把AI写完,不过可能需要一段时间。

在最短路径的解法上,Dijkstra算法并不是最理想的解法。盲目搜索的效率很低。考虑到地图上存在着两点间距离等信息,可以使用一种启发式搜索算法,如BFS(Best-First Search),以及大名鼎鼎的A*算法。在中文互联网我能找到的有关于A*算法的资料不多,将来得花些时间好好研究下。

打开App,阅读手记

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值