《游戏引擎实现原理》7.1 动态数组与开放寻址哈希

从零实现 helio 的两个基础容器:增长因子可配的动态数组,以及用 FNV-1a 加线性探测、Robin Hood 抢占与墓碑删除的开放寻址哈希表。给出 1.25×/1.5×/2× 三档因子的扩容与搬运实测,以及探测步数分布和墓碑退化数据,全部本机跑出。

7.1 动态数组与开放寻址哈希

引擎里最常用的两个容器,不是内置的 Array 和 Map,而是自己写的动态数组和自己写的开放寻址哈希。理由很直接:Array 什么时候扩容、扩多大由 V8 决定,你插不上手;Map 是链地址实现,每个条目是一个独立的哈希节点,遍历时是一串指针追逐。这两点在内核热路径上都是负担。这一节从零实现 helio 的两个基础容器,并给出实测的增长与探测开销。

本章与站内既有内容的分工

第 7 章是「容器与数学」的第一章。站内已经有人把动态数组和哈希表的原理讲透了,本节不再重复「是什么」,只写「在引擎里怎么落地、跑出什么数」:

既有文章它负责什么本节只做什么
/posts/typescript/TS 里数组、Map、Set 的语义与常见用法绕开内置容器,自己控容量与增长因子
/posts/golang/Go 的 slice 扩容、map 的哈希实现(工程视角)用 TypeScript 复刻同一套机制并实测
/posts/cs-fundamentals/哈希、探测、装载因子的教科书定义交付 helio 里可直接编译运行的实现

一句话:原理去看上面三处,本节只交付能编译、能跑、有数字的实现。

增长策略:1.5× 还是 2×

动态数组的第一个决定是扩容因子。容量不足时按因子翻倍,代价是要把旧内容搬进新缓冲区。因子越大,扩容次数越少,但峰值超配越多;因子越小越省内存,但搬运更频繁。下面是 helio 的 DynArray,把因子做成构造参数,并统计「扩容次数」与「累计搬运元素数」,让取舍变成可测量的:

// file: src/core/dyn-array.ts
// tier: L1
// verify: npx vitest run src/core/dyn-array.test.ts
//
// 动态数组:容量不足时按固定因子扩容。增长因子可配(1.25 / 1.5 / 2),
// 并统计「扩容次数」与「累计搬运元素数」——这两个数直接决定增长因子的取舍。

export class DynArray<T> {
  #data: (T | undefined)[];
  #len = 0;
  #cap: number;
  #factor: number;
  #minCap: number;

  /** 扩容次数:容量每翻一次加一。 */
  reallocations = 0;
  /** 累计搬运元素数:每次扩容要把旧内容拷到新 buffer。 */
  copies = 0;

  constructor(growFactor = 2, minCap = 8) {
    this.#factor = growFactor;
    this.#minCap = minCap;
    this.#cap = 0;
    this.#data = [];
  }

  get length(): number {
    return this.#len;
  }

  get capacity(): number {
    return this.#cap;
  }

  /** 把容量至少抬到 need,仍不足则按因子继续翻。 */
  #grow(need: number): void {
    let cap = this.#cap === 0 ? this.#minCap : this.#cap;
    while (cap < need) cap = Math.ceil(cap * this.#factor);
    const next: (T | undefined)[] = new Array(cap);
    for (let i = 0; i < this.#len; i++) next[i] = this.#data[i];
    this.copies += this.#len;
    this.reallocations++;
    this.#data = next;
    this.#cap = cap;
  }

  /** 预留容量,避免后续多次扩容。 */
  reserve(need: number): void {
    if (need > this.#cap) this.#grow(need);
  }

  push(value: T): void {
    if (this.#len === this.#cap) this.#grow(this.#len + 1);
    this.#data[this.#len++] = value;
  }

  pop(): T | undefined {
    if (this.#len === 0) return undefined;
    const v = this.#data[--this.#len];
    this.#data[this.#len] = undefined;
    return v;
  }

  get(index: number): T | undefined {
    if (index < 0 || index >= this.#len) return undefined;
    return this.#data[index];
  }

  set(index: number, value: T): void {
    if (index < 0 || index >= this.#len) throw new RangeError(`index ${index} out of range`);
    this.#data[index] = value;
  }

  /** 就地清零,容量保留(下次 push 不再扩容)。 */
  clear(): void {
    for (let i = 0; i < this.#len; i++) this.#data[i] = undefined;
    this.#len = 0;
  }

  /** 顺序遍历有效区间,不越界到预留容量。 */
  *[Symbol.iterator](): Generator<T> {
    for (let i = 0; i < this.#len; i++) yield this.#data[i] as T;
  }

  toArray(): T[] {
    return [...this];
  }
}

用它 push 100 万个整数,本机实测三档因子:

因子扩容次数累计搬运最终超配耗时
1.25×534.17 M1.04×21.3 ms
1.5×302.10 M1.05×10.7 ms
2×181.05 M1.05×7.4 ms

(原始数据见附录 C;绝对数值不可移植,请看方向。)

结论很清楚:搬运量近似与因子成反比——2× 搬 1.05 M,1.5× 搬 2.10 M,1.25× 搬 4.17 M。而「最终超配」三档都在 1.05× 附近,因为触发扩容的那一刻容量总是刚越过阈值。所以 2× 在这个模型里几乎全胜:搬得最少、最快。那为什么很多引擎(包括 Go 的 slice)在数组变大后改用 1.25×~1.5×?

因为上表只算了 push 的成本,没算 内存的代价。2× 的容量在扩容瞬间可以翻倍,一段已经很大的缓冲区再来一次 2× 可能就是几十 MB 的瞬时浪费,而游戏机的内存预算卡得很死。工程上的通行做法是分段因子:小容量时用 2×(拷贝便宜、省次数),容量超过某个阈值后降到 1.5× 或 1.25×(省峰值)。helio 的 DynArray 把因子暴露出来,正是为了让你按内存预算自己选这一档。

开放寻址哈希:线性探测、Robin Hood 与墓碑

哈希表有两种基本布局:链地址(每个桶挂一条链)和开放寻址(所有元素挤在同一数组里,冲突了就往后找下一个空槽)。引擎更偏爱开放寻址,因为它把所有槽位放在一段连续内存里,查找顺着缓存行往下走,不像链地址那样到处追指针。

helio 的 HashMap 用 FNV-1a 把字符串键压成 32 位哈希,再做线性探测。它支持两种模式:普通线性探测,和 Robin Hood 抢占。删除用墓碑标记——这是开放寻址最容易踩的坑,下面会看到为什么不能直接把槽位清成空。

// file: src/core/hash-map.ts
// tier: L1
// verify: npx vitest run src/core/hash-map.test.ts
//
// 开放寻址哈希表(字符串键)。线性探测 + 墓碑删除,可选 Robin Hood 抢占。
// 不靠链地址,所有槽位都在同一段连续数组里,缓存友好。
// 统计总探测步数,用来量化「装载因子升高时查找代价怎么涨」。

export const ProbeMode = { LINEAR: 'linear', ROBIN_HOOD: 'robinhood' } as const;
export type ProbeMode = (typeof ProbeMode)[keyof typeof ProbeMode];

const EMPTY = 0;
const OCCUPIED = 1;
const TOMB = 2;

/** FNV-1a 32 位哈希:字符串 → 无符号整数。 */
export function fnv1a(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

export class HashMap<V> {
  #keys: (string | undefined)[];
  #values: (V | undefined)[];
  #state: Uint8Array;
  #dist: Int32Array; // 探测距离(Robin Hood 用)
  #mask: number;
  #size = 0;
  #tombstones = 0;
  #mode: ProbeMode;
  #maxLoad: number;

  /** 总探测步数:每访问一个槽位计一步。 */
  probes = 0;

  constructor(mode: ProbeMode = ProbeMode.LINEAR, initialCap = 16, maxLoad = 0.75) {
    let cap = 1;
    while (cap < initialCap) cap <<= 1;
    this.#mode = mode;
    this.#maxLoad = maxLoad;
    this.#mask = cap - 1;
    this.#keys = new Array(cap);
    this.#values = new Array(cap);
    this.#state = new Uint8Array(cap);
    this.#dist = new Int32Array(cap);
  }

  get size(): number {
    return this.#size;
  }

  get capacity(): number {
    return this.#mask + 1;
  }

  #rehash(cap: number): void {
    const oldKeys = this.#keys;
    const oldValues = this.#values;
    const oldState = this.#state;
    this.#keys = new Array(cap);
    this.#values = new Array(cap);
    this.#state = new Uint8Array(cap);
    this.#dist = new Int32Array(cap);
    this.#mask = cap - 1;
    this.#size = 0;
    this.#tombstones = 0;
    for (let i = 0; i < oldKeys.length; i++) {
      if (oldState[i] === OCCUPIED) this.#insert(oldKeys[i] as string, oldValues[i] as V);
    }
  }

  /** 返回键所在的槽位下标;不存在返回 -1。遇到 EMPTY 才停(墓碑不停)。 */
  #find(key: string): number {
    let slot = fnv1a(key) & this.#mask;
    for (;;) {
      this.probes++;
      const st = this.#state[slot];
      if (st === EMPTY) return -1;
      if (st === OCCUPIED && this.#keys[slot] === key) return slot;
      slot = (slot + 1) & this.#mask;
    }
  }

  /** 插入一个「确认不存在」的新键,占第一个 EMPTY 或 TOMB 槽。 */
  #insert(key: string, value: V): void {
    let slot = fnv1a(key) & this.#mask;
    let dist = 0;
    let curKey = key;
    let curValue = value;
    for (;;) {
      this.probes++;
      const st = this.#state[slot];
      if (st === EMPTY || st === TOMB) {
        if (st === TOMB) this.#tombstones--;
        this.#keys[slot] = curKey;
        this.#values[slot] = curValue;
        this.#state[slot] = OCCUPIED;
        this.#dist[slot] = dist;
        this.#size++;
        return;
      }
      if (this.#mode === ProbeMode.ROBIN_HOOD && this.#dist[slot] < dist) {
        // 抢占:把「更富」的槽位让给探测距离更长的键,被换出的键继续向后找
        const tk = this.#keys[slot] as string;
        const tv = this.#values[slot] as V;
        const td = this.#dist[slot];
        this.#keys[slot] = curKey;
        this.#values[slot] = curValue;
        this.#dist[slot] = dist;
        curKey = tk;
        curValue = tv;
        dist = td;
      }
      slot = (slot + 1) & this.#mask;
      dist++;
    }
  }

  set(key: string, value: V): void {
    const found = this.#find(key);
    if (found >= 0) {
      this.#values[found] = value; // 已存在:原地覆盖
      return;
    }
    if ((this.#size + this.#tombstones + 1) / (this.#mask + 1) > this.#maxLoad) {
      this.#rehash((this.#mask + 1) * 2);
    }
    this.#insert(key, value);
  }

  get(key: string): V | undefined {
    const slot = this.#find(key);
    return slot >= 0 ? this.#values[slot] : undefined;
  }

  has(key: string): boolean {
    return this.#find(key) >= 0;
  }

  /** 墓碑删除:标记为 TOMB,查找时继续向后扫,不打断探测链。 */
  delete(key: string): boolean {
    const slot = this.#find(key);
    if (slot < 0) return false;
    this.#state[slot] = TOMB;
    this.#values[slot] = undefined;
    this.#size--;
    this.#tombstones++;
    return true;
  }

  /** 平均每次访问的探测步数(get/set/delete 的累计除以条目数)。 */
  avgProbes(): number {
    return this.probes / Math.max(1, this.#size);
  }

  *entries(): Generator<[string, V]> {
    for (let i = 0; i < this.#state.length; i++) {
      if (this.#state[i] === OCCUPIED) yield [this.#keys[i] as string, this.#values[i] as V];
    }
  }
}

三个关键点:#find 遇到 EMPTY 才停、遇到 TOMB 继续走——删除时若把槽清成 EMPTY,就会在探测链中间挖一个洞,链上后面的键再也找不到,所以删除只标记 TOMB;#insert 只负责插「确认不存在」的新键,因此可以安全复用第一个 EMPTY 或 TOMB 槽,不会产生重复键;Robin Hood 在插入时抢占——若当前槽住户的探测距离比自己短,就把槽让给自己、把住户挤到后面继续找位置,查找的平均代价不变,但探测长度的尾部会短很多。

可运行产物与实测

把实现放进 src/core/,配一个 vitest 用例就能真跑:线性探测与 Robin Hood 对同一批键给出完全一致的结果,删除一半再查剩下的键、删除后再插入同键都不产生重复。npx vitest run src/core/hash-map.test.ts 全部通过。装载因子固定在 ~0.74、插入 39 万个键,本机实测两种模式的查找探测步数分布:

模式平均探测p99最大
线性探测2.36 步19 步133 步
Robin Hood2.36 步8 步21 步

平均一模一样,尾部差了一个量级——这正是 Robin Hood 的价值:它不改善平均,而是把「最坏的那一次」压下来。游戏里一次卡顿往往就来自那个 133 步的极端探测。

墓碑的代价也能量出来。同样是 19.5 万个存活条目、同一容量,干净表的「未命中查找」平均 1.75 步,而积累了 50% 墓碑的表要 7.78 步——多了 4.4 倍。因为墓碑占着槽位,查找要扫过更多非空槽才碰到 EMPTY。这解释了为什么工程实现会在墓碑过多时重建表:helio 的 set 用「存活 + 墓碑」的占比触发扩容,rehash 顺带丢掉墓碑,就是同一思路。

一个必须记住的坑:#insert 复用 TOMB 槽之前,一定要先确认键不存在。否则「先删一个键、再插入同键」会在新槽落座的同时,旧的 OCCUPIED 槽还留着,表里出现两个同键条目。helio 把「查存在性」和「插入」拆成 #find + #insert 两步,就是为了堵这个洞。

阅读导航:上一节:目录 · 下一节:位集合、环形缓冲与侵入式链表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径