A*搜索算法的更多内容

A*算法,也许你会习惯称它为「A*寻路算法」。许多人大概是因寻路——尤其是「网格地图」寻路认识它的,网上很多教程也是以网格地图为例讲解它的算法实现。这导致了许多人在遇到同样用了A*算法的地方,例如GOAP或者基于八叉树的立体空间寻路时会一头雾水:A*算法原来有这么多「变种」吗(⊙ˍ⊙)?其实A*算法是没有变的,只是我们原先 错误地将它与「网络地图」捆绑在了一起。A*算法本身是一种 搜索算法,这次我们从另一视角看看「A*搜索算法」,并一起完成一个更泛用的「A*搜索器」,最后再探讨一些常见的正确优化方式与错误优化方式。

注意:本文并不会详细将A*算法的逻辑原理,希望你至少已了解用于网格地图的A*寻路算法 ̄へ ̄,本文算是对《人工智能:一种现代的方法(第3版)》第三章以及《游戏人工智能(Game AI Pro)2015版》第17讲相关内容的「复述」,感兴趣的同学可以亲自看看呀~

1. 启发式的搜索策略

「宁滥勿缺」的 「 广度优先搜索(Breath First Search,简称BFS)」和「不撞南墙不回头」的「 深度优先搜索(Depth First Search,简称DFS)」是最为人所知的两种 「盲目搜索策略」 。相比于它们的「一根筋」,有些搜索策略通过 记录些额外信息 就能更清楚地知道往哪搜索 「更有希望」 接近目标,这类搜索策略就是 「启发式搜索策略」

我们要讲的A*搜索算法就是启发式搜索策略中最出名的一种,你一定还记得A*算法中的这个式子:f(n) = g(n) + h(n)

这里的g(n)表示 从开始节点 到当前的n节点已经花费的代价,而h(n)表示 该节点 到目标节点 所需的估计代价。可以看出,f(n)可谓「瞻前顾后」,其中h(n),即启发式函数(heuristic function)的设计便是关键所在。

2. 陌生的启发式函数

如果你学过用于网格地图寻路的A*搜索算法的同学,一定会想到h(n)的几种设计方式,比如曼哈顿距离、欧式距离、对角线估价……但这些都是针对网格地图,如果我们面对的是中继点图(Waypoint)呢?

你一时或许的确不知道该怎么设计h(n),但这没关系,你应该清楚的是A*搜索算法的逻辑依旧没变,让我们对A*感到陌生的原因仅仅是 启发式函数不同 而已。

h(n)会根据搜索问题的不同而不同,比如,在GOAP中h(n)需要被设计为能估计当前状态与目标状态的接近程度的函数,这比起寻路时的距离估计明显抽象了不少。但设计h(n)依旧是有思路可循的:

  1. 可采纳性。可采纳性是指h(n)从不会过高(超过实际的)估计到达目标的代价。也就是说要「乐观」,h(n)估计的到达目标的代价值要小于实际执行时的代价。比如,我们在网格地图寻路时,一般都不会采用曼哈顿距离。因为我们都知道:三角形的两边之和大于第三边,假设实际中,当前节点n与目标节点就是一条线过去,那么曼哈顿距离这种「横平竖直」的估计方式就导致 估计值 > 实际值,不够「乐观」。
  1. 一致性。对于用于图搜索的A*算法,通常h(n)都是满足一致性的。「一致」说的是这么一回事:假设,现在处于n节点,我们可以采取任一行动抵达下个节点n1(下图中由红圈表示),我们需要保证 h(n) 不能大于「n→n1花费的代价 + h(n1) 」,通俗地说就是「不能与过去的h(n)的预测相矛盾」,如果h(n)不满足一致性的话,即 h(n) > 「n→n1花费的代价 + h(n1) 」,很明显h(n)的就不满足可采纳性了。这样的h(n)无法保障找到最优解。

3. 泛用的A*搜索算法

了解了这些,我们可以开始设计A*算法的通用结构了。首先要考虑搜索的节点:

  1. 大多数情况下,我们需要记录节点的 父节点 以便搜索完成时可以回溯生成路径;
  2. 节点 自身也有代价 ,用于表示从其他节点走向这个节点时的花费代价(就像之前提到的「n→n1花费的代价」);
  3. 节点都应当有用于 记录f(n)、g(n)、h(n)的值
  4. 节点都有 邻居节点 。如果一个节点没有邻居节点就意味着它不可抵达,就没必要纳入搜索;
  5. 由于启发式函数的设计需要,节点需要 衡量从当前节点到目标节点代价的函数

考虑到这些,我们可以将A*的节点以接口方式实现:

using System.Collections.Generic;

public interface IAStarNode<T> where T : IAStarNode<T>
{
public T Parent { get; set; }//父节点,通过泛型使它的类型与具体类一致
public float SelfCost { get; set; }//自身单步花费代价
public float GCost { get; set; }//记录g(n),距初始状态的代价
public float HCost { get; set; }//记录h(n),距目标状态的代价
public float FCost { get; }//记录f(n),总评估代价 /// <summary>
/// 获取与指定节点的预测代价
/// </summary>
public float GetDistance(T otherNode); /// <summary>
/// 获取后继(邻居)节点
/// </summary>
/// <param name="nodeMap">寻路所在的地图,类型看具体情况转换,
/// 故用object类型</param>
/// <returns>后继节点列表</returns>
public List<T> GetSuccessors(object nodeMap);
}

这样一来,我们只需要让充当节点的具体类继承这个接口,实现这其中的两个函数,就能参与A*搜索了。当然,在有些情况下可能一个节点还要额外记录它所连接的边(比如GOAP),这些就是需要在具体类中额外添加的内容了。

终于,到了 「A*搜索器」 的设计,经过前面已经反复强调了:A*搜索算法本身的逻辑是不变的,变化的只是启发式函数。而我们已经把启发式函数的设计留在节点类的GetDistance了,所以我们可以设计出一个通用的搜索器。

A*搜索器只负责搜索(寻路)并返回搜索的序列结果(路径),而这个任务可以分为:

  1. 维护openList与closeList。 这是A*搜索所依赖的额外信息,在搜索过程中,那些有被搜过但还没被选中要走的节点就会放在「边缘集(openList)」中 ;而已经走过的节点则会放在「搜索集(closeList)」中。A*搜索便会不断地将结点加入到openList以备选,并不断地将走过的节点加入closeList以避免重复搜索。
  2. 生成路径。 在找到了目标(或实在找不到目标)时,我们需要返回一路走来的所有结点,我们要考虑这些点的顺序,而且最好能将路径返回到一个外部容器中存储,而不是函数内创建用于存储的容器再返回出去。为什么?因为大多数情况下,我们是为对象单独分配一个搜索的结果,比如每个角色都有自己的路径,这是个一对一的关系。如果采用后者的方案,那么即便只有一个角色要寻路,我们也会每次在生成路径时,就会重复创建容器并返回,是十分浪费的。

下面就来看看具体代码吧:

using System.Collections.Generic;

/// <summary>
/// A星搜索器
/// </summary>
/// <typeparam name="T_Map">搜索的图类</typeparam>
/// <typeparam name="T_Node">搜索的节点类</typeparam>
public class AStar_Searcher<T_Map, T_Node> where T_Node: IAStarNode<T_Node>, IComparable<T_Node>
{
private readonly HashSet<T_Node> closeList;//探索集
private readonly MyHeap<T_Node> openList;//边缘集
private readonly T_Map nodeMap;//搜索空间(地图)
public AStar_Searcher(T_Map map, int maxNodeSize = 200)
{
nodeMap = map;
closeList = new HashSet<T_Node>();
//maxNodeSize用于限制路径节点的上限,避免陷入无止境搜索的情况
openList = new MyHeap<T_Node>(maxNodeSize);
}
/// <summary>
/// 搜索(寻路)
/// </summary>
/// <param name="start">起点</param>
/// <param name="target">终点</param>
/// <param name="pathRes">返回生成的路径</param>
public void FindPath(T_Node start, T_Node target, Stack<T_Node> pathRes)
{
T_Node currentNode;
pathRes.Clear();//清空路径以备存储新的路径
closeList.Clear();
openList.Clear();
openList.PushHeap(start);
while (!openList.IsEmpty)
{
currentNode = openList.Top;//取出边缘集中最小代价的节点
openList.PopHeap();
closeList.Add(currentNode);//拟定移动到该节点,将其放入探索集
if (currentNode.Equals(target) || openList.IsFull)//如果找到了或图都搜完了也没找到时
{
GenerateFinalPath(start, target, pathRes);//生成路径并保存到pathRes中
return;
}
UpdateList(currentNode, target);//更新边缘集和探索集
}
return;
}
private void GenerateFinalPath(T_Node startNode, T_Node endNode, Stack<T_Node> pathStack)
{
pathStack.Push(endNode);//因为回溯,所以用栈储存生成的路径
var tpNode = endNode.Parent;
while (!tpNode.Equals(startNode))
{
pathStack.Push(tpNode);
tpNode = tpNode.Parent;
}
pathStack.Push(startNode);
}
private void UpdateList(T_Node curNode, T_Node endNode)
{
T_Node sucNode;
float tpCost;
bool isNotInOpenList;
var successors = curNode.GetSuccessors(nodeMap);//找出当前节点的后继节点
for (int i = 0; i < successors.Count; ++i)
{
sucNode = successors[i];
if (closeList.Contains(sucNode))//后继节点已被探索过就忽略
continue;
tpCost = curNode.GCost + sucNode.SelfCost;
isNotInOpenList = !openList.Contains(sucNode);
if (isNotInOpenList || tpCost < sucNode.GCost)
{
sucNode.GCost = tpCost;
sucNode.HCost = sucNode.GetDistance(endNode);//计算启发函数估计值
sucNode.Parent = curNode;//记录父节点,方便回溯
if (isNotInOpenList)
{
openList.PushHeap(sucNode);
}
}
}
}
}

上面有用到自己实现的优先队列(MyHeap),如果你也有自己的实现也可以进行替换。如果没有的话,可以暂时用用我的:

using System;
using System.Collections.Generic; namespace JufGame.Collections.Generic
{
public class MyHeap<T> where T : IComparable<T>
{
public int NowLength { get; private set; }
public int MaxLength { get; private set; }
public T Top => heap[0];
public bool IsEmpty => NowLength == 0;
public bool IsFull => NowLength >= MaxLength - 1;
private readonly Dictionary<T, int> nodeIdxTable; // 记录结点在数组中的位置,方便查找
private readonly bool isReverse;
private readonly T[] heap; public MyHeap(int maxLength, bool isReverse = false)
{
NowLength = 0;
MaxLength = maxLength;
heap = new T[MaxLength + 1];
nodeIdxTable = new Dictionary<T, int>();
this.isReverse = isReverse;
}
public T this[int index]
{
get => heap[index];
}
public void PushHeap(T value)
{
if (NowLength < MaxLength)
{
if (nodeIdxTable.ContainsKey(value))
nodeIdxTable[value] = NowLength;
else
nodeIdxTable.Add(value, NowLength);
heap[NowLength] = value;
Swim(NowLength);
++NowLength;
}
}
public void PopHeap()
{
if (NowLength > 0)
{
nodeIdxTable[heap[0]] = -1;
heap[0] = heap[--NowLength];
nodeIdxTable[heap[0]] = 0;
Sink(0);
}
}
public bool Contains(T value)
{
return nodeIdxTable.ContainsKey(value) && nodeIdxTable[value] != -1;
}
public T Find(T value)
{
if (Contains(value))
return heap[nodeIdxTable[value]];
return default;
}
public void Clear()
{
nodeIdxTable.Clear();
NowLength = 0;
}
private void SwapValue(T a, T b)
{
var aIdx = nodeIdxTable[a];
var bIdx = nodeIdxTable[b];
heap[aIdx] = b;
heap[bIdx] = a;
nodeIdxTable[a] = bIdx;
nodeIdxTable[b] = aIdx;
} private void Swim(int index)
{
int father;
while (index > 0)
{
father = (index - 1) >> 1;
if (IsBetter(heap[index], heap[father]))
{
SwapValue(heap[father], heap[index]);
index = father;
}
else return;
}
} private void Sink(int index)
{
int largest, left = (index << 1) + 1;
while (left < NowLength)
{
largest = left + 1 < NowLength && IsBetter(heap[left + 1], heap[left]) ? left + 1 : left;
if (IsBetter(heap[index], heap[largest]))
largest = index;
if (largest == index) return;
SwapValue(heap[largest], heap[index]);
index = largest;
left = (index << 1) + 1;
}
}
private bool IsBetter(T v1, T v2)
{
return isReverse ? (v2.CompareTo(v1) < 0 ): (v1.CompareTo(v2) < 0);
}
}
}

4. 正确优化A*的方式

  1. 良好的启发式函数。 前面我们讨论的那些正好可以说明这一点,故不再赘述。
  2. 合适的搜索空间表示。 「搜索空间」可以理解为我们要来寻路的地图,合适的表示能够减少搜索时的结点数量,从而减少搜索时间。一般的表示方式有:网格图、中继点图、导航网络。(虽说一般也只能自主设计前面两种就是了
  1. 预分配所有必要的内存。 就是说,在实际搜索时不要分配内存,当然,这并不是说不能使用临时变量,只是说不要使用需要分配大量内存的临时变量,比如一个大数组。如果真有需要,也可以使用像「内存池」提前分配好内存,避免重复的开辟与回收。
  2. 用优先队列做开结点表(openList)。 A*搜索时常需要找出「开结点表」中最小代价的结点。如果使用「优先队列(一般二叉堆即可)」就可以省去排序的过程,以O(1)的时间复杂度找到这个结点。
  3. 缓存后继节点。 在静态场景中,一个节点的后继节点(邻居节点)通常是固定的,如果我们在查找一次后就将它们记录下来,那么后续查找可以节省很多时间(因为查找节点的邻居是很经常的事),只不过需要额外的内存开销。

5. 错误优化A*的方式

  1. 并行执行多个搜索。 通过多线程,我们可以在只消耗一次搜索的时间里同时处理10个搜索,这不是很好吗?问题在于,如果你要同时进行10次搜索,那势必要在单独多开一些openList和closeList,这 需要大量的内存 。而且如果在这10次搜索中,有一次搜索情况「不顺」导致它 拖延了其它的搜索 ,又当如何(做好搜索上限判断,这种情况一般就不会发生)?其实也不是不能使用多线程,我们可以同时只执行2个搜索,一个负责处理较为快速的搜索,另一个负责处理需要长时间的搜索。

  2. 双向搜索。 可能有些同学曾做过一些搜索相关(主要是关于BFS和DFS)的算法题,发现「双向搜索」似乎能更快地找到路径。但其实这对于A*搜索,会 花费双倍的工作量 。我们可以看看下面几张图(横着看):

    通过这两次寻路不难看出,正向寻路所得的路径和反向寻路的 路径重合度非常低 ,换句话说,几乎就是找了两条路。如果你不信,也可以自己动手试试看,在这个网页中找到下图部分,自己编辑下地形、切换起点和终点,观察路径情况。

    造成这现象的一大原因,就是正反搜索时同一个节点的h(n)是不同的。因此,如下图般理想的双向搜索,在A*中是很难见到的。

  3. 缓存路径。 有时可能会想:将这次找到的路保存下来,下次再找时可以直接调用。这种做法的价值并不大,「两次相同的寻路」概率并不大,而且保存过多的路径会占用很大的内存。

6. 尾声

在我初学A*时,总以为它是基于网格寻路而生的一种算法,希望这次与大家交流的内容也能帮助曾经和我一样有类似想法的同学更准确地认识A*。

A星搜索算法的更多细节的更多相关文章

  1. 更多细节的理解RSA算法

    一.概述 RSA算法是1977年由Ron Rivest.Adi Shamir 和 Leonard Adleman三人组在论文A Method for Obtaining Digital Signatu ...

  2. 编程实践中C语言的一些常见细节

    对于C语言,不同的编译器采用了不同的实现,并且在不同平台上表现也不同.脱离具体环境探讨C的细节行为是没有意义的,以下是我所使用的环境,大部分内容都经过测试,且所有测试结果基于这个环境获得,为简化起见, ...

  3. ASP.NET常被忽视的一些细节

    原文:ASP.NET常被忽视的一些细节 前段时间碰到一个问题:为什么在ASP.NET程序中定时器有时候会不工作? 这个问题看起来很奇怪,代码好像也没错,但就是结果与预期不一致. 其实这里是ASP.NE ...

  4. C语言的一些常见细节

    C语言的一些常见细节 对于C语言,不同的编译器采用了不同的实现,并且在不同平台上表现也不同.脱离具体环境探讨C的细节行为是没有意义的,以下是我所使用的环境,大部分内容都经过测试,且所有测试结果基于这个 ...

  5. Exception.Data 为异常添加更多调试信息

    我们抛出异常是为了知道程序中目前的状态发生了错误.为了能够知道错误的详细信息便于我们将来避免产生这样的错误,我们会选用合适的异常类型,在异常中编写易于理解的 message 信息.但是有时我们需要更多 ...

  6. 从细节处谈Android冷启动优化

    本文来自网易云社区 Android APP冷启动优化,对于Android开发同学而言可能是个老生常谈的技优了. 之所以花时间写一篇冷启动优化的文章: 我想从另外一个角度来说冷启动优化,如题所述,从细节 ...

  7. UI5 Source code map机制的细节介绍

    在我的博客A debugging issue caused by source code mapping里我介绍了在我做SAP C4C开发时遇到的一个曾经困扰我很久的问题,最后结论是这个问题由于Jav ...

  8. 动态符号链接的细节 与 linux程序的加载过程

    转: http://hi.baidu.com/clivestudio/item/4341015363058d3d32e0a952 值得玩味的一篇分析程序链接.装载.动态链接细节的好文档 导读: by ...

  9. 03--(二)编程实践中C语言的一些常见细节

    编程实践中C语言的一些常见细节(转载) 对于C语言,不同的编译器采用了不同的实现,并且在不同平台上表现也不同.脱离具体环境探讨C的细节行为是没有意义的,以下是我所使用的环境,大部分内容都经过测试,且所 ...

  10. Tapdata Cloud 2.1.2 来啦:大波细节已就绪!字段类型可批量修改、支持微信扫码登录、新增支持 Vika 为目标

    Tapdata Cloud cloud.tapdata.net 让数据实时可用 Tapdata Cloud 是国内首家异构数据库实时同步云平台,目前支持 Oracle.MySQL.PG.SQL Ser ...

随机推荐

  1. Java智能之Spring AI:5分钟打造智能聊天模型的利器

    前言 尽管Python最近成为了编程语言的首选,但是Java在人工智能领域的地位同样不可撼动,得益于强大的Spring框架.随着人工智能技术的快速发展,我们正处于一个创新不断涌现的时代.从智能语音助手 ...

  2. [oeasy]python0097_苹果诞生_史蒂夫_乔布斯_沃兹尼亚克_apple_I

    苹果诞生 回忆上次内容 上次时代华纳公司 凭借手中的影视ip和资本 吞并了雅达利公司 此时 雅达利公司 曾经开发过pong的 优秀员工 乔布斯 还在 印度禅修 寻找自我 看到游戏行业 蓬勃发展 乔布斯 ...

  3. C# 实现Eval(字符串表达式)的三种方法

    一.背景 假如给定一个字符串表达式"-12 * ( - 2.2 + 7.7 ) - 44 * 2",让你计算结果,熟悉JavaScript的都知道有个Eval函数可以直接进行计算, ...

  4. 浅谈 I/O 与 I/O 多路复用

    1.基础知识 网络编程里常听到阻塞IO.非阻塞IO.同步IO.异步IO等概念,总听别人聊不如自己下来钻研一下.不过,搞清楚这些概念之前,还得先回顾一些基础的概念. 下面说的都是Linux环境下,跟Wi ...

  5. onnxruntime无法使用GPU加速 加速失败 解决方法【非常详细】

    onnx 无法使用GPU加速 加速失败 解决方法[非常详细] 应该是自目前以来最详细的加速失败解决方法GPU加速,收集了各方的资料.引用资料见后文 硬件配置: GPU CUDA版本:12.2 客户架构 ...

  6. 对比python学julia(第三章:游戏编程)--(第三节)疯狂摩托(3)

    3.3.    编程实现 2.  控制摩托车和箱子 在这个步骤中,将编程控制摩托车和箱子角色的运动,让摩托车在沙漠公路上能够加速或减速行驶,在碰到箱子时能够停止,以及显示麾托车的行驶速度和里程等. ( ...

  7. vue里使用px2rem

    安装 yarn add postcss-px2rem 配置 在vue.config.js中添加以下配置 const px2rem = require('postcss-px2rem') module. ...

  8. kimchi – kvm虚拟机网页管理

    参考: https://mangolassi.it/topic/15882/kimchi-kvm-updated-and-better-and-easy-guide-for-kvm-beginners ...

  9. OneFlow计算框架的OneAgent是不是一个子虚乌有的东西?

    自己是搞强化学习的,今天看了些OneFlow计算框架的一些资料,发现OneFlow官方一直有宣传自己的强化学习框架--OneAgent,但是十分诡异的是从了OneFlow的官方宣传可以看到这个词,但是 ...

  10. CCF A类会议 —— CVPR 2022 论文审稿模板

    ============================================= Edit ReviewThank you for accepting to serve as a revie ...