当前位置:   article > 正文

A*寻路算法之解决路径多拐点问题

A*寻路算法之解决路径多拐点问题

1.问题描述

最近公司正在开发的游戏涉及到了寻路算法,然后我从网上找了一份A*算法代码,整理了一下写了一个A*算法基础实现。然而,在真正实用时A*寻路时,却发现了几个问题:

  1. 基础实现版的A*寻路算法在大地图的搜索上,耗时较长;

  2. 使用最小堆实现的OpenList来优化A*算法后,发现最后得到的路径往往是S型的;

然后策划看到效果后,提出了两点要求:1)寻路的路径中,拐点必须最少;2)存在多条路径时,必须优先走最快碰到拐点的路径。

稍微解释一下上面的两个需求:假如出发点和目的点是"日"字型时,可经过上中下三条路径到达目的地。上下两条路径的拐点是1个,而中间路径的拐点是2个,淘汰中间路径。另外,上路走一步碰到拐点,而下路走两步碰到拐点,那么优先选择上路。

1

2. A*算法基本实现及优化

假设看这篇文章的朋友,都是已经了解A*算法的。如果还不了解A*算法,可以先百度一下,网上介绍A*算法的文章很多。

从网上找了一个A*算法的JAVA版实现,经过整理后实现如下:

  1. public class AStar {
  2. /** 地图 */
  3. private GameMap map;
  4. private ArrayList<Node> openList = new ArrayList<>();
  5. private ArrayList<Node> closeList = new ArrayList<>();
  6. public AStar(GameMap map) {
  7. this.map = map;
  8. }
  9. public Node findPath(Node startNode, Node endNode) {
  10. // 把起点加入 open list
  11. openList.add(startNode);
  12. while (openList.size() > 0) {
  13. // 遍历 open list ,查找 F值最小的节点,把它作为当前要处理的节点
  14. Node currNode = findMinFNodeInOpenList();
  15. // 从open list中移除
  16. openList.remove(currNode);
  17. // 把这个节点移到 close list
  18. closeList.add(currNode);
  19. // 查找最小FCost的节点
  20. ArrayList<Node> neighborNodes = findNeighborNodes(currNode);
  21. for (Node nextNode : neighborNodes) {
  22. int gCost = calcNodeCost(currNode, nextNode) + currNode.gCost;
  23. if (exists(openList, nextNode)) {
  24. // 如果新的路径fCost更小,更新nextNode节点
  25. if (gCost < nextNode.gCost) {
  26. nextNode.parent = currNode;
  27. nextNode.gCost = gCost;
  28. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  29. }
  30. } else {
  31. // 计算nextNode节点的fCost
  32. nextNode.parent = currNode;
  33. nextNode.gCost = gCost;
  34. nextNode.hCost = calcNodeCost(nextNode, endNode);
  35. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  36. openList.add(nextNode);
  37. }
  38. }
  39. Node node = find(openList, endNode);
  40. if (node != null) {
  41. return node;
  42. }
  43. }
  44. return find(openList, endNode);
  45. }
  46. public Node findMinFNodeInOpenList() {
  47. Node tempNode = openList.get(0);
  48. for (Node node : openList) {
  49. if (node.fCost < tempNode.fCost) {
  50. tempNode = node;
  51. }
  52. }
  53. return tempNode;
  54. }
  55. public ArrayList<Node> findNeighborNodes(Node currentNode) {
  56. ArrayList<Node> arrayList = new ArrayList<Node>();
  57. addNode(arrayList, currentNode.x, currentNode.y - 1);
  58. addNode(arrayList, currentNode.x, currentNode.y + 1);
  59. addNode(arrayList, currentNode.x - 1, currentNode.y);
  60. addNode(arrayList, currentNode.x + 1, currentNode.y);
  61. return arrayList;
  62. }
  63. private void addNode(ArrayList<Node> arrayList, int x, int y) {
  64. if (map.canReach(x, y) && !exists(closeList, x, y)) {
  65. arrayList.add(new Node(x, y));
  66. }
  67. }
  68. private int calcNodeCost(Node node1, Node node2) {
  69. return abs(node2.x - node1.x) + abs(node2.y - node1.y);
  70. }
  71. public int abs(final int x) {
  72. final int i = x >>> 31;
  73. return (x ^ (~i + 1)) + i;
  74. }
  75. public static Node find(List<Node> nodes, Node point) {
  76. for (Node n : nodes)
  77. if ((n.x == point.x) && (n.y == point.y)) {
  78. return n;
  79. }
  80. return null;
  81. }
  82. public static boolean exists(List<Node> nodes, Node node) {
  83. for (Node n : nodes) {
  84. if ((n.x == node.x) && (n.y == node.y)) {
  85. return true;
  86. }
  87. }
  88. return false;
  89. }
  90. public static boolean exists(List<Node> nodes, int x, int y) {
  91. for (Node n : nodes) {
  92. if ((n.x == x) && (n.y == y)) {
  93. return true;
  94. }
  95. }
  96. return false;
  97. }
  98. }

上面的代码实现了A*算法的逻辑,但是在200*200的地图上测试时,从(0,0)点走到(199,199)寻路一次通常要几秒到十几秒。

那么,我们先分析一下代码的性能为什么那么低。主要有两点:

1)  findMinFNodeInOpenList() 方法需要遍历 open list ,查找 F值最小的节点,该方法的时间复杂度是O(n)。虽然该方法时间复杂度是线性的,但是每次检查一个节点时,都会执行一次该方法。

2) 添加新节点或者更新下一个节点时,都会调用一次exists()方法判断节点是否在openList或者closeList中。该方法同样是线性的,但是添加新节点或者更新下一个节点时,总会调用一次或多次,因此时间复杂度是m*O(n)。

针对上面两个痛点进行优化:首先,在一个队列中获取获取最大/最小的元素,我们通常首先想到的就是最大/小堆,因此,利用优先队列PriorityQueue实现openList,那么每次从openList中查找 最小F值的节点时,时间复杂度将降为O(lg n)。其次,分析上下文发现,closeList仅在添加节点到openList时做去重判断,而没有其他作用,那么可以通过数组标记或者Map存储遍历信息,使时间复杂度达到O(1)。以下是优化后的代码:

  1. public class AStarOptimization {
  2. /** 地图 */
  3. private GameMap map;
  4. private PriorityQueue<Node> newOpenList = new PriorityQueue<>(new Comparator<Node>() {
  5. @Override
  6. public int compare(Node o1, Node o2) {
  7. return o1.fCost - o2.fCost;
  8. }
  9. });
  10. private Set<String> openSet = new HashSet<>();
  11. private Set<String> closeSet = new HashSet<>();
  12. public AStarOptimization(GameMap map) {
  13. this.map = map;
  14. }
  15. public List<Node> findPath() {
  16. return findPath(map.getStartNode(), map.getEndNode());
  17. }
  18. public List<Node> findPath(Node startNode, Node endNode) {
  19. newOpenList.add(startNode);
  20. Node currNode = null;
  21. while ((currNode = newOpenList.poll()) != null) {
  22. removeKey(openSet, currNode.x, currNode.y);
  23. addKey(closeSet, currNode.x, currNode.y);
  24. ArrayList<Node> neighborNodes = findNeighborNodes(currNode);
  25. for (Node nextNode : neighborNodes) {
  26. int gCost = calcNodeCost(currNode, nextNode) + currNode.gCost;
  27. if (contains(openSet, nextNode.x, nextNode.y)) {
  28. if (gCost < nextNode.gCost) {
  29. nextNode.parent = currNode;
  30. nextNode.gCost = gCost;
  31. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  32. }
  33. } else {
  34. nextNode.parent = currNode;
  35. nextNode.gCost = gCost;
  36. nextNode.hCost = calcNodeCost(nextNode, endNode);
  37. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  38. newOpenList.add(nextNode);
  39. addKey(openSet, nextNode.x, nextNode.y);
  40. }
  41. }
  42. if (contains(openSet, endNode.x, endNode.y)) {
  43. Node node = findOpenList(newOpenList, endNode);
  44. return getPathList(node);
  45. }
  46. }
  47. Node node = findOpenList(newOpenList, endNode);
  48. return getPathList(node);
  49. }
  50. public ArrayList<Node> findNeighborNodes(Node currentNode) {
  51. ArrayList<Node> arrayList = new ArrayList<Node>();
  52. addNode(arrayList, currentNode.x, currentNode.y - 1);
  53. addNode(arrayList, currentNode.x, currentNode.y + 1);
  54. addNode(arrayList, currentNode.x - 1, currentNode.y);
  55. addNode(arrayList, currentNode.x + 1, currentNode.y);
  56. return arrayList;
  57. }
  58. private void addNode(ArrayList<Node> arrayList, int x, int y) {
  59. if (map.canReach(x, y) && !contains(closeSet, x, y)) {
  60. arrayList.add(new Node(x, y));
  61. }
  62. }
  63. private int calcNodeCost(Node node1, Node node2) {
  64. return abs(node2.x - node1.x) + abs(node2.y - node1.y);
  65. }
  66. private Node findOpenList(PriorityQueue<Node> nodes, Node point) {
  67. for (Node n : nodes) {
  68. if ((n.x == point.x) && (n.y == point.y)) {
  69. return n;
  70. }
  71. }
  72. return null;
  73. }
  74. public List<Node> getPathList(Node parent) {
  75. List<Node> list = new ArrayList<>();
  76. while (parent != null) {
  77. list.add(new Node(parent.x, parent.y));
  78. parent = parent.parent;
  79. }
  80. return list;
  81. }
  82. public int abs(final int x) {
  83. final int i = x >>> 31;
  84. return (x ^ (~i + 1)) + i;
  85. }
  86. private void addKey(Set<String> set, int x, int y) {
  87. set.add(getKey(x, y));
  88. }
  89. private void removeKey(Set<String> set, int x, int y) {
  90. set.remove(getKey(x, y));
  91. }
  92. private boolean contains(Set<String> set, int x, int y) {
  93. return set.contains(getKey(x, y));
  94. }
  95. private String getKey(int x, int y) {
  96. StringBuilder sb = new StringBuilder();
  97. sb.append(x).append('_').append(y);
  98. return sb.toString();
  99. }
  100. }

优化后的代码在200*200的地图上测试,在障碍物比例为0.3时(200 * 200 * 0.3),从(0,0)点走到(199,199)寻路一次基本在30ms~50ms。

3. 多拐点问题

由于基于最小堆实现的优先队列是不稳定,在多个具有相同F值的节点中选择最小节点的顺序是无法保证的。因此,以上优化后的代码走出来的路径通常都是S型的,也就是存在拐点过多的问题。

是否使用基础版本的A*算法是否可以消除多拐点的问题呢?答案是否定的,基础版本的A*算法是按照一定的方向顺序添加节点,在相同F值时,获取节点也是按照相同方向顺序获取节点,这在空旷的地形中是不会出现S型路径。但是,当路径上有障碍物时,按照顺序添加节点然后按照顺序取节点的方法,就会出现走S型路径的问题。

例如,如图所示情形,当从A点出发移动到B, A*算法按照上下左右的方向顺序添加节点,A从绿色路径移动到B;相反,如果从B移动到A点,A*算法会向下走到A点,但是沿途有障碍物,于是就形成了S型路径。

2 

那么,基于最小堆实现的openList,如何杜绝路径多拐点呢?路径是算法执行过程中动态生成,生成或有路径去选择一条是不可能。最好的办法是,根据路径的特点调整路径上拐点的F值。

A*算法是利用启发函数:F(n) = G(n) + H(n),来确定每个点的F值,采用贪心策略选择F值最小的点作为下一个待更新节点,直到找到终点。其中G(n)表示从起始点走到该点的代价,H(n)估算从该点走到终点的代价。通常G(n)采用走到该点所用的步数来表示,而H(n)使用该点到终点的距离来表示。

从启发函数来看,A*算法对于路径的特点根本没有做任何要求,只要是最小F值的节点都可以加入路径当中。因此,我们可以在启发函数中加入对节点的路径特征的评判,使算法选择的最终结果符合我们的预期。基于此目的,修改启发函数:

F(n) = G(n) + H(n) + E(n)

其中E(n)表示加入该节点后,对路径的评分进行的微调。话不多说,先上代码:

  1. public List<Node> findPath(Node startNode, Node endNode) {
  2. newOpenList.add(startNode);
  3. Node currNode = null;
  4. while ((currNode = newOpenList.poll()) != null) {
  5. removeKey(openSet, currNode.x, currNode.y);
  6. addKey(closeSet, currNode.x, currNode.y);
  7. ArrayList<Node> neighborNodes = findNeighborNodes(currNode);
  8. for (Node nextNode : neighborNodes) {
  9. // G + H + E
  10. int gCost = 10 * calcNodeCost(currNode, nextNode) + currNode.gCost
  11. + calcNodeExtraCost(currNode, nextNode, endNode);
  12. if (contains(openSet, nextNode.x, nextNode.y)) {
  13. if (gCost < nextNode.gCost) {
  14. nextNode.parent = currNode;
  15. nextNode.gCost = gCost;
  16. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  17. }
  18. } else {
  19. nextNode.parent = currNode;
  20. nextNode.gCost = gCost;
  21. nextNode.hCost = 10 * calcNodeCost(nextNode, endNode);
  22. nextNode.fCost = nextNode.gCost + nextNode.hCost;
  23. newOpenList.add(nextNode);
  24. addKey(openSet, nextNode.x, nextNode.y);
  25. }
  26. }
  27. if (contains(openSet, endNode.x, endNode.y)) {
  28. Node node = findOpenList(newOpenList, endNode);
  29. return getPathList(node);
  30. }
  31. }
  32. Node node = findOpenList(newOpenList, endNode);
  33. return getPathList(node);
  34. }
  35. private int calcNodeExtraCost(Node currNode, Node nextNode, Node endNode) {
  36. // 第一个点或直线点
  37. if (currNode.parent == null || nextNode.x == currNode.parent.x
  38. || nextNode.y == currNode.parent.y) {
  39. return 0;
  40. }
  41. // 拐向终点的点
  42. if (nextNode.x == endNode.x || nextNode.y == endNode.y) {
  43. return 1;
  44. }
  45. // 普通拐点
  46. return 2;
  47. }

代码的终点是calcNodeExtraCost()方法,方法中判断如果nextNode和之前的节点是保持直线的,那么E值为0,否则如果是一个拐点的话,E值将大于0,并且这个E值会和G值存在一起,作为新的G值。

A*路径多拐点的问题暂时写到这里了,之后再补上后面的内容。

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/502084
推荐阅读
相关标签
  

闽ICP备14008679号