OYLZ
博客游戏其他OY_UIPixelForge
备案信息: 粤ICP备18091077号-1

康威生命游戏计算优化

2026/11/4 15:40:0
算法 ,数据结构 ,游戏

之前给博客写过一个康威生命游戏,可以直接在画布上绘制细胞,再按照规则逐代演化。

细胞不多的时候没什么问题,数量上去之后,每计算一代都要等很久。这次主要调整了计算部分的数据结构:从原来的字符串坐标对象,尝试改成数值编码的 Set/Map,最后换成按列存储的嵌套 Map/Set。

比较反直觉的是,把两个坐标合并成一个数字,结构看起来更简单,但实际测试下来,反而是多套了一层的 Map/Set 更快。

原来已经是稀疏存储了

先简单说一下规则。标准生命游戏中,一个细胞有八个邻居:

  • 活细胞有两个或三个活邻居,下一代继续存活。
  • 死细胞恰好有三个活邻居,下一代变为存活。
  • 其他情况下一代都是死亡状态。

所以,计算下一代时,不需要扫描整个画布。只需要处理当前的活细胞,以及这些活细胞周围可能出生的位置。

原来的实现已经是这样做的。世界里只保存活细胞,用坐标字符串作为对象的属性名:

const word = {
    "0,0": undefined,
    "1,0": undefined,
    "2,0": undefined,
}

这里的 undefined 没有特殊含义,只看这个属性是否存在。

计算时遍历活细胞,统计周围的活邻居。同时用另一个对象 deadNeighbour,记录每个死亡位置被多少个活细胞邻接。

最后,符合存活条件的细胞写入新世界,计数等于三的死亡位置也写入新世界。整代算完,再替换旧世界。

这个思路本身没什么问题。每个活细胞检查八个邻居,再遍历一遍死亡候选位置,工作量大致随活细胞数量增长,并没有扫描大片空白区域。

问题更多出在每次邻居检查具体做了什么。

字符串坐标来回拆装

原来生成邻居的代码是这样的:

findNeighbour(coord) {
    let [x, y] = coord.split(',')
    x = parseInt(x)
    y = parseInt(y)

    return [
        `${x - 1},${y + 1}`, `${x},${y + 1}`, `${x + 1},${y + 1}`,
        `${x - 1},${y}`,                     `${x + 1},${y}`,
        `${x - 1},${y - 1}`, `${x},${y - 1}`, `${x + 1},${y - 1}`,
    ]
}

一个坐标进来,先拆成字符串数组,再转换成两个数字。算出八个邻居之后,又把数字拼回字符串,装进一个新数组。

后续再拿这些字符串查询世界。下面是简化后的处理:

const alive = this.word.hasOwnProperty(neighbor)

if (!alive) {
    deadNeighbour[neighbor] = deadNeighbour[neighbor]
        ? deadNeighbour[neighbor] + 1
        : 1
}

如果有十万个活细胞,一代就要执行十万次坐标拆分、二十万次数字解析,以及八十万次邻居坐标字符串构造。

同一个位置还会被不同的活细胞反复访问。比如 (10, 10) 周围有三个活细胞,这个位置就可能被构造成三次 "10,10",再拿去访问同一个计数项。

这些操作都在最里面的循环里。

所以这里慢,不能简单归结成“Object 不如 Map”。字符串的拆分、解析、构造,邻居数组的创建,以及动态属性查询,都包含在原来的计算路径中。

临时数据也会增加分配和回收压力。不过当时没有单独做分配和 GC 的性能剖析,不能把总耗时中的某个比例直接归给 GC。

另外,这种大量动态坐标属性,也不能直接套用固定字段对象的访问性能。V8 对对象属性有不同的内部存储方式,固定结构的快速属性访问,与动态字典式访问并不是一回事。V8 对属性存储的说明

先把坐标从字符串里拿出来,是比较直接的一步。

第一版尝试:把坐标编码成数字

字符串的作用,是把两个坐标合成一个可以查询的键。既然如此,也可以把它们合成一个数字。

测试原型采用的是下面这个编码。这里截取核心公式,测试数据的坐标范围在生成时做了校验:

const STRIDE = 2 ** 24
const BIAS = 2 ** 23

function encode(x, y) {
    return (x + BIAS) * STRIDE + y + BIAS
}

BIAS 用来处理负坐标,STRIDE 用来把两个坐标隔开。在约定的坐标范围内,每个位置都对应一个不同的数字。

存活细胞改用 Set<number>,死亡位置的邻居计数改用 Map<number, number>。

这个方案还有一个方便的地方:计算邻居时,不需要先把数字解码成 x、y,直接加偏移就可以:

const offsets = [
    -STRIDE - 1, -STRIDE, -STRIDE + 1,
    -1,                   1,
     STRIDE - 1,  STRIDE,  STRIDE + 1,
]

于是核心计算变成:

for (const key of alive) {
    let sum = 0

    for (const offset of offsets) {
        const neighbor = key + offset

        if (alive.has(neighbor)) {
            sum++
        } else {
            dead.set(neighbor, (dead.get(neighbor) || 0) + 1)
        }
    }

    if (rule(sum)) next.add(key)
}

for (const [key, count] of dead) {
    if (count === 3) next.add(key)
}

字符串拆分和拼接都没有了,也不用给每个细胞生成一个邻居数组。八个偏移可以一直复用。

本地一万活细胞、约 30% 密度的测试中,原来单代计算的中位数是 32.97 ms,数值编码版降到了 7.44 ms。

只看这个规模,效果已经比较明显。

但继续增加数量,结果就没那么理想了。十万活细胞需要 506.84 ms,三十万需要 2205.39 ms。虽然仍然比原实现快,但三十万细胞算一代还是两秒多。

字符串确实是一部分开销,去掉字符串也确实有效,但把坐标换成数字之后,仍然有继续调整的空间。

而且,这个方案还有一个额外限制:编码有边界。

如果 y 超出预留范围,就可能进入下一个 x 对应的编码区间。实际使用时必须保证细胞及其邻居都在有效范围内,不能只检查初始图案。

这里也没有使用位运算打包或 BigInt,仍然是普通的 JavaScript Number。编码结果可以保持精确,但它对应的坐标范围是有限的。

再换一种组织方式:按列存储

后面尝试不再把 x、y 合并,而是直接保存两层结构:

// x -> 这一列所有活细胞的 y
const word = new Map([
    [0, new Set([0, 1, 2])],
    [1, new Set([1])],
])

类型就是:

Map<number, Set<number>>

判断 (x, y) 是否存活:

word.get(x)?.has(y)

死亡位置的邻居计数对应改成:

Map<number, Map<number, number>>

外层仍然是 x,内层是 y 到计数的映射。

乍看多了一层查询,似乎应该更慢。但生命游戏恰好有一个可以利用的特点:同一列里的所有细胞,都只会访问左、中、右三列。

处理 x 这一列之前,先把这三列拿出来:

const columns = [
    this.word.get(x - 1),
    column,
    this.word.get(x + 1),
]

死亡位置的计数表也一样,先取出或创建对应的三列:

const deadColumns = []

for (let dx = -1; dx <= 1; dx++) {
    let counts = deadNeighbour.get(x + dx)

    if (!counts) {
        counts = new Map()
        deadNeighbour.set(x + dx, counts)
    }

    deadColumns.push(counts)
}

然后再遍历当前列的细胞。下面是当前实现中统计邻居的部分,省略了后续写入新世界的代码:

for (const y of column) {
    let sum = 0

    for (let ci = 0; ci < 3; ci++) {
        const current = columns[ci]
        const counts = deadColumns[ci]

        for (let dy = -1; dy <= 1; dy++) {
            if (ci === 1 && dy === 0) continue

            const ny = y + dy

            if (current?.has(ny)) {
                sum++
            } else {
                counts.set(ny, (counts.get(ny) || 0) + 1)
            }
        }
    }

    // 根据 sum 判断当前细胞能否存活,写入新世界。
}

这里仍然检查八个邻居,规则没有变。但外层列的查找已经移到了细胞循环之外。

一列里有一百个细胞,就可以让这一百个细胞复用同一组列引用,而不是每检查一个邻居,都重新定位它所属的列。

为什么嵌套的反而更快

这里需要把两件事分开。

首先,嵌套结构本身不保证快。如果直接在最里面写:

word.get(nx)?.has(ny)

每个邻居都查一次外层 Map,再查一次内层 Set,那多出来的一层确实可能成为负担。

这次实现利用了按列遍历,把外层查询提前,并且复用了死亡位置的计数列。数据结构和遍历方式是一起改的。

其次,数值编码版本来就只需要一次 alive.has(key)。所以不能说嵌套版把它的八次邻居查询减少了。八个邻居还是要检查,变化在于查询使用的键,以及查询落在哪个集合里。

数值编码版把整个世界放在一个大 Set 里,死亡候选也集中在一个大 Map 里,每次访问使用打包后的大整数。

嵌套版则把访问分散到各列的小集合里。内层只保存 y 坐标,处理同一列时持续复用几个集合。集合规模和访问局部性都发生了变化,不过具体能节省多少,仍然需要测试。

还有一个容易忽略的地方:数值键也不一定具有相同的处理成本。

这个编码中,即使 (0, 0),得到的键也已经在 2 ** 47 附近。它虽然是精确的整数,但已经远超 V8 小整数表示的范围。

V8 对小整数有专门的 Smi 表示,不必把每个值都当成独立的堆对象处理。嵌套版测试数据中的 x、y 则都比较小,可以落在这个范围内。V8 对小整数表示的说明

因此,打包后的大整数,与直接使用小坐标,不能只因为 JavaScript 层面都叫 number,就认为内部成本相同。

不过,这次没有单独拆分数值表示、集合访问和内存分配各自的耗时。可以确认的是这份嵌套实现整体更快,不能把全部差距归因于其中某一点,也不能据此认为所有数值编码方案都会输。

三种结构放在一起测

测试环境是 Windows x64、i5-14600KF、Node.js v22.17.0。三种实现使用相同的初始坐标,预热后轮换执行,各测五次取中位数。只统计单代计算,不包含初始化和绘制。

初始活细胞与分布字符串对象数值编码 Set/Map嵌套 Map/Set
1 万,约 30% 密度32.97 ms7.44 ms4.18 ms
10 万,约 30% 密度906.30 ms506.84 ms60.84 ms
30 万,约 30% 密度3213.80 ms2205.39 ms202.31 ms
10 万,约 1% 密度1470.15 ms671.51 ms113.76 ms
10 万,约 80% 密度516.62 ms153.34 ms18.81 ms
3 万,沿对角线分散且互不相邻358.91 ms123.02 ms63.79 ms

数值编码在这些场景中都比字符串对象快,但数量上来后,收益没有一开始那么明显。三十万细胞这一组,数值编码只提升了约 1.46 倍,嵌套版则提升了约 15.9 倍。

同样是十万个细胞,密度不同,耗时也差不少。分布越稀疏,通常会产生越多互不重叠的死亡候选位置,需要维护的计数集合也就越大。只看活细胞数量,并不能判断这一代有多少工作要做。

最后一组每列只有一个细胞,基本用不上列内复用,还要创建很多小集合。嵌套版这次仍然更快,但样本波动较大,不能据此认为它在所有分布下都会占优。

连续演化也测了。两万初始细胞、约 30% 密度,按标准规则运行十代,总耗时分别是 825.72 ms、223.80 ms、95.72 ms。所以最后选了嵌套 Map/Set。

存储可以按列,更新仍然按代

这里保留了原来的更新方式:所有判断读取旧世界,结果写入新世界,整代结束后才统一替换。

const newWord = new Map()

// 读取 this.word,计算并写入 newWord。
// 所有列处理完毕后,再切换到下一代。
this.word = newWord

不能算完一列就覆盖一列,否则后面的细胞会读到已经更新过的邻居,同一轮里混入两代数据。

验证时也没有只比较细胞数量,而是逐代比较完整坐标集合。三种实现经过静物、振荡器、滑翔机、随机分布和负坐标等测试,结果一致后,才接回项目。

实际 checkRule() 又单独测了一轮:十万细胞从 765.03 ms 降到 54.23 ms,三十万从 2300.83 ms 降到 178.13 ms,约有 13~14 倍的提升。

这次没有减少八个邻居的检查,也没有改变演化规则。主要省掉了坐标字符串的来回转换,并通过按列组织数据,让重复的列查询能够复用。

目前保留的就是这套嵌套 Map/Set。三十万细胞算一代仍然需要一百多毫秒,谈不上解决了所有性能问题,但原来一次计算要等几秒的情况已经改善了很多。这个耗时也不等于页面帧率,画布绘制和交互的开销,还需要另外计算。