DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 11
STEP 01 / CODE / LOGIC

先说结论

A 星中的 G 是从起点走到当前节点的真实累计成本,H 是到终点的估计成本,F = G + H 用来选择下一步扩展谁。Parent 不保存整条路径,只记录当前最优路线从哪里来。

旧路线:Start -> A -> C,G(C)=4+5=9
新路线:Start -> B -> C,G(C)=2+3=5

分类:算法

先说结论

A 星中的 G 是从起点走到当前节点的真实累计成本,H 是到终点的估计成本,F = G + H 用来选择下一步扩展谁。Parent 不保存整条路径,只记录当前最优路线从哪里来。

一次换 Parent 的数字

旧路线:Start -> A -> C,G(C)=4+5=9
新路线:Start -> B -> C,G(C)=2+3=5

发现新路线后,把 C 的 G 从 9 改为 5,并把 Parent(C) 从 A 改成 B。到达终点后再沿 Parent 反向回溯,得到 End -> C -> B -> Start,最后反转为实际路径。

A 星解决什么问题

A 星寻路用于在地图中寻找一条从起点到终点的路径,并尽量绕开障碍、缩短距离。

它的核心公式是:

F = G + H

其中:

G:从起点走到当前点,已经花了多少成本。
H:从当前点到终点,估计还要多少成本。
F:综合评分。

跟着代码看一次“换父节点”

先只实现 A* 中最关键的一步:发现一条更便宜的路线时,更新邻居的 GParent。把下面两个类型放在 Node.cs

using System;
using System.Collections.Generic;

sealed class Node
{
    public string Id { get; }
    public int G { get; set; } = int.MaxValue;
    public Node? Parent { get; set; }

    public Node(string id) => Id = id;
}

static class AStarStep
{
    public static bool TryRelax(Node current, Node neighbor, int edgeCost)
    {
        int newG = current.G + edgeCost;

        if (newG >= neighbor.G)
            return false;

        neighbor.G = newG;
        neighbor.Parent = current;
        return true;
    }

    public static List<string> BuildPath(Node goal)
    {
        List<string> path = new();

        for (Node? node = goal; node != null; node = node.Parent)
            path.Add(node.Id);

        path.Reverse();
        return path;
    }
}

构造一个很小的图:

S --3--> A
S --1--> B --1--> A

先从 S 直接发现 A:

Node s = new("S") { G = 0 };
Node a = new("A");
Node b = new("B");

AStarStep.TryRelax(s, a, 3);
Console.WriteLine($"A: G={a.G}, Parent={a.Parent?.Id}");
// A: G=3, Parent=S

再经过 B 到达 A:

AStarStep.TryRelax(s, b, 1); // B.G = 1
AStarStep.TryRelax(b, a, 1); // 新路线成本 1 + 1 = 2

Console.WriteLine($"A: G={a.G}, Parent={a.Parent?.Id}");
// A: G=2, Parent=B

第二次更新不是为 A 保存另一条完整路径,而是覆盖两个字段:

A.G:3 -> 2
A.Parent:S -> B

第二步:沿 Parent 恢复最终路径

BuildPath 已经在 AStarStep 中。Program.cs 调用它:

Console.WriteLine(string.Join(" -> ", AStarStep.BuildPath(a)));
// S -> B -> A

搜索阶段不断修改 GParent;只有找到终点后,才从终点沿 Parent 反向走回起点。后文所有关于 Open、Closed 和路径正确性的讨论,都可以对应到这两个操作:TryRelax 负责改进记录,BuildPath 负责最终回溯。

G 值为什么重要

G 看起来像“过去的账”,但它非常关键。

A 星不是只想找“看起来离终点近”的点,而是想找:

从起点到终点的总路程尽量短。

这个总路程必须包含:

已经走过的距离 G
还没走的估计距离 H

如果只看 H,就会变成贪心搜索:

谁看起来离终点近,就先选谁。

这样很容易被障碍骗。

例如某个点虽然离终点很近,但为了到达它已经绕了很远。

G 的作用就是提醒算法:

你虽然看起来离目标近,但你已经付出了很高成本。

一句话:

H 让 A 星有方向感。
G 让 A 星不被方向感骗。

数字例子

假设有两个候选点:

A 点:
G = 10  已经走了 10 步
H = 2   估计离终点还差 2 步
F = 12

B 点:
G = 3   已经走了 3 步
H = 6   估计离终点还差 6 步
F = 9

如果只看 H

A 更好,因为 H = 2。

但看总成本:

A 总成本约 12。
B 总成本约 9。

所以 B 可能更值得继续探索。

搜索阶段不是移动阶段

A 星搜索时,角色并没有真的移动。

搜索阶段只是算法在内存里推演:

哪些点可能成为路线?
每个点当前最便宜的到达方式是什么?
哪个候选点最值得继续探索?

等找到最终路径之后,角色才开始沿路径移动。

所以不会发生:

角色走到一半发现路线错了,再一步步倒退。

单次 A 星是:

先计算路线,再执行移动。

A 星不为每个候选点保存完整路径

如果每个候选点都保存一整条路径:

A: [S, A]
B: [S, A, B]
C: [S, A, B, C]
D: [S, X, Y, D]

会浪费很多内存。

A 星更常见的做法是:

每个节点只保存 Parent。

例如:

B.parent = A
C.parent = B
G.parent = C

找到终点后,从终点往回追:

G -> C -> B -> A -> S

再反转:

S -> A -> B -> C -> G

这就是最终路径。

换路线时发生了什么

假设某个节点 X 之前是这样到达的:

S -> A -> B -> X

成本:

X.G = 10
X.Parent = B

后来发现另一条更短的路:

S -> C -> X

成本:

newG = 6

那就更新:

X.G = 6
X.Parent = C

这不是让角色倒退,也不是修改一条正在走的路径 List。

它只是把数据记录改成:

到 X 的当前最佳来源是 C。

以后如果最终路径经过 X,回溯出来的就是新路线:

S -> C -> X

而不是旧路线:

S -> A -> B -> X

保证路径正确的关键

关键规则是:

如果第一次发现某个节点,记录它的 G 和 Parent。
如果之后发现更低 G 的路线,更新它的 G 和 Parent。

伪代码:

if (newG < neighbor.G || !openSet.Contains(neighbor))
{
    neighbor.G = newG;
    neighbor.H = Heuristic(neighbor, goal);
    neighbor.F = neighbor.G + neighbor.H;
    neighbor.Parent = current;
}

这句就是保证最终路径正确的关键。

运行边界:什么时候没有路径

如果 Open 集合已经为空,终点仍未被发现,结果应该是“不可达”,而不是返回离终点最近的半条路径:

if (open.Count == 0 && !states.ContainsKey(goal))
    return PathResult.Unreachable;

还要明确这些边界:

  • 普通 A* 不能处理负成本边。
  • G + edgeCost 要防止整数溢出,int.MaxValue 不能直接参与加法。
  • 起点等于终点时,正确结果是只包含起点的零成本路径。
  • 地图在搜索期间变化时,旧 Parent 链可能穿过新障碍,需要版本检查或重新寻路。
  • 启发函数如果高估真实剩余成本,就不再保证最短路径。

失败结果应该返回状态和原因,调用方再决定原地等待、换目标还是请求最近可达点。

最重要的收获

A 星搜索时不是角色在走路,而是在内存里推演路线。
G 记录从起点到当前点的真实成本。
H 估计从当前点到终点的剩余成本。
Parent 记录当前节点目前最优的来源节点。

最终路径不是一路维护一个大 List,而是:

从终点沿 Parent 回溯,再反转得到。