本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:A 寻路算法是一种在游戏开发、地图导航和机器人路径规划中广泛应用的高效路径搜索算法,结合了Dijkstra算法的最优性与BFS的高效性,并通过启发式函数指导搜索方向,以最小化总路径成本。本文介绍基于C#语言实现的A 算法源码,涵盖节点表示、图结构构建、开放/关闭列表管理及路径回溯等核心逻辑,适用于Unity3D游戏开发及各类路径规划场景。通过本源码学习,开发者可深入理解A*算法的工作机制,掌握优先队列优化、邻接结构设计等关键技术,提升实际项目中的路径搜索实现能力。

A*寻路算法:从理论到Unity实战的深度解析

在现代游戏开发与智能系统中,路径规划早已不再是“绕开障碍走过去”这么简单。无论是《星际争霸》里千军万马精准调度,还是自动驾驶汽车实时避障,背后都离不开一套高效、可靠的寻路机制。而在这片技术丛林中, A*(A星)算法 就像一把被反复打磨的瑞士军刀——它不一定是最快的,但足够聪明、足够稳健,能在绝大多数场景下给出最优解。

那问题来了:为什么是A*?Dijkstra不是也能找最短路吗?BFS不行吗?答案其实藏在一个词里: 启发式搜索 。A*之所以强大,是因为它既不像广度优先那样盲目扫描整个地图,也不像纯贪心那样容易掉进局部陷阱。它的核心智慧在于用一个“预估函数”来引导方向,让搜索过程像有经验的老猎人一样直奔目标,同时又保留回溯纠错的能力,确保最终找到的是真正的最短路径。

听起来很玄乎?别急,咱们一步步拆开看。先从那个决定一切的公式说起:

$$ f(n) = g(n) + h(n) $$

这行数学表达式就是A*的灵魂。其中:
- $ g(n) $ 是你已经走过的代价,比如从起点走到当前位置花了多少步;
- $ h(n) $ 是你对未来路程的估计,也就是“我觉得离终点还有多远”;
- 而 $ f(n) $ 就是两者的总和,代表这个节点的综合吸引力。

每次我们都会选择 $ f $ 值最小的那个节点继续探索,这就像是在说:“现在哪个地方看起来离目标最近且代价最低,我就往哪儿走。”
是不是有点像你在陌生城市打车时跟司机说:“走最快也最便宜的路线”?

当然啦,光有想法不够,还得能落地。接下来我们就看看怎么把这套逻辑变成代码,让它真正在程序里跑起来 🚀


数据结构设计:为A*搭建骨架

要让A*动起来,首先得给它准备合适的“零件”。这些零件不仅要准确描述世界的状态,还得支持高效的查询和更新。毕竟,没人希望自己的游戏角色卡着顿一顿地挪步吧?

节点类的设计:不只是坐标那么简单

在A*的世界里,每一个可行走的位置都被抽象成一个“节点”。但你以为这只是个 (x, y) 点就完事了?错!它是承载状态流转的核心容器。

来看一个典型的 Node 类定义(以C#为例):

public class Node
{
    public int X { get; set; }
    public int Y { get; set; }

    // 唯一标识符,用于快速查找
    public string Id => $"{X},{Y}";

    // 实际已发生的路径成本
    public float G { get; set; } = float.PositiveInfinity;

    // 到目标的启发式估值
    public float H { get; set; } = 0;

    // 综合评估值
    public float F => G + H;

    // 回溯用的父节点引用
    public Node Parent { get; set; }

    public Node(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override bool Equals(object obj) =>
        obj is Node n && X == n.X && Y == n.Y;

    public override int GetHashCode() => HashCode.Combine(X, Y);
}

💡 小贴士 G 初始化为正无穷大是个关键技巧!这意味着只要第一次到达该节点,任何有限值都能成功“松弛”它,从而保证我们始终记录的是当前已知的最小路径成本。

你可能会问:为啥要用字符串 "x,y" 当ID?因为哈希表查起来快啊!想象一下你要在一个百万级的地图上反复判断某个点是否处理过,用字符串键比遍历列表快太多了 ✨

而且别忘了重写 Equals GetHashCode ,否则放进 HashSet Dictionary 时会按引用比较,导致相同坐标的节点被视为不同个体——这种bug调试起来能让你怀疑人生 😵‍💫

classDiagram
    class Node {
        +int X
        +int Y
        +string Id
        +float G
        +float H
        +float F
        +Node Parent
        +bool Equals(object)
        +int GetHashCode()
    }

这个简单的类图背后,其实藏着整套算法的状态机模型。每个字段都不是摆设:
- F 决定谁先被扩展;
- G 记录真实开销;
- H 提供方向感;
- Parent 支撑路径重建。

它们共同构成了A*运行时的记忆网络。


g、h、f三兄弟的分工协作

这三个代价值可不是随便算算的,它们各自承担着不同的角色:

字段 含义 特性
$ g(n) $ 实际代价 必须精确计算,反映真实移动成本
$ h(n) $ 预估代价 可以近似,但必须满足“可采纳性”(即不大于真实剩余距离)
$ f(n) $ 综合优先级 决定搜索顺序,越小越优先

举个例子:如果地图允许对角线移动,那么横向/纵向一步的成本是1.0,对角线则是√2 ≈ 1.414。这时候如果你把所有移动都当成1来算,就会高估某些路径的优势,进而影响结果准确性。

至于 $ h(n) $,常见的实现方式有两种:

曼哈顿距离(适合四向移动)

$$ h(n) = |x_1 - x_2| + |y_1 - y_2| $$

适用于只能上下左右走的情况,比如老式RPG游戏里的角色移动。

public static float ManhattanDistance(Node a, Node b)
{
    return Math.Abs(a.X - b.X) + Math.Abs(a.Y - b.Y);
}
欧几里得距离(适合八向或连续空间)

$$ h(n) = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} $$

更适合支持斜向移动或模拟真实物理世界的场景。

public static float EuclideanDistance(Node a, Node b)
{
    int dx = a.X - b.X;
    int dy = a.Y - b.Y;
    return (float)Math.Sqrt(dx * dx + dy * dy);
}

⚠️ 注意事项:欧氏距离虽然更准,但平方根运算耗时较高。性能敏感场合可以用“切比雪夫距离”或提前缓存常用值做优化。

下面这张对比图直观展示了不同启发函数对搜索范围的影响:

barChart
    title 启发式函数对搜索效率的影响
    x-axis 启发式类型
    y-axis 搜索节点数量(相对值)
    series A*: [100, 60, 40]
    A* : ["Dijkstra (h=0)", "Manhattan", "Euclidean"]

看到没?使用欧几里得启发式时,搜索节点数直接砍掉六成!这就是“聪明”的代价——少走冤枉路 🎯


父节点指针:通往最优路径的时光机

很多人初学A*时会忽略 Parent 的重要性,直到发现根本没法还原路径才恍然大悟。其实它的作用非常明确: 记住每一步是谁带来的更好方案

假设你原本是从A→B→C,后来发现A→D→C居然更短。这时就要把C的 Parent 改成D,这样最终回溯时才能正确重建新路径。

路径重构代码如下:

public static List<Node> ReconstructPath(Node node)
{
    var path = new List<Node>();
    while (node != null)
    {
        path.Add(node);
        node = node.Parent;
    }
    path.Reverse(); // 从起点到终点
    return path;
}

时间复杂度只有 $ O(k) $,$ k $ 是路径长度,完全不影响整体性能。但它却是连接局部决策与全局最优之间的桥梁 🌉

sequenceDiagram
    participant SearchEngine
    participant CurrentNode
    participant Neighbor
    participant PathBuilder

    SearchEngine->>CurrentNode: Expand neighbors
    loop For each neighbor
        SearchEngine->>Neighbor: Compute tentative G
        alt Lower than current G?
            Neighbor-->>SearchEngine: Update G and Parent
        end
    end

    SearchEngine->>PathBuilder: Call ReconstructPath(target)
    PathBuilder->>PathBuilder: Traverse Parent links
    PathBuilder-->>SearchEngine: Return reversed path list

你看,整个过程中 Parent 就像是一条不断修正的历史线,确保最后输出的是最佳轨迹。


图结构构建:如何表示这个世界?

再厉害的算法也得有个舞台。对于A*来说,这个舞台就是“图”。但在实际项目中,我们通常面对的是二维网格地图。那问题是:该怎么把这个格子世界转成算法能理解的形式?

使用二维数组建模可行走区域

最常见的做法是将地图划分为规则的网格,每个格子对应一个节点,并用布尔值标记是否可通过。

public enum CellType
{
    Walkable,
    Obstacle
}

public class GridMap
{
    private CellType[,] _cells;
    public int Width { get; }
    public Height { get; }

    public GridMap(int width, int height)
    {
        Width = width;
        Height = height;
        _cells = new CellType[width, height];
        InitializeAsWalkable();
    }

    private void InitializeAsWalkable()
    {
        for (int x = 0; x < Width; x++)
            for (int y = 0; y < Height; y++)
                _cells[x, y] = CellType.Walkable;
    }

    public bool IsWalkable(int x, int y) =>
        x >= 0 && y >= 0 && x < Width && y < Height && _cells[x, y] == CellType.Walkable;

    public void SetObstacle(int x, int y) => _cells[x, y] = CellType.Obstacle;
}

这种方法的优点非常明显:内存连续、访问速度快($O(1)$),非常适合中小规模静态地图。

但也有些局限:
- 固定大小,难以动态扩展;
- 对稀疏地图浪费空间;
- 不适合非网格化地形。

此时你可以考虑改用哈希表存储有效节点,例如 Dictionary<string, Node> ,只记录可行走点。虽然失去了局部性缓存优势,但在开放世界或大规模地图中反而更省资源。

graph LR
    subgraph Map Representation
        A[GridMap] --> B[CellType[,]]
        B --> C{IsWalkable(x,y)?}
        C -->|Yes| D[Include in search]
        C -->|No| E[Skip]
    end

流程图清楚地告诉我们:只有合法且可通行的节点才会参与后续计算。这是性能优化的第一道防线!


邻接关系怎么存?矩阵 vs 表 vs 动态生成

说到图的表示方法,经典教材总会提两个名字: 邻接矩阵 邻接表

方法 存储空间 查询速度 适用场景
邻接矩阵 $O(V^2)$ $O(1)$ 密集图
邻接表 $O(V + E)$ $O(\deg(v))$ 稀疏图

但等等……在A*的实际应用中,我们几乎从来不用显式存边!为啥?

因为 网格本身就是隐式的图结构 啊!每个节点最多有4或8个邻居,通过坐标偏移就能动态生成。

public static readonly (int dx, int dy)[] Directions4 = {
    (0, 1), (1, 0), (0, -1), (-1, 0)
};

public static readonly (int dx, int dy)[] Directions8 = {
    (0, 1), (1, 1), (1, 0), (1, -1),
    (0, -1), (-1, -1), (-1, 0), (-1, 1)
};

配合前面的 IsWalkable() 方法,轻松搞定邻居获取:

public List<Node> GetNeighbors(Node node, GridMap map, bool allowDiagonal = true)
{
    var neighbors = new List<Node>();
    var directions = allowDiagonal ? Directions8 : Directions4;

    foreach (var (dx, dy) in directions)
    {
        int nx = node.X + dx;
        int ny = node.Y + dy;

        if (map.IsWalkable(nx, ny))
        {
            neighbors.Add(new Node(nx, ny));
        }
    }

    return neighbors;
}

🎯 关键洞察:这种方式不需要预建图,完全按需生成,内存占用极低,且循环次数恒定(4或8次),时间复杂度视为 $O(1)$。

相比之下,显式维护邻接表不仅增加初始化开销,还可能导致冗余存储。除非你的地图拓扑极其复杂(比如地下管道迷宫),否则真没必要。

flowchart TD
    Start --> CheckDirections[For each direction (dx,dy)]
    CheckDirections --> CalcCoord["nx = x+dx, ny = y+dy"]
    CalcCoord --> ValidityCheck{Is (nx,ny) walkable?}
    ValidityCheck -->|Yes| AddNode[Add new Node(nx,ny)]
    ValidityCheck -->|No| Skip
    AddNode --> NextDir
    Skip --> NextDir
    NextDir --> EndLoop{All directions processed?}
    EndLoop -->|No| CheckDirections
    EndLoop -->|Yes| OutputNeighbors

这张流程图揭示了邻居生成的确定性和简洁性——没有复杂的依赖,也没有额外的数据结构,一切都建立在清晰的规则之上。


开放列表与关闭列表:掌控搜索节奏的双引擎

如果说节点是细胞,图是身体,那这两个列表就是A*的大脑和记忆中枢。

关闭列表(Closed Set):防止重复劳动

一旦某个节点被彻底探索完毕,就应该把它放进“已完成”名单,避免再次处理。这就是关闭列表的作用。

通常用哈希集合实现:

var closedSet = new HashSet<string>(); // 存储节点Id

// 使用示例
if (closedSet.Contains(currentNode.Id))
    continue; // 已处理过,跳过

// 处理完成后加入
closedSet.Add(currentNode.Id);

🔥 重点提醒:一定要用唯一标识比较!如果你不小心用了引用地址,哪怕两个节点坐标一样,也会被视为不同对象,导致无限循环或严重性能退化!

开放列表(Open List):按优先级排队等待

开放列表才是真正的“调度中心”。我们需要不断地从中取出 $ f $ 值最小的节点进行扩展。

如果只是用普通列表,每次找最小值就得遍历一遍,时间复杂度 $O(n)$,对于大型地图简直是灾难。

解决方案?当然是 优先队列 (Priority Queue)!

在C#中可以使用 .NET 6+ 提供的内置类型:

var openList = new PriorityQueue<Node, float>();
openList.Enqueue(startNode, startNode.F);

while (openList.Count > 0)
{
    openList.Dequeue(out var currentNode, out var priority);
    // 处理 currentNode...
}

底层基于二叉堆,插入和弹出均为 $O(\log n)$,效率飞跃提升 🚀

但如果语言环境不支持呢?我们可以自己动手写一个最小堆:

public class MinHeap<T> where T : IComparable<T>
{
    private List<T> heap = new List<T>();

    public void Push(T item)
    {
        heap.Add(item);
        HeapifyUp(heap.Count - 1);
    }

    public T Pop()
    {
        if (heap.Count == 0) throw new InvalidOperationException("Heap is empty.");
        T root = heap[0];
        T last = heap[^1];
        heap[0] = last;
        heap.RemoveAt(heap.Count - 1);
        if (heap.Count > 0) HeapifyDown(0);
        return root;
    }

    private void HeapifyUp(int index)
    {
        while (index > 0)
        {
            int parentIndex = (index - 1) / 2;
            if (heap[index].CompareTo(heap[parentIndex]) >= 0) break;
            Swap(index, parentIndex);
            index = parentIndex;
        }
    }

    private void HeapifyDown(int index)
    {
        int smallest;
        while (true)
        {
            int left = 2 * index + 1;
            int right = 2 * index + 2;
            smallest = index;

            if (left < heap.Count && heap[left].CompareTo(heap[smallest]) < 0)
                smallest = left;
            if (right < heap.Count && heap[right].CompareTo(heap[smallest]) < 0)
                smallest = right;

            if (smallest == index) break;
            Swap(index, smallest);
            index = smallest;
        }
    }

    private void Swap(int i, int j)
    {
        T temp = heap[i];
        heap[i] = heap[j];
        heap[j] = temp;
    }

    public bool Contains(T item) => heap.Contains(item);
    public int Count => heap.Count;
}

虽然基础版本缺少索引映射(无法快速定位某节点位置),但对于大多数应用场景已经够用了。若追求极致性能,可配合 Dictionary<Node, int> 实现 $O(1)$ 定位,支持高效的 decrease-key 操作。

下面是两种实现的性能对比:

操作 普通列表 二叉堆 提升幅度
插入节点 O(1) O(log n) 可接受
弹出最小节点 O(n) O(log n) 显著提升
查找节点 O(n) O(1)* 需辅助结构
更新节点优先级 O(n) O(log n) 带索引时有效

*注:需配合哈希表维护节点位置索引

graph TD
    A[Start] --> B[Enqueue Start with F=H]
    B --> C{Open List Empty?}
    C -->|No| D[Dequeue Min-F Node]
    D --> E[Process & Generate Neighbors]
    E --> F{Neighbor in Closed?}
    F -->|Yes| G[Skip]
    F -->|No| H[Tentative G Calculation]
    H --> I{Better G Found?}
    I -->|Yes| J[Update G, Parent, Enqueue or Re-prioritize]
    I -->|No| K[Ignore]
    J --> L[Move to Open List]
    K --> M[Continue]
    G --> M
    M --> C

整个控制流清晰明了,正是靠着这对“开-闭”组合拳,A*才能在海量节点中游刃有余地穿梭。


核心流程编码实现:让算法真正跑起来

好了,零件齐了,图纸有了,现在该组装发动机了!

完整的A*主循环大致长这样:

public List<Node> FindPath(Node start, Node goal, GridMap map)
{
    var openList = new PriorityQueue<Node, float>();
    var closedSet = new HashSet<string>();

    start.G = 0;
    start.CalculateH(goal); // 设置初始H值
    openList.Enqueue(start, start.F);

    while (openList.Count > 0)
    {
        openList.Dequeue(out var current, out _);

        if (current.Id == goal.Id)
            return ReconstructPath(current);

        closedSet.Add(current.Id);

        foreach (var neighbor in GetNeighbors(current, map))
        {
            if (closedSet.Contains(neighbor.Id)) continue;

            float tentativeG = current.G + GetDistance(current, neighbor);

            if (tentativeG < neighbor.G)
            {
                neighbor.G = tentativeG;
                neighbor.Parent = current;
                neighbor.CalculateH(goal);

                if (!openList.Contains(neighbor))
                    openList.Enqueue(neighbor, neighbor.F);
                else
                    openList.UpdatePriority(neighbor, neighbor.F); // 若支持
            }
        }
    }

    return null; // 无路径可达
}

⚠️ 注意边界情况处理:
- 起点即终点?提前返回。
- 起点或终点被阻挡?抛异常或返回空。
- 浮点精度比较?加容差阈值(如 1e-6 )。

特别要注意的是,当同一节点被多次加入开放列表时,可能出现重复项。为了避免浪费资源,可以在弹出时检查是否已在 closedSet 中,若是则跳过——这就是所谓的“惰性删除”策略。


Unity3D集成实战:从脚本到可视化

终于到了激动人心的环节:把这一切放进Unity里跑起来!

地图构建与交互控制

利用Unity的 Tilemap 系统,我们可以轻松创建可编辑的网格地图:

public class AStarController : MonoBehaviour
{
    public Tilemap groundMap;
    public TileBase obstacleTile;
    public Transform player;
    public LineRenderer pathLine;

    private AStarPathfinding astar;
    private GridGraph graph;

    void Start()
    {
        BoundsInt bounds = groundMap.cellBounds;
        graph = new GridGraph(bounds.size.x, bounds.size.y);

        foreach (Vector3Int pos in bounds.allPositionsWithin)
        {
            if (groundMap.HasTile(pos))
            {
                Vector2Int gridPos = new((int)pos.x, (int)pos.y);
                if (groundMap.GetTile(pos) == obstacleTile)
                    graph.SetObstacle(gridPos, true);
            }
        }

        astar = new AStarPathfinding(graph);
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Vector3 worldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            Vector2Int clickPos = new((int)worldPos.x, (int)worldPos.y);
            Vector2Int playerPos = new((int)player.position.x, (int)player.position.y);

            var path = astar.FindPath(playerPos, clickPos);
            DisplayPath(path);
        }
    }

    void DisplayPath(List<Vector2Int> path)
    {
        if (path == null || path.Count == 0) return;

        Vector3[] positions = path.Select(p => new Vector3(p.x + 0.5f, p.y + 0.5f)).ToArray();
        pathLine.positionCount = positions.Length;
        pathLine.SetPositions(positions);
    }
}

点击地面即可生成路径,并用 LineRenderer 实时绘制出来,效果清爽直观 ✨


性能优化建议

  1. 对象池复用节点实例 :频繁创建销毁 PathNode 会导致GC压力大,尤其在RTS游戏中单位众多时。可用对象池减少分配。
  2. 使用整型代替浮点 :如果地图规模不大,尽量用 int 计算距离,避免浮点误差和性能损耗。
  3. 增量式A* (LPA*):适用于动态障碍环境,仅重新计算受影响区域,大幅提升响应速度。
  4. 分层导航 :结合NavMesh做全局粗略规划 + A*局部精修,兼顾效率与灵活性。

可视化调试技巧

善用 OnDrawGizmos 展示内部状态:

void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    foreach (var pos in closedSet)
        Gizmos.DrawCube((Vector3)pos + Vector3.one * 0.5f, Vector3.one * 0.9f);

    Gizmos.color = Color.green;
    foreach (var node in openList)
        Gizmos.DrawSphere((Vector3)node.Position + Vector3.up * 0.2f, 0.3f);
}

红点是已处理节点,绿球是待探索节点,一眼就能看出搜索方向是否合理 👀


结语:A*不止于寻路

A*的伟大之处,不在于它有多快或多炫酷,而在于它完美诠释了一个工程原则: 在完备性与效率之间找到平衡

它不像Dijkstra那样穷尽所有可能,也不像贪心那样冒进。它懂得借助知识(启发函数)加速进程,又能随时回头纠正错误。这种“理性探索”的哲学,早已超越了路径规划本身,广泛应用于任务调度、AI决策、推荐系统等领域。

当你下次看到游戏角色流畅穿行于复杂地形时,请记住:背后或许正有一位沉默的智者,手持 f = g + h 这把钥匙,在无数可能性中为你选出那条最优之路 🗝️

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:A 寻路算法是一种在游戏开发、地图导航和机器人路径规划中广泛应用的高效路径搜索算法,结合了Dijkstra算法的最优性与BFS的高效性,并通过启发式函数指导搜索方向,以最小化总路径成本。本文介绍基于C#语言实现的A 算法源码,涵盖节点表示、图结构构建、开放/关闭列表管理及路径回溯等核心逻辑,适用于Unity3D游戏开发及各类路径规划场景。通过本源码学习,开发者可深入理解A*算法的工作机制,掌握优先队列优化、邻接结构设计等关键技术,提升实际项目中的路径搜索实现能力。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐