Post

记一次纯手搓 A* (A-Star) 算法的踩坑复盘

A*算法

记一次纯手搓 A* (A-Star) 算法的踩坑复盘

记一次纯手搓 A* (A-Star) 算法的踩坑复盘

今天突发奇想,决定不依赖任何参考资料,全凭记忆纯手搓一个基础版的 A* 寻路算法。

本以为是个信手拈来的小任务,结果现实狠狠打脸——做了这么久的算法开发,真要脱离了框架和现成的库从零写起,竟然折腾了一整个下午才弄出初版。在这里记录一下,不仅是为了纪念,更是对自己的一次自省:纸上得来终觉浅,绝知此事要躬行。

在手写和 Debug 的过程中,我总结了 5 个看似基础、但极容易踩坑的核心要点:

1. 寻路不只是 OpenList,CloseList 同样致命

一开始凭着对 BFS 的肌肉记忆写,差点忘了 A* 的核心机制之一是 CloseList(已探索集合)。如果没有 CloseList 或者标记不严谨,算法在复杂的障碍物环境中极容易陷入死循环或重复计算。

2. 回溯路径的极致优化:丢掉指针

传统的教程里,每个节点通常会存一个 Node* parent 指针用来最终回溯路径。但我发现,在网格地图中其实不需要维护繁琐的指针优化方案: 只需要在节点中记录它相对于父节点的“移动方向(dir)”。到达终点后,直接用当前坐标减去 dir 就能完美逆推回起点,省内存且逻辑更清爽。不过这个情况只适用于栅格地图,对于其他有向图则无法适用。

3. 没有优先队列,A* 就会退化为 BFS

A* 的灵魂在于 $F = G + H$。如果图省事只用了一个普通的 std::queue 或者 std::vector,那实际上每次取出的并不是代价最小的节点,整个算法就直接退化成了盲目的广度优先搜索(BFS)。必须使用优先队列(Priority Queue),根据 $F$ 值动态排序。

4. 别忘了更新 OpenList 中的“更优路径”

这是我卡了最久的一个 Bug。当探测到一个已经存在于 OpenList 中的相邻节点时,不能直接跳过!必须检查当前路径到达该节点的 $G$ 值是否比它原有的 $G$ 值更小。如果更小,说明找到了一条更近的路,必须更新它的父节点(dir)和 $G$ 值。

5. 启发函数 (Heuristic) 必须与移动方式匹配

最容易犯的数学错误:

  • 如果你的地图只能 上下左右 4 方向 移动,启发函数用 曼哈顿距离 (Manhattan Distance)
  • 但如果是 8 方向 移动,如果还用曼哈顿距离,会导致高估代价(不满足 Admissible 条件),可能找不到最短路径。必须改为 切比雪夫距离 (Chebyshev Distance)对角线距离

基本 A* 算法实现

虽然过程有些磕绊,但最终跑通的那一刻成就感还是拉满了。下面是我最终手搓出来的一版基础代码,留作纪念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <functional>
#include <iostream>
#include <queue>
#include <vector>

int MAX_ROW = 1000;
int MAX_COL = 1000;

std::vector<int> map_data(MAX_ROW *MAX_COL);

// f = g + h
struct Node {
  int g;
  int h;
  int x;
  int y;
  int dir;
  int state;
};
struct CompareNode {
  bool operator()(const Node &a, const Node &b) const {
    return a.g + a.h > b.g + b.h; // f 小的优先
  }
};

std::vector<Node> list_(MAX_ROW *MAX_COL);
// std::queue<Node> open;
std::priority_queue<Node, std::vector<Node>, CompareNode> open;

static const int DX[8] = {0, 0, -1, 1, -1, 1, -1, 1};
static const int DY[8] = {-1, 1, 0, 0, -1, -1, 1, 1};
static const int COST[8] = {10, 10, 10, 10, 14, 14, 14, 14};

inline int heuristic(int x, int y, int ex, int ey) {
  int dx = std::abs(x - ex);
  int dy = std::abs(y - ey);
  // Octile: 10 * max(dx,dy) + 4 * min(dx,dy)   [= 10*直线 + 14*对角 的等价形式]
  return 10 * std::max(dx, dy) + 4 * std::min(dx, dy);
}

std::vector<int> astar() {
  list_.assign(MAX_ROW * MAX_COL, Node{0, 0, 0, 0, -1, 0});

  int sx = 20;
  int sy = 20;
  int ex = 800;
  int ey = 900;

  Node n{0, 0, sx, sy, -1, 1};
  open.push(n);
  std::vector<int> path;

  while (!open.empty()) {
    auto now = open.top();
    open.pop();
    auto nn = now.y * MAX_COL + now.x;
    if (list_[nn].state == 2)
      continue;
    list_[nn].state = 2; // 关闭

    if (now.x == ex && now.y == ey) {
      std::cout << "Find\n";
      // 回溯
      int backx = ex, backy = ey;
      while (list_[backy * MAX_COL + backx].dir != -1) {
        const auto &back_node = list_[backy * MAX_COL + backx];
        backx = back_node.x - DX[back_node.dir];
        backy = back_node.y - DY[back_node.dir];
        std::cout << "v" << list_[backy * MAX_COL + backx].dir;
        path.push_back(backy * MAX_COL + backx);
        std::reverse(path.begin(), path.end());
      }
      return path;
    }
    if (now.x == 0 || now.x == MAX_ROW - 1 || now.y == 0 ||
        now.y == MAX_COL - 1) {
      continue;
    }

    for (int i = 0; i < 8; i++) {
      int nx = now.x + DX[i];
      int ny = now.y + DY[i];
      // 在生成邻居时就检查边界
      if (nx < 0 || nx >= MAX_COL || ny < 0 || ny >= MAX_ROW)
        continue;
      int idx = ny * MAX_COL + nx;
      if (map_data[idx] != 0)
        continue;
      if (list_[idx].state != 0)
        continue;
      int new_g = now.g + COST[i];

      Node next{now.g + 1, 0, now.x + DX[i], now.y + DY[i], i, 1};
      next.h = std::abs(next.x - ex) + std::abs(next.y - ey);
      if (list_[idx].state == 0 || new_g < list_[idx].g) {
        open.push(next);
        Node next;
        next.g = new_g;
        next.h = heuristic(nx, ny, ex, ey);
        next.x = nx;
        next.y = ny;
        next.dir = i;
        next.state = 1;
        list_[idx] = next;
        open.push(next); // 允许重复入队,靠 state==2 跳过旧副本
      }
    }
  }
  return path;
}

int main() {
  //
  auto path = astar();
  std::cout << "path size:" << path.size() << "\n";
  for (const auto &p : path) {
    std::cout << p / MAX_COL << "," << p % MAX_COL << "\n";
  }
}


小结: 日常工作中我们习惯了调用各种封装好的库(比如 Nav2 里的各类 Planner),但偶尔像这样回归底层,纯手工实现一遍经典算法,依然能发现自己对细节的把控还有提升空间。路漫漫其修远兮,继续敲代码吧!

This post is licensed under CC BY 4.0 by the author.