路径规划A-star算法详解

路径规划A-star算法详解

原文来源于Amit’s A* Pages,译文参考CSDN博客。参考自游戏中的路径规划,放在现实世界中的机器人和无人车同样适用,文章结构和原文相同,但更多的是意译,新增和删减部分内容,使得文章读起来适合我的口味,作为参考笔记,同时共享给大家。

起源于游戏中的路径搜索 ( P a t h   f i n d i n g ) (Path\ finding) (Path finding),试图找到一条避开障碍物和敌人,并且代价(燃料、时间、距离、装备、金钱等)最小化的路径,运动器 ( M o v e m e n t ) (Movement) (Movement)的目的则是沿着这条路径行进。一种极端情况是,沿着路径搜索规划的路径就可以到达目的地,而不太需要运动器的作用,另一种极端情况是,仅依靠运动器一次走一步,并更新局部环境。最佳的情况是 P a t h   f i n d i n g Path\ finding Path finding M o v e m e n t Movement Movement一起用。
是应用在游戏中的路径搜索,同时也可以借鉴作为现实中移动机器人和无人车的路径规划参考,因为在代价约束上有许多一致的地方,比如后面提到的地形代价。A*算法在诸多需要规划路径的场景中,常同RRT被作为首选算法使用,其原因在于A*具有最优性和完备性,而且规划的路径也可以直接用于运动器,这也是它优于RRT的地方。

一、导言

M o v e m e n t Movement Movement看起来容易, P a t h   f i n d i n g Path\ finding Path finding就不太容易了,如下图,当面对一个U型障碍物时,运动器将会沿着障碍物找到红色路径,而路径搜索器则会扫描一个更大的区域(淡蓝色区域),从而规划出一条更短的蓝色路径。当然,运动器也可以进行扩展,将U型障碍物标记为不可通过(除非目的地在里面),这样可以避免陷入其中。

img img

路径搜索算法是在数学意义上的图(Graph)中工作的–由边联接起来的顶点(Vertex)的集合,而一个地图(Map)可以视作是一个图,它们是四联通或者八联通的。而许多算法领域的路径搜索算法是基于任意图(Arbitrary Graph)设计的,而不是基于栅格(grid)的图。

1.1 Dijstra和最佳优先搜索

具体见传统规划算法->图搜索算法。Dijkstra算法从对象所在的初始点开始,访问图中的顶点,迭代检查待检查顶点集合中的顶点,并把和该顶点邻近的尚未检查的顶点加入待检查顶点集中,直到找到目标点。Dijstra算法能找到一条从初始点到目标点的最短路径,只要所有的边都有一个非负的代价,显然,它所花费的时间是巨大的。

最佳优先搜索算法(Best First Search)流程类似,不同的是它需要评估的是从当前顶点到目标点的代价,它选择的是离目标点最近的顶点。BestFS采用了启发式代价,比Dijkstra快多了,但当通过障碍物时,找到的路径明显不理想,问题在于BestFS基于贪心策略,它仅仅考虑到达目标点的代价而忽略了当前已花费的代价,于是尽管路径变得很长,它仍然选择走下去。

Dijkstra BestFS A*
图1.Dijstra 图2.BestFS 图3.A*

1.2 A*算法

A*算法是启发式方法(Heuristic Approaches)如BestFS和Dijstra算法的结合,尽管A*无法保证有最佳解的启发方式如BestFS,但它能保证找到一条最短路径。启发式方法详细见图论->启发式算法。它的重点在于将两种算法的参考代价结合起来,用 g ( n ) g(n) g(n)表示从初始点到任意顶点 n n n 所花费的代价, h ( n ) h(n) h(n)表示从顶点 n n n 到目标点的启发式代价,A*算法权衡这两个代价,每次进行主循环时,它检查 f ( n ) f(n) f(n)最小的顶点,其中 f ( n ) = g ( n ) + h ( n ) f(n)=g(n)+h(n) f(n)=g(n)+h(n)

二、启发式方法

启发式函数 h ( n ) h(n) h(n)表示从任意顶点 n n n 到目标点的最小代价评估值,选择一个好的启发式代价是很重要的。

2.1 启发式函数

2.1.1 A*对启发式函数的使用

启发式函数可以控制A*的行为:

  • 一种极端情况是,如果 h ( n ) = 0 h(n)=0 h(n)=0,此时A*演变成Dijstra算法,这能保证找到最短路径;
  • 如果 h ( n ) h(n) h(n)经常比顶点 n n n 到目标点的实际代价小(或者相等),则A*保证能找到一条最短路径, h ( n ) h(n) h(n)越小,A*扩展的顶点越多,运行就越慢;
  • 如果 h ( n ) h(n) h(n)精确地等于从顶点 n n n 到目标点的实际代价,则A*只会寻找最佳路径而不扩展别的任何顶点,这会运行得非常快。尽管这样的情况不可能总是发生,但一些特殊情况下,仍可以使它们精确地相等,只要提供完美的信息,A*就可以运行得很完美;
  • 如果 h ( n ) h(n) h(n)比从顶点 n n n 到目标点的实际代价大,则A*不能保证找到一条最短路径,但它会运行得更快;
  • 另一种极端情况是,如果 h ( n ) h(n) h(n) g ( n ) g(n) g(n)大很多,则可以忽略 g ( n ) g(n) g(n)的作用,A*演变成BestFS算法,这不能保证找到最短路径。

这些情况很有用,使得A*的结果可以被控制,权衡 g ( n ) g(n) g(n) h ( n ) h(n) h(n)可以输出想要的最短路径或者最快路径。

在学术上,如果启发式函数的值低于实际代价,A*算法被称为简单的A算法(Simply A)。

2.1.2 速度和精确度

在很多场景下,并不需要得到最短路径,只需要近似的就够了,这样可以使得算法运行得更快。假设地图上有平原(Flat Land)和山丘(Mountain)两种地形,而它们的代价分别是 1 和 3 ,那么A*在平原上的搜索路径长度将会是山丘的 3 倍,如果有一条路径是同时经过平原和山丘的话。因此,可以设定 1.5 的启发式距离(Heuristic Distance),再怎么样也不会比 1 的距离差。也因此,不会花大量的时间在山丘地形寻找路径。或者可以加速A*查找,通过降低在山丘附近查找的次数,比如设定山丘上移动的代价为 2 而不是 3,如此,在平原上搜索的距离长度就只是山丘的 2 倍了。这两种方式都放弃寻找理想路径,而是选择了速度更快的查找方式。

速度和精确度之间的选择不是静态的,可以基于CPU的速度、用于路径搜索的时间限制、地图上单元(units)的数量、单元的重要性、组(group)的大小、难度或者其他因素来进行动态的选择。折衷的一个办法是,建立一个启发式函数,假定通过一个栅格的最小代价为 1,然后建立一个代价函数(cost function)用于测量(scales)
g ′ ( n ) = 1 + α ⋅ ( g ( n ) − 1 ) g'(n)=1+\alpha\cdot (g(n)-1) g(n)=1+α(g(n)1)
α \alpha α为 0时,则改进后的代价函数的值总为 1,这种情况下,地形代价被完全忽略,A*的工作变为简单地判断一个栅格能否通过。 α \alpha α为 1 时,则最初的代价函数将起作用。

速度和精确度的选择并不是唯一的和全局的,在某些区域,可能精确度比较重要,对此可以动态地进行选择。假设需要在某点停止,重新计算路径或者改变方向,则在接近当前位置的地方,精确度更为重要。或者,当从敌人村庄逃跑时,速度和安全性更为重要。

2.1.3 精确的启发式函数(TODO)

2.2 栅格地图中的启发式函数

2.2.1 常用的启发式函数

曼哈顿距离: h ( n ) = c o s t ∗ ( Δ x + Δ y ) h(n)=cost*(\Delta x+\Delta y) h(n)=cost(Δx+Δy) Δ x , Δ y \Delta x,\Delta y Δx,Δy是当前点到目标点的位移量,是正数。

切比雪夫距离:
h ( n ) = { c o s t ∗ max ⁡ ( Δ x , Δ y ) , 对 角 线 代 价 和 直 线 代 价 相 等 为 c o s t c o s t 1 ∗ min ⁡ ( Δ x , Δ y ) + c o s t ∗ ( Δ x + Δ y − m i n ( Δ x , Δ y ) ) , 对 角 线 代 价 为 c o s t 1 , 直 线 运 动 代 价 为 c o s t h(n)=\begin{cases}cost*\max(\Delta x,\Delta y),对角线代价和直线代价相等为cost \\cost1*\min(\Delta x,\Delta y)+cost*(\Delta x+\Delta y-min(\Delta x, \Delta y)) ,对角线代价为cost1,直线运动代价为cost\end{cases} h(n)={costmax(Δx,Δy),线线costcost1min(Δx,Δy)+cost(Δx+Δymin(Δx,Δy)),线cost1线cost
欧几里德距离: h ( n ) = c o s t ∗ Δ x 2 + Δ y 2 h(n)=cost*\sqrt{\Delta x^2+\Delta y^2} h(n)=costΔx2+Δy2

图1.Manhattan 图2.Chebyshev 图3.Euclid

由于欧几里德距离始终会比曼哈顿距离和切比雪夫距离短,导致启发式函数的值会比代价函数 g ( n ) g(n) g(n)的值小,A*将会运行得更慢。即便如此,如果舍去平方根,使用欧几里德距离的平方,在某些情况下,会使得启发式函数的值大于 g ( n ) g(n) g(n),A*退化成BestFS。

2.2.2 启发式函数的陷阱

启发式函数的一个陷阱是,当某些路径具有相同的 f ( n ) f(n) f(n)值时,它们都会被搜索到,为了解决这一问题,可以给启发式函数添加一个附加值(small tie breaker)。附加值对于顶点必须是确定的,而且要使 f ( n ) f(n) f(n)的值体现区别,使得相同的 f ( n ) f(n) f(n)值只有一个会被检测。

一种添加附加值的方法是,稍微改变 h ( n ) h(n) h(n)的衡量单位,当减少 h ( n ) h(n) h(n)的衡量单位时,当朝着目标移动时 f ( n ) f(n) f(n)将逐渐增加,这会导致A*倾向于扩展到靠近初始点的顶点而不是目标点。增加 h ( n ) h(n) h(n)的衡量单位时,A*就会倾向于扩展到靠近目标的顶点。
h ( n ) = h ( n ) ∗ ( 1.0 + p ) , 选 择 因 子 p < 移 动 一 步 的 最 小 代 价 期 望 的 最 长 路 径 长 度 h(n)=h(n)*(1.0+p),选择因子p<\frac{移动一步的最小代价}{期望的最长路径长度} h(n)=h(n)(1.0+p)p<
添加了附加值 p p p 的结果是,A*搜索的顶点数更少了,当存在障碍物时,仍然要在它们周围寻找路径,但当绕过障碍物后,A*搜索的区域更小了。Steven van Dijk建议,一个更直截了当的方法是把 h ( n ) h(n) h(n)传递到比较函数(comparison function)。当 f ( n ) f(n) f(n)值相等时,比较函数检查 h ( n ) h(n) h(n),然后添加附加值。

另一种添加附加值的方法是,使用两点间的直线距离:
h ( n ) = h ( n ) + 0.001 ∗ ∣ ( x c u r r e n t − x g o a l ) ( y s t a r t − y g o a l ) − ( x s t a r t − x g o a l ) ( y c u r r e n t − y g o a l ) ∣ = h ( n ) + 0.001 ∗ ∣ ( x c u r r e n t − x g o a l ) ( x s t a r t − x g o a l ) ( y s t a r t − y g o a l x s t a r t − x g o a l − y c u r r e n t − y g o a l x c u r r e n t − x g o a l ) ∣ \begin{array}{l} h(n)&=h(n)+0.001*|(x_{current}-x_{goal})(y_{start}-y_{goal})-(x_{start}-x_{goal})(y_{current}-y_{goal})| \\ &=h(n)+0.001*\left|(x_{current}-x_{goal})(x_{start}-x_{goal}) \left(\frac{y_{start}-y_{goal}}{x_{start}-x_{goal}}-\frac{y_{current}-y_{goal}}{x_{current}-x_{goal}} \right) \right| \end{array} h(n)=h(n)+0.001(xcurrentxgoal)(ystartygoal)(xstartxgoal)(ycurrentygoal)=h(n)+0.001(xcurrentxgoal)(xstartxgoal)(xstartxgoalystartygoalxcurrentxgoalycurrentygoal)
它计算的是初始-目标向量和当前-目标向量的向量叉积,这两个向量平行时附加值为0,垂直时达到最大。结果是,它选的路径稍微倾向于从初始点到目标点的直线,当没有障碍物时,A*不仅搜索区域小,而且找到的路径也更好。然而当出现障碍物时,搜索的路径将会出现奇怪的结果,尽管它也是最佳的。

图1.没有添加附加值 图2.添加附加值p 图3.添加附加值为向量的叉积

还有一个添加附加值的方式是,构造A*优先队列时,使新插入的具有特殊 f ( n ) f(n) f(n)值的顶点总是比那些以前插入的具有相同值的旧顶点要好一些。AlphA*具有较好的适应性,效果可能比这些附加值方法更好。

2.3 区域搜索

如果想要搜索的是临近目标的任意顶点,可以建立一个启发函数 h ′ ( x ) = min ⁡ ( h 1 ( x ) , h 2 ( x ) , h 3 ( x ) , ⋯   ) h'(x)=\min(h_1(x),h_2(x),h_3(x),\cdots) h(x)=min(h1(x),h2(x),h3(x),),即邻近顶点的启发式函数的最小值。又或者,不取最小值,而是只要取到邻近顶点就立即结束搜索并返回路径。

三、算法的实现

3.1 概述

如果不考虑具体实现代码,A*算法是相当简单的。有两个集合,OPEN集和CLOSED集。其中OPEN集保存待考查的顶点。开始时,OPEN集只包含一个元素:初始点。CLOSED集保存已考查过的顶点。开始时,CLOSED集是空的。如果绘成图,OPEN集就是被访问区域的边境(frontier)而CLOSED集则是被访问区域的内部(interior)。每个顶点同时保存其父顶点的指针因此我们可以知道它是如何被找到的。

在主循环中重复地从OPEN集中取出最好的结点 n n n f ( n ) f(n) f(n)值最小的结点)并检查之。如果 n n n 是目标结点,则我们的任务完成了。否则,结点 n n n 被从OPEN集中删除并加入CLOSED集。然后检查它的邻居 n ’ n’ n。如果邻居 n ’ n’ n在CLOSED集中,那么它是已经被检查过的,所以我们不需要考虑它;如果 n ’ n’ n在OPEN集中,那么它是以后肯定会被检查的,所以我们现在不考虑它*。否则,把它加入OPEN集,把它的父结点设为 n n n。到达 n ’ n’ n的路径的代价 g ( n ’ ) g(n’) g(n),设定为 g ( n ) + m o v e m e n t c o s t ( n , n ’ ) g(n) + movementcost(n, n’) g(n)+movementcost(n,n)

这里我忽略了一个小细节。你确实需要检查结点的 g ( n ) g(n) g(n) 值是否更小了,如果是的话,需要重新打开(re-open)它。

OPEN = priority queue containing START
CLOSED = empty set
while lowest rank in OPEN is not the GOAL:
  current = remove lowest rank item from OPEN
  add current to CLOSED
  for neighbors of current:
    cost = g(current) + movementcost(current, neighbor)
    if neighbor in OPEN and cost less than g(neighbor):
      remove neighbor from OPEN, because new path is better
    # 如果有一个可行的启发式函数,这里就不会发生
    # 但实际上,很难构造一个可行的启发式函数
    if neighbor in CLOSED and cost less than g(neighbor): 
      remove neighbor from CLOSED
    if neighbor not in OPEN and neighbor not in CLOSED:
      set g(neighbor) to cost
      add neighbor to OPEN
      set priority queue rank to g(neighbor) + h(neighbor)
      set neighbor's parent to current

随后只需要根据父顶点重新构造从目标点到起点的路径即可。

我自己的(旧的)C++A*代码是可用的:path.cpppath.h,但是不容易阅读。还有一份更老的代码(更慢的,但是更容易理解),和很多其它的A*实现一样,它在Steve Woodcock的游戏AI页面(http://www.gameai.com/ai.html)。

在网上,你能找到C,C++,Visual Basic ,Java,Flash/Director/Lingo, C#, Delphi, Lisp, Python, Perl, 和Prolog 实现的A*代码。一定的阅读Justin Heyes-Jones的C++实现(http://www.geocities.com/jheyesjones/astar.html)。

3.2 集合的表示(TODO)-暂时修改到这里

你首先想到的用于实现OPEN集和CLOSED集的数据结构是什么?如果你和我一样,你可能想到“数组”。你也可能想到“链表”。我们可以使用很多种不同的数据结构,为了选择一种,我们应该考虑我们需要什么样的操作。

在OPEN集上我们主要有三种操作:主循环重复选择最好的结点并删除它;访问邻居结点时需要检查它是否在集合里面;访问邻居结点时需要插入新结点。插入和删除最佳是优先队列的典型操作。

选择哪种数据结构不仅取决于操作,还取决于每种操作执行的次数。检查一个结点是否在集合中这一操作对每个被访问的结点的每个邻居结点都执行一次。删除最佳操作对每个被访问的结点都执行一次。被考虑到的绝大多数结点都会被访问;不被访问的是搜索空间边缘(fringe)的结点。当评估数据结构上面的这些操作时,必须考虑fringe(F)的最大值。

另外,还有第四种操作,虽然执行的次数相对很少,但还是必须实现的。如果正被检查的结点已经在OPEN集中(这经常发生),并且如果它的f值比已经在OPEN集中的结点要好(这很少见),那么OPEN集中的值必须被调整。调整操作包括删除结点(f值不是最佳的结点)和重插入。这两个步骤必须被最优化为一个步骤,这个步骤将移动结点。

3.2.1 常用数据结构

未排序数组或链表

最简单的数据结构是未排序数组或链表。集合关系检查操作(Membership test)很慢,扫描整个结构花费O(F)。插入操作很快,添加到末尾花费O(1)。查找最佳元素(Finding the best element)很慢,扫描整个结构花费O(F)。对于数组,删除最佳元素(Removing the best element)花费O(F),而链表则是O(1)。调整操作中,查找结点花费O(F),改变值花费O(1)。

排序数组

为了加快删除最挂操作,可以对数组进行排序。集合关系检查操作将变成O(log F),因为我们可以使用折半查找。插入操作会很慢,为了给新元素腾出空间,需要花费 O(F)以移动所有的元素。查找最佳元素操作会很快,因为它已经在末尾了所以花费是O(1)。如果我们保证最佳排序至数组的尾部(best sorts to the end of the array),删除最佳元素操作花费将是O(1)。调整操作中,查找结点花费O(logF),改变值/位置花费O(F)。

排序链表

在排序数组中,插入操作很慢。如果使用链表则可以加速该操作。集合关系检查操作很慢,需要花费O(F)用于扫描链表。插入操作是很快的,插入新元素只花费O(1)时间,但是查找正确位置需要花费O(F)。查找最佳元素很快,花费O(1)时间,因为最佳元素已经在表的尾部。删除最佳元素也是O(1)。调整操作中,查找结点花费O(F),改变值/位置花费O(1)。

排序跳表

在未排序链表中查找元素是很慢的。如果用跳表(http://en.wikipedia.org/wiki/Skip_list)代替链表的话,可以加速这个操作。在跳表中,如果有排序键(sort key)的话,集合关系检查操作会很快:O(log F)。如果你知道在何处插入的话,和链表一样,插入操作也是O(1)。如果排序键是f,查找最佳元素很快,达到O(1),删除一个元素也是O(1)。调整操作涉及到查找结点,删除结点和重插入。

如果我们用地图位置作为跳表的排序键,集合关系检查操作将是O(log F)。在完成集合关系检查后,插入操作是O(1)。查找最佳元素是O(F),删除一个结点是O(1)。因为集合关系检查更快,所以它比未排序链表要好一些。

如果我们用f值作为跳表的排序键,集合关系检查操作将是O(F)。插入操作是O(1)。查找最佳元素是O(1),删除一个结点是O(1)。这并不比排序链表好。

索引数组

如果结点的集合有限并且数目是适当的,我们可以使用直接索引结构,索引函数i(n)把结点n映射到一个数组的索引。未排序与排序数组的长度等于OPEN集的最大值,和它们不同,对所有的n,索引数组的长度总是等于max(i(n))。如果你的函数是密集的(没有不被使用的索引),max(i(n))将是你地图中结点的数目。只要你的地图是网格的,让索引函数密集就是容易的。

假设i(n)是O(1)的,集合关系检查将花费O(1),因为我们几乎不需要检查Array[i(n)]是否包含任何数据。Insertion is O(1), as we just ste Array[i(n)].查找和删除最佳操作是O(numnodes),因为我们必须搜索整个结构。调整操作是O(1)。

哈希表

索引数组使用了很多内存用于保存不在OPEN集中的所有结点。一个选择是使用哈希表。哈希表使用了一个哈希函数h(n)把地图上每个结点映射到一个哈希码。让哈希表的大小等于N的两倍,以使发生冲突的可能性降低。假设h(n) 是O(1)的,集体关系检查操作花费O(1);插入操作花费O(1);删除最佳元素操作花费O(numnodes),因为我们需要搜索整个结构。调整操作花费O(1)。

二元堆

一个二元堆(不要和内存堆混淆)是一种保存在数组中的树结构。和许多普通的树通过指针指向子结点所不同,二元堆使用索引来查找子结点。C++ STL包含了一个二元堆的高效实现,我在我自己的A*代码中使用了它。

在二元堆中,集体关系检查花费O(F),因为你必须扫描整个结构。插入操作花费O(log F)而删除最佳操作花费也是O(log F)。调整操作很微妙(tricky),花费O(F)时间找到节点,并且很神奇,只用O(log F)来调整。

我的一个朋友(他研究用于最短路径算法的数据结构)说,除非在你的fringe集里有多于10000个元素,否则二元堆是很不错的。除非你的游戏地图特别大,否则你不需要更复杂的数据结构(如multi-level buckets)。你应该尽可能不用Fibonacci 堆(http://www.star-lab.com/goldberg/pub/neci-tr-96-062.ps),因为虽然它的渐近复杂度很好,但是执行起来很慢,除非F足够大。

伸展树

堆是一种基于树的结构,它有一个期望的O(log F)代价的时间操作。然而,问题是在A*算法中,通常的情况是,一个代价小的节点被移除(花费O(log F)的代价,因为其他结点必须从树的底部向上移动),而紧接着一些代价小的节点被添加(花费O(log F)的代价,因为这些结点被添加到底部并且被移动到最顶部)。在这里,堆的操作在预期的情况下和最坏情况下是一样的。如果我们找到这样一种数据结构,最坏情况还是一样,而预期的情况好一些,那么就可以得到改进。

伸展树(Splay tree)是一种自调整的树结构。任何对树结点的访问都尝试把该结点推到树的顶部(top)。这就产生了一个缓存效果(“caching” effect):很少被使用的结点跑到底部(bottom)去了并且不减慢操作(don’t slow down operations)。你的splay树有多大并不重要,因为你的操作仅仅和你的“cache size”一样慢。在A*中,低代价的结点使用得很多,而高代价结点经常不被使用,所以高代价结点将会移动到树的底部。

使用伸展树后,集体关系检查,插入,删除最佳和调整操作都是期望的O(log F)(注:原文为expected O(log F) ),最坏情况是O(F)。然而有代表性的是,缓存过程(caching)避免了最坏情况的发生。Dijkstra算法和带有低估的启发函数(underestimating heuristic)的A*算法却有一些特性让伸展树达不到最优。特别是对结点n和邻居结点n’来说,f(n’) >= f(n)。当这发生时,也许插入操作总是发生在树的同一边结果是使它失去了平衡。我没有试验过这个。

HOT队列

还有一种比堆好的数据结构。通常你可以限制优先队列中值的范围。给定一个限定的范围,经常会存在更好的算法。例如,对任意值的排序可以在O(N log N)时间内完成,但当固定范围时,桶排序和基数排序可以在O(N)时间内完成。

我们可以使用HOT(Heap On Top)队列(http://www.star-lab.com/goldberg/pub /neci-tr-97-104.ps)来利用f(n’) >= f(n),其中n’是n的一个邻居结点。我们删除f(n)值最小的结点n,插入满足f(n) <= f(n’) <= f(n) + delta的邻居n’,其中delta <= C。常数C是从一结点到邻近结点代价改变量的最大值。因为f(n)是OPEN集中的最小f值,并且正要被插入的所有结点都小于或等于f(n) + delta,我们知道OPEN集中的所有f值都不超过一个0…delta的范围。在桶/基数排序中,我们可以用“桶”(buckets)对OPEN集中的结点进行排序。

使用K个桶,我们把O(N)的代价降低到平均O(N/K)。通过HOT队列,顶端的桶使用二元堆而所有其他的桶都是未排序数组。因而,对顶部的桶,集合关系检查代价是预期的O(F/K),插入和删除最佳是O(log (F/K))。对其他桶,集合关系检查是O(F/K),插入是O(1),而删除最佳根本不发生!如果顶端的桶是空的,那么我们必须把下一个桶即未排序数组转换为二元堆。这个操作(“heapify”)可以在O(F/K)时间内完成。在调整操作中,删除是O(F/K),然后插入是O(log (F/K))或O(1)。

在A*中,我们加入OPEN集中的许多结点实际上根本是不需要的。在这方面HOT队列很有优势,因为不需要的元素的插入操作只花费O(1)时间。只有需要的元素被heapified(代价较低的那些)。唯一一个超过O(1)的操作是从堆中删除结点,只花费O(log (F/K))。

另外,如果C比较小,我们可以只让K = C,则对于最小的桶,我们甚至不需要一个堆,国为在一个桶中的所有结点都有相同的f值。插入和删除最佳都是O(1)时间!有人研究过,HOT队列在至多在OPEN集中有800个结点时和堆一样快,并且如果OPEN集中至多有1500个结点,则比堆快20%。我期望随着结点的增加,HOT队列也更快。

HOT队列的一个简单的变化是一个二层队列(two-level queue):把好的结点放进一个数据结构(堆或数组)而把坏的结点放进另一个数据结构(数组或链表)。因为大多数进入OPEN集中的结点都“坏的”,它们从不被检查,因而把它们放进出一个大数组是没有害处的。

3.2.2 比较

注意有一点很重要,我们并不是仅仅关心渐近的行为(大O符号)。我们也需要关心小常数(low constant)下的行为。为了说明原因,考虑一个O(log F)的算法,和另一个O(F)的算法,其中F是堆中元素的个数。也许在你的机器上,第一个算法的实现花费10000log(F)秒,而另一个的实现花费2F秒。当F=256时,第一个算法将花费80000秒而第二个算法花费512秒。在这种情况下,“更快”的算法花费更多的时间,而且只有在当F>200000时才能运行得更快。

你不能仅仅比较两个算法。你还要比较算法的实现。同时你还需要知道你的数据的大小(size)。在上面的例子中,第一种实现在F>200000时更快,但如果在你的游戏中,F小于30000,那么第二种实现好一些。

基本数据结构没有一种是完全合适的。未排序数组或者链表使插入操作很快而集体关系检查和删除操作非常慢。排序数组或者链表使集体关系检查稍微快一些,删除(最佳元素)操作非常快而插入操作非常慢。二元堆让插入和删除操作稍微快一些,而集体关系检查则很慢。伸展树让所有操作都快一些。HOT队列让插入操作很快,删除操作相当快,而集体关系检查操作稍微快一些。索引数组让集体关系检查和插入操作非常快,但是删除操作不可置信地慢,同时还需要花费很多内存空间。哈希表和索引数组类似,但在普通情况下,它花费的内存空间少得多,而删除操作虽然还是很慢,但比索引数组要快。

关于更高级的优先队列的资料和实现,请参考Lee Killough的优先队列页面(http://members.xoom.com/killough/heaps.html)。

3.2.3 混合实现

为了得到最佳性能,你将希望使用混合数据结构。在我的A*代码中,我使用一个索引数组从而集合关系检查是O(1)的,一个二元堆从而插入操作和删除最佳都是O(log F)的。对于调整操作,我使用索引数组从而花费O(1)时间检查我是否真的需要进行调整(通过在索引数组中保存g值),然后在少数确实需要进行调整的情况中,我使用二元堆从而调整操作花费O(F)时间。你也可以使用索引数组保存堆中每个结点的位置,这让你的调整操作变成O(log F)。

3.3 与游戏循环的交互

交互式的(尤其是实时的)游戏对最佳路径的计算要求很高。能够得到一个解决方案比得到最佳方案可能更重要。然而在所有其他因素都相同的情况下,短路径比长路径好。

一般来说,计算靠近初始结点的路径比靠近目标结点的路径更重要一些。立即开始原理(The principle of immediate start):让游戏中的物体尽可能快地开始行动,哪怕是沿着一条不理想的路径,然后再计算一条更好的路径。在实时游戏中,应该更多地关注A*的延迟情况(latency)而不是吞吐量(throughput)。

可以对物体编程让它们根据自己的本能(简单行为)或者智力(一条预先计算好的路径)来行动。除非它们的智力告诉它们怎么行动,否则它们就根据自己的本能来行动(这是实际上使用的方法,并且Rodney Brook在他的机器人体系结构中也用到)。和立即计算所有路径所不同,让游戏在每一个,两个,或者三个循环中搜索一条路径。让物体在开始时依照本能行动(可能仅仅是简单地朝着目标直线前进),然后才为它们寻找路径。这种方法让让路径搜索的代价趋于平缓,因此它不会集中发生在同一时刻。

3.3.1 提前退出

可以从A*算法的主循环中提前退出来同时得到一条局部路径。通常,当找到目标结点时,主循环就退出了。然而,在此之前的任意结点,可以得到一条到达OPEN中当前最佳结点的路径。这个结点是到达目标点的最佳选择,所以它是一个理想的中间结点(原文为so it’s a reasonable place to go)。

可以提前退出的情况包括检查了一定数量的结点,A*算法已经运行了几毫秒时间,或者扫描了一个离初始点有些距离的结点。当使用路径拼接时,应该给被拼接的路径一个比全路径(full path)小的最大长度。

3.3.2 中断算法

如果需要进行路径搜索的物体较少,或者如果用于保存OPEN和CLOSED集的数据结构较小,那么保存算法的状态是可行的,然后退出到游戏循环继续运行游戏。

3.3.3 组运动

路径请求并不是均匀分布的。即时策略游戏中有一个常见的情况,玩家会选择多个物体并命令它们朝着同样的目标移动。这给路径搜索系统以沉重的负载。

在这种情况下,为某个物体寻找到的路径对其它物体也是同样有用的。一种方法是,寻找一条从物体的中心到目的地中心的路径P。对所有物体使用该路径的绝大部分,对每一个物体,前十步和后十步使用为它自己寻找的路径。物体i得到一条从它的开始点到P[10]的路径,紧接着是共享的路径P[10…len§-10],最后是从P[len§-10]到目的地的路径。

为每个物体寻找的路径是较短的(平均步数大约是10),而较长的路径被共享。大多数路径只寻找一次并且为所有物体所共享。然而,当玩家们看到所有的物体都沿着相同的路径移动时,将对游戏失去兴趣。为了对系统做些改进,可以让物体稍微沿着不同的路径运动。一种方法是选择邻近结点以改变路径。

另一种方法是让每个物体都意识到其它物体的存在(或许是通过随机选择一个“领导”物体,或者是通过选择一个能够最好地意识到当前情况的物体),同时仅仅为领导寻路。然后用flocking算法让它们以组的形式运动。

然而还有一种方法是利用A算法的中间状态。这个状态可以被朝着相同目标移动的多个物体共享,只要物体共享相同的启发式函数和代价函数。当主循环退出时,不要消除OPEN和CLOSED集;用A上一次的OPEN和CLOSED集开始下一次的循环(下一个物体的开始位置)。(这可以被看成是中断算法和提前退出部分的一般化)

3.3.4 细化

如果地图中没有障碍物,而有不同代价的地形,那么可以通过低估地形的代价来计算一条初始路径。例如,如果草地的代价是1,山地代价是2,山脉的代价是3,那么A会考虑通过3个草地以避免1个山脉。通过把草地看成1,山地看成1.1,而山脉看成1.2来计算初始路径,A将会用更少的时间去设法避免山脉,而且可以更快地找到一条路径(这接近于精确启发函数的效果)。一旦找到一条路径,物体就可以开始移动,游戏循环就可以继续了。当多余的CPU时间是可用的时候,可以用真实的移动代价去计算更好的路径。

四、A*算法的变种

4.1 beam search beam search

在A的主循环中,OPEN集保存所有需要检查的结点。Beam Search是A算法的一个变种,这种算法限定了OPEN集的尺寸。如果OPEN集变得过大,那些没有机会通向一条好的路径的结点将被抛弃。缺点是你必须让排序你的集合以实现这个,这限制了可供选择的数据结构。

4.2 迭代深化

迭代深化是一种在许多AI算法中使用的方法,这种方法从一个近似解开始,逐渐得到更精确的解。该名称来源于游戏树搜索,需要查看前面几步(比如在象棋里),通过查看前面更多步来提高树的深度。一旦你的解不再有更多的改变或者改善,就可以认为你已经得到足够好的解,当你想要进一步精确化时,它不会再有改善。在ID-A中,深度是f值的一个cutoff。当f的值太大时,结点甚至将不被考虑(例如,它不会被加入OPEN集中)。第一次迭代只处理很少的结点。此后每一次迭代,访问的结点都将增加。如果你发现路径有所改善,那么就继续增加cutoff,否则就可以停止了。更多的细节请参考这些关于ID-A的资料:http://www.apl.jhu.edu/~hall/AI-Programming/IDA-Star.html。

我本人认为在游戏地图中没有太大的必要使用ID-A*寻路。ID算法趋向于增加计算时间而减少内存需求。然而在地图路径搜索中,“结点”是很小的——它们仅仅是坐标而已。我认为不保存这些结点以节省空间并不会带来多大改进。

4.3 动态衡量

在动态衡量中,你假设在开始搜索时,最重要的是讯速移动到任意位置;而在搜索接近结束时,最重要的是移动到目标点。

f§ = g§ + w§ * h§

启发函数中带有一个权值(weight)(w>=1)。当你接近目标时,你降低这个权值;这降低了启发函数的重要性,同时增加了路径真实代价的相对重要性。

4.4 带宽搜索

带宽搜索(Bandwidth Search)有两个对有些人也许有用的特性。这个变种假设h是过高估计的值,但不高于某个数e。如果这就是你遇到的情况,那么你得到的路径的代价将不会比最佳路径的代价超过e。重申一次,你的启发函数设计的越好,最终效果就越好。

另一个特性是,你可以丢弃OPEN集中的某些结点。当h+d比路径的真实代价高的时候(对于某些d),你可以丢弃那些f值比OPEN集中的最好结点的f值高至少e+d的结点。这是一个奇怪的特性。对于好的f值你有一个“范围”(“band”),任何在这个范围之外的结点都可以被丢弃掉,因为这个结点肯定不会在最佳路径上。

好奇地(Curiously),你可以对这两种特性使用不同的启发函数,而问题仍然可以得到解决。使用一个启发函数以保证你得到的路径不会太差,另一个用于检查从OPEN集中去掉哪些结点。

4.5 双向搜索

与从开始点向目标点搜索不同的是,你也可以并行地进行两个搜索——一个从开始点向目标点,另一个从目标点向开始点。当它们相遇时,你将得到一条好的路径。

这听起来是个好主意,但我不会给你讲很多内容。双向搜索的思想是,搜索过程生成了一棵在地图上散开的树。一棵大树比两棵小树差得多,所以最好是使用两棵较小的搜索树。然而我的试验表明,在A中你得不到一棵树,而只是在搜索地图中当前位置附近的区域,但是又不像Dijkstra算法那样散开。事实上,这就是让A算法运行得如此快的原因——无论你的路径有多长,它并不进行疯狂的搜索,除非路径是疯狂的。它只尝试搜索地图上小范围的区域。如果你的地图很复杂,双向搜索会更有用。

面对面的方法(The front-to-front variation)把这两种搜索结合在一起。这种算法选择一对具有最好的g(start,x) + h(x,y) + g(y,goal)的结点,而不是选择最好的前向搜索结点——g(start,x) + h(x,goal),或者最好的后向搜索结点——g(y,goal) + h(start,y)。

Retargeting方法不允许前向和后向搜索同时发生。它朝着某个最佳的中间结点运行前向搜索一段时间,然后再朝这个结点运行后向搜索。然后选择一个后向最佳中间结点,从前向最佳中间结点向后向最佳中间结点搜索。一直进行这个过程,直到两个中间结点碰到一块。

4.6 动态A*与终身计划A*

有一些A的变种允许当初始路径计算出来之后,世界发生改变。D用于当你没有全局所有信息的时候。如果你没有所有的信息,A可能会出错;D的贡献在于,它能纠正那些错误而不用过多的时间。LPA用于代价会改变的情况。在A中,当地图发生改变时,路径将变得无效;LPA可以重新使用之前A的计算结果并产生新的路径。然而,D和LPA都需要很多内存——用于运行A并保存它的内部信息(OPEN和CLOSED集,路径树,g值),当地图发生改变时,D或者LPA会告诉你,是否需要就地图的改变对路径作调整。在一个有许多运动着的物体的游戏中,你经常不希望保存所有这些信息,所以D和LPA在这里并不适用。它们是为机器人技术而设计的,这种情况下只有一个机器人——你不需要为别的机器人寻路而重用内存。如果你的游戏只有一个或者少数几个物体,你可以研究一下D或者LPA*。

五、处理运动障碍物

一个路径搜索算法沿着固定障碍物计算路径,但是当障碍物会运动时情况又怎样?当一个物体到达一个特写的位置,原来的障碍物也许不再在那儿了,或者一个新的障碍物也许到达那儿。处理该问题的一个方法是放弃路径搜索而使用运动算法(movement algorithms)替代,这就不能look far ahead;这种方法会在后面的部分中讨论。这一部分将对路径搜索方法进行修改从而解决运动障碍物的问题。

5.1 重新计算路径

当时间渐渐过去,我们希望游戏世界有所改变。以前搜索到的一条路径到现在也许不再是最佳的了。对旧的路径用新的信息进行更新是有价值的。以下规则可以用于决定什么时候需要重新计算路径:

  • 每N步:这保证用于计算路径的信息不会旧于N步。
  • 任何可以使用额外的CPU时间的时候:这允许动态调整路径的性质;在物体数量多时,或者运行游戏的机器比较慢时,每个物体对CPU的使用可得到减少。
  • 当物体拐弯或者跨越一个导航点(waypoint)的时候。
  • 当物体附近的世界改变了的时候。

重计算路径的主要缺点是许多路径信息被丢弃了。例如,如果路径是100步长,每10步重新计算一次,路径的总步数将是100+90+80+70+60+50+40+30+20+10 = 550。对M步长的路径,大约需要计算M^2步。因此如果你希望有许多很长的路径,重计算不是个好主意。重新使用路径信息比丢弃它更好。

5.2 路径拼接

当一条路径需要被重新计算时,意味着世界正在改变。对于一个正在改变的世界,对地图中当前邻近的区域总是比对远处的区域了解得更多。因此,我们应该集中于在附近寻找好的路径,同时假设远处的路径不需要重新计算,除非我们接近它。与重新计算整个路径不同,我们可以重新计算路径的前M步:

  1. 令p[1]…p[N]为路径(N步)的剩余部分
  2. 为p[1]到p[M]计算一条新的路径
  3. 把这条新路径拼接(Splice)到旧路径:把p[1]…p[M]用新的路径值代替

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5rRgh5uq-1602554129703)(assets/mtn_path.png)]

因为p[1]和p[M]比分开的M步小(原文:Since p[1] and p[M] are fewer than M steps apart),看起来新路径不会很长。不幸的是,新的路径也许很长而且不够好。上面的图显示了这种情况。最初的红色路径是1-2-3-4,褐色的是障碍物。如果我们到达2并且发现从2到达3的路径被封锁了,路径拼接技术会把2-3用2-5-3取代,结果是物体沿着路径1-2-5-3-4运动。我们可以看到这不是一条好的路径,蓝色的路径1-2-5-4是一条更好的路径。

通常可以通过查看新路径的长度检测到坏的路径。如果这严格大于M,就可能是不好的。一个简单的解决方法是,为搜索算法设置一个最大路径长度。如果找不到一条短的路径,算法返回错误代码;这种情况下,用重计算路径取代路径拼接,从而得到路径1-2-5-4.。

对于其它情况,对于N步的路径,路径拼接会计算2N或者3N步,这取决于拼接新路径的频率。对于对世界的改变作反应的能力而言,这个代价是相当低的。令人吃惊的是这个代价和拼接的步数M无关。M不影响CPU时间,而控制了响应和路径质量的折衷。如果M太大,物体的移动将不能快速对地图的改变作出反应。如果M太小,拼接的路径可能太短以致不能正确地绕过障碍物;许多不理想的路径(如1-2-5-3-4)将被找到。尝试不同的M值和不同的拼接标准(如每3/4 M步),看看哪一种情况对你的地图最合适。

路径拼接确实比重计算路径要快,但它不能对路径的改变作出很好的反应。经常可以发现这种情况并用路径重计算来取代。也可以调整一些变量,如M和寻找新路径的时机,所以可以对该方法进行调整(甚至在运行时)以用于不同的情况。

Note:Bryan Stout 有两个算法,Patch-One和Patch-All,他从路径拼接中得到灵感,并在实践中运行得很好。他出席了GDC 2007(https://www.cmpevents.com/GD07/a.asp?option =C &V=11& SessID=4608);一旦他把资料放在网上,我将链接过去。

Implementation Note:

反向保存路径,从而删除路径的开始部分并用不同长度的新路径拼接将更容易,因为这两个操作都将在数组的末尾进行。本质上你可以把这个数组看成是堆栈因为顶部的元素总是下一个要使用的。

5.3 监视地图变化

与间隔一段时间重计算全部或部分路径不同的是,可以让地图的改变触发一次重计算。地图可以分成区域,每个物体都可以对某些区域感兴趣(可以是包含部分路径的所有区域,也可以只是包含部分路径的邻近区域)。当一个障碍物进入或者离开一个区域,该区域将被标识为已改变,所有对该区域感兴趣的物体都被通知到,所以路径将被重新计算以适应障碍物的改变。

这种技术有许多变种。例如,可以每隔一定时间通知物体,而不是立即通知物体。多个改变可以成组地触发一个通知,因此避免了额外的重计算。另一个例子是,让物体检查区域,而不是让区域通知物体。

监视地图变化允许当障碍物不改变时物体避免重计算路径,所以当你有许多区域并不经常改变时,考虑这种方法。

5.4 预测障碍物的运动

如果障碍物的运动可以预测,就能为路径搜索考虑障碍物的未来位置。一个诸如A的算法有一个代价函数用以检查穿过地图上一点的代价有多难。A可以被改进从而知道到达一点的时间需求(通过当前路径长度来检查),而现在则轮到代价函数了。代价函数可以考虑时间,并用预测的障碍物位置检查在某个时刻地图某个位置是否可以通过。这个改进不是完美的,然而,因为它并不考虑在某个点等待障碍物自动离开的可能性,同时A*并不区分到达相同目的地的不同的路径,而是针对不同的目的地,所以还是可以接受的。

六、预计算路径的空间代价

有时,路径计算的限制因素不是时间,而是用于数以百计的物体的存储空间。路径搜索器需要空间以运行算法和保存路径。算法运行所需的临时空间(在A*中是OPEN和CLOSED集)通常比保存结果路径的空间大许多。通过限制在一定的时间计算一条路径,可以把临时空间数量最小化。另外,为OPEN和CLOSED集所选择的数据结构的不同,最小化临时空间的程度也有很大的不同。这一部分聚集于优化用于计算路径的空间代价。

6.1 位置VS方向

一条路径可以用位置或者方向来表示。位置需要更多的空间,但是有一个优点,易于查询路径中的任意位置或者方向而不用沿着路径移动。当保存方向时,只有方向容易被查询;只有沿着整个路径移动才能查询位置。在一个典形的网格地图中,位置可以被保存为两个16位整数,每走一步是32位。而方向是很少的,因此用极少的空间就够了。如果物体只能沿着四个方向移动,每一步用两位就够了;如果物体能沿着6个或者8个方向移动,每一步也只需要三位。这些对于保存路径中的位置都有明显的空间节省。Hannu Kankaanpaa指出可以进一步减少空间需求,那就是保存相对方向(右旋60度)而不是绝对方向(朝北走)。有些相对方向对某些物体来说意义不大。比如,如果你的物体朝北移动,那么下一步朝南移动的可能性很小。在只有六种方向的游戏中,你只有五个有意义的方向。在某些地图中,也许只有三个方向(直走,左旋60度,右旋60度)有意义,而其它地图中,右旋120度是有效的(比如,沿着陡峭的山坡走之字形的路径时)。

6.2 路径压缩

一旦找到一条路径,可以对它进行压缩。可以用一个普通的压缩算法,但这里不进行讨论。使用特定的压缩算法可以缩小路径的存储,无论它是基于位置的还是基于方向的。在做决定之前,考察你的游戏中的路径以确定哪种压缩效果最好。另外还要考虑实现和调试,代码量,and whether it really matters.如果你有300个物体并且在同一时刻只有50个在移动,同时路径比较短(100步),内存总需求大概只有不到50k,总之,没有必要担心压缩的效果。

6.2.1 位置存储

在障碍物比地形对路径搜索影响更大的地图中,路径中有大部分是直线的。如果是这种情况,那么路径只需要包含直线部分的终止点(有时叫waypoints)。此时移动过程将包含检查下一结点和沿着直线向前移动。

6.2.2 方向存储

保存方向时,有一种情况是同一个方向保存了很多次。可以用简单的方法节省空间。

一种方法是保存方向以及朝着该方向移动的次数。和位置存储的优化不同,当一个方向并不是移动很多次时,这种优化的效果反而不好。同样的,对于那些可以进行位置压缩的直线来说,方向压缩是行不通的,因为这条直线可能没有和正在移动的方向关联。通过相对方向,你可以把“继续前进”当作可能的方向排除掉。Hannu Kankaanpaa指出,在一个八方向地图中,你可以去掉前,后,以及向左和向右135度(假设你的地图允许这个),然后你可以仅用两个比特保存每个方向。

另一种保存路径的方法是变长编码。这种想法是使用一个简单的比特(0)保存最一般的步骤:向前走。使用一个“1”表示拐弯,后边再跟几个比特表示拐弯的方向。在一个四方向地图中,你只能左转和右转,因此可以用“10”表示左转,“11”表示右转。

变长编码比run length encoding更一般,并且可以压缩得更好,但对于较长的直线路径则不然。序列(向北直走6步,左转,直走3步,右转,直走5步,左转,直走2步)用run length encoding表示是[(NORTH, 6), (WEST, 3), (NORTH, 5), (WEST, 2)]。如果每个方向用2比特,每个距离用8比特,保存这条路径需要40比特。而对于变长编码,你用1比特表示每一步,2比特表示拐弯——[NORTH 0 0 0 0 0 0 10 0 0 0 11 0 0 0 0 0 10 0 0]——一共24比特。如果初始方向和每次拐弯对应1步,则每次拐弯都节省了一个比特,结果只需要20比特保存这条路径。然而,用变长编码保存更长的路径时需要更多的空间。序列(向北直走200步)用run length encoding表示是[(NORTH, 200)],总共需要10比特。用变长编码表示同样的序列则是[NORTH 0 0 …],一共需要202比特。

6.3 计算导航点

一个导航点(waypoint)是路径上的一个结点。与保存路径上的每一步不同,在进行路径搜索之后,一个后处理(post-processing)的步骤可能会把若干步collapse(译者:不好翻译,保留原单词)为一个简单的导航点,这经常发生在路径上那些方向发生改变的地方,或者在一个重要的(major)位置如城市。然后运动算法将在两个导航点之间运行。

6.4 极限路径长度

当地图中的条件或者秩序会发生改变时,保存一条长路径是没有意义的,因为在从某些点开始,后边的路径已经没有用了。每个物体都可以保存路径开始时的特定几步,然后当路径已经没用时重新计算路径。这种方法虑及了(allows for)对每个物体使用数据的总量的管理。

6.5 总结

在游戏中,路径潜在地花费了许多存储空间,特别是当路径很长并且有很多物体需要寻路时。路径压缩,导航点和beacons通过把多个步骤保存为一个较小数据从而减少了空间需求。Waypoints rely on straight-line segments being common so that we have to store only the endpoints, while beacons rely on there being well-known paths calculated beforehand between specially marked places on the map.(译者:此处不好翻译,暂时保留原文)如果路径仍然用了许多存储空间,可以限制路径长度,这就回到了经典的时间-空间折衷法:为了节省空间,信息可以被丢弃,稍后才重新计算它。

  • 4
    点赞
  • 59
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值