概念页
容器与哈希表
Containers & Hash Tables
容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。
常见容器(卷 I 6.3)
| 容器 | 要点 | 标准库对应 |
|---|---|---|
| 数组 | 有序、连续、按索引访问,长度通常编译期确定,可多维 | int a[5] |
| 动态数组 | 运行时可变长 | std::vector |
| 链表 | 有序,元素不连续,靠指针相连 | std::list |
| 栈 / 队列 / 双端队列 | LIFO / FIFO / 两端高效增删 | std::stack / std::queue / std::deque |
| 树 | 层级结构,每个节点零或一个父节点;是 DAG 的特例 | — |
| 二叉搜索树 | 每节点至多两个子节点并保持有序;红黑树、伸展树、AVL 树等 | — |
| 二叉堆 | 形状性质(完全填满、最后一行从左到右)+ 堆性质(每个节点「大于等于」其子节点) | — |
| 优先队列 | 任意顺序加入,按优先级取出;通常用堆实现,只支持取最高优先级元素 | std::priority_queue |
| 字典 | 键值对表,按键快速查找;哈希表只是其一种实现 | std::map(红黑树等平衡树)、std::unordered_map(哈希表);书中写的 std::hash_map 见下方注 |
| 集合 | 元素唯一,像只有键的字典 | — |
| 图 / DAG | 任意连接的节点;有向无环图 | — |
- 原书问题:① 书中把
std::hash_map列为标准库字典,但它从未进入 C++ 标准,只是 SGI STL / MSVC 等的非标准扩展(MSVC 中已弃用);标准的哈希表容器是 C++11 的std::unordered_map(cppreference「std::unordered_map」,since C++11)。② 书中说二叉堆「像二叉搜索树一样保持排序」并不准确:堆只保证父节点与子节点之间的偏序(堆性质),兄弟之间、左右子树之间无序,因此不能按序遍历或 查找任意元素,只能 取堆顶(Wikipedia「Binary heap」)。
常见操作:插入、删除(可能需要先查找;有迭代器时直接用迭代器删更高效)、顺序访问(迭代)、随机访问、查找、排序。
迭代器(卷 I 6.3.2)
- 迭代器是「知道」如何高效遍历某种容器的小类,像数组索引或指针:指向一个元素、可前进、可判断是否遍历完。
- 好处:不破坏封装(通常是容器的友元,好的容器只能通过迭代器遍历);简化遍历——即使是中序深度优先遍历树,也能写得像遍历数组一样简单。
- 前置还是后置自增(卷 I 6.3.2.1):GEA 认为在值被使用的表达式中,
++p会引入数据依赖(CPU 要等自增完成才能用值),在深流水线 CPU 上造成停顿,而p++的值可以立即使用、自增可稍后或并行进行,所以对内置类型偏好后置自增;在for的更新表达式中两者没有区别,好的编译器能看出值没被使用。例外:重载了自增运算符的类(如迭代器),后置版本按定义必须返回未修改的副本,复制成本可能让前置自增更划算。(这是 GEA 的观点;流水线与数据依赖见 并发与并行基础。)
算法复杂度与内存布局(卷 I 6.3.3)
- 用大 O 只保留增长最快的项:、、 都是 。与元素个数无关为 ;遍历一次为 ;两重循环为 ;二分查找最坏访问 个元素,为 ;执行 n 次 子算法为 。从快到慢:、、、、、。
- 选容器时看最常用的操作,再选这些操作性能最好的容器。
- 还要考虑内存布局:数组连续、除元素外几乎无额外开销(动态数组有很小的固定开销);链表每个元素包在含 next(可能还有 prev)指针的节点里,64 位机上每元素最多 16 字节开销,且节点往往不连续。连续内存对缓存友好得多,所以高速算法通常用数组(除非链表节点本身从小块连续内存分配);频繁插入删除时链表更好。
自研还是用现成库(卷 I 6.3.4)
引擎(尤其主机和移动平台)常自研容器,理由:
- 完全控制内存需求、算法、何时如何分配;
- 优化机会:针对目标主机硬件或引擎中的特定用途调优;
- 可定制:提供标准库或 Boost 没有的算法(如查找最相关的 n 个元素);
- 去除外部依赖:问题能立即调试修复,不必等库的下一个版本(可能在游戏发售之后);
- 控制并发:Naughty Dog 在 PS4 上多数并发数据结构使用轻量的自旋锁互斥体,因为它们与基于纤程的任务调度系统配合良好,第三方库可能不给这种灵活性(见 线程同步原语)。
| 方案 | 优点 | 缺点 / 注意 |
|---|---|---|
| C++ 标准库(STL 风格容器) | 功能丰富;实现健壮、完全可移植 | 头文件晦涩(文档不错);通用容器常比专用结构慢、更耗内存;大量动态分配,难以控制;模板分配器系统不够灵活,无法配合栈分配器等(见下方注) |
| Boost | 提供标准库没有的设施;绕过标准库某些设计问题;很好地处理智能指针等复杂问题;文档出色,是学习软件设计的好材料;许多组件已进入 C++11 标准 | 部分库编译成很大的 .lib;无质量保证,bug 要自己处理;注意 Boost Software License |
| Folly | Andrei Alexandrescu 与 Facebook 工程师开发,扩展(而非替代)标准库和 Boost,强调易用与高性能 | — |
| Loki | 最著名的 C++ 模板元编程库(Alexandrescu),值得学习 | 难读难懂;部分组件依赖编译器「副作用」行为,移植性差 |
- 补充(与书中观点并列):书中关于分配器的批评主要针对 C++11 之前的模板分配器模型。C++17 引入了多态内存资源
std::pmr:std::pmr::monotonic_buffer_resource可以用一块预先提供的缓冲(甚至栈上数组)做只增不减、整体释放的分配,本质上就是栈/线性分配器,std::pmr::vector、std::pmr::list等容器都能直接使用(cppreference「std::pmr::monotonic_buffer_resource」,since C++17)。是否在运行时代码中使用 STL 仍是工程取舍。 - 原书问题:书中说更多 Boost 组件已进入「标准委员会的库技术报告 TR2」并将成为未来标准。实际上 TR2 从未发布:征集提案后 ISO 流程改变,不会有 TR2,改为以多份技术规范 (Technical Specification,如 Filesystem TS) 发布扩展(Wikipedia「C++ Technical Report 1」中的 Technical Report 2 一节)。
- 实例:PC 版《荣誉勋章:太平洋突袭》大量使用 STL,通过谨慎限制和控制其使用解决了由此引起的性能问题;OGRE 也大量使用 STL 风格容器;Naughty Dog 禁止在运行时游戏代码中使用 STL 容器(离线工具代码允许)。GEA 结论:可行,但要谨慎。
- GEA 还提醒智能指针复杂且可能很耗性能,通常句柄更好(详见卷 II 17.5 节);句柄见 自定义内存分配器。
- Loki 的基于策略的编程 (policy-based programming) 等概念可用于任何 C++ 项目,作者推荐 Alexandrescu 的《Modern C++ Design》。
动态数组与分块分配(卷 I 6.3.5)
- 定长 C 数组在游戏中很常用:无需分配、连续、缓存友好,追加和查找都高效。
- 动态数组:先分配 n 个元素的缓冲,超出时分配更大的新缓冲、复制、释放旧缓冲;按固定增量 n 或翻倍增长。作者见过的实现大多只增不减(清空为零可能释放也可能不释放),缓冲大小成为「高水位线」,
std::vector就是这样(标准规定std::vector::clear()不改变容量,需要shrink_to_fit()或与空 vector 交换才可能释放)。补充:按固定增量增长时,追加 个元素的总复制量是 ;按倍数(如翻倍)增长时追加的均摊成本为 ,这也是std::vector要求push_back均摊常数时间的原因。 - 增长可能极其昂贵(重新分配和复制),释放旧缓冲还会造成碎片。若能确定高水位线,最好在引擎启动时一次分配好。GEA 建议开发期用动态数组,确定内存预算后改为定长数组。
字典与哈希表(卷 I 6.3.6)
- 字典通常用二叉搜索树(按键有序,查找 )或哈希表实现。
- 哈希表:把键哈希成整数,对表长取模得到槽位索引;无冲突时查找 。例:5 个槽时键 3 存于 3 号槽,键 6 存于 1 号槽。
冲突处理(卷 I 6.3.6.1)
| 方式 | 别名 | 做法 | 取舍 |
|---|---|---|---|
| 开放哈希 (open hashing) | 封闭寻址 (closed addressing)、链地址法 (chaining) | 每个槽存一个链表 | 易实现、无容量上限;每次插入都要动态分配 |
| 封闭哈希 (closed hashing) | 开放寻址 (open addressing) | 冲突时按确定算法探测空槽 | 实现稍难、容量有上限(每槽一个键值对);内存固定、无动态分配,常是主机引擎的好选择 |
名称容易混淆:「开放/封闭寻址」指元素地址是否完全由哈希值决定。
哈希函数(卷 I 6.3.6.2)
- ,。键是唯一整数时可用恒等函数;32 位浮点可直接把位模式重新解释为 32 位整数(书中用
memcpy做类型双关);字符串则把所有字符的 ASCII/UTF 码合成一个 32 位整数。 - 好的哈希函数:把键均匀分布到整张表以减少冲突、计算足够快、确定性(同输入同输出)。
- GEA 表 6.1(吞吐量为粗略的相对等级,SMHasher 分数):
| 算法 | 吞吐量 | SMHasher | 密码学 |
|---|---|---|---|
| xxHash | 高 | 10 | 否 |
| MurmurHash 3a | 高 | 10 | 否 |
| SBox | 中 | 9 | 否(但是对称密钥算法的组件) |
| Lookup3 | 中 | 9 | 否 |
| CityHash64 | 中 | 10 | 否 |
| CRC32 | 低 | 9 | 否 |
| MD5-32 | 低 | 10 | 是 |
| SHA1-32 | 低 | 10 | 是 |
密码学哈希故意做得慢,目标是极难碰撞、极难由哈希值反推输入。
封闭哈希表的探测(卷 I 6.3.6.3–6.3.6.4)
- 线性探测:槽 i 被占则试 i+1、i+2……(到表尾回绕);变体是前后交替 i+1、i−1、i+2、i−2……。缺点是键值对容易「扎堆」。
- 二次探测:试 ,即 (都要对表长取模),减少聚集。
- 封闭哈希宜用素数表长;素数表长配合二次探测,覆盖槽位最好、聚集最少。
- 补充(书中未说明的前提):单向二次探测 在素数表长 下只保证前约 个探测位置互不相同,负载因子超过 1/2 时插入可能找不到空槽;若像书中那样正负交替 (),且表长是模 4 余 3 的素数(如 7、11、19、23、31),前 个探测恰好覆盖全部槽位,只要有空槽就一定能找到(Wikipedia「Quadratic probing」)。
- Robin Hood 哈希:近年流行的封闭哈希探测方案,即使表接近满也能提升性能(GEA 未展开细节)。
与 UE5 的对照
容器与字符串 记录 UE5 的 TArray 是带可选分配器参数、默认堆分配并多分配余量的连续容器,TMap/TSet 基于哈希与稀疏数组、遍历顺序不保证、扩容可能使元素指针失效。这对应 GEA 所说的动态数组「增长需重新分配与复制」,以及自研容器可接入自定义分配器的理由。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 6 章 6.3 节
相关页面
游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发 · UE5容器与字符串Containers & StringsUE 不用 STL,而用自带的值类型容器:TArray(连续数组)、TMap/TMultiMap(哈希映射)和 TSet(哈希集合),都假定元素可按字节直接搬移。字符串分三种:FName 存索引、比较便宜,FText 用于可本地化的界面文本,FString 可以修改但开销最大。选错类型的代价主要是性能和本地化。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发资源管理器Resource Manager游戏由网格、材质、纹理、着色器、动画、音频、关卡布局、碰撞与物理参数等大量资源(也叫资产、媒体)构成。内存有限,所以引擎要保证每个资源在内存中只有一份(五个网格共用一张纹理时只载入一份)。每个引擎都有某种资源管理器:有的是统一集中的子系统,有的散布在多年间由不同人写的各个子系统中,但职责和要解决的问题是一样的。本页讲运行时部分:职责、文件组织与归档、文件格式、资源 GUID、资源注册表、生命周期(引用计数)和资源的内存管理;交叉引用与加载后初始化见 资源交叉引用与加载后初始化,离线工具链见 资产管线与工具链。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。