一、Dijkstra 与 A* 的分工
Dijkstra 每次从开放集取出累计代价最小的节点向四周扩展,直到目标出队。它没有方向感,但只要求边权非负,因此适合单源多目标、全图距离表和流场。A* 在排序键上增加启发函数 h(n),优先扩展“看起来更接近目标”的节点;h 恒为 0 时,A* 就退化成 Dijkstra。
两者的区别不在正确性,而在扩展规模。地图越开阔、目标越远,好的启发函数省下的节点越多;反过来,如果地图很小、目标经常成批出现,Dijkstra 一次填表反而更划算。
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单次点到点,地图规整 | A* | 扩展节点通常更少 |
| 一次计算到多个目标 | Dijkstra 或多源 BFS | 距离表可以复用 |
| 需要全图可达性或流场 | Dijkstra | 一次填满整张图 |
| 启发函数难写或不可靠 | Dijkstra | 不会因为高估而次优 |
| 阻挡频繁变化 | 重算或增量算法 | 旧的 gScore 不能直接复用 |
世界坐标如何落到格子上,见 /game-server-in-action-711/;本篇只讨论格子图上的搜索本身。
二、可采纳与一致性
可采纳(admissible)指 h(n) 永远不高估从 n 到目标的真实剩余代价。只要可采纳,A* 返回的就是最优路径。一致性(consistent,也叫单调性)更强:对任意相邻节点 n 与 n’,满足 h(n) ≤ c(n, n') + h(n')。一致性保证节点第一次出队时 g 已经最优,因此不需要重新打开已关闭的节点。
地形成本不是常量时,启发函数要用最低地形成本。例如地图上有一条成本 0.5 的公路,其余草地成本 1,那么 h = 欧氏距离 × 0.5 仍然可采纳;若仍用 1 作为系数,遇到必须绕开公路的布局时就会高估。
| 启发函数 | 可采纳 | 一致性 | 适用 |
|---|---|---|---|
| 0 | 是 | 是 | Dijkstra 对照 |
| 欧氏距离 × 最低成本 | 是 | 是 | 八方向、地形成本 ≥ 最低值 |
| 曼哈顿距离 × 最低成本 | 四方向可采纳 | 是 | 禁止斜向的网格 |
| 欧氏距离 × 平均成本 | 否 | 否 | 只能当近似,不能保证最优 |
| 曼哈顿距离 × 2 | 否 | 否 | 常见的高估错误 |
三、网格费用模型
费用定义在“进入格子”上:起点不收费,每一步支付目标格的地形成本。斜向费用取两端成本的较大值再乘 √2,这是一个保守上界,避免从高成本格斜穿到低成本格时低估。
| 移动 | 几何距离 | 费用 | 备注 |
|---|---|---|---|
| 正交 | 1 | 目标格地形成本 | 最常见 |
| 斜向 | √2 | √2 × max(两端成本) | 保守,避免低估 |
| 楼梯、传送门 | 特殊边 | 单独建模 | 不要塞进格子成本 |
| 动态阻挡 | ∞ | 不可进入 | 与静态地形分层 |
| 单位占位 | 不进入地形层 | 由避障或局部解算处理 | 否则会把队友当墙 |
动态阻挡不应该直接改写静态成本数组。把静态层与动态层分开,撤销时才能精确恢复;把两者压成一个 walkable 布尔数组,一旦某个来源提前移除,就会把仍然存在的另一个来源一起放通。
四、开放集与重复访问
最小堆按 f = g + h 排序;f 相同时优先选择 g 更大的节点,让它更接近目标,可以减少大片等值区域上的来回扩展。Go 标准库的 container/heap 需要实现 Len/Less/Swap/Push/Pop 五个方法。
visited 去重有两种常见写法:
- 布尔数组:每次寻路前清零,简单但 O(N)。
- epoch 戳:每次寻路把 epoch 加一,
seen[idx] != epoch视为未访问;只有 gScore 的 epoch 匹配时才可信。这样复用时不需要清空整张表。
堆里允许同一节点存在多个条目,出队时若它的 g 大于当前 gScore 就跳过。这称为惰性删除,比实现 decrease-key 简单,代价是堆可能变大。对格子地图来说,这个代价通常可以接受。
预分配同样重要。gScore、cameFrom、seen 都按地图尺寸分配一次,放在 Searcher 上复用;只有地图尺寸变化时才重新分配。每次寻路 make 几个与地图等大的数组,在千乘千的地图上会制造明显的 GC 压力。
五、路径平滑
A* 输出的是格子序列,直接让角色沿格心走会呈现锯齿。常用做法是 string pulling:从当前锚点向后寻找最远的可直视路点,用视线检测替换中间格子。平滑必须在可通行约束下进行,只检查线段经过的格子还不够;斜穿墙角时还要检查两侧正交格,否则会把一条合法路径平滑成穿墙路径。
平滑后的路点用于客户端表现,原始格路径仍应保留给服务端校验和回放。两者混用会让“客户端看起来没穿墙、服务端判定穿墙”这类问题很难复现。
六、失败场景分析
场景一:启发函数高估。 某地图把 h 写成“曼哈顿距离 × 2”。目标直线距离 10,真实代价 12,h 却是 20。A* 先扩展了看起来更近、实际需要绕行的分支,最终返回代价 14 的路径。它仍然能走,但不是最短路,而且线上很难发现。修复是把系数降到不超过最低地形成本,并用 Dijkstra 对同一张图做对照测试。
场景二:斜线穿墙角。 两个正交格都被阻挡,只有斜向两端可走。如果允许斜向而不检查两侧,角色会从墙缝中挤过去。修复是在斜向扩展前检查 (x+dx, y) 与 (x, y+dy) 是否都可通行。
场景三:每次寻路重新分配大数组。 地图越大,分配越贵,表现为周期性的移动卡顿。修复是复用 Searcher 的数组与 epoch 戳,并在性能观测里记录 Expanded,确认扩展数没有异常翻倍。
七、Go 实现
下面的 Searcher 使用最小堆、epoch 去重和惰性删除,默认启发函数为欧氏距离乘以最低地形成本;Dijkstra 复用同一套代码,只把启发函数置零。
// file: ch07/path/astar.go
// tier: L1
// verify: go test ./ch07/path/
package path
import (
"container/heap"
"errors"
"math"
)
var (
ErrNoPath = errors.New("path: no route")
ErrOutOfBounds = errors.New("path: out of bounds")
ErrBlocked = errors.New("path: endpoint blocked")
)
// Point 是整数格子坐标,X 向右、Y 向下。
type Point struct{ X, Y int }
// Grid 提供地形成本;Cost 的第二个返回值表示格子能否进入,
// MinCost 是任意可进入格子的最低成本,必须大于 0。
type Grid interface {
Width() int
Height() int
Cost(p Point) (float64, bool)
MinCost() float64
}
type Result struct {
Path []Point
Cost float64
Expanded int
}
// Searcher 预分配与地图等大的数组,同一张地图上的多次寻路复用它们。
type Searcher struct {
grid Grid
heuristic func(Point, Point) float64
w, h int
gScore []float64
cameFrom []int
seen []uint32
epoch uint32
open nodeHeap
buf []Point
}
// NewSearcher 的默认启发函数是欧氏距离乘以最低地形成本,保证不高估。
func NewSearcher(g Grid) *Searcher {
minCost := g.MinCost()
if minCost <= 0 {
minCost = 1
}
s := &Searcher{grid: g, w: g.Width(), h: g.Height()}
s.heuristic = func(a, b Point) float64 {
return math.Hypot(float64(a.X-b.X), float64(a.Y-b.Y)) * minCost
}
n := s.w * s.h
s.gScore = make([]float64, n)
s.cameFrom = make([]int, n)
s.seen = make([]uint32, n)
return s
}
// Dijkstra 等价于零启发函数的 A*,用于对照最短路。
func Dijkstra(g Grid, start, goal Point) (Result, error) {
s := NewSearcher(g)
s.heuristic = func(Point, Point) float64 { return 0 }
return s.Find(start, goal)
}
func (s *Searcher) Find(start, goal Point) (Result, error) {
if !s.inBounds(start) || !s.inBounds(goal) {
return Result{}, ErrOutOfBounds
}
if _, ok := s.grid.Cost(start); !ok {
return Result{}, ErrBlocked
}
if _, ok := s.grid.Cost(goal); !ok {
return Result{}, ErrBlocked
}
s.epoch++
if s.epoch == 0 {
clear(s.seen)
s.epoch = 1
}
startIdx, goalIdx := s.index(start), s.index(goal)
s.seen[startIdx] = s.epoch
s.gScore[startIdx] = 0
s.cameFrom[startIdx] = -1
s.open = s.open[:0]
heap.Push(&s.open, node{idx: startIdx, f: s.heuristic(start, goal)})
expanded := 0
for s.open.Len() > 0 {
cur := heap.Pop(&s.open).(node)
if s.seen[cur.idx] != s.epoch || cur.g > s.gScore[cur.idx] {
continue
}
expanded++
if cur.idx == goalIdx {
return s.build(startIdx, goalIdx, cur.g, expanded), nil
}
cp := s.point(cur.idx)
for _, nb := range s.neighbors(cp) {
ng := cur.g + s.stepCost(cp, nb)
ni := s.index(nb)
if s.seen[ni] == s.epoch && ng >= s.gScore[ni] {
continue
}
s.seen[ni] = s.epoch
s.gScore[ni] = ng
s.cameFrom[ni] = cur.idx
heap.Push(&s.open, node{idx: ni, g: ng, f: ng + s.heuristic(nb, goal)})
}
}
return Result{}, ErrNoPath
}
var directions = [8][2]int{
{1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {1, -1}, {-1, 1}, {-1, -1},
}
func (s *Searcher) neighbors(p Point) []Point {
out := s.buf[:0]
for i, d := range directions {
q := Point{p.X + d[0], p.Y + d[1]}
if !s.inBounds(q) {
continue
}
if _, ok := s.grid.Cost(q); !ok {
continue
}
// 斜向要求两侧正交格都可通行,禁止从墙角挤过去。
if i >= 4 {
if _, ok := s.grid.Cost(Point{p.X + d[0], p.Y}); !ok {
continue
}
if _, ok := s.grid.Cost(Point{p.X, p.Y + d[1]}); !ok {
continue
}
}
out = append(out, q)
}
s.buf = out
return out
}
func (s *Searcher) stepCost(a, b Point) float64 {
ca, _ := s.grid.Cost(a)
cb, _ := s.grid.Cost(b)
if a.X != b.X && a.Y != b.Y {
return math.Sqrt2 * math.Max(ca, cb)
}
return cb
}
func (s *Searcher) build(start, goal int, cost float64, expanded int) Result {
var reversed []Point
for at := goal; at != -1; at = s.cameFrom[at] {
reversed = append(reversed, s.point(at))
if at == start {
break
}
}
path := make([]Point, len(reversed))
for i := range reversed {
path[len(reversed)-1-i] = reversed[i]
}
return Result{Path: path, Cost: cost, Expanded: expanded}
}
func (s *Searcher) index(p Point) int { return p.Y*s.w + p.X }
func (s *Searcher) point(i int) Point { return Point{X: i % s.w, Y: i / s.w} }
func (s *Searcher) inBounds(p Point) bool {
return p.X >= 0 && p.Y >= 0 && p.X < s.w && p.Y < s.h
}
type node struct {
idx int
g float64
f float64
}
type nodeHeap []node
func (h nodeHeap) Len() int { return len(h) }
func (h nodeHeap) Less(i, j int) bool {
if h[i].f == h[j].f {
return h[i].g > h[j].g
}
return h[i].f < h[j].f
}
func (h nodeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *nodeHeap) Push(x any) { *h = append(*h, x.(node)) }
func (h *nodeHeap) Pop() any {
old := *h
n := len(old)
it := old[n-1]
*h = old[:n-1]
return it
}
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch07/path && go vet ./ch07/path/ && go test ./ch07/path/ -count=1
ok gspractice/ch07/path 0.262s
测试覆盖最短路与 Dijkstra 的代价对照、完全不可达、起点阻挡与越界、地形最低成本下的启发函数可采纳性、2×2 墙角不可斜穿,以及同一个 Searcher 连续两次寻路结果一致。
小结
Dijkstra 负责“没有方向也要正确”,A* 负责“有方向时少扩展”。选择哪一种取决于目标数量、地图规模和启发函数的可信度,而不是哪种算法名字更流行。实现层面,可采纳的启发函数、正确的地形最低成本、斜向墙角检查、复用的数组与 epoch 去重,共同决定了路径是否既正确又稳定。
常见错误与边界
- 启发函数使用平均成本或放大系数,路径可走但不是最短路。
- 斜向移动不检查两侧正交格,角色从墙角挤过去。
- 把动态阻挡直接写进静态地形数组,撤销时无法恢复原状。
- 每次寻路重新分配与地图等大的数组,GC 抖动被误判为网络卡顿。
- 堆里不做过期条目判断,已改进的节点被旧条目覆盖。
- 起点或终点本身被阻挡时没有明确错误,返回一条空路径让调用方猜。
- 只平滑客户端路径,服务端仍按原始格子校验,双方对“穿墙”的判断不一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。