提到寻路算法,大家都会想到A*算法。
在度娘找了不少代码,看了不少教程之后,尤其是这个文章中提到的总结:http://www.cppblog.com/christanxw/archive/2006/04/07/5126.html
A*算法总结(Summary of the A* Method)
Ok ,现在你已经看完了整个的介绍,现在我们把所有步骤放在一起:
1. 把起点加入 open list 。
2. 重复如下过程:
a. 遍历 open list ,查找 F 值最小的节点,把它作为当前要处理的节点。
b. 把这个节点移到 close list 。
c. 对当前方格的 8 个相邻方格的每一个方格?
◆ 如果它是不可抵达的或者它在 close list 中,忽略它。否则,做如下操作。
◆ 如果它不在 open list 中,把它加入 open list ,并且把当前方格设置为它的父亲,记录该方格的 F , G 和 H 值。
◆ 如果它已经在 open list 中,检查这条路径 ( 即经由当前方格到达它那里 ) 是否更好,用 G 值作参考。更小的 G 值表示这是更好的路径。如果是这样,把它的父亲设置为当前方格,并重新计算它的 G 和 F 值。如果你的 open list 是按 F 值排序的话,改变后你可能需要重新排序。
d. 停止,当你
◆ 把终点加入到了 open list 中,此时路径已经找到了,或者
◆ 查找终点失败,并且 open list 是空的,此时没有路径。
3. 保存路径。从终点开始,每个方格沿着父节点移动直至起点,这就是你的路径。
我按照这个思路中的总结,写了一个算法出来,开启列表和关闭列表是基于stl来实现的。
大概10000*10000的地图,寻路寻下来,要用30秒的时间,汗颜,如果比较复杂的地形,要用1分多钟。。。
最后我自己对我自己的代码做了一个总结,发现主要慢的地方是这几个步骤:
a. 遍历 open list ,查找 F 值最小的节点,把它作为当前要处理的节点。
实际上这个步骤,是为了直接对路径进行排序,如果不这么做,最终的路径,很可能会出现很多重复来回走的路,但是,这个BUG是可以在最终筛选节点的时候在来处理的,最终筛选的时候来处理的效率要比在寻路算法中直接搜索效率得多,如果你是游戏开发程序员,那么你的算法不得不这么做,使用二叉堆来搞这个步骤会比较快,或者你先实现短距离最佳路径,在使用远距离寻路时用短距离的走路函数配合最后筛选的方式也可以实现寻路,如果你是游戏外挂作者,你就可以不排序,而最后来筛选。
◆ 如果它是不可抵达的或者它在 close list 中,忽略它。否则,做如下操作。
◆ 如果它不在 open list 中,把它加入 open list ,并且把当前方格设置为它的父亲,记录该方格的 F , G 和 H 值。
◆ 如果它已经在 open list 中,检查这条路径 ( 即经由当前方格到达它那里 ) 是否更好,用 G 值作参考。更小的 G 值表示这是更好的路径。如果是这样,把它的父亲设置为当前方格,并重新计算它的 G 和 F 值。如果你的 open list 是按 F 值排序的话,改变后你可能需要重新排序。
在估价8方向的逻辑中,遍历两个列表,是非常不明智的,尤其是关闭列表,这个列表只会一直增长,不会有减少的情况。
遍历open list,是很要命的事,10000*10000的地图,很轻松的就到几万个节点了。每次寻找下一步,都遍历一下,最终搞下来,那是相当之慢啊。
但是更要命的是查close list,每寻找下一个节点,就要查8次。如果真的去搜容器,搜链表,那最终寻路寻下来会奇慢无比。
找到问题所在之后,那就对症下药,抛弃这些耗时的代码,重新设计寻路流程:
1、自行分配内存,创建一个二值化的地图,这样就避免了查找关闭列表,而是以数组的形式直接访问坐标的状态。
2、实现设置/获取某个坐标状态接口(开启或关闭),既然需要自己创建二值化地图,那么就应该要实现设置,获取二值化地图的接口给寻路算法使用。
3、把起始坐标作为路径列表头
重复如下过程:
a、从起点开始估价,每次估价设置当前节点为关闭,然后对比7方向(上一个节点已经在上一次置为关闭,当前节点也在估价开始时关闭,所以是7个方向)
b、选择距离目标坐标最近的一个方向来作为下一次估价的节点,并设置当前节点为它的父节点。
c、如果7个方向都不能走,说明是一条死路,回溯到他的父节点重新寻找其他方向。
结束:直到你的当前坐标为目标坐标时,或者路径列表为空时
结束后,如果路径列表不为空,则根据路径列表筛选出最终路径。
整个算法流程就是这样,修改后,10000*10000的简单地图寻路,只需0-30毫秒,比较复杂的地图不超过100毫秒,反正跟之前对比是比较神了。
反正我自己也不知道这个算法算不算A*算法了,反正效率是比那种随时查表的方式高多了。
关键代码如下:
#define MHD_X 2
#ifndef MINSIFT
#define MINSIFT 60.0
#endif
#ifndef MAXSIFT
#define MAXSIFT 120.0
#endif
#ifndef MHD_X
#define MHD_X 1
#endif
#ifndef MHD_Y
#define MHD_Y 1
#endif
typedef struct _APOINT{
int x;
int y;
_APOINT *parent;
}APOINT, *LPAPOINT;
class CAStarFinder
{
public:
CAStarFinder(){m_nTX = -1; m_nTY = -1; m_pMap = NULL; m_pSafePoint = NULL;}
CAStarFinder(int nTX, int nTY) : m_nTX(nTX), m_nTY(nTY), m_pMap(NULL) , m_pSafePoint(NULL){}//构造函数参数 终点X, 终点Y
void Search(int X, int Y, std::vector<POINT> &vResult);//搜索函数,参数为:起点X, 起点Y, 传入一个容器返回最终筛选好的节点
void SetMapAttributes(bool *pMap, DWORD nWidth = 0xFFFFFFFF, DWORD nHeight = 0xFFFFFFFF);
//设置地图属性,参数为:地图指针, 宽度, 高度
void SetTargetPos(int X, int Y){m_nTX = X; m_nTY = Y;}//由于这个类是2个构造函数,一个带参数,一个不带参数,所以公开一个设置目标地址参数
private:
LPAPOINT Manhattan(LPAPOINT lpPoint);//估价函数
private:
int m_nTX, m_nTY;
bool *m_pMap;
DWORD m_nMapWidth, m_nMapHeight;
LPAPOINT m_pSafePoint;
};
//算法中使用的获取距离函数
double _p2g(int x1, int y1, int x2, int y2)
{
return sqrt(pow(double(abs(x1 - x2)), 2) + pow(double(abs(y1 - y2)), 2));
}
//调试输出函数
void _outf(const char *format, ...)
{
va_list al;
char buf[BLX_MAXSIZE];
va_start(al, format);
_vsnprintf(buf, BLX_MAXSIZE, format, al);
va_end(al);
OutputDebugStringA(buf);
}
struct _find_astar_note_gap{
_find_astar_note_gap(int X, int Y, double Gap) : _X(X), _Y(Y), _Gap(Gap) {}
bool operator()(POINT &point){return _p2g(point.x, point.y, _X, _Y) < _Gap;}
int _X, _Y;
double _Gap;
};
void CAStarFinder::SetMapAttributes(bool *pMap, DWORD nWidth, DWORD nHeight)
{
m_pMap = pMap;
m_nMapWidth = nWidth;
m_nMapHeight = nHeight;
}
LPAPOINT CAStarFinder::Manhattan(LPAPOINT lpPoint)
{
int nX = lpPoint->x, nY = lpPoint->y;
int aX[3] = {nX - MHD_X, nX, nX + MHD_X};
int aY[3] = {nY - MHD_Y, nY, nY + MHD_Y};
_SetMapValue(m_pMap, nX, nY, false);//设置坐标开启关闭状态,这个函数应该由你自己编写
bool bState = 0;
double dbMinGap = 10000000.0, dbTemp;
nX = -1;
nY = -1;
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 3; j++)
{
//使用交叉方式遍历7个位置(当前坐标在一开始就已经置为关闭了,他的父节点也在上次置为关闭了)
if(aX[j] > m_nMapWidth || aY[i] > m_nMapHeight)
continue;//坐标越界则直接下一圈
bState = _GetMapValue(m_pMap, aX[j], aY[i]);//获取坐标开启关闭状态,这个函数应该由你自己编写
//由于_GetMapValue会用得非常频繁,所以不应该以任何面向对象的形式或者各种复杂的结构指针的形式来调用,直接写一个函数来让编译器硬编码定位过去,是最效率的方法
if(!bState)
{
//为关闭则直接下一圈循环
continue;
}
else
{
dbTemp = _p2g(aX[j], aY[i], m_nTX, m_nTY);//计算绝对距离
if(dbTemp < dbMinGap)
{
//更小的绝对距离表示更好的方向
dbMinGap = dbTemp;
nX = aX[j];
nY = aY[i];
}
}
}
}
if(nX > 0 && nY > 0)
{
//7方向任意一个有效时,新建一个节点,把当前节点设置为他的父节点,并返回这个新建的节点
LPAPOINT pResult = new APOINT;
pResult->x = nX;
pResult->y = nY;
pResult->parent = lpPoint;
return pResult;
}
//否则就是死路,返回NULL
return NULL;
}
void CAStarFinder::Search(int X, int Y, std::vector<POINT> &vResult)
{
outf("%d, %d, %d, %d, %d, %d", X, Y, m_nTX, m_nTY, m_nMapWidth, m_nMapHeight);
if((int)m_nTX < 0 || (int)m_nTY < 0 || X < 0 || Y < 0 || (DWORD)X > m_nMapWidth || (DWORD)Y > m_nMapHeight || !m_pMap)
return;
APOINT *sp = new APOINT;
sp->x = X;
sp->y = Y;
sp->parent = NULL;
LPAPOINT point = sp;
LPAPOINT p2 = NULL;
while(m_pSafePoint != NULL)
{
//这个循环是防止同一个对象操作时因线程强制结束而导致的内存泄漏问题
p2 = m_pSafePoint;
m_pSafePoint = m_pSafePoint->parent;
delete p2;
}
DWORD dwTime = clock();
do{
p2 = point;
point = Manhattan(p2);//估价并返回下一个最小距离的节点
if(!point)
{
//估价后,如果返回NULL,则说明这条路是死路,此时应该回溯到上一个节点(父节点)重新搜索
point = p2->parent;//置当前节点为父节点
delete p2;
if(!point)
break;
//如果当前节点置为他的父节点后仍为NULL,则说明这个路径列表已经为空了,应该检查:
//1、坐标是否正确(起点和终点)
//2、地图数据是否正确
//3、获取地图状态值的函数是否正常
}
}while(_p2g(point->x, point->y, m_nTX, m_nTY) > 20.0);
//由于我对最终目标坐标不是绝对精确,所以我就这么干了,如果要求绝对精确,应该直接==判断。
int nCount = 0;
int nResultCount = 0;
int nX, nY;
POINT ptTemp;
m_pSafePoint = point;
if(point)
{
//筛选路径,我是根据已经选定的所有节点与下一节点的距离必须大于MINSIFT 最后选定的节点与下一节点的距离必须小于 MAXSIFT的距离来筛选,该距离可以自行根据需要设定,这两个常量宏在头文件中,可以在包含前自定义
nX = point->x;
nY = point->y;
ptTemp.x = nX;
ptTemp.y = nY;
vResult.clear();
do{
if( _p2g(ptTemp.x, ptTemp.y, point->x, point->y) < 120.0 && std::find_if(vResult.begin(), vResult.end(), _find_astar_note_gap(point->x, point->y, 60.0)) == vResult.end() )
{
//为什么要遍历整个容器来筛选距离大于MINSIFT的节点而不是只对比上个节点?
//原因是列表不是排序好的,如果只遍历一个节点,非常有可能选到往回走的路
//将符合条件的节点加入到容器
ptTemp.x = point->x;
ptTemp.y = point->y;
vResult.push_back(ptTemp);
nResultCount++;
}
//清理内存
p2 = point;
point = point->parent;
delete p2;
nCount++;
m_pSafePoint = point;
}while(point != NULL);
}
dwTime = clock() - dwTime;
_outf("最终寻路到:%X, %X", nX, nY);
_outf("路径节点数:%d", nCount);
_outf("筛选节点数:%d", nResultCount);
_outf("算法耗时:%d 毫秒", dwTime);
}
必要的stl的头文件:
#include <vector>
#include <algorithm>
必要的C函数库头文件:
#include <math.h>
其他的我也忘了还需要什么了,好像调试输出的函数需要一个可变参数的头文件。
返回最终路径,建议使用queue或者list,push_front向前添加节点,如果像我一样使用vector,则返回的最终路径是反向的。
该算法寻路的准确性不高,这是弱点,如果要对这个算法改良,需要在选到一个节点之后,进行判断所有已有节点中是否有和他相邻的节点,如果有,把已有的父节点之后的节点删除,重设他的父节点,这样做,对效率是有一些提升,因为整个列表中,那些无用的节点已经被排除在外了,但是准确性依旧不高。但对于游戏外挂寻路来说,这个算法实际上是很好的。
最后要注意的问题:
1、不要试图把这种寻路算法写成基类然后继承来调用,不信你可以往类中加入一个虚函数看看那效率,会慢很多,因为通过访问虚函数表,在调用最终的函数,这个过程会很有很大开销。在处理量少的情况下可能影响不大,但是处理量大的时候,目测是会慢3-10倍的样子,根据地图复杂度而定。
2、最好不要以面向过程的编程方式来写这种算法,因为需要的参数比较多,如果全靠压栈传参,效率会比面向对象的方式慢不少。
最后装B的说明下理由:
C++的thiscall 是用寄存器来传递this指针的 外mov ecx, esi 内mov esi, ecx 大概是这种方式
一个参数压栈指令push 会比上面那两条指令慢n倍,push会访问内存,内存跟的速度跟寄存器不是一个级别的。
而在函数的内部,面向过程的函数始终还是要通过ebp或者esp寄存器来访问栈中的参数,thiscall内部可以通过this指针来访问类成员,这和访问参数是一个道理,反正都要寻址,这个是避免不了了,所以能避免的就是尽可能的少的传递参数,如果你实在纠结这点开销,你可以把类成员函数都设计成无参数,全靠成员变量来操作,当然这个代码得你自己去改了。