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× | 53 | 4.17 M | 1.04× | 21.3 ms |
| 1.5× | 30 | 2.10 M | 1.05× | 10.7 ms |
| 2× | 18 | 1.05 M | 1.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 Hood | 2.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 两步,就是为了堵这个洞。
阅读导航:上一节:目录 · 下一节:位集合、环形缓冲与侵入式链表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。