65.9K
CodeProject 正在变化。 阅读更多。
Home

A*:感谢 Seunghyop Back 的介绍

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (12投票s)

2014年7月31日

CPOL

6分钟阅读

viewsIcon

13303

downloadIcon

4

修改现有 A* 实现的尝试和学习成果。

致谢与介绍

我写这篇文章是为了回应 seunghyop back 之前发表的一篇文章《Very simple A* algorithm implementation》。我更喜欢直接的沟通方式,但一直找不到直接向 Code Project 用户发送消息并交换源代码的方法,所以希望通过撰写文章来按预期使用此网站。本文无意批评,而是表达感谢。我最初并没有打算分享这些修改,但后来觉得与其让它们闲置在硬盘上,不如分享出来。我希望我的做法符合所有适用的礼仪。我认为这些修改主要是建议性的,属于个人品味问题。我无意在此与原代码展开竞争。

背景

说来惭愧,尽管我从事软件工程师多年,直到最近才熟悉 A*。根据我这段时间所了解到的,A* 至今仍有广泛的应用,并且在许多流行的游戏大作中都有使用,这只是其中一个例子。我一直在寻找一种最快的 A* 入门方法,以及一个可以用来进行实验的可用实现,以确保我能完全理解其算法。sounghyop 的文章是我偶然发现的。在没有具体目标的情况下(这里故意用了“寻路”的双关语),我开始尝试修改提供的源代码,并做了一些小的改动。

对原代码的修改

以下是我进行的一些修改列表,顺序不分先后,不保证完整性。

我发现了将容器“SortedCostNodeList”替换为 .Net 的 SortedSet 的机会。这里的一个挑战是,节点需要按照 A* 定义的成本进行排序,因此传递给 SortedSet 的 IComparer 实现应该根据该属性区分节点。但是,SortedSet 会忽略它认为值相等的多个元素的插入,而 A* 实现则必须允许为给定的地图位置存在多个节点,每个节点具有不同的成本。这是因为 A* 在生成到目标节点的路径时,可以生成多个可能的路径,这些路径有时会在同一位置相交。如果 NodeComparer 没有被修改,算法很可能会陷入死胡同,因为未经修改的集合会丢弃那些与列表中“开放”(尚未完全探索)的先前节点相等的节点。

我希望上面对以下修改的解释是有道理的。如果没有,请尝试使用带有旧 IComparer 实现的新实现,运行一些示例,我相信您很快就会遇到算法在障碍物前结束路径,无法绕行的示例。

class NodeComparer : IComparer<Node>
{
    int IComparer<Node>.Compare(Node n1, Node n2)
    {
        if (n1.TotalCost == n2.TotalCost)
        {
            if (n1.X == n2.X && n1.Y == n2.Y)
            {
                return 0;
            }

            // Allow multiple nodes of equal cost but different coordinates.
            return -1;
        }

        return n1.TotalCost - n2.TotalCost;
    }
}

我之所以给出如此详细的解释,部分原因在于 NodeComparer 的这项更改现在允许将大约 75 行代码的原始“SortedCostNodeList”简单地替换为以下内容:

public class NodeSet : SortedSet<Node>
{
    public NodeSet() : base(new NodeComparer()) { }
}

 

SortedSet 提供了 A* 所需的所有操作和属性,并保证了它们的可用性。我选择这个集合是因为它似乎为简化现有代码提供了最简单的接口。我试图简化算法的编码,并且在此不考虑或讨论渐进运行时复杂度。尽管如此,我认为可以通过过滤节点集中的候选节点,只保留那些成本较低的节点,来改进当前确定优于当前后继节点(或相邻节点)的线性查找。节点集已经按节点成本排序,因此可以避免遍历整个节点迭代器。特别是,“GetViewBetween”这个方法,我还没有花时间研究它可能的优点。

通过这些修改、一些重构以及少量使用 Linq 来提高对集合操作的表达能力,A* 实现的顶层被简化为以下内容:

public static Node FindPath(Location start, Location goal)
{
    Node goalNode = new Node(goal);
    Node startNode = new Node(start, goalNode);

    NodeSet open = new NodeSet() {startNode};
    NodeSet closed = new NodeSet();

    while (open.Any())
    {
        Node best = open.Min;
        open.Remove(best);

        if (best.Coincides(goalNode))
        {
            goalNode.Parent = best.Parent;
            break;
        }

        foreach (Node successor in best.ConnectedNodes())
        {
            Node betterOpen = open.NodesBetterThan(successor);
            if (betterOpen != null)
            {
                continue;
            }

            Node betterClosed = closed.NodesBetterThan(successor);
            if (betterClosed != null)
            {
                continue;
            }

            if (betterOpen != null)
            {
                open.Remove(betterOpen);
            }

            if (betterClosed != null)
            {
                closed.Remove(betterClosed);
            }

            if (betterClosed == null && betterOpen == null)
            {
                open.Add(successor);
            }
        }

        closed.Add(best);
    }

    return goalNode;
}

与原版相比,我保留了主要的代码流程。我想将尽可能多的非 A* 算法核心代码移出主方法,希望能让其机制更加清晰易懂。

根据我的经验,Linq 代码很容易占据总运行时时间的很大一部分,因此需要特别小心,尤其是在此类算法的主循环中使用它时。我没有对这个实现进行性能分析,所以不知道它实际占用了多少时间,但在这里使用它,是因为我觉得通过使用 Linq,算法的某些操作可以以特别富有表现力和简洁的方式实现,例如以下操作:

public static class NodeSetExtensions
{
    public static Node NodesBetterThan(this NodeSet set, Node node)
    {
        return set.Where(n => node.TotalCost > n.TotalCost && n.Coincides(node)).FirstOrDefault();
    }
}

上面的代码实现为一个扩展方法,因为我认为“所有优于给定节点的节点”这个概念,作为节点集的属性比作为节点集上的独立操作(可以实现为常规静态方法)更清晰地表达。请参考上面 A* 主循环的代码,看看它的用法,并比较如果它是一个常规方法而不是扩展方法,代码会有什么样的“感觉”。

还有其他修改。大多数都是实现了等效的功能。据我回忆,只有一个修改是为了方便:您现在可以将起点和终点“绘制”到地图上。如果这样做,实际上会导致一个我迄今为止尚未处理的异常。

使用代码

绘制地图以查看 A* 的工作原理!地图现在是一个字符串数组,所以您不再需要到处设置逗号。示例如下:

static string[] MapData = 
{
"    #######################              #####                      ",
"                  ####             #############  #####     ",
"     ####                #####        ##########                   ",
"     S###   ######            ##      ##############                   ",
"     ########    #                 #############                   ",
"            # #  #########                                        ",
"            # #        ###    ###   ###### ######                   ",
"            #G########  ##            #### ##########                 ",
"            #####      ###    ###########  ####                         ",
"             #        ############  ###                          ",
"             #                                    ",
};

在上面,“S”代表“Start”(不是“sucks”),“G”代表“Goal”。最终得到的路径如下:

    #######################              #####
     oooo     o   ####             #############  #####
    o####oo oo ooo       #####oo      ##########
     o###  o######o  oo oo  oo##o     ##############
     ######## oo # oo  o  oo     o #############
            #o# o#########        oooooooo
            #o#  ooooo ###    ###   ######o######
            #o########o ##            ####o##########
            #####     o###    ###########o ####
             #       o############  ### o
             #        oooooooooooooooooo
注意:我注意到上面的地图可能不会以等宽字体显示,即使我使用了“pre”标签。这可能会使地图和路径难以辨认。如果发生这种情况,请考虑将上面的内容粘贴到具有等宽字体的编辑器中。

关注点

我亲身体验了 A* 据称会产生的曲折路径,这很有趣。对于那些比我更熟悉 A* 的人来说,这可能显而易见,但看到具体的例子比单独在脑海中推测这些情况要直观得多。如果您看上面的例子,可以看到它们很清楚,主要是在障碍物周围。

理解这些曲折路径不会影响路径的总长度很重要。如果您将每个曲折路径与完成直线所需的节点数进行比较,您会满意地发现每种情况下的节点数都是相同的。上面例子中尤其是在路径中间的部分,乍一看似乎是一个完整的绕行。然而,如果路径是通往右边“狭窄走廊入口”的直线,那么这四个向右上方斜向插入的步骤是必需的。如果我们不允许对角线移动(这可以通过限制“Node::ConnectedNodes”方法很容易做到),A* 会在探索到目标节点的路径时,放弃这些路径,转而选择更短、更直的路径。

© . All rights reserved.