理解A*寻路算法具体过程

6 篇文章 0 订阅

这两天研究了下 A* 寻路算法, 主要学习了这篇文章, 但这篇翻译得不是很好, 我花了很久才看明白文章中的各种指代. 特写此篇博客用来总结, 并写了寻路算法的代码, 觉得有用的同学可以看看. 另外因为图片制作起来比较麻烦, 所以我用的是原文里的图片.

        当然寻路算法不止 A* 这一种, 还有递归, 非递归, 广度优先, 深度优先, 使用堆栈等等, 有兴趣的可以研究研究~~

简易地图

        如图所示简易地图, 其中绿色方块的是起点 (用 A 表示), 中间蓝色的是障碍物, 红色的方块 (用 B 表示) 是目的地. 为了可以用一个二维数组来表示地图, 我们将地图划分成一个个的小方块.

        二维数组在游戏中的应用是很多的, 比如贪吃蛇和俄罗斯方块基本原理就是移动方块而已. 而大型游戏的地图, 则是将各种"地貌"铺在这样的小方块上.

寻路步骤

        1. 从起点A开始, 把它作为待处理的方格存入一个"开启列表", 开启列表就是一个等待检查方格的列表.

        2. 寻找起点A周围可以到达的方格, 将它们放入"开启列表", 并设置它们的"父方格"为A.

        3. 从"开启列表"中删除起点 A, 并将起点 A 加入"关闭列表", "关闭列表"中存放的都是不需要再次检查的方格

        图中浅绿色描边的方块表示已经加入 "开启列表" 等待检查. 淡蓝色描边的起点 A 表示已经放入 "关闭列表" , 它不需要再执行检查.

        从 "开启列表" 中找出相对最靠谱的方块, 什么是最靠谱? 它们通过公式 F=G+H 来计算.

        F = G + H

                表示从起点 A 移动到网格上指定方格的移动耗费 (可沿斜方向移动).

                表示从指定的方格移动到终点 B 的预计耗费 (H 有很多计算方法, 这里我们设定只可以上下左右移动).

        我们假设横向移动一个格子的耗费为10, 为了便于计算, 沿斜方向移动一个格子耗费是14. 为了更直观的展示如何运算 FGH, 图中方块的左上角数字表示 F, 左下角表示 G, 右下角表示 H. 看看是否跟你心里想的结果一样?

        从 "开启列表" 中选择 F 值最低的方格 C (绿色起始方块 A 右边的方块), 然后对它进行如下处理:

        4. 把它从 "开启列表" 中删除, 并放到 "关闭列表" 中.

        5. 检查它所有相邻并且可以到达 (障碍物和 "关闭列表" 的方格都不考虑) 的方格. 如果这些方格还不在 "开启列表" 里的话, 将它们加入 "开启列表", 计算这些方格的 G, H 和 F 值各是多少, 并设置它们的 "父方格" 为 C.

        6. 如果某个相邻方格 D 已经在 "开启列表" 里了, 检查如果用新的路径 (就是经过C 的路径) 到达它的话, G值是否会更低一些, 如果新的G值更低, 那就把它的 "父方格" 改为目前选中的方格 C, 然后重新计算它的 F 值和 G 值 (H 值不需要重新计算, 因为对于每个方块, H 值是不变的). 如果新的 G 值比较高, 就说明经过 C 再到达 D 不是一个明智的选择, 因为它需要更远的路, 这时我们什么也不做.

        如图, 我们选中了 C 因为它的 F 值最小, 我们把它从 "开启列表" 中删除, 并把它加入 "关闭列表". 它右边上下三个都是墙, 所以不考虑它们. 它左边是起始方块, 已经加入到 "关闭列表" 了, 也不考虑. 所以它周围的候选方块就只剩下 4 个. 让我们来看看 C 下面的那个格子, 它目前的 G 是14, 如果通过 C 到达它的话, G将会是 10 + 10, 这比 14 要大, 因此我们什么也不做.

        然后我们继续从 "开启列表" 中找出 F 值最小的, 但我们发现 C 上面的和下面的同时为 54, 这时怎么办呢? 这时随便取哪一个都行, 比如我们选择了 C 下面的那个方块 D.

        D 右边已经右上方的都是墙, 所以不考虑, 但为什么右下角的没有被加进 "开启列表" 呢? 因为如果 C 下面的那块也不可以走, 想要到达 C 右下角的方块就需要从 "方块的角" 走了, 在程序中设置是否允许这样走. (图中的示例不允许这样走)

        就这样, 我们从 "开启列表" 找出 F 值最小的, 将它从 "开启列表" 中移掉, 添加到 "关闭列表". 再继续找出它周围可以到达的方块, 如此循环下去...

        那么什么时候停止呢? —— 当我们发现 "开始列表" 里出现了目标终点方块的时候, 说明路径已经被找到.

如何找回路径

        如上图所示, 除了起始方块, 每一个曾经或者现在还在 "开启列表" 里的方块, 它都有一个 "父方块", 通过 "父方块" 可以索引到最初的 "起始方块", 这就是路径.

将整个过程抽象

把起始格添加到 "开启列表" 
do 

       寻找开启列表中F值最低的格子, 我们称它为当前格. 
       把它切换到关闭列表. 
       对当前格相邻的8格中的每一个 
          if (它不可通过 || 已经在 "关闭列表" 中) 
          { 
                什么也不做. 
           } 
          if (它不在开启列表中) 
          { 
                把它添加进 "开启列表", 把当前格作为这一格的父节点, 计算这一格的 FGH 
          if (它已经在开启列表中) 
          { 
                if (用G值为参考检查新的路径是否更好, 更低的G值意味着更好的路径) 
                    { 
                            把这一格的父节点改成当前格, 并且重新计算这一格的 GF 值. 
                    } 
} while( 目标格已经在 "开启列表", 这时候路径被找到) 
如果开启列表已经空了, 说明路径不存在.

最后从目标格开始, 沿着每一格的父节点移动直到回到起始格, 这就是路径.

主要代码

程序中的 "开启列表" 和 "关闭列表"

 
1
List<Point> CloseList;
List<Point> OpenList;

Point 类

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public  class  Point
{
     public  Point ParentPoint { get ; set ; }
     public  int  F { get ; set ; }  //F=G+H
     public  int  G { get ; set ; }
     public  int  H { get ; set ; }
     public  int  X { get ; set ; }
     public  int  Y { get ; set ; }
 
     public  Point( int  x, int  y)
     {
         this .X = x;
         this .Y = y;
     }
     public  void  CalcF()
     {
         this .F = this .G + this .H;
     }
}

寻路过程

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public  Point FindPath(Point start, Point end, bool  IsIgnoreCorner)
{
     OpenList.Add(start);
     while  (OpenList.Count != 0)
     {
         //找出F值最小的点
         var  tempStart = OpenList.MinPoint();
         OpenList.RemoveAt(0);
         CloseList.Add(tempStart);
         //找出它相邻的点
         var  surroundPoints = SurrroundPoints(tempStart, IsIgnoreCorner);
         foreach  (Point point in  surroundPoints)
         {
             if  (OpenList.Exists(point))
                 //计算G值, 如果比原来的大, 就什么都不做, 否则设置它的父节点为当前点,并更新G和F
                 FoundPoint(tempStart, point);
             else
                 //如果它们不在开始列表里, 就加入, 并设置父节点,并计算GHF
                 NotFoundPoint(tempStart, end, point);
         }
         if  (OpenList.Get(end) != null )
             return  OpenList.Get(end);
     }
     return  OpenList.Get(end);
}

下载代码

        

本文链接: http://www.cnblogs.com/technology/archive/2011/05/26/2058842.html

作者:Create Chen 
出处:http://technology.cnblogs.com
说明:文章为作者平时里的思考和练习,可能有不当之处,请博客园的园友们多提宝贵意见。
知识共享许可协议本作品采用知识共享署名-非商业性使用-相同方式共享 2.5 中国大陆许可协议进行许可。

分类:  C#Algorithm
标签:  A*寻路算法
23
(请您对文章做出评价)
« 上一篇: 共享一下我的博客皮肤
» 下一篇: 解决服务器返回JSON数据中文乱码问题
posted @  2011-05-26 15:57  Create Chen 阅读( 42547) 评论( 46编辑  收藏
  
#1楼   2011-05-26 17:41  类菌体   
呵呵,不错,记得去年暑假深蓝的右手来我们实验室指导我们silverlight,那时接触到,不过时隔蛮久了,谢谢再次看到...
  
#2楼 [ 楼主2011-05-26 17:43  Create Chen   
@类菌体
呵呵, 有看深蓝色右手的那篇关于A*的博客,不过没写算法过程. 只是在里面有个关于A*算法的链接,我就点进去学习了一下~
  
#3楼   2011-05-26 18:05  winter-cn   
H值讲的少了些 H值是区分A*与A的关键 这个挺重要的
  
#4楼   2011-05-26 19:34  卡索   
这个算法中其实会存在有两条路径长度一样的情况,原文中并未对为什么要选择下面的这(54 14 40)一组做全面的解释,其实也蛮期待说来说说这个//
  
#5楼 [ 楼主2011-05-26 21:02  Create Chen   
@winter-cn
实际上一开始看原文我也没看这个H值究竟是怎么计算的,不过看了图中每个方格的H值,再看看文字,就晓得了.描述这个H值如何计算,最好的还是图片~
  
#6楼 [ 楼主2011-05-26 21:04  Create Chen   
@卡索
我的程序中每次给这个"开启列表"按照F值排序,然后取列表第一个就是那个F值最小的.
至于文章中这个两个F都是54情况,我不关心,我只管取列表中的第一个元素
  
#7楼   2011-05-26 22:09  winter-cn   
@Create Chen
那个原文的H值描述是错的 所以我很在意
A*算法的要求是H值必须恒小于实际路径长,可以用数学方法证明这样的H值可以保证最终能找到最短路径
原文描述的曼哈顿算法显然不行的
  
#8楼 [ 楼主2011-05-26 22:21  Create Chen   
@winter-cn
原文中说H有好多种算法,它只举例的是曼哈顿算法
那么怎么计算H比较好呢?也允许沿斜方向移动吗?
  
#9楼   2011-05-26 22:39  winter-cn   
@Create Chen
曼哈顿算法是错的 用曼哈顿算法不一定能得到最优路径 只能叫A搜索 不能叫A*搜索
A*本身不限制H使用的估计算法 如max(dx,dy)、sqrt(dx*dx+dy*dy)、min(dx,dy)*(0.414)+max(dx+dy)这些都可以(可惜曼哈顿算法dx+dy不在此列),像我前面说的,只要你能保证H值恒小于实际路径长,A*就是成立的。你甚至可以取一个常数0,这样A*就退化为广搜了
  
#10楼 [ 楼主2011-05-26 22:58  Create Chen   
@winter-cn
上回我下载了深蓝色右手这篇日志http://www.cnblogs.com/alamiye010/archive/2009/06/17/1505339.html里说到的http://www.codeguru.com/csharp/csharp/cs_misc/designtechniques/article.php/c12527/这个工具.
比如下图, 我发现不管我选何种算法, 寻找出来的路径却不是最短路径. 我甚至还怀疑A*能不能找到最短路径...
  
#11楼   2011-05-26 23:37  winter-cn   
@Create Chen
A*当然能找到最优值了 找不到最优值的不叫A*
他的代码写错了 不是估值函数的问题
  
#12楼   2011-05-26 23:43  winter-cn   
你参考一下吧 JS写的 看算法还是比较容易的
http://bbs.51js.com/viewthread.php?tid=77031&highlight=
  
#13楼   2011-05-27 09:47  懒人工具[未注册用户]
不错,看看<a href=" http://www***">懒人工具</a>
  
#14楼 [ 楼主2011-05-27 15:40  Create Chen   
@winter-cn
恩,谢谢.
我已发现了程序哪里的问题了,原来的程序貌似节点一放进"关闭列表"后,它的父节点,估价值以后都不会变了...也没有再次翻身到"开启列表"的机会:)
对于H值用这种"曼哈顿"式的方法确实有点不妥.如果改为允许沿斜方向移动的话,其实就相当于用"欧几里德距离"做估值,应该是可以保证恒小于等于实际步长的
  
#15楼   2011-05-27 18:41  winter-cn   
@Create Chen
关闭列表没有翻身到"开启列表"的机会貌似没错啊 它的问题貌似是当一个节点同时能几个开启节点扩展时,应当选择G小的那个方式

欧几里德距离是对的 不就是我写的那个sqrt(dx*dx+dy*dy)
  
#16楼 [ 楼主2011-05-27 18:57  Create Chen   
@winter-cn
我在 百度百科里看到了这么一段:
引用 if(X inCLOSE) { 
  if( X的估价值小于CLOSE表的估价值 ){ 
  把n设置为X的父亲; 
  更新CLOSE表中的估价值; 
  把X节点放入OPEN //取最小路径的估价值 
  }
  
#17楼   2011-05-29 23:20  winter-cn   
@Create Chen
这个啊 这个情况发生在A*正统的模型下面 你在图论里的"图"上做A*
可能会出现这种情况
  
#18楼   2011-05-30 17:51  玩玩樂樂   
哥们 能帮我分析一下么? 
我现在递归哪里的时候总是报错 
IList<CompassDirections> allCompassDirections = CompassDirectionsHelper.GetAllCompassDirections();

“System.StackOverflowException”类型的未经处理的异常出现在 说我无限循环,无限递归,一直都没找到错误在哪里,不知道你们遇到过没?
  
#19楼 [ 楼主2011-05-30 18:00  Create Chen   
@玩玩樂樂
引用 CompassDirectionsHelper.GetAllCompassDirections();

你应该很有必要把这个方法贴出来吧.
  
#20楼   2011-05-30 18:48  玩玩樂樂   

这里,我不知道到底是我调用的时传过去的点有问题,还是怎么样!~
  
#21楼   2011-05-30 18:49  玩玩樂樂   
  
#22楼   2011-05-30 18:49  玩玩樂樂   
额~ 发代码发不上去了......
  
#23楼   2011-05-30 20:13  玩玩樂樂   

这个图片的点 195,7 和61, 149 这两个点 白色的区域为障碍物, 为什么我调用您的方法之后 还是判断出有连接呢???
  
#24楼 [ 楼主2011-05-30 21:37  Create Chen   
@玩玩樂樂
这个图什么意思?
我的方法使用的迷宫,迷宫四周围应该都是墙.
  
#25楼   2011-05-31 09:51  玩玩樂樂   
这是我随便的画的图啊!~ 按理说, 如果是寻路的话, 195,7 和61,149 这两个点之间是不存在路的啊! 但是调用方法之后居然告诉我存在连接........
所有的白色d区域都算是墙
  
#26楼 [ 楼主2011-05-31 10:01  Create Chen   
@玩玩樂樂
你把你的构造迷宫的0101那块发来,我来调试看看.
  
#27楼   2011-05-31 10:17  玩玩樂樂   
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int [,] array =  new  int [bitmap.Width, bitmap.Height];
for  ( int  i = 0; i < bitmap.Width; i++)
{
     for  ( int  j = 0; j < bitmap.Height; j++)
     {
         Color GetColor = bitmap.GetPixel(i, j);
         float  GetBrightnes = GetColor.GetBrightness() * 240;
         if  (GetBrightnes > 100)
         {
             array[i, j] = 1;
         }
         else
         {
             array[i, j] = 0;
         }
     }
}
Maze maze =  new  Maze(array);
  
#28楼 [ 楼主2011-05-31 10:35  Create Chen   
@玩玩樂樂
是这样的, 你需要保证你构造出来的迷宫上下左右都是墙,如我用1代表的是墙,我构造了一个迷宫:
1
2
3
4
5
6
7
8
9
10
int [,] array = {
                { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
                { 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1},
                { 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1},
                { 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1},
                { 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1},
                { 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1},
                { 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1},
                { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}
                };
  
#29楼   2011-05-31 10:46  玩玩樂樂   
那可以这样不,我给array的 arrar[0,-1]=1 到arrar[9999,-1]=1(假如宽是9999) 我这样给赋值可以不? 然后其他的边也给赋值为1
  
#30楼 [ 楼主2011-05-31 10:52  Create Chen   
@玩玩樂樂
你可以反过来思考,也就是你要构造的迷宫比原来构造的长和宽都大两个单位,初始化的时候,每个单位都是1(墙).
然后从数组的[1,1]开始到[length-1,length-1],把需要将1改成0的改变一下~
这样应该是可以的.
  
#31楼   2012-06-29 13:13  blowing00   
这个算法会走进死路。

如果绿色方块的上面、右上、下面、右下也都是障碍物,那么按照算法,第一步会走到C,之后无路可走。
遇到这种情况如何处理?
  
#32楼   2013-05-04 05:44  zjWang   
@blowing00
你好!不会走入思路,因为如果是你描述的情况,第一步open list里面会有C点和起点左边的三个点。检查C点时没有点被更新或者加入open list,所以C点就直接被放入了close list并且没有节点的父节点被更新为C点(也就是C点不会被当做路径)。后面的就是遍历剩下的三个点了,就不赘述了。所以说不会进入死胡同,因为open list的其他节点还要继续遍历。
  
#33楼   2013-06-18 11:13  龙马8586   
楼主代码有点问题,判断绊脚的地方需要有方向判断, 你试一下你的代码
Point start = new Point(4, 10);
Point end = new Point(2, 10);
运行结果就有绊脚的...
  
#34楼   2013-09-05 14:53  挥剑对风尘   

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int [,] mapData =  new  int [12, 18]{
{ 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5 },
{ 5, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5 },
{ 5, 0, 0, 0, 0, 0, 3, 3, 0, 0, 3, 0, 0, 0, 0, 0, 0, 5 },
{ 5, 0, 0, 2, 2, 0, 0, 3, 3, 3, 3, 0, 0, 2, 2, 0, 0, 5 },
{ 5, 0, 0, 2, 2, 0, 0, 0, 0, 0, 3, 3, 3, 2, 2, 0, 0, 5 },
{ 5, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 5 },
{ 5, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 5 },
{ 5, 0, 0, 2, 2, 3, 3, 3, 0, 0, 0, 0, 0, 2, 2, 0, 0, 5 },
{ 5, 0, 0, 2, 2, 0, 0, 3, 3, 3, 3, 0, 0, 2, 2, 0, 0, 5 },
{ 5, 0, 0, 0, 0, 0, 0, 3, 0, 0, 3, 3, 0, 0, 0, 0, 0, 5 },
{ 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 5 },
{ 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5 }
};
 
Maze maze =  new  Maze(mapData);
Point start =  new  Point(1, 1);
Point end =  new  Point(9, 9);
var  parent = maze.FindPath(start, end,  false );

为什么找不到路径了?
  
#35楼   2013-09-16 17:41  OctoberS   
好喜欢博主的排版哦 赞一个 ... 复制了也是无法替代的 顶个 ...
  
#36楼   2013-12-06 12:59  Learning hard   
思路非常好,排版也非常好,不得不赞一个
  
#37楼   2014-04-02 18:30  欢乐侠   
博主,那个每个节点中的G和H的值是在何时计算的,是在确定目标以后,就把整个列表总的每个节点中的G值和H值都计算出来了吗?还是在程序中动态计算出来的?
  
#38楼   2014-04-15 15:59  lika   
楼主您好,感谢您这么详细的讲解A*算法,并附上详细的代码,在这里我想指出一点代码中的错误,
1
2
3
4
5
public  static  Point MinPoint( this  List<Point> points)
     {
         points = points.OrderBy(p => p.F).ToList();
         return  points[0];
     }

这里根据实验并没有将排序后的list赋值给原list导致后面RemoveAt(0)时删除了不该删除的节点,于是在某些特定情况下无法找到想要找到路径。

另外由于本人水平有限(C#一日速成党,该死的unity)
1
2
3
4
5
6
7
8
9
10
private  void  FoundPoint(Point tempStart, Point point)
         {
             var  G = CalcG(tempStart, point);
             if  (G < point.G)
             {
                 point.ParentPoint = tempStart;
                 point.G = G;
                 point.CalcF();
             }
         }

这里很不理解point在函数呼出时并没有把list中对应x,y的那个Point对象赋值给函数,但是为什么这里point却能直接取G?surroundPoints中new 的Point对象应该都只填入x,y才对啊?谢谢
  
#39楼   2014-12-01 19:55  AlanYume   
@Create Chen 【回复10楼】
九楼解释的很清楚了,因为还存在斜对角线做法,所以令H=dx+dy并不一定是永远小于实际路径长度。这用计算出来的F值是偏大的。在结果偏大的前提下,第一次搜索到目的点,就提前终止了搜索,那么最终求得的当然不能算是最短距离咯!

"曼哈顿算法是错的 用曼哈顿算法不一定能得到最优路径 只能叫A搜索 不能叫A*搜索
A*本身不限制H使用的估计算法 如max(dx,dy)、sqrt(dx*dx+dy*dy)、min(dx,dy)*(0.414)+max(dx+dy)这些都可以(可惜曼哈顿算法dx+dy不在此列),像我前面说的,只要你能保证H值恒小于实际路径长,A*就是成立的。你甚至可以取一个常数0,这样A*就退化为广搜了"-by winter-cn

感谢 winter-cn的提醒,差点错过了对A*算法核心(H函数)的理解,3Q:)
  
#40楼   2014-12-12 16:59  融于自然   
楼主,首先非常感谢您提供A*的算法及代码,我在学习当中发现代码中的两个问题
1:CanReach函数关于“绊脚”的判断,“if (CanReach(Math.Abs(x - 1), y) && CanReach(x, Math.Abs(y - 1)))”是不是有问题,这样无法确定当前格左上,左下,右上,右下这四个相邻格是否能访问,以下这个判断应该可行“if(CanReach(Math.Abs(point.x + (x - point.x)), point.y) && CanReach(point.x, Math.Abs(point.y + (y - Point.y))));
2:第二次while循环中,函数FoundPoint的参数分别为C,D时,函数CalcG中parentG中,C和D的父节点均为起点,起点的G值为0,会导致FoundPoint函数产生错误。直接使用第二个参数的G值Point.G就可以了。
  
#41楼   2014-12-12 17:01  融于自然   
上面最后一点笔误了,应该是直接使用第一个参数的G值:start.G
  
#42楼   2015-03-20 15:40  vinker   
楼主, 似乎有几处小问题, 
1. CalcG 函数里面应该是 
int G = (Math.Abs(point.X - start.X) + Math.Abs(point.Y - start.Y)) == 1 ? STEP : OBLIQUE;

2. CanReach 函数里里面判断绊脚情况应该是:
if (CanReach(start.x, y) && CanReach(x, start.y))
return true;
else
return IsIgnoreCorner;
学习了。。。。。 ^_^
  
#43楼   2015-08-25 13:32  liushaofeng.cn   
@blowing00
这个在贪吃蛇的游戏中有体现,这种情况属于陷阱,解决办法就是,先判断,如果走了下一步之后,蛇头任然能够找到蛇尾,那么就可以走这个,否则,就不行!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值