一周前看见了贪吃蛇AI算法,受到震撼于是就把以前的win32贪吃蛇加了个AI实现,让我这个渣渣写了好几天才完工,终于能吃完全屏了,虽然离自己看的那个贪吃蛇AI的gif还有些距离emmmm,贪吃蛇AI不可避免的用到了寻路算法,所以今天当做复习总结提一提,
不说了,进入正题吧,常见的搜索有深度优先搜索,广度优先搜索,迭代加深搜索,双向广度优先先搜索,A*搜索,IDA*搜索等,这些搜索分为盲目式搜索跟启发式搜索。
举个例子,加入你在学校操场,老师叫你去国旗那集合,你会怎么走?
假设你是瞎子,你看不到周围,那如果你运气差,那你可能需要把整个操场走完才能找到国旗。这便是盲目式搜索,即使知道目标地点,你可能也要走完整个地图。
假设你眼睛没问题,你看得到国旗,那我们只需要向着国旗的方向走就行了,我们不会傻到往国旗相反反向走,那没有意义。
这种有目的的走法,便被称为启发式的。
而今天要提到的深度优先跟广度优先,便是盲目式搜索,而A*跟IDA*,便是启发式搜索。
广度优先(BFS)是,每次先搜索周围,先把原点方圆1m找完,如果找不到,就找再向外扩展1m,如果找不到,就再向外扩展1m,每次扩大自己的圈子,直到整个地图走完。很像传染病,从开始一个点慢慢传染到整个地区
BFS则是用队列实现跟循环实现,每次把当前节点周围的节点加入队列,然后pop出队列,不断循环,直到队列为空。一种用递归一种用循环,很明显,在时间上,深度优先搜索一般要比广度优先搜索慢,但广度优先搜索需要大量的空间。所以说这两种算法各有优缺点。
在说启发式搜索之前,先说说寻路算法
寻路算法
一般要到终点,我们会关注两件事,要走多远以及这条路要怎么走。然后为了不重复走过的路,我们还需要标记这条路已经走过,即判重。
走多远,即是最少步数。如果用DFS来说,即是深度,用BFS来说,便是向外扩展了多少米(其实也是深度)。
那怎么记录路径呢?办法是每个节点加个指向前一个节点的指针,每一步记录前一步,便能记录整条路径。DFS不用说,就是解答树的一个分支,而BFS则是会形成一颗BFS树
但是这样得出来的路径是从终点到起点的路径,这里便需要从终点开始用递归打印出来,便能打印起点到终点的路径。
判重的方法可以用bool数组,然后true表示走过节点,false表示没走过的节点,如果是比较复杂的状态等,其他判重的方法如hash(快速查找O(1)),红黑树(查找较快)等比较复杂的数据结构。C++红黑树直接用set或者map就是了,一般不会为了写个搜索亲自去写麻烦的数据结构的(当然hash还是可以的)
因此BFS缺点是非常浪费空间。经典的以空间换时间的算法。下面给出
#include
#include
using namespace std;
const int maxn = 100;
const int inf = 0x3fffffff;
//1代表墙,0代表空地,2代表终点
int G[maxn][maxn];
//此数组记录到此节点的最小步数,类似dp。同时用来判重
int book[maxn][maxn];
int n, m;
struct Node
{
int x;
int y;
Node(int x, int y):x(x), y(y){}
};
//移动时的坐标改变量
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
//bfs寻找最短路径,不存在返回-1
int bfs(int sx, int sy)
{
for(int i = 0; i < n; i++)
{
for(int j = 0; j < n; j++)
{
book[i][j] = inf;
}
}
book[sx][sy] = 0;
queue q;
Node temp(sx, sy);
q.push(temp);
while(!q.empty())
{
Node u = q.front();
q.pop();
int x = u.x;
int y = u.y;
//尝试向左,向右,向上,向下走
for(int k = 0; k < 4; k++)
{
int next_x = x + dx[k];
int next_y = y + dy[k];
//不能越界,也不能往障碍走
if(next_x >= n || next_x < 0 || next_y >= m || next_y < 0 || G[next_x][next_y] == 1)
continue;
//如果可以走,看是否之前走过
if(book[next_x][next_y] == inf)
{
//从此点继续扩展
Node temp(next_x, next_y);
q.push(temp);
//更新步数,其实这个可以不用,因为,bfs原本就是从近到远,上次一定不比这次远,为了跟上面保持一致而进行
book[next_x][next_y] = book[x][y] + 1;
}
else
{
//更新步数
book[next_x][next_y] = min(book[next_x][next_y], book[x][y] + 1);
}
if(G[next_x][next_y] == 2)
{
return book[next_x][next_y];
}
}
}
return -1;
}
int main()
{
cin >> n >> m;
cout << "请输入一个" << n << "*" << m << "的地图" << endl;
for(int i = 0; i < n; i++)
{
for(int j = 0; j < m; j++)
{
cin >> G[i][j];
}
}
cout << bfs(0, 0);
return 0;
}
深度优先(DFS)正如他的名字,每次先往深度走,如果此路走到底都没找到,退回到原点,换另一条路找,如果走到底还是没找到,继续换一条路,直到全部路走完。
DFS由于每次向深处搜索然后返回,很容易就让人想到用栈实现,而系统本来就有提供栈,即用递归实现。DFS函数里,一般都是在一个循环调用自己完成递归过程,如果用迷宫比喻的话,每次递归返回便是走完一条路,而这个循环便是有多少条路,这样每个路口都会实现同样的操作,便能把整个迷宫搜索完。因其每次会返回的特点DFS在枚举算法里也称为回溯法,DFS走的路径被称为解答树。下面的图中,只有(1,3,0,2)跟(2,0,3,1)是到达了目标,其他都是死路
DFS以遍历树时,不会重复,无需判重。但如果有一些节点有相交的话,则需要判重,不然虽然一般可以达到目的,但浪费大量时间。具体Google(记忆化搜索——动态规划)。
DFS递归算法比较简单(这也是我比起bfs更喜欢dfs的理由),这里就不具体给出了,Google一堆,这里提供DFS栈实现寻找最短路径
#include
#include
using namespace std;
const int maxn = 100;
const int inf = 0x3fffffff;
//1代表墙,0代表空地,2代表终点
int G[maxn][maxn];
//此数组记录到此节点的最小步数,类似dp。同时用来判重
int book[maxn][maxn];
int n, m;
struct Node
{
int x;
int y;
int k;//控制方向
Node(int x, int y, int k):x(x), y(y), k(k){};
};
//移动时的坐标改变量
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
//dfs栈实现,非递归
int dfs(int sx, int sy)
{
for(int i = 0; i < n; i++)
{
for(int j = 0; j < n; j++)
{
book[i][j] = inf;
}
}
book[sx][sy] = 0;
stack s;
Node temp(sx, sy, 0);
s.push(temp);
while(!s.empty())
{
Node u = s.top();
s.pop();
int x = u.x;
int y = u.y;
//尝试向左,向右,向上,向下走
if(u.k < 4)
{
int next_x = x + dx[u.k];
int next_y = y + dy[u.k];
//将原栈帧k+1继续入栈(因为C++这栈的元素貌似是不可变数据结构,你改变top的k值,下次top时还是原来的k值)
Node tmp(x, y, u.k + 1);
s.push(tmp);
//不能越界,也不能往障碍走
u.k = u.k + 1;
if(next_x >= n || next_x < 0 || next_y >= m || next_y < 0 || G[next_x][next_y] == 1)
continue;
//如果可以走,看是否之前走过
if(book[next_x][next_y] == inf)
{
//从此点继续扩展
Node temp(next_x, next_y, 0);
s.push(temp);
//更新步数
book[next_x][next_y] = book[x][y] + 1;
}
else
{
//更新步数
book[next_x][next_y] = min(book[next_x][next_y], book[x][y] + 1);
}
if(G[next_x][next_y] == 2)
{
return book[next_x][next_y];
}
}
}
return -1;
}
int main()
{
cin >> n >> m;
cout << "请输入一个" << n << "*" << m << "的地图" << endl;
for(int i = 0; i < n; i++)
{
for(int j = 0; j < m; j++)
{
cin >> G[i][j];
}
}
cout << dfs(0, 0) << endl;
return 0;
}
启发式搜索的好坏基本都取决于评估函数。
要说IDA*算法,就先说迭代加深搜索吧。本来不打算说的,先说一个DFS的一个问题,如果现在的路的深度没有上限,即没有底,那么直接DFS就回不来了。。。所以就有了迭代加深搜索,即每次给DFS加个记录深度的(或者说解答树的层数)参数d,每次走到相应的深度就返回。由于深度d是慢慢递增的,这样的得出了的答案毫无疑问是最小深度(即少步数),但是缺点很明显,重复遍历解答树上层多次,造成巨大浪费。而IDA*则多了评估函数(或者说剪枝),每次预估如果这条路如果继续下去也无法到达终点,则放弃这条路(剪枝的一种,即最优性剪枝)。IDA*原理实现起来简单,非常方便,但难的地方是评估函数的编写,足够好的评估函数可以避免走更多的不归路,如果评估函数太差或者没有评估函数,那会退化到迭代加深搜索那种浪费程度。如果想要学IDA*算法,我推荐算法竞赛入门经典中的暴力枚举法—迭代加深搜索一章。
#include
#include
using namespace std;
const int maxn = 100;
const int inf = 0x3fffffff;
//1代表墙,0代表空地,2代表终点
int G[maxn][maxn];
int n, m;
//移动时的坐标改变量
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int endx, endy;
int maxd;
//多了个d记录深度
bool dfs(int x, int y, int d)
{
//到达最大深度则返回
if(d == maxd)
{
//找到终点返回true,否则false
if(G[x][y] == 2)
return true;
return false;
}
//预剪枝:如果可步数小于最短距离, 直接返回
if(abs(x - endx) + abs(y - endy) > maxd - d)
return false;
//尝试向左,向右,向上,向下走
for(int i = 0; i < 4; i++)
{
int next_x = x + dx[i];
int next_y = y + dy[i];
//不能越界,也不能往障碍走
if(next_x >= n || next_x < 0 || next_y >= m || next_y < 0 || G[next_x][next_y] == 1)
continue;
//只要有一个返回true
if(dfs(next_x, next_y, d+1))
{
return true;
}
}
return false;
}
int main()
{
cin >> n >> m;
cout << "请输入一个" << n << "*" << m << "的地图" << endl;
for(int i = 0; i < n; i++)
{
for(int j = 0; j < m; j++)
{
cin >> G[i][j];
//记下终点
if(G[i][j] == 2)
{
endx = i;
endy = j;
}
}
}
//枚举步数,最少多少步能到达目标
for(maxd = 1; ;maxd++)
{
if(dfs(0, 0, 0))
{
cout << maxd << endl;
break;
}
}
return 0;
}
A*算法我想很多人都听说过,这是AI算法应用最广泛的算法之一,经常用到游戏里寻找最短路的算法里。如果说IDA*算法是改进DFS算法来的,A*算法便是BFS算法的升级版。A*算法跟BFS一样,扩展周围的几个点,但是A*会评估这几个点哪个到终点比较近,然后选择近的走,然后继续评估,又选择近的走,直到走到终点。
如何每次保证选择到的是最近的呢?这里就需要用到堆了。建立一个最小堆,每次取最上面的一个。而比较大小的标准是通过评估值F的大小,以最短路径来说,每次会选F值最小的。对于节点n有这样的定义,f(n) = g(n) + h(n), 其中g(n)是到节点n已经花费的代价,h是到g(n)的估计代价,也可以认为是最少代价。在最短路径中h函数可以用欧几里得距离,或者曼哈顿距离来判断,欧几里得距离通俗点说即两点间长度。曼哈顿距离就是x轴距离+y轴距离。至于g(n),可以设置走一步代价为1(当然你想设置成8,9,10之类的随便,只是一个度量),g值跟Dijkstra的松弛一样,需要更新,每次找到一个节点可以让它更新为更小的,就更新g值跟f值(h值不用更新,因为对于每个点的h值都是确定的)。
A*算法跟BFS一样,需要用father来记录上一个节点,从而实现遍历最短路径。至于判重操作,A*的判重是通过两个表(或许该说是两个堆)判重的,一般叫做open表跟close表(当然BFS也可以通过open跟close队列实现判重),close表存放已经走过的点,open表中是还没走的节点,每次从open取出f值最小的作为当前节,然后扩展当前节点周围的点push进open表,然后pop并把当前点放到close表中,然后继续重复以上操作,每次从open取出f值最小的作为当前节.......如果此条最近的路不通,如遇到墙之类的,A*便选择第二短的路(当然第一第二可能一样短),(这里最近的路是如操场无障碍那种,那我们走直线距离肯定最近,但是如果我们走近发现前面有一堵墙,此路不通,我们就得退回去试试其他路,A*会马上跳转到刚才第二近的路进行下一步扩展),如果还不通,就选择第三短的路。。。直到open表中出现了目标节点,则表示已经找到最短路,退出循环,如果open表为空,则表示不存在到达目标的路径。
总结起来:A*算法=BFS的扩展方式+预估函数的+Dijkstra的松弛方式+堆的使用+open跟close表的判重+father记录路径。
关于A*算法,这是我看过的一个写的不错的文章http://blog.csdn.net/u012234115/article/details/47152137,可以当做参考。
总结,DFS在搜索中使用栈返回,IDA*是DFS的延伸还是用到栈,BFS使用队列扩展,A*使用堆来取出评估最好的值,理解这三个数据结构,才能较好理解这三种算法,无非就是,如果这点符合条件的就扩展,然后用不同的数据结构扩展,前两者不管数据如何,看到就放入,后者用堆,实现大小排序,可以选择更好的路径。 几种搜索不同的条件下返回,DFS是走到底,A*跟BFS是队列为空,IDA*是到达目标深度或者剪枝
最后,提供一个最近看到的搜索可视化的链接https://qiao.github.io/PathFinding.js/visual/
几种搜索算法就说到这,暂时想到这么多,由于本人知识有限,如果有什么错误,望各位大佬纠正