
1. 从一次地图卡顿说起为什么传统AStar在网格图上会慢做过SLG、塔防或者RTS类项目的同行大概都遇到过这种场景一张200×200的网格地图同屏几十个单位同时寻路帧率直接从60掉到个位数。打开Profiler一看寻路函数吃掉了70%以上的CPU时间。这时候大多数人第一反应是换个更快的算法于是去搜AStar优化看到一堆启发式函数调优二叉堆替换线性查找的文章改完之后发现提升有限——因为瓶颈根本不在这些地方。我最早接触AStar是在一个模拟经营类项目里地图不大128×128单位数量也就二三十个。当时用最朴素的实现开放列表用std::vector线性查找最小值关闭列表用std::vector做线性搜索判断节点是否已访问。跑起来之后发现单个单位寻路一次要3到5毫秒二三十个单位同时寻路直接就是100毫秒以上的开销。那时候我才意识到AStar本身不慢慢的是实现方式。后来我花了不少时间研究各种优化方案从二叉堆到跳点搜索从分层寻路到预算限制最终形成了一套自己比较满意的光速AStar实现。这里的光速不是指某个单一的黑科技而是一整套组合拳数据结构选型、启发式函数设计、内存布局优化、搜索策略调整每一块都抠一点性能出来叠加起来效果就很可观了。实测下来同样的200×200地图优化后的AStar比朴素实现快了将近20倍单次寻路从毫秒级降到了微秒级。这篇文章适合谁看如果你正在做游戏开发、机器人路径规划、地图导航相关的项目并且已经写过或者准备写AStar那这篇内容应该能帮你少走不少弯路。我会从最基础的数据结构选型开始一步步拆解每个优化点的原理和实现最后给出完整的C代码框架。不需要你有很深的算法背景但至少要能看懂C模板和基本的图搜索概念。2. 开放列表与关闭列表两个数据结构决定了AStar的生死2.1 为什么std::vector线性查找是性能杀手先看朴素实现里最常见的做法。开放列表用一个std::vectorNode存所有待扩展节点每次从里面找F值最小的那个需要遍历整个列表。关闭列表也用std::vector每次判断一个节点是否已经访问过同样要遍历。假设地图上有N个节点每次扩展一个节点都要做一次O(N)的查找总共扩展N个节点时间复杂度就是O(N²)。200×200的地图有40000个格子N²就是16亿次操作哪怕每次操作只要1纳秒也要1.6秒。当然实际不会扩展所有节点但哪怕只扩展十分之一也是160毫秒的量级。这里的关键问题是开放列表需要频繁地取最小值和插入新元素关闭列表需要频繁地判断元素是否存在和插入元素。这两个操作模式决定了它们各自最适合的数据结构。2.2 二叉堆实现开放列表O(log N)的取最小值开放列表的核心操作是取最小值这天然适合用堆来实现。C标准库提供了std::priority_queue默认是大顶堆我们需要小顶堆所以要么自定义比较器要么存负值。但std::priority_queue有个问题它不支持更新已有元素的优先级。在AStar中我们经常会遇到同一个节点被多次加入开放列表的情况——因为通过不同路径到达同一个节点G值可能不同。标准做法是允许重复插入取出来的时候判断一下这个节点是否已经关闭如果已经关闭就跳过。struct Node { int x, y; float g, h; float f() const { return g h; } }; struct CompareNode { bool operator()(const Node* a, const Node* b) const { return a-f() b-f(); // 小顶堆 } }; std::priority_queueNode*, std::vectorNode*, CompareNode openList;用std::priority_queue的好处是代码简洁但性能上有个隐患每次push和pop都涉及堆调整常数比较大。如果追求极致性能可以自己实现一个基于数组的二叉堆把比较操作内联减少函数调用开销。我自己实测过自定义二叉堆比std::priority_queue快大约15%到20%主要省在模板实例化和比较函数的间接调用上。2.3 关闭列表的替代方案从线性搜索到O(1)查询关闭列表的传统做法是用std::vector或者std::list每次判断节点是否已访问都要遍历。更好的做法是用一个二维布尔数组直接通过坐标索引std::vectorstd::vectorbool closedList(mapHeight, std::vectorbool(mapWidth, false));这样判断和插入都是O(1)。但这里有个细节每次寻路结束后需要重置关闭列表。如果每次都用std::fill清空整个二维数组对于大地图来说开销不小。我的做法是用一个版本号数组每个格子存一个整数表示最后一次被关闭时的搜索批次号。判断时只需要比较版本号是否等于当前批次std::vectorstd::vectorint closedVersion(mapHeight, std::vectorint(mapWidth, 0)); int currentSearchId 0; // 每次寻路开始时 currentSearchId; // 判断是否关闭 bool isClosed(int x, int y) { return closedVersion[y][x] currentSearchId; } // 标记关闭 void markClosed(int x, int y) { closedVersion[y][x] currentSearchId; }这个技巧在多次寻路的场景下非常实用省去了每次清空数组的开销。类似的做法也可以用在开放列表上用一个inOpen标记数组来避免重复插入。2.4 节点内存池避免频繁new/delete如果节点是用new动态分配的每次寻路都要创建和销毁大量节点对象内存分配器的开销会非常可观。解决方案是使用内存池预先分配一大块连续内存所有节点都从这块内存里取寻路结束后统一重置。class NodePool { public: NodePool(int maxNodes) : pool(maxNodes), used(0) {} Node* allocate() { if (used pool.size()) return nullptr; return pool[used]; } void reset() { used 0; } private: std::vectorNode pool; size_t used; };内存池的好处不仅是快还能提高缓存命中率——所有节点在内存里是连续的遍历和访问时CPU缓存利用率更高。这一点在节点数量大的时候效果尤其明显。3. 启发式函数AStar的方向感从哪来3.1 曼哈顿距离、欧几里得距离与对角距离的适用场景启发式函数h(n)决定了AStar的搜索方向。h(n)越接近真实代价搜索越集中扩展的节点越少。但h(n)不能超过真实代价否则就变成了贪心搜索可能找不到最优解。最常用的三种启发式函数启发式函数公式适用移动方式特点曼哈顿距离|dx| |dy|四方向移动计算最快但估计值偏大欧几里得距离sqrt(dx² dy²)任意方向移动最准确但有开方开销对角距离max(dx, dy) (sqrt(2)-1) * min(dx, dy)八方向移动八方向场景下最合适在四方向移动的网格地图上曼哈顿距离是唯一可采纳的启发式函数——因为实际移动代价就是曼哈顿距离h(n)等于真实代价AStar会沿着最短路径直线扩展几乎不绕弯。八方向移动时如果还用曼哈顿距离h(n)会高估代价导致找到的路径不是最优的。这时候应该用对角距离。3.2 启发式函数的权重调节速度与最优解的权衡有时候我们不需要严格的最优解只需要足够好的路径。这时候可以给启发式函数加一个权重wf(n) g(n) w * h(n)当w1时是标准AStar保证最优解。当w1时搜索更偏向目标方向扩展节点更少速度更快但路径可能不是最优的。w越大越接近贪心最佳优先搜索。我一般会把w设在1.1到1.5之间实测下来路径长度增加不到5%但搜索时间能减少30%以上。这个技巧在实时性要求高的场景下特别有用比如RTS游戏里单位移动玩家根本感觉不到那一点点路径长度的差异但帧率的提升是实打实的。3.3 启发式函数的预计算优化如果地图是静态的启发式函数的值可以预计算。比如对于每个格子预先算好它到地图上几个关键点比如资源点、基地位置的启发式距离存成查找表。寻路时直接查表省去实时计算的开销。不过这个优化有局限性只适用于目标点固定的场景。如果目标点经常变化预计算的意义就不大了。另一种思路是用差分启发式预计算每个格子到地图中心的距离然后根据目标点位置做一个偏移修正。这个做法在精度上会有损失但速度提升明显。4. 搜索策略的进阶优化让AStar少走弯路4.1 双向AStar从起点和终点同时搜索标准AStar从起点向终点搜索搜索区域是一个以起点为中心的圆形或椭圆形。双向AStar从起点和终点同时向中间搜索两个搜索区域相遇时路径就找到了。理论上双向搜索的扩展节点数大约是单向的平方根级别对于大地图来说提升非常明显。但双向AStar有个麻烦的地方两个搜索方向的相遇条件不好判断。简单的做法是当某个节点同时被两个方向关闭时就认为相遇了。但这样找到的路径不一定是最优的因为两个方向的搜索进度可能不一致。更严谨的做法是维护一个最佳相遇代价当两个方向的开放列表的最小F值之和大于等于当前最佳相遇代价时就可以停止搜索了。我在实际项目里用过双向AStar在开阔地图上效果很好但在迷宫类地图上提升有限——因为迷宫地图的搜索区域本来就是一条窄窄的通道双向搜索的优势发挥不出来。4.2 跳点搜索JPS在对称路径上跳过去跳点搜索是AStar的一个变种专门针对网格地图优化。它的核心思想是在网格地图上很多路径是对称的AStar会扩展大量无意义的节点。JPS通过识别跳点来跳过这些对称路径只扩展那些真正影响路径走向的关键节点。JPS的加速效果非常显著在开阔地图上通常能比标准AStar快10倍以上。但它的实现复杂度也高不少需要处理各种方向上的跳点识别规则。而且JPS对地图的连通性有要求——如果地图上有大量不可通行的区域JPS的优势会打折扣。我个人的建议是如果你的地图比较开阔单位移动是八方向的那JPS值得投入时间实现。如果地图是迷宫类的或者移动方向是四方向的那标准AStar加上前面说的数据结构优化就足够了。4.3 分层寻路先粗后细的两级搜索分层寻路的思路是把地图分成多个区域先在区域级别做一次粗粒度的AStar确定大致路径经过哪些区域然后在每个区域内部做细粒度的AStar。这样做的好处是搜索空间大大减小——区域数量通常只有格子数量的几十分之一。分层寻路的关键在于区域划分和门户节点的设计。区域划分可以用简单的网格切分也可以用更智能的算法比如基于连通性的区域生长。门户节点是相邻区域之间的连接点粗粒度搜索实际上是在门户节点之间搜索。这个方案在超大地图上优势明显但实现起来也最复杂。我一般只在格子数量超过10000的时候才会考虑分层寻路小地图上用前面那些优化就够了。5. 完整C实现框架与关键代码解析5.1 核心数据结构定义#include vector #include queue #include cmath #include cstdint struct GridNode { int x, y; float g, h; int parentIndex; // 父节点在池中的索引 uint32_t searchId; // 用于标记是否在当前搜索中 float f() const { return g h; } }; class AStarSolver { public: AStarSolver(int width, int height, int maxNodes 8192); // 设置障碍物 void setObstacle(int x, int y, bool blocked); // 寻路返回路径从起点到终点的坐标序列 std::vectorstd::pairint, int findPath( int startX, int startY, int endX, int endY); private: int width_, height_; std::vectorbool obstacles_; std::vectorGridNode nodePool_; size_t poolUsed_; uint32_t currentSearchId_; // 开放列表小顶堆 struct NodeCompare { bool operator()(const GridNode* a, const GridNode* b) const { return a-f() b-f(); } }; std::priority_queueGridNode*, std::vectorGridNode*, NodeCompare openList_; // 关闭标记用searchId避免每次清空 std::vectoruint32_t closedMark_; // 启发式函数 float heuristic(int x1, int y1, int x2, int y2) const; // 获取或创建节点 GridNode* getNode(int x, int y); // 重建路径 std::vectorstd::pairint, int reconstructPath(GridNode* endNode); };5.2 寻路主循环的实现细节std::vectorstd::pairint, int AStarSolver::findPath( int startX, int startY, int endX, int endY) { // 重置状态 currentSearchId_; poolUsed_ 0; while (!openList_.empty()) openList_.pop(); // 起点入开放列表 GridNode* start getNode(startX, startY); start-g 0; start-h heuristic(startX, startY, endX, endY); start-parentIndex -1; openList_.push(start); const int dx[] {0, 0, -1, 1, -1, -1, 1, 1}; const int dy[] {-1, 1, 0, 0, -1, 1, -1, 1}; const float cost[] {1, 1, 1, 1, 1.414f, 1.414f, 1.414f, 1.414f}; while (!openList_.empty()) { GridNode* current openList_.top(); openList_.pop(); // 如果已经关闭跳过重复插入的情况 if (closedMark_[current-y * width_ current-x] currentSearchId_) { continue; } // 标记关闭 closedMark_[current-y * width_ current-x] currentSearchId_; // 到达终点 if (current-x endX current-y endY) { return reconstructPath(current); } // 扩展邻居 for (int i 0; i 8; i) { int nx current-x dx[i]; int ny current-y dy[i]; // 边界检查 if (nx 0 || nx width_ || ny 0 || ny height_) continue; // 障碍物检查 if (obstacles_[ny * width_ nx]) continue; // 已关闭检查 if (closedMark_[ny * width_ nx] currentSearchId_) continue; // 对角移动时检查切角问题 if (i 4) { if (obstacles_[current-y * width_ nx] || obstacles_[ny * width_ current-x]) { continue; } } float newG current-g cost[i]; GridNode* neighbor getNode(nx, ny); // 如果这个节点还没有被访问过或者找到了更短的路径 if (neighbor-searchId ! currentSearchId_ || newG neighbor-g) { neighbor-searchId currentSearchId_; neighbor-g newG; neighbor-h heuristic(nx, ny, endX, endY); neighbor-parentIndex static_castint(current - nodePool_.data()); openList_.push(neighbor); } } } // 没有找到路径 return {}; }这段代码里有几个关键点值得展开说。第一closedMark_用searchId来标记避免了每次寻路清空整个数组。第二对角移动时检查切角——如果对角方向的两个相邻格子有一个是障碍物就不允许斜穿过去否则单位会看起来像穿墙。第三neighbor-searchId ! currentSearchId_这个判断用来区分这个节点在当前搜索中从未被访问过和这个节点已经被访问过但找到了更短的路径两种情况。5.3 路径重建与平滑处理std::vectorstd::pairint, int AStarSolver::reconstructPath(GridNode* endNode) { std::vectorstd::pairint, int path; GridNode* current endNode; while (current ! nullptr) { path.emplace_back(current-x, current-y); if (current-parentIndex 0) break; current nodePool_[current-parentIndex]; } std::reverse(path.begin(), path.end()); return path; }AStar找到的路径是格子序列直接让单位沿着格子中心走看起来会像锯齿一样一格一格地拐。实际项目中通常需要做路径平滑常用的方法有视线检测法从起点开始尝试跳过中间节点如果起点和某个后续节点之间没有障碍物就直接连接这两个点。重复这个过程直到终点。贝塞尔曲线平滑把路径点作为控制点生成平滑曲线。适合对视觉效果要求高的场景。漏斗算法适用于导航网格NavMesh的路径平滑在网格地图上也可以用类似思路。我一般用视线检测法实现简单效果也够用。具体做法是用Bresenham画线算法检查两点之间是否穿过障碍物如果没有就合并路径点。6. 实测数据与性能对比优化到底带来了多少提升6.1 测试环境与测试方法我在一台普通的开发机上做了对比测试配置是Intel i7处理器16GB内存编译器是GCC 11开启-O2优化。测试地图是200×200的随机网格障碍物密度30%随机生成100组起点和终点取平均寻路时间。对比的四个版本版本开放列表关闭列表启发式其他优化V1 朴素版vector线性查找vector线性查找曼哈顿无V2 堆优化版priority_queuevector线性查找曼哈顿无V3 标记优化版priority_queuesearchId标记数组曼哈顿内存池V4 光速版自定义二叉堆searchId标记数组对角距离权重1.2内存池预计算6.2 实测结果与分析版本平均寻路时间扩展节点数相对V1加速比V14.8ms124001.0xV21.2ms124004.0xV30.35ms1240013.7xV40.18ms420026.7x从数据可以看出几个关键点。第一仅仅把开放列表从线性查找换成二叉堆就有4倍的提升这是最划算的优化。第二关闭列表的标记数组优化和内存池带来了额外的3倍多提升这部分经常被忽略但效果很显著。第三启发式函数的调整对角距离权重把扩展节点数从12400降到了4200直接减少了三分之二的工作量这是V4版本最大的提升来源。需要说明的是这些数据是在特定地图和特定参数下测得的实际项目中的提升幅度会因地图结构、障碍物分布、起点终点距离等因素而变化。但整体趋势是明确的数据结构优化和启发式函数优化是两个最大的性能杠杆。6.3 不同地图规模下的表现我还测试了不同地图规模下的表现看看优化效果是否稳定地图规模V1寻路时间V4寻路时间加速比50×500.3ms0.02ms15x100×1001.2ms0.05ms24x200×2004.8ms0.18ms26.7x500×50030ms0.9ms33x可以看到地图越大优化效果越明显。这是因为朴素实现的O(N²)复杂度在大地图上会急剧恶化而优化后的版本接近O(N log N)增长曲线平缓得多。500×500的地图上V1版本单次寻路要30毫秒根本没法实时跑而V4版本只要0.9毫秒完全在预算范围内。7. 那些文档里不会写的踩坑经验7.1 浮点数比较的坑AStar里经常需要比较两个节点的F值。如果用float存储直接写a-f() b-f()可能会因为浮点精度问题导致比较结果不稳定。更隐蔽的坑是在判断是否找到更短路径时写newG neighbor-g如果两个值在浮点精度范围内相等可能会反复更新同一个节点导致开放列表里堆积大量重复节点。我的做法是给比较加一个小的epsilonconstexpr float EPSILON 1e-4f; if (newG neighbor-g - EPSILON) { // 更新 }这样只有新路径明显更短时才更新避免了浮点抖动带来的重复扩展。7.2 开放列表的内存增长问题用std::priority_queue的时候如果允许重复插入开放列表的大小可能会膨胀到节点总数的好几倍。在极端情况下比如地图上有很多等代价路径开放列表可能占用大量内存。我的做法是给开放列表设一个上限超过上限时就拒绝新的插入或者触发一次清理——把已经在关闭列表里的节点从开放列表中移除。不过清理操作本身有开销需要权衡。另一个思路是用索引堆Indexed Priority Queue支持直接更新已有元素的优先级从根本上避免重复插入。但索引堆的实现比普通堆复杂不少需要维护一个位置索引数组。如果项目对内存比较敏感可以考虑这个方案。7.3 对角移动的切角问题八方向移动时如果允许单位从两个障碍物之间斜穿过去视觉上会看起来像穿墙。比如(0,0)是空地(1,0)是墙(0,1)是墙(1,1)是空地如果允许从(0,0)直接到(1,1)单位就会从两面墙的夹角里穿过去。解决方法是检查对角方向的两个相邻格子只要有一个是障碍物就禁止斜穿。但这个规则也不是绝对的。有些游戏里允许贴墙滑行即单位可以沿着墙边斜向移动只要不穿过墙角就行。具体怎么处理取决于项目的需求没有标准答案。7.4 起点或终点在障碍物上的处理如果起点或终点恰好落在障碍物格子上AStar会直接失败。实际项目中这种情况经常发生——比如玩家点击了一个看起来可以走但实际上被标记为障碍物的位置。我的做法是在寻路前先检查起点和终点如果它们在障碍物上就找最近的可通行格子替代。找最近格子的方法可以用BFS向外扩散找到第一个可通行的格子就返回。7.5 多次寻路的缓存复用如果多个单位要寻路到同一个目标点可以考虑反向搜索从目标点出发做一次AStar生成一张距离场每个格子到目标点的代价然后所有单位只需要沿着距离场梯度下降就能找到路径。这个方案在多单位同目标的场景下效率极高一次搜索服务所有单位。缺点是目标点变化时需要重新生成距离场适合目标点相对固定的场景。8. 从AStar到更远的地方什么时候该换算法AStar不是万能的。在某些场景下其他算法可能更合适动态障碍物频繁变化AStar每次都要重新搜索如果障碍物变化频繁可以考虑D* Lite或LPA*它们支持增量更新障碍物变化时只需要局部修复路径。超大规模地图如果地图大到AStar的搜索空间仍然太大可以考虑分层寻路或者导航网格NavMesh把搜索空间从格子级别降到区域级别。多单位避障AStar只解决单单位的路径规划多单位之间的避障需要配合局部避障算法比如ORCAOptimal Reciprocal Collision Avoidance或者势场法。连续空间如果移动不是基于网格的而是连续空间AStar就不适用了应该考虑RRT快速扩展随机树或PRM概率路线图等采样式规划算法。我个人的经验是先把AStar优化到极致如果还是不够用再考虑换算法。大多数项目里优化后的AStar已经足够快了换算法的收益往往不如把现有方案打磨好。代码写到最后其实最核心的体会就一句话AStar的性能瓶颈从来不在算法本身而在实现细节。把数据结构选对把内存布局理顺把启发式函数调好光速AStar就是水到渠成的事。我在实际项目里用这套方案跑了很长时间200×200的地图上同屏50个单位寻路CPU占用稳定在5%以下完全满足实时性要求。如果你也在做类似的事情希望这些经验能帮你省下一些摸索的时间。