《游戏服务端编程实践》7.2.1 A*、Dijkstra 算法原理

从 Dijkstra 与 A* 的适用边界出发,说明启发函数为何必须可采纳且保持一致、网格直线与斜线的费用模型、开放集最小堆与 visited 去重的实现取舍,并给出可复用的 Go 搜索器、与 Dijkstra 的最短路对照,以及不可达、墙角穿越、启发函数可采纳性等测试要点。

一、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 抖动被误判为网络卡顿。
  • 堆里不做过期条目判断,已改进的节点被旧条目覆盖。
  • 起点或终点本身被阻挡时没有明确错误,返回一条空路径让调用方猜。
  • 只平滑客户端路径,服务端仍按原始格子校验,双方对“穿墙”的判断不一致。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist