3、路径搜索算法入门:图论基础、Dijkstra算法原理、A*算法原理、启发式函数设计

各位同学,欢迎来到第三章。说实话,路径搜索是整个航线规划系统的「心脏」。你前面学的坐标系、航点,最终都要靠搜索算法把它们串起来。这一章我会把图论基础、Dijkstra、A* 和启发式函数揉碎了讲,结合我这些年踩过的坑,帮你把地基打牢。

3.1 图论基础:把世界抽象成点和线

先问个问题:航线规划里的「地图」到底是什么?

说白了,我们不需要关心地面是草地还是水泥地。我们只关心两个东西——位置和连通关系。这就是图论的核心思想。

图(Graph) = 顶点(Vertex) + 边(Edge)

  • 顶点:代表航点、路口、关键位置
  • 边:代表可通行的路径,可以带权重(距离、时间、油耗)

我在项目中遇到过最典型的坑:把地图网格直接当图用。其实网格只是视觉表现,真正的图结构是抽象的邻接关系。你想想看,一个 1000x1000 的网格,如果每个格子都连四个邻居,那就是 100 万个顶点、近 400 万条边——搜索起来慢得让人崩溃。

3.1.1 图的存储方式

实际工程里,我推荐两种存储方式:

存储方式 适用场景 空间复杂度 查询边耗时
邻接矩阵 顶点少(< 1000)、稠密图 O(V²) O(1)
邻接表 顶点多、稀疏图(航线规划典型场景) O(V+E) O(度)

我的习惯:航线规划几乎都是稀疏图——每个航点只连附近几个点。所以邻接表是默认选择。别用邻接矩阵,除非你确定顶点数不超过 500。

3.1.2 有向图 vs 无向图

这个区别很关键。无人机航线里,有些路径是单向的(比如禁飞区边缘只能绕行),有些是双向的。我建议一开始就按有向图建模,双向路径用两条反向边表示。这样后面加约束条件时不用改数据结构。

// 邻接表结构示例(C++风格)
struct Edge {
    int to;          // 目标顶点编号
    double weight;   // 边权重(距离/时间)
};

vector<vector<Edge>> graph;  // graph[u] 存储从 u 出发的所有边

3.2 Dijkstra算法原理:最稳妥的「地毯式搜索」

Dijkstra 算法,我做了这么多年项目,依然觉得它是「最让人放心的算法」。为什么?因为它保证找到最短路径——前提是所有边的权重非负。

3.2.1 核心思想

说白了就一句话:每次从「未确定」的点里,挑一个距离起点最近的点,然后用它去更新邻居。

我刚开始学的时候总觉得这太简单了,能行吗?后来自己手写了一遍才明白,它的巧妙在于「贪心 + 松弛」的组合。

  1. 初始化:起点距离为 0,其他点为无穷大
  2. 维护一个优先队列(小顶堆),按当前距离排序
  3. 每次弹出距离最小的顶点 u,标记为「已确定」
  4. 遍历 u 的所有邻居 v,如果 dist[u] + w(u,v) < dist[v],就更新 dist[v]
  5. 重复直到队列为空

我曾经踩过的坑:有一次用普通队列代替优先队列,结果算法退化成了 O(V²)。在 10 万个顶点的图上跑了 3 分钟没出结果。后来换成优先队列,同样的图只用了 0.3 秒。记住:Dijkstra 必须用优先队列,否则别叫它 Dijkstra。

3.2.2 时间复杂度

实现方式 时间复杂度 备注
普通数组 + 遍历找最小 O(V²) 只适合小图
二叉堆(优先队列) O((V+E) log V) 工程中最常用
斐波那契堆 O(V log V + E) 理论最优,实际常数大

3.3 A*算法原理:带「方向感」的搜索

Dijkstra 虽然稳,但它有个问题——像个无头苍蝇一样往四面八方搜。你想想看,如果目标在东边,它还会往西边搜半天。A* 算法就是来解决这个问题的。

3.3.1 核心公式

A* 的评估函数就一行:

f(n) = g(n) + h(n)
  • g(n):从起点到当前节点 n 的实际代价(和 Dijkstra 一样)
  • h(n):从 n 到目标节点的估计代价(启发式函数)
  • f(n):总估计代价,优先队列按 f(n) 排序

说白了,A* 就是在 Dijkstra 的基础上加了一个「指南针」。这个指南针就是 h(n)。

3.3.2 算法流程

  1. 把起点加入 open list(优先队列),g(start)=0,f(start)=h(start)
  2. 从 open list 中弹出 f 值最小的节点 n
  3. 如果 n 是目标节点,搜索结束
  4. 否则,把 n 加入 closed list(已处理列表)
  5. 对 n 的每个邻居 m:
    • 如果 m 在 closed list 中,跳过
    • 计算 tentative_g = g(n) + w(n, m)
    • 如果 m 不在 open list 中,或 tentative_g < g(m),更新 g(m) 和 f(m),并把 m 加入 open list
  6. 重复步骤 2-5

我个人的习惯:open list 用二叉堆实现,closed list 用哈希表(或布尔数组)。别用链表存 closed list,查找太慢。在 10 万节点的图上,哈希表查找是 O(1),链表是 O(n),差距巨大。

3.4 启发式函数设计:A* 的灵魂

启发式函数 h(n) 设计得好不好,直接决定 A* 是「智能搜索」还是「比 Dijkstra 还慢的废物」。

3.4.1 可采纳性与一致性

两个关键概念:

  • 可采纳性(Admissible):h(n) 永远不大于实际代价。这样才能保证 A* 找到最优路径。
  • 一致性(Consistent):h(n) ≤ w(n,m) + h(m)。满足一致性时,A* 不需要重新检查 closed list 中的节点,效率更高。

我曾经犯过的错:在一个有障碍物的地图上,用了欧几里得距离作为启发式,但实际路径必须绕障碍物。结果 h(n) 严重低估实际代价,A* 扩展了大量节点,比 Dijkstra 还慢。后来换成曼哈顿距离 + 障碍物惩罚项,才恢复正常。

3.4.2 常用启发式函数

函数 公式 适用场景 是否可采纳
曼哈顿距离 |x1-x2| + |y1-y2| 四方向网格(上下左右) 是
欧几里得距离 √((x1-x2)² + (y1-y2)²) 任意方向移动 是
切比雪夫距离 max(|x1-x2|, |y1-y2|) 八方向网格(可斜走) 是
对角线距离 dx+dy + (√2-2)*min(dx,dy) 八方向网格,精确代价 是

3.4.3 工程中的调优技巧

嗯,这里要注意:理论上的可采纳性在工程里往往需要「打折」。我一般会给 h(n) 乘一个略大于 1 的系数(比如 1.01),让搜索更快收敛。虽然这样会损失最优性,但在航线规划里,99% 最优比 100% 最优但慢 10 倍要实用得多。

// 加权 A* 示例
double h_weighted = 1.01 * heuristic(current, goal);
double f = g + h_weighted;

3.5 本章知识体系图

下面这张图把本章的核心概念串起来了。我建议你把它存下来,后面学更高级的算法时回来对照。

路径搜索算法知识体系 图论基础 顶点 + 边 邻接表 / 邻接矩阵 Dijkstra 算法 贪心 + 松弛 优先队列实现 A* 算法 f = g + h open list + closed list 启发式函数设计 可采纳性 一致性 曼哈顿 / 欧几里得 核心要点 Dijkstra 是 A* 的特例(h=0) 启发式函数决定搜索效率,可采纳性保证最优性

3.6 本章小结

这一章我们走完了路径搜索的「三件套」:图论是骨架,Dijkstra 是基本功,A* 是进阶武器,启发式函数是调优关键。我个人觉得,初学者最容易犯的错就是跳过图论直接写 A*,结果连邻接表都写不对。万丈高楼平地起,把图论基础打扎实了,后面学 RRT*、Theta* 这些高级算法时才能游刃有余。