Using Binary Heaps in A* Pathfinding

原文链接(英文)

在A*寻路中使用二叉堆 

作者:Patrick Lester(2003年4月11日更新)  翻译:Panic 

 2005年3月28日 译者序: 

   这一篇文章,是“A* Pathfinding for Beginners”,也就是我翻译的另一篇文章“A*寻路初探”的补充,在这篇文章里,作者再一次展现了他阐述复杂话题的非凡能力,用通俗易懂的语句清晰的解释了容易让人迷惑的问题。还是那句话,如果你看了这篇文章仍然无法领会作者的意图,那只能怪我的翻译太蹩脚了。请参考原文做进一步的理解。    这里讲解的二叉堆,其实是以堆的形式存在的二叉树,这个特殊的结构把A*算法对开启列表的排序需求演绎的出神入化,毫无疑问是A*的最佳拍档。最后,希望这篇文章对你有所帮助。  原文链接:Using Binary Heaps in A* Pathfinding http://www.policyalmanac.org/games/binaryHeaps.htm   以下是翻译的正文: 

这篇文章是我的主题文章“A* Pathfinding for Beginners”的补充。在读这篇文章之前,你应该先读那一篇文章,或者对A*做透彻的理解。 

   A*算法中最缓慢的部分就是在开启列表中寻找F值最低的节点或者方格。取决于地图的大小,你可能有十几,成百甚至上千的节点需要在某个时候使用A*搜索。无需多讲,反复搜索这么大的列表会严重拖慢整个过程。然而,这些时间在极大程度上受你存储列表的方式影响。

有序和无序的开启列表:简单的方法 

  最简单的方法就是顺序存储每个节点,然后每次需要提取最低耗费元素的时候都遍历整个列表。这提供可快速的插入速度,但是移除速度可能是最慢的,因为你需要检查每个元素才能够确定哪个才是F值最低的。 

  通常你可以保持你列表处于有序状态来提升效率。这花费了稍微多一点的预处理时间,因为你每次插入新元素都必须把他们放在恰当的位置。不过移除元素倒是很快。你只要移除第一个元素就可以了,它一定是F值最低的。   有很多方法可以保持你的数据有序(选择排序,冒泡排序,快速排序,等等)并且你可以用你最熟悉的搜索引擎找到这方面的文章。不过我们至少可以先提几种想法。最简单的方法可能是,当你需要添加新元素的时候,从列表开始的地方,依次比较每个元素的F值和要插入的F值的大小。一旦找到一个相等或者更高的F值,你就可以把新元素插入到列表中那个元素的前面。取决于你使用的计算机于亚,使用class或者struct实现的链表可能是不错的方法。   这种方法可以通过保持列表中所有元素的平均值来得到改进,使用这个平均值来决定是从头(如上所说)还是从尾开始处理。总的说来,比平均F值低的新元素将被从头开始处理,而比平均F值高的则从末尾开始。这种方法可以节省一半的时间。 

  复杂一些,但是更快的方法是把这一想法提高到新的层次使用快速排序,它基本上是从比较新元素和列表中间元素的F值开始。如果新元素的F值低,你接着把它和1/4处元素进行比较,如果还是更低你就比较它和1/8处的元素,如此这般,不断的折半你的列表并且比较,直到找到合适的位置。这个描述很简单,你可能会想到网上寻找快速排序的更多资料。这比至此描述的任何方法都快。 二叉堆 

 二叉堆和刚才说的快速排序很像,经常被那些苛求A*速度的人使用。根据我的经验,二叉堆平均提高寻路速度2-3倍,对于包含大量节点的地图(也就是说100×100节点或者更多)效果更明显。友情提醒,然而二叉堆很难处理,除非你使用含有大量节点的地图,速度至关重要,否则不值得为它头痛。   文章其他的部分深入说明了二叉堆和它在A*算法中的用途。如果你对我的文章存有疑惑,在文章末尾进一步阅读的小节中提供了更多的观点。

仍然有兴趣?好,我们继续。。。 


 在有序列表中,每个元素都按照由低到高或由高到低的顺序保存在恰当的位置。这很有用,但是还不够。事实上,我们并不关心数字127是否比128在更低的位置上。我们只是想让F值最低的元素能放在列表顶端以便容易访问。列表的其他部分即使是混乱的也不必在意。列表的其他部分只有在我们需要另一个F值最低的元素的时候,才有必要保

持有序。 

   基本上,我们真正需要的是一个“堆”,确切的说,是个二叉堆。二叉堆是一组元素,其中最大或者最小(取决于需要)的元素在堆顶端。既然我们要寻找F值最小的元素,我们就把它放在堆顶端。这个元素有两个子节点,每个的F值等于,或者略高于这个元素。每个子节点又有两个子节点,他们又有和他们相等或略高的子节点。。。依次类推。这里是一个堆可能的样子: 

 

注意,F值最低的元素(10)在最顶端,第二低的元素(20)是它的一个子节点。可是,其后就没有任何疑问了。在这个特定的二叉堆里,第三低的元素是24,它离堆顶有两步的距离,它比30小,但是30却在左侧离堆顶一步之遥的地方。简单的堆放,其他的元素在堆的哪个位置并不重要,每个单独的元素只需要和它的父节点相等或者更高,而和它的两个子节点相比,更低或者相等,这就可以了。这些条件在这里都完全符合,所以这是个有效的二叉堆。 

   很好,你可能会想,这的确有趣,但是如何把它付诸实施呢?嗯,关于二叉堆的一个有趣的事实是,你可以简单的把它存储在一个一维数组中。

在这个数组中,堆顶端的元素应该是数组的第一个元素(是下标1而不是0)。两个子节点会在2和3的位置。这两个节点的4个子节点应该在4-7的位置。

 总的来说,任何元素的两个子节点可以通过把当前元素的位置乘以2(得到第一个子节点)和乘2加1(得到第二个子节点)来得到。就这样,例如堆中第三个元素(数值是20)的两个子节点,可以在位置2*3 = 6和2*3 +1 = 7这两个位置找到。那两个位置上的数字非别是30和24,当你查看堆的时候就能理解。 

   你其实不必要知道这些,除了表明堆中没有断层之外知道这些没有任何价值。7个元素,就完整的填满了一个三层堆的每一层。然而这并不是必要的。为了让我们的堆有效,我们只需要填充最底层之上的每一行。最底层自身可以是任意数值的元素,同时,新的元素按照从左到右的顺序添加。这篇文章描述的方法就是这样做的,所以你不必多虑。 

往堆中添加新元素 

  当我们实际在寻路算法中使用二叉堆的时候,还需要考虑更多,但是现在我们只是学习一下如何使用二叉堆。我跳过这部分以便更容易理解基本的东西。我会在文章后面的部分给出处理这一切的完整公式,但了解这些细节仍然十分重要。 

  大致的,为了往堆里添加元素,我们把它放在数组的末尾。然后和它在 当前位置/2 处的父节点比较,分数部分被圆整。如果新元素的F值更低,我们就交换这两个元素。然后我们比较这个元素和它的新父节点,在 (当前位置)/2 ,小数部分圆整,的地方。如果它的F值更低,我们再次交换。我们重复这个过程直到这个元素不再比它的父节点低,或者这个元素已经到达顶端,处于数组的位置1。 

  我们来看如何把一个F值为17的元素添加到已经存在的堆中。我们的堆里现在有7个元素,新元素将被添加到第8个位置。这就是堆看起来的样子,新元素被加了下划线。 10 30 20 34 38 30 24 17 

 接下来我们比较它和它的父节点,在 8/2 也就是 4的位置上。位置4当前元素的F值是34。既然17比34低,我们交换两元素的位置。现在我们的堆看起来是这样的: 

10 30 20 17 38 30 24 34 

  然后我们把它和新的父节点比较。因为我们在位置4,我们就把它和 4/2 = 2 这个位置上的元素比较。那个元素的F值是30。因为17比30低,我们再次交换,现在堆看起来是这样的: 10 17 20 30 38 30 24 34 

接着我们比较它和新的父节点。现在我们在第二个位置,我们把它和 2/2 = 1,也就是堆顶端的比较。这次,17不比10更低,我们停止,堆保持成现在的样子。 

从堆中删除元素 

从堆中删除元素是个类似的过程,但是差不多是反过来的。首先,我们删除位置1的元素,现在它空了。然后,我们取堆的最后一个元素,移动到位置1。在堆中,这是结束的条件。以前的末元素被加了下划线。 

34 17 20 30 38 30 24 

然后我们比较它和两个子节点,它们分别在位置(当前位置*2)和(当前位置* 2 + 1)。如果它比两个子节点的F值都低,就保持原位。反之,就把它和较低的子节点交换。那么,在这里,该元素的两个子节点的位置在 1 * 2 = 2和 1*2 + 1 = 3。显然,34不比任何一个子节点低,所以我们把它和较低的子节点,也就是17,交换。结果看起来是这样: 

17 34 20 30 38 30 24 

接着我们把它和新的子节点比较,它们在 2*2 = 4,和2*2 + 1 = 5的位置上。它不比任何一个子节点低,所以我们把它和较低的一个子节点交换(位置4上的30)。现在是这样: 

17 30 20 34 38 30 24 

最后一次,我们比较它和新的子节点。照例,子节点在位置 4*2 = 8和4*2+1 = 9的位置上。但是那些位置上并没有元素,因为列表没那么长。我们已经到达了堆的底端,所以我们停下来。

二叉堆为什么这么快? 

现在你知道了堆基本的插入和删除方法,你应该明白为什么它比其他方法,比如说插入排序更快。假设你有个有1000个节点的开启列表,在一格有很多节点的相当大的地图上,这不是不可能(记住,即使是100×100的地图,上面也有10,000个节点)。如果你使用插入排序,从起点开始,到找到新元素恰当的位置,在把新元素插入之前,平均需要做500次比较。 

 使用二叉堆,你从底端开始,可能只要1-3次比较就能把新元素插入到正确的位置。你还需要9次比较用来从开启列表中移除一个元素,同时保持堆仍然有序。在A*中,你通常每次只需要移除一个元素(F值最低的元素),在任意位置添加0到5个新节点(就像主文章里描述的2D寻路)。这总共花费的时间大约是同样数量节点进行插入排序的1%。差别随你地图的增大(也就是节点更多)呈几何增长。地图越小,就越没优势,这也是为什么你的地图和节点越少,二叉堆的价值就越低的原因。 

顺便,使用二叉堆并不意味着你的寻路算法会快100倍。在下面还讲了一些棘手的问题。额外的,A*不仅仅是为开启列表排序。然而,根据我的经验,用二叉堆在大部分场合可以提高2-3倍的速度,更长的路径,速度提高的更多。 

创建开启列表数组 

现在我们了解了二叉堆,那么如何使用它呢?首先要做的是构造我们的一维数组。为此,我们先要确定它的大小。一般来说,列表大小不会超过地图上的节点总数(在最坏的情况下,我们搜索整个地图寻找路径)。在一个方形二维地图中,就如我的主文章中描述的,我们的节点不超过 地图宽 × 地图高。那么我们的一维数组就是那个大小。在这个例子里,我们叫这个数组 openList()。堆最顶端的元素被存储在openList(1),第二个元素在openList(2),依此类推。

使用指针 

现在我们有了正确大小的数组,几乎可以开始用来寻路了。不过,在进一步的添加或者删除操作之前,我们再看看原始的堆。

现在,它只是个F值的列表,而且已经被正确安排。但是我们忽略了一个重要的元素。是的,我们有一系列的F值按顺序保存在堆里,但是我们没有他们代表哪一格的任何线索。基本上,我们只是知道10是堆中最低的F值。但那指的是那个格子? 

为了解决这个问题,我们必须改变数组中元素的值。我们不储存排序好的F值,取而代之的是保存关联到地图网格的唯一标志。我的方法是为每个新加入堆的元素创建一个唯一ID叫做squaresChecked。每次往开启列表中添加新元素,我们给squaresChecked增加1,并把它作为列表中新元素的唯一ID。第一个添加进列表的是#1,第二个是#2,依此类推。 

最后,我们把具体的F值存储在单独的一维数组中,我把它叫做 Dcost()。和开启列表相同,我们把它的大小定为(地图宽 x 地图高)。我们同时存储节点的x和y坐标在类似的一维数组中,叫做 openX() 和 openY()。看起来就像下面的图:

尽管这看起来有点复杂,但它和前面讲的堆是相同的。只是储存的信息更多了。 

#5元素,有最低的F值10,仍然在堆顶,在一维数组的第一列。不过现在我们在堆里存储它的唯一ID 5,而不是它的F值。换句话说,openList(1) = 5。这个唯一数值用来查找元素的F值,以及地图x和y坐标。这个元素的F值是Fcost(5) = 10,x坐标是openX(5) = 12,y坐标是openY(5) = 22。 

顶端的元素有两个子节点,数值是2和6,他们的F值是30和20,分别存储在opneList()中2和3的位置,等等。基本上,我们的堆和以前相同,只是多了一些关于元素在地图上的位置,F值是多少,等等的信息。 

 

在堆中添加新元素(第二部分) 

好,我们实际的把这种技术用在A*寻路的开启列表排序中。我们使用的技术和先前描述的大体相同。 

我们添加到开启列表中的第一个元素,一般是起始节点,被分配了一个数值是1的唯一ID,然后放入开启列表的#1位置。也就是 openList(1) = 1.我们还跟踪开启列表中元素的数量,现在也是1。我们把它保存在名为numberOfOpenListItems的变量里。 

当我们往开启列表中添加新元素的时候,首先我们计算G,H和F值,就如在主文章中所述。然后按照前面讲的方法把他们添加进开启列表。 

首先我们给新元素分配一格唯一 ID号,也就是squaresChecked变量的用途。每次我们添加一个新节点,就给这个变量加1,然后把它的数值分配给新节点。然后给numberOfOpenListItems加1。然后把它添加到开启列表的底部,所有这些可以翻译成:

squaresChecked = squaresChecked +1 

numberOfOpenListItems = numberOfOpenListItems+1 openList(numberOfOpenListItems) = squaresChecked 

然后我们依次把它和父节点比较直到它到达正确的位置。这是这些操作的代码: m = numberOfOpenListItems 

While m <> 1 While item hasn''t bubbled to the top (m=1)   ;Check if child is <= parent. If so, swap them. 

  If Fcost(openList(m)) <= Fcost(openList(m/2)) Then 

temp = openList(m/2) 

    openList(m/2) = openList(m)     openList(m) = temp     m = m/2    Else 

    Exit exit the while/wend loop   End If Wend 

从堆中删除元素(第二部分) 

无疑,我们不能只建立堆,当不需要的时候,我们也要从堆中删除元素。特别的,在A*寻路中,我们在检查和切换到关闭列表之后,从堆顶需要删除F值最低的元素。 

如前所述,你从把末元素移动到堆顶开始,然后把堆中的元素总数减1。伪代码是这样: openList(1) = openList(numberOfOpenListItems) 

numberOfOpenListItems = numberOfOpenListItems - 1 

接着我们需要依次比较它和两个子节点的数值。如果它的F值更高,我们就把它和更低F值的子节点交换。然后我们把它和新的子节点比较(看它是否更低)。如果它的F值比两个子节点更高,我们把它和较低的一个交换。我们重复这个过程直到找到它的正确位置,这可能会一直持续到堆底,但并不是完全必要。伪代码看起来是这样:

v = 1 

;Repeat the following until the item sinks to its proper spot in the binary heap. Repeat   u = v 

  If 2*u+1 <= numberOfOpenListItems if both children exist     ;Select the lowest of the two children. 

    If Fcost(openList(u)) >= Fcost(openList(2*u))then v = 2*u SEE NOTE BELOW 

    If Fcost(openList(v)) >= Fcost(openList(2*u+1))then v = 2*u+1 SEE NOTE BELOW  

  Else If 2*u <= numberOfOpenListItems if only child #1 exists     ;Check if the F cost is greater than the child 

    If Fcost(openList(u)) >= Fcost(openList(2*u))then v = 2*u   End If  

  If u <> v then  If parent''s F > one or both of its children, swap them     temp = openList(u) 

    openList(u) = openList(v)     openList(v) = temp   Else 

    Exit if item <= both children, exit repeat/forever loop   End if 

Forever Repeat forever 

请注意两行代码中粗体(红色)的u和v的数值。在第二行,你应该使用 v而不是u,这不是很显而易见。这确保了你把它和较低的子节点交换。如果做错会造成不完整的堆,完全打乱你的寻路。 

对开启列表的元素重排序 

就如在主文章中描述的,有时候你会发现现有的开启列表中的元素会改变。这种情况发生的时候,我们不必要取出这个元素重新来过。只要从当前位置开始,用它新的(更低的)F值和它的父节点比较。如果它的F值低到足以替换它的父节点,你就把它替换掉(不然你就会得到一个错误的堆,一切都完了)。一般,你使用和“在堆中添加新元素”的小节中相同的代码,并做额外处理如下: 

不幸的是,因为你的数据是在一个庞大,无序的堆里,你需要遍历整个堆查找先有开启列表中的元素。你主要要查找由openX(openList()) 和openY(openList())获取的确切坐标的格子,找到之后,你就可以从那一点开始,像往常那样做必要的比较和交换。 

最后的注解 

好了,我希望你仍然能读懂,没有被搞昏头。如果你不着急,并且希望在自己的寻路算法中使用二叉堆,那么这就是我的建议。 

首先,把二叉堆放一放。把注意力放在你的A*寻路算法上,使用简单点的排序算法,保证算法正常工作没有bug。一开始你并不需要它很快速,你只需要它能工作。 

 其次,在你把二叉堆添加到代码中之前,试着把二叉堆写成独立的功能,用适当的方法添加和删除元素。确保你写的程序中,可以看到整个过程中每一步的操作,也许可以把结果打印在屏幕上,如果你愿意。你还得包含一些中途退出的代码,以便在必要的时候结束整个流程。如果二叉堆写的不对,你很容易就会陷入到无限循环中。 

一旦你确信两个程序都运行无误,备份他们,然后开始把他们结合起来。除非你比我还聪明,否则一开始你难免遇到麻烦。输出都是些错误信息 并且/或者 看到寻路者因为bug走出各种怪异的方向。不过最终你会搞定一切。 

进一步阅读 

和以往一样,网上有很多其他的关于这个话题的好文章。这里有些入门的。第一篇用图示。第二篇说明了怎么用一个简单的数组(如果我的说明很麻烦的话)实现二叉堆。第三篇探讨了寻路算法中堆的一般用途。 

 http://www.onthenet.com.au/~grahamis/int2008/week11/lect11.html;   http://www.purists.org/pqueue/;  

 http://theory.stanford.edu/~amitp/GameProgramming/ImplementationNotes.html#S5;

http://theory.stanford.edu/~amitp/GameProgramming/ImplementationNotes.html#S5;  

最后,你可能想看看我的寻路代码,这里可以找到。它和文中的伪代码相互照应,注释详尽,有C++和Blitz两个版本,Blitz是一个比其他大多数都容易理解的语言。不使用C++的程序员会很容易理解Basic版本的代码。



  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
To express SOS1 variables using binary variables in MATLAB, you can use the following steps: 1. Create binary variables for each of the original variables in your SOS1 set. For example, if your SOS1 set has variables x1, x2, x3, then you would create binary variables y1, y2, y3. 2. Add the following constraints to your model: a. For each pair of binary variables yi and yj, add the constraint yi + yj <= 1. This ensures that at most one binary variable can take the value of 1. b. For each original variable xi and its corresponding binary variable yi, add the constraint xi <= yi. This ensures that if the binary variable is set to 0, then the original variable must also be 0. c. For each pair of adjacent variables in the SOS1 set, xi and xj, and their corresponding binary variables, yi and yj, add the constraint yi + yj >= xj. This ensures that if xj is non-zero, then at least one of the corresponding binary variables must also be non-zero. 3. Add your objective function and any additional constraints to your model as needed. 4. Solve the model using a solver such as MATLAB's built-in linprog function or an external solver. Here is an example code snippet that demonstrates how to express SOS1 variables using binary variables in MATLAB: ``` % Define original variables x = [5; 2; 3; 1]; % Create binary variables y = binvar(4,1); % Add constraints constraints = [y(1) + y(2) + y(3) + y(4) == 1]; for i = 1:4 constraints = [constraints, y(i) <= x(i)]; end for i = 1:3 constraints = [constraints, y(i) + y(i+1) >= x(i+1)]; end for i = 1:4 for j = 1:4 if i ~= j constraints = [constraints, y(i) + y(j) <= 1]; end end end % Define objective function f = [1; 1; 1; 1]; % Solve the model [x_opt, fval] = linprog(f, [], [], constraints, [], zeros(4,1)); ``` In this example, we have a set of four original variables (x1, x2, x3, x4) and we create binary variables (y1, y2, y3, y4) for each of them. We then add the constraints described above and define an objective function. Finally, we solve the model using the linprog function in MATLAB.

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值