4.2 十字链表 AOI 从零实现
上一节的九宫格快,但它把「附近」绑死在「固定格边长」上:格太大,稀疏区浪费;格太小,密集区退化。有没有一种结构,不预设任何尺寸、直接按坐标顺序把实体串起来?有,就是十字链表。
十字链表 AOI 的核心思想:给每个实体同时挂两条有序链表——一条按 X 升序,一条按 Y 升序。查询「谁在我附近」于是变成两次区间推进:沿 X 链从自己向两侧走,收集 X 距离在半径内的候选(X 带);沿 Y 链同样走一遍(Y 带);两条带取交,再按圆形半径收尾。它不需要格边长这个参数,天然适配任意密度。
代价在别处:链表是有序的,插入一个实体必须沿链走一遍找到正确位置,是 O(n)。这让「移动」从九宫格的 O(1) 变成 O(n)——本节会把这件事写清楚,并说明为什么生产实现通常要给十字链表再配一个索引。
节点与插入:四条边怎么接
十字链表的节点有四个指针:xPrev / xNext 挂在 X 链上,yPrev / yNext 挂在 Y 链上,合起来是一个「十」字。两条链各带一个哨兵头(headX / headY),链表成环,插入和删除都不用判空。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/crosslink.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
// xnode 是十字链表的节点:一个实体同时挂在「按 X 有序」与「按 Y 有序」两条
// 双向链表上,四条边 xPrev/xNext/yPrev/yNext 构成一个「十」字。整张表由
// 两个哨兵头 headX / headY 各领一条环状链表。
type xnode struct {
id EntityID
p Vec2
xPrev, xNext *xnode
yPrev, yNext *xnode
}
// CrossLink 是十字链表 AOI。两条链表各带一个哨兵头,按坐标单调不减排列;
// 查询时沿 X 链左右推进取「X 带」,沿 Y 链左右推进取「Y 带」,两带取交,
// 再按圆形半径收尾。它不需要预先设定格边长,能自适应任意密度分布。
type CrossLink struct {
headX *xnode
headY *xnode
index map[EntityID]*xnode
mark map[EntityID]int // 查询复用:记录某实体是否落在本次 X 带内
stamp int
}
func NewCrossLink() *CrossLink {
cl := &CrossLink{
headX: &xnode{},
headY: &xnode{},
index: make(map[EntityID]*xnode),
mark: make(map[EntityID]int),
}
cl.headX.xPrev, cl.headX.xNext = cl.headX, cl.headX
cl.headY.yPrev, cl.headY.yNext = cl.headY, cl.headY
return cl
}
// Add 把一个实体挂进两条链表。
func (cl *CrossLink) Add(id EntityID, p Vec2) {
if _, ok := cl.index[id]; ok {
cl.Move(id, p)
return
}
n := &xnode{id: id, p: p}
cl.index[id] = n
insertX(cl.headX, n)
insertY(cl.headY, n)
}
// Move 先摘后挂。十字链表的移动是 O(n) 的(要沿链表重新找位置),这是它
// 的主要代价;格点法用格坐标 O(1) 定位,这是它在均匀分布下更快的原因。
func (cl *CrossLink) Move(id EntityID, p Vec2) {
n, ok := cl.index[id]
if !ok {
cl.Add(id, p)
return
}
cl.unlink(n)
n.p = p
insertX(cl.headX, n)
insertY(cl.headY, n)
}
// Remove 从两条链表摘掉实体。
func (cl *CrossLink) Remove(id EntityID) {
n, ok := cl.index[id]
if !ok {
return
}
cl.unlink(n)
delete(cl.index, id)
}
// Len 返回当前实体数,供测试断言链表完整性。
func (cl *CrossLink) Len() int { return len(cl.index) }
func (cl *CrossLink) unlink(n *xnode) {
n.xPrev.xNext, n.xNext.xPrev = n.xNext, n.xPrev
n.yPrev.yNext, n.yNext.yPrev = n.yNext, n.yPrev
}
// insertX 把 n 按 X 坐标插进 head 领的链表,保持单调不减。
func insertX(head, n *xnode) {
for x := head.xNext; x != head; x = x.xNext {
if x.p.X >= n.p.X {
n.xNext, n.xPrev = x, x.xPrev
x.xPrev.xNext, x.xPrev = n, n
return
}
}
tail := head.xPrev
n.xNext, n.xPrev = head, tail
tail.xNext, head.xPrev = n, n
}
// insertY 把 n 按 Y 坐标插进 head 领的链表,保持单调不减。
func insertY(head, n *xnode) {
for y := head.yNext; y != head; y = y.yNext {
if y.p.Y >= n.p.Y {
n.yNext, n.yPrev = y, y.yPrev
y.yPrev.yNext, y.yPrev = n, n
return
}
}
tail := head.yPrev
n.yNext, n.yPrev = head, tail
tail.yNext, head.yPrev = n, n
}
三个设计点。第一,index(map[EntityID]*xnode)让「按 ID 找节点」是 O(1)——没有它,任何按实体的操作都得先线性找节点。第二,insertX / insertY 从哨兵后第一个节点开始扫,找到第一个坐标不小于自己的节点就插在它前面,保持链表单调不减。第三,哨兵成环让「插到末尾」和「插到中间」共用同一段收尾代码,不需要特判空链表。
Move 是先 unlink 再重新 insertX / insertY——这就是 O(n) 的来源:它不利用「只挪了一点点」这个信息,每次都从头找位置。生产实现会在这里加一层按坐标分桶的索引,把定位降到 O(1),本节的朴素版是为了把结构本身讲清楚。
查询:X 带与 Y 带取交
查询分两趟。第一趟沿 X 链从自己向左右两侧走,遇到 X 距离超过半径就停,把经过的节点标进「本次 X 带」;第二趟沿 Y 链同样走,只把「既在 X 带里、又落在圆内」的节点收进结果。两带取交用 mark map 加一个自增的 stamp 实现:每趟查询换一个 stamp,就等价于清空了上次的标记,于是查询不需要反复分配 map。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/crosslink_query.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
// NeighborsInRadius 返回圆形半径内的其它实体。
//
// 分两趟:先沿 X 链从自己向两侧走,把 |dx| <= r 的节点标进本次的 X 带;
// 再沿 Y 链走,只把「既在 X 带里、又落在圆内」的节点收进结果。两带取交
// 用 mark + 自增的 stamp 实现:每趟查询换一个 stamp 就等价于清空了上次
// 的标记,于是查询不用反复分配 map。
func (cl *CrossLink) NeighborsInRadius(id EntityID, r float64) []EntityID {
n, ok := cl.index[id]
if !ok {
return nil
}
self := n.p
cl.stamp++
// X 带:从节点沿 X 链向两侧走,直到 |dx| > r 为止。
for x := n.xPrev; x != cl.headX && self.X-x.p.X <= r; x = x.xPrev {
cl.mark[x.id] = cl.stamp
}
for x := n.xNext; x != cl.headX && x.p.X-self.X <= r; x = x.xNext {
cl.mark[x.id] = cl.stamp
}
// Y 带:沿 Y 链走,落在 X 带里且距离在圆内的才是最终结果。
out := make([]EntityID, 0, 16)
for y := n.yPrev; y != cl.headY && self.Y-y.p.Y <= r; y = y.yPrev {
if cl.mark[y.id] == cl.stamp && within(self, y.p, r) {
out = append(out, y.id)
}
}
for y := n.yNext; y != cl.headY && y.p.Y-self.Y <= r; y = y.yNext {
if cl.mark[y.id] == cl.stamp && within(self, y.p, r) {
out = append(out, y.id)
}
}
return out
}
为什么必须两条链都走?只用 X 带会把「X 近但 Y 很远」的实体混进结果(比如正上方很远的人),只用 Y 带同理。十字链表用两条一维链表,把二维的「矩形邻域」拆成两个一维区间的交,这是它名字里「十字」的真正含义。查询复杂度是 O(kx + ky),kx、ky 是两条带里的节点数——均匀分布下它们正比于「带宽 / 世界宽度 × N」,与全服人数成线性关系。
正确性:与九宫格逐点比对
十字链表和九宫格的语义完全相同——都是「半径 r 内的其它实体」。既然上一节的九宫格已经跑通、测过,最好的验证方式就是:同一批随机点同时喂给两种结构,逐个实体比对查询结果。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/crosslink_test.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
import (
"math/rand/v2"
"testing"
)
// TestCrossLinkMatchesGrid 用同一批随机点分别喂给十字链表与九宫格,
// 逐个实体比对半径查询结果——两种结构语义相同,结果必须完全一致。
func TestCrossLinkMatchesGrid(t *testing.T) {
rng := rand.New(rand.NewPCG(7, 11))
cl := NewCrossLink()
g := NewGrid()
const n = 500
for i := 0; i < n; i++ {
id := MakeEntityID(1, uint64(i))
p := Vec2{X: rng.Float64() * 100, Y: rng.Float64() * 100}
cl.Add(id, p)
g.Add(id, p)
}
for i := 0; i < n; i++ {
id := MakeEntityID(1, uint64(i))
got := ids(cl.NeighborsInRadius(id, 8))
want := ids(g.NeighborsInRadius(id, 8))
if !eq(got, want) {
t.Fatalf("id %d: crosslink=%v grid=%v", i, got, want)
}
}
}
func TestCrossLinkMoveAndRemove(t *testing.T) {
cl := NewCrossLink()
a := MakeEntityID(1, 1)
b := MakeEntityID(1, 2)
cl.Add(a, Vec2{X: 0, Y: 0})
cl.Add(b, Vec2{X: 50, Y: 50})
if got := ids(cl.NeighborsInRadius(a, 8)); len(got) != 0 {
t.Fatalf("far: %v", got)
}
cl.Move(b, Vec2{X: 3, Y: 4})
if got := ids(cl.NeighborsInRadius(a, 8)); !eq(got, []uint64{uint64(b)}) {
t.Fatalf("near: %v", got)
}
cl.Remove(b)
if got := ids(cl.NeighborsInRadius(a, 8)); len(got) != 0 {
t.Fatalf("after remove: %v", got)
}
// 链表完整性:全部移除后两条环状链表应只剩哨兵自指。
cl.Remove(a)
if cl.Len() != 0 {
t.Fatalf("index not empty: %d", cl.Len())
}
if cl.headX.xNext != cl.headX || cl.headX.xPrev != cl.headX {
t.Fatal("X list not empty after removing all")
}
if cl.headY.yNext != cl.headY || cl.headY.yPrev != cl.headY {
t.Fatal("Y list not empty after removing all")
}
}
TestCrossLinkMatchesGrid 用固定种子的 PCG 生成 500 个点,对每个点都断言「十字链表结果 == 九宫格结果」。它同时压住了两条链表的插入顺序、查询的带内过滤,以及 map 迭代顺序无关的排序规范化。TestCrossLinkMoveAndRemove 另外覆盖了移动与删除,并在全部移除后断言两条环状链表回到「只剩哨兵」的状态——链表结构的 bug 最常见的表现就是删除后指针没接好,这个断言能直接抓住它。
本节小结 / 实测结论
十字链表 AOI 到这里就完整了:两条有序链表、四指针节点、两带取交的查询、与九宫格逐点一致的验证,全部本机 go test ./internal/aoi/ 通过。它最大的价值是没有格边长这个参数:无论地图是稀疏荒野还是挤满人的主城,它都按坐标顺序自适应,不需要人工调格。
代价也很明确:移动是 O(n)。下一节我们用基准把三种 AOI——不做管理的暴力扫描、九宫格、十字链表——放在同一批玩家、同一张地图上跑,量出各自的 CPU 成本与带宽成本,并给出「什么场景选哪种」的判据。
阅读导航:上一节:4.1 九宫格 AOI 从零实现 · 下一节:4.3 基准:三种 AOI 的 CPU 与带宽对照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。