其他八股总结
感觉有哪些遗漏但是不好分类的就放在这里
本篇部分非个人总结来源于卡哥的github
github地址
这里写目录标题
- 什么时候会产生栈溢出
- 以下是一些产生栈溢出的常见情况:
- 递归函数容易导致栈溢出的原因包括:
- 什么是堆,如何维护堆
- 红黑树
- 与平衡二叉树和B/B+树相比,红黑树有以下几点区别:
- 什么是哈夫曼树
- 二叉树的各种类型
- 如何判断图中是否有环(拓扑排序)
- 数组和链表的区别
- 数组:
- 链表:
- 栈和队列的区别
- 栈:
- 队列:
- 适用场景:
- 二叉树和链表的区别
- 结构:
- 存储方式:
- 操作效率:
- 应用场景:
- 单例模式
- 饿汉式单例
- 懒汉式单例
- 线程安全的懒汉式改进
- 饿汉式 vs 懒汉式对比
- 游戏开发中的实际选择
- 单例模式的替代方案
- 处理大规模数据
- **1. 排行榜(Top-N 实时排序)**
- **2. 大型开放世界动态对象管理**
- **3. 大规模玩家同屏战斗**
- **4. 海量技能/特效管理**
- **5. 超多AI单位决策**
- **6. 大数据量存储与查询**
- **通用优化原则**
- 玩家排行榜下拉卡顿优化
- 塔防游戏防御塔索敌优化
- 2D超大地图加载卡顿优化
- 游戏引擎的渲染管线
- 渲染管线核心流程
- 现代渲染管线技术演进
- 性能优化关键点
- 帧同步与状态同步
- 帧同步
- 状态同步
- Unity中的可视化编程
- UE蓝图
- 点乘叉乘
- 几何算法
- 游戏开发中的性能优化
- 哈希表相关
- 在遍历容器时删除元素的方法
什么时候会产生栈溢出
栈溢出是指程序运行时使用的栈空间超过了系统为栈分配的内存大小,导致栈溢出错误。栈通常用来存储函数调用、局部变量和临时数据,当递归层次过深或者函数调用过多时,会消耗大量栈空间,导致栈溢出。
以下是一些产生栈溢出的常见情况:
递归调用:在递归函数中,每次调用都会将函数的参数、返回地址等信息存放在栈中,如果递归层次过深,栈空间会被消耗殆尽。
大量局部变量:如果函数中定义了大量的局部变量,也会增加栈空间的使用。
无限循环:在一个循环中不断地调用函数或者产生新的栈帧,会迅速消耗栈空间。
递归函数容易导致栈溢出的原因包括:
没有合适的终止条件:如果递归函数没有设置终止条件或者终止条件设计不当,会导致无限递归调用。
递归调用次数过多:每次递归调用都会在栈中创建一个新的栈帧,如果递归调用次数过多,会消耗大量栈空间。
什么是堆,如何维护堆
堆是一种特殊的树形数据结构,通常用于实现优先队列。堆分为最大堆(Max Heap)和最小堆(Min Heap),其中最大堆满足父节点的值大于等于子节点的值,最小堆满足父节点的值小于等于子节点的值。
维护堆的主要操作有两个:插入和删除。在插入一个新元素时,需要保持堆的性质不变;在删除堆顶元素时,同样需要保持堆的性质不变。以下是维护堆的具体操作:
- 插入元素:
- 将新元素插入到堆的末尾。
- 从新元素开始向上调整(最大堆时向下调整),直至满足堆的性质。
- 删除堆顶元素:
- 将堆顶元素(根节点)与堆的最后一个元素交换。
- 删除最后一个元素,即原来的堆顶元素。
- 从根节点开始向下调整(最大堆时向上调整),直至满足堆的性质。
在维护堆时,通常会使用“上浮”和“下沉”的方式进行调整,保持堆的性质。具体来说,插入时新元素上浮,删除堆顶元素后,末尾元素下沉。这样可以确保堆的顺序关系不被破坏。
维护堆的时间复杂度为 O(log n),其中 n 为堆的大小。
红黑树
红黑树是一种自平衡二叉查找树,具有以下特点:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色。
- 叶子节点(NIL节点)是黑色。
- 如果一个节点是红色,则它的子节点必须是黑色。
- 从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。
红黑树通过对节点着色和旋转等操作来维持平衡,保证在最坏情况下的查询、插入、删除等操作的时间复杂度为 O(log n),是广泛应用于数据结构和算法中的一种重要数据结构。
插入时默认红色节点,会根据红黑树的特性调整
与平衡二叉树和B/B+树相比,红黑树有以下几点区别:
平衡二叉树(如AVL树):平衡二叉树要求左右子树的高度差不超过1,因此需要频繁地进行旋转操作来维持平衡,相比之下红黑树更加灵活,在实际应用中性能更好。
红黑树左右子树高度差不超过两倍,AVL左右子树高度差不超过1,AVL树对高度要求更严格
红黑树插入删除效率高,AVL树查找效率更高
B/B+树:B树和B+树是多路搜索树,适合磁盘存储等场景,可以一次读取多个关键字。而红黑树更适用于内存中数据结构的实现,虽然红黑树也可以作为B树和B+树的基础实现。
红黑树相对于B树和B+树来说,对于数据的插入、删除等操作可能更为简单高效,但在某些特定场景下B树和B+树可能更适合。
什么是哈夫曼树
哈夫曼树是一种带权路径长度最短的二叉树,通常用于数据压缩中。构造哈夫曼树的过程称为哈夫曼编码。
构造哈夫曼树的步骤如下:
- 将所有的节点按照权值进行升序排序。
- 选取权值最小的两个节点作为左右子节点,生成一个新的节点,其权值为这两个节点的权值之和。
- 将新生成的节点插入到原来的节点集合中,并从节点集合中删除这两个被合并的节点。
- 重复上述步骤,直到节点集合只剩下一个节点,即为根节点,构成哈夫曼树。
哈夫曼树的应用场景主要在数据压缩中,通过构建哈夫曼树并生成对应的哈夫曼编码(即将频率高的字符编码短、频率低的字符编码长),可以实现数据的有损压缩,减少数据的存储空间和传输带宽。常见的应用包括文本文件压缩、图像压缩、音频压缩等。哈夫曼编码还被广泛应用于网络传输、文件存储等领域。
二叉树的各种类型
-
二叉搜索树:
二叉搜索树是一种特殊的二叉树,对于每个节点,其左子树中的所有节点值都小于该节点的值,右子树中的所有节点值都大于该节点的值。
二叉搜索树的中序遍历结果是有序的。 -
平衡二叉树:
平衡二叉树是一种特殊的二叉搜索树,它保持左右子树的高度差不超过1,以确保树的高度平衡。
平衡二叉树的插入和删除操作会导致树的自平衡调整,以保持平衡性。 -
完全二叉树:
完全二叉树是一个二叉树,除了最后一层外,其他层都是满的,且最后一层的节点从左向右依次排列。
完全二叉树通常使用数组来存储,可以利用数组索引计算节点之间的关系。 -
满二叉树:
满二叉树是一种特殊的二叉树,每个节点要么没有子节点,要么有两个子节点。
满二叉树的叶子节点都在同一层,且所有非叶子节点都有两个子节点。
总结:
二叉树是具有两个子节点的树状结构。
二叉搜索树是一种特殊的二叉树,左子树节点值小于根节点,右子树节点值大于根节点。
平衡二叉树是一种保持平衡性的二叉搜索树。
完全二叉树是除了最后一层外都是满的二叉树。
满二叉树是每个节点要么没有子节点,要么有两个子节点的二叉树。
如何判断图中是否有环(拓扑排序)
- 统计每个顶点的入度:
对于一个有向图,顶点的入度是指指向该顶点的边的数量。这一步是为了找出图中没有前驱节点的顶点,因为在一个无环的有向图(即有向无环图,DAG)中,一定存在一些顶点没有前驱节点(入度为 0)。
例如,在一个表示课程先修关系的有向图中,那些没有先修要求的课程对应的顶点入度就为 0。 - 将所有入度为 0 的顶点加入一个队列:
队列是一种先进先出的数据结构。将入度为 0 的顶点加入队列,是为了后续依次处理这些顶点。这些顶点可以被看作是拓扑排序的起始点,因为它们没有前驱节点,可以先被 “处理”。 - 从队列中依次取出顶点,并将其邻接节点的入度减 1:
当从队列中取出一个顶点时,意味着这个顶点已经在拓扑排序中找到了合适的位置。因为这个顶点没有前驱节点(或者其前驱节点已经被处理完了),所以可以将它从图中 “移除”。
移除的方式是将它的所有邻接节点(即从该顶点出发有边指向的节点)的入度减 1。这是因为该顶点已经被处理,它的存在不再影响其邻接节点的入度。 - 如果邻接节点的入度变为 0,则将其加入队列:
在将某个顶点的邻接节点的入度减 1 后,如果某个邻接节点的入度变为 0,说明这个邻接节点现在也没有未处理的前驱节点了,它也可以被处理了,所以将其加入队列。
这样不断重复处理,直到队列为空。 - 判断是否有环:
如果在处理完所有顶点后(即队列为空),图中所有顶点都被处理过了(即每个顶点的入度都变为 0),那么说明图中不存在环,因为拓扑排序成功完成。
反之,如果队列为空后,图中仍然存在一些顶点的入度不为 0,说明这些顶点存在循环依赖,即图中存在环。这是因为存在环的情况下,环上的顶点的入度永远不会变为 0,因为它们相互依赖,无法找到一个没有前驱节点的起始点来开始拓扑排序。
数组和链表的区别
数组:
- 数组是一种线性数据结构,元素在内存中连续存储。
- 数组具有固定的大小,在创建时需要指定大小。
- 可以通过索引直接访问数组中的元素,时间复杂度为O(1)。
- 插入和删除元素时,需要移动其他元素来保持连续性,时间复杂度为 O(n)。
- 适合用于元素个数固定、对随机访问要求较高的场景。
链表:
- 链表是一种非连续的线性数据结构,通过指针将元素串联在一起。
- 链表的大小可以动态调整,不需要预先指定大小。
- 链表插入和删除元素的操作简单,时间复杂度为 O(1)。
- 链表不能通过索引直接访问元素,需要从头节点开始遍历,时间复杂度为 O(n)。
- 分为单向链表、双向链表和循环链表等多种形式,提供了更多的灵活性。
- 适合用于频繁插入和删除操作、对内存空间要求较高或元素个数变化较大的场景。
栈和队列的区别
栈:
- 栈是一种后进先出(LIFO,Last In First Out)的数据结构,类似于一摞盘子,只能在栈顶进行操作。
- 栈的插入(压栈)和删除(弹栈)操作都发生在栈顶元素,时间复杂度为 O(1)
- 栈通常用于实现函数调用、表达式求值、括号匹配等场景。
- 常见的栈包括数组实现的顺序栈和链表实现的链式栈。
队列:
- 队列是一种先进先出(FIFO,First In First Out)的数据结构,类似于排队等候的过程,新元素在队尾插入,从队头删除。
- 队列的插入(入队)和删除(出队)操作分别在队尾和队头进行,时间复杂度为 O(1)。
- 队列通常用于任务调度、缓冲区管理、广度优先搜索等场景。
- 常见的队列包括数组实现的顺序队列和循环队列,以及链表实现的链式队列。
适用场景:
栈适合用于需要后进先出的场景,如函数调用、逆波兰表达式求值等。
队列适合用于需要先进先出的场景,如任务调度、消息传递等。
在实际应用中,根据具体需求选择合适的数据结构可以提高程序效率和简化算法设计。
二叉树和链表的区别
结构:
- 链表是由节点顺序连接而成的线性数据结构,每个节点包含数据域和指向下一个节点的指针。
- 二叉树是一种树状结构,每个节点最多有两个子节点,分别为左子节点和右子节点。
存储方式:
- 链表的节点在内存中是按顺序存储的,通过指针进行连接。
- 二叉树的节点在内存中可以采用链式存储(使用指针连接)或者数组存储(通过计算索引实现)。
操作效率:
- 在链表中,插入和删除操作的时间复杂度为 O(1),查找操作的时间复杂度为 O(n)。
- 在二叉树中,对于平衡二叉搜索树,插入、查找、删除等操作的时间复杂度为 O(log n)。
应用场景:
- 链表适合动态管理数据集合,特别是频繁需要插入和删除操作的场景。
- 二叉树适合用于搜索、排序等需要快速查找的应用,如二叉搜索树用于快速查找。
单例模式
单例模式的饿汉式和懒汉式是两种经典实现方式,核心区别在于实例初始化的时机和线程安全性。结合游戏开发的实际场景,我们来详细对比它们的实现、优缺点和适用场景。
饿汉式单例
核心思想:
提前初始化:在程序启动时(类加载阶段)立即创建实例,无论是否使用。
线程安全:由于实例在程序启动时已初始化,多线程环境下无需额外同步。
C++ 实现:
class AudioManager {
private:static AudioManager instance; // 静态成员变量,类加载时初始化AudioManager() {} // 私有构造函数public:static AudioManager& GetInstance() {return instance; // 直接返回已初始化的实例}// 删除拷贝构造函数和赋值运算符AudioManager(const AudioManager&) = delete;AudioManager& operator=(const AudioManager&) = delete;
};// 初始化静态成员变量(必须在类外定义)
AudioManager AudioManager::instance; // 程序启动时初始化
优点:
线程安全:无需考虑多线程初始化问题。
代码简单:没有复杂的锁机制。
缺点:
资源浪费:即使未使用单例,实例也会占用内存。
启动性能影响:若初始化耗时,会拖慢程序启动速度。
适用场景:
实例初始化成本低且必须提前加载(如游戏引擎的核心模块)。
需要严格保证线程安全的简单场景。
懒汉式单例
核心思想:
延迟初始化:在首次调用 GetInstance() 时才创建实例。
按需加载:避免不必要的资源占用。
基础实现(非线程安全):
class ResourceManager {
private:static ResourceManager* instance; // 静态指针,初始为 nullptrResourceManager() {}public:static ResourceManager* GetInstance() {if (!instance) {instance = new ResourceManager(); // 首次调用时初始化}return instance;}// 删除拷贝构造函数和赋值运算符ResourceManager(const ResourceManager&) = delete;ResourceManager& operator=(const ResourceManager&) = delete;
};// 初始化静态指针
ResourceManager* ResourceManager::instance = nullptr;
问题:
线程不安全:多线程环境下,多个线程可能同时进入 if (!instance) 导致重复创建实例。
线程安全的懒汉式改进
(1) 加锁(低效版):
#include <mutex>class NetworkManager {
private:static NetworkManager* instance;static std::mutex mtx; // 互斥锁NetworkManager() {}public:static NetworkManager* GetInstance() {std::lock_guard<std::mutex> lock(mtx); // 每次调用都加锁if (!instance) {instance = new NetworkManager();}return instance;}
};// 初始化静态成员
NetworkManager* NetworkManager::instance = nullptr;
std::mutex NetworkManager::mtx;
缺点:
性能瓶颈:每次调用 GetInstance() 都加锁,影响效率。
(2) 双重检查锁定(Double-Checked Locking):
class PhysicsEngine {
private:static PhysicsEngine* instance;static std::mutex mtx;PhysicsEngine() {}public:static PhysicsEngine* GetInstance() {if (!instance) { // 第一次检查(无锁)std::lock_guard<std::mutex> lock(mtx);if (!instance) { // 第二次检查(加锁后)instance = new PhysicsEngine();}}return instance;}
};
// 初始化静态成员
PhysicsEngine* PhysicsEngine::instance = nullptr;
std::mutex PhysicsEngine::mtx;
优点:
减少锁竞争:只有首次初始化时需要加锁。
注意事项:
C++11 前的内存可见性问题:某些编译器可能优化指令顺序,导致其他线程看到未完全初始化的实例。需使用 volatile 或原子操作(C++11 后可用 std::atomic)。
(3) C++11 后的最简线程安全实现(Meyers’ Singleton):
class InputManager {
private:InputManager() {}public:static InputManager& GetInstance() {static InputManager instance; // C++11保证静态局部变量初始化线程安全return instance;}
};
优点:
简洁安全:C++11 标准规定,静态局部变量的初始化是线程安全的。
自动销毁:程序结束时自动调用析构函数(无需手动释放内存)。
适用场景:
需要延迟初始化且追求代码简洁性的现代C++项目。
饿汉式 vs 懒汉式对比
游戏开发中的实际选择
优先使用 Meyers’ Singleton(懒汉式):
代码简洁,线程安全,自动释放内存。
适合大多数场景(如音频管理、资源加载)。
饿汉式用于关键系统:
如游戏引擎的渲染管理器、物理引擎核心模块,需确保尽早初始化。
单例模式的替代方案
即使解决了线程安全问题,单例模式仍可能带来全局状态耦合和测试困难。
全局状态耦合
单例的全局访问特性会导致模块间隐式依赖,代码难以解耦。例如:
模块A和模块B都依赖单例GameManager,修改单例的实现可能影响多个模块。
无法灵活替换单例的实现(如测试时需要用Mock对象替代)。
测试困难
单例状态残留:单例在测试中保持全局状态,导致多个测试用例互相干扰。
无法模拟(Mock):测试时难以替换单例的逻辑(如禁用网络请求)。
以下替代方案更灵活:
依赖注入:
class AchievementSystem {
public:virtual void Unlock(const string& id) = 0;
};class Player {
private:AchievementSystem& achievements; // 通过构造函数注入
public:Player(AchievementSystem& system) : achievements(system) {}
};
服务定位器模式:
class ServiceLocator {
public:static AudioManager& GetAudio() { /* ... */ }static PhysicsEngine& GetPhysics() { /* ... */ }
};
总结
饿汉式:简单粗暴,适合初始化快、必须提前加载的场景。
懒汉式:灵活按需,推荐使用C++11的静态局部变量实现(Meyers’ Singleton)。
核心原则:单例应作为最后的选择,优先考虑依赖注入或服务定位器降低耦合。
处理大规模数据
在游戏开发中,处理大规模数据是常见挑战。以下是几个典型的大数据量问题及其通用解决方案,涵盖 内存、计算、存储、同步 等多个维度:
1. 排行榜(Top-N 实时排序)
问题:
百万玩家实时积分变化,要求每10秒更新全服前100名排行榜,且支持玩家查询自己的名次。
解决方案:
- 分层数据结构:
- 跳表(Skip List) 或 平衡树(红黑树):维护全量玩家积分的排序结构,插入/删除复杂度为
O(logN)
。 - 分桶统计:将玩家按积分区间分桶(如0-1000, 1001-2000),记录每个桶的人数,查询名次时快速定位区间。
- 跳表(Skip List) 或 平衡树(红黑树):维护全量玩家积分的排序结构,插入/删除复杂度为
- 近似排名:
允许排名存在小误差(如误差±10名),通过 HyperLogLog 算法估算玩家位置,降低计算压力。 - 分页缓存:
仅预计算并缓存前1000名数据,玩家查询自身排名时按需计算(如Redis的ZRANK
命令)。
2. 大型开放世界动态对象管理
问题:
开放世界中有10万个动态对象(NPC、车辆、可破坏物品),需要高效管理其状态更新与渲染。
解决方案:
- 空间分区(Spatial Partitioning):
- 四叉树/八叉树:快速剔除视野外的对象。
- 网格划分:将世界划分为50x50的网格,仅加载玩家附近网格内的对象。
- LOD(Level of Detail):
根据距离动态调整对象细节(如1km外的NPC简化为点精灵)。 - ECS架构:
使用实体组件系统(如Unity DOTS)批量处理同类对象逻辑,减少CPU缓存未命中。
3. 大规模玩家同屏战斗
问题:
MMO游戏中1000名玩家同屏战斗,需同步位置、技能状态,且避免网络拥堵。
解决方案:
- AOI(Area of Interest):
只同步玩家视野范围内的实体(如九宫格法),减少网络包大小。 - 状态同步优化:
- Delta Compression:仅发送变化的状态字段(如坐标从
(100,200)
变为(101,200)
,发送+1,0
)。 - Dead Reckoning:客户端预测移动轨迹,服务器定时校正。
- Delta Compression:仅发送变化的状态字段(如坐标从
- 技能特效简化:
同屏人数过多时,自动隐藏低优先级特效(如队友技能仅保留音效)。
4. 海量技能/特效管理
问题:
同时存在5000个技能特效(粒子、碰撞检测),导致CPU/GPU过载。
解决方案:
- 对象池(Object Pooling):
复用特效实例,避免频繁Instantiate/Destroy。 - GPU Instancing:
合并相同特效的绘制调用,减少Draw Call(如所有火球术使用同一材质批量渲染)。 - 碰撞检测优化:
- 空间划分:使用网格或KD树加速碰撞查询。
- 简化碰撞体:远处特效用球形替代复杂Mesh碰撞。
5. 超多AI单位决策
问题:
RTS游戏中控制1万个单位寻路和决策,导致CPU卡顿。
解决方案:
- 群体行为(Flocking Algorithm):
将单位分组,按群体计算移动方向(参考Boid算法)。 - 异步分帧更新:
每帧仅更新1/10的单位(如万单位分10帧更新,每帧处理1000个)。 - 导航网格烘焙:
预计算静态场景的导航网格(NavMesh),动态障碍物用局部避障(RVO算法)。
6. 大数据量存储与查询
问题:
需要存储1亿玩家的装备数据,并支持快速查询(如“查找所有拥有‘炎龙剑’的玩家”)。
解决方案:
- 数据库分库分表:
按玩家ID哈希分表(如1024张表,每表存约10万玩家数据)。 - 倒排索引:
为装备ID建立索引表(如装备ID -> [玩家ID1, 玩家ID2]
)。 - 冷热分离:
活跃玩家数据存Redis,离线数据存MySQL,通过定时任务同步。
通用优化原则
- 分而治之:将大数据拆分为小块处理(分页、分帧、分片)。
- 空间换时间:通过缓存、预计算、索引加速查询。
- 异步化:利用多线程、JobSystem、ECS卸载主线程压力。
- 近似与降级:允许可控误差,极端场景下牺牲细节保流畅。
这些思路可以灵活组合,具体取决于项目需求和技术栈。理解问题本质(是CPU瓶颈?内存瓶颈?IO瓶颈?)是选择方案的关键。
玩家排行榜下拉卡顿优化
问题核心:UI元素过多导致渲染压力和数据处理延迟。
解决方案:
虚拟化列表:仅渲染可视区域内的条目(如20条),滚动时动态复用UI元素,减少实例化开销。
数据分页加载:每次加载固定数量(如50条),滚动到底部时异步加载下一页,避免一次性加载全部数据。
简化条目结构:使用纯文本和预生成图标,禁用复杂动画。
后台排序:提前在服务器或本地缓存排序好的数据,避免实时计算排名。
塔防游戏防御塔索敌优化
问题核心:大量敌人遍历导致CPU计算瓶颈。
解决方案:
空间分区索引:将地图划分为网格,每个网格记录其中的敌人。塔检测时只遍历所在网格及相邻网格。
事件驱动检测:敌人进入塔攻击范围时触发OnEnemyEnter事件,塔直接锁定目标,无需每帧遍历。
距离平方快速筛选:用(x² + y²)代替Vector3.Distance避免开方运算,粗略筛选后再精确检测。
批量处理(ECS):使用Unity DOTS架构,通过Job System多线程批量处理索敌逻辑。
2D超大地图加载卡顿优化
问题核心:资源集中加载导致主线程阻塞和内存峰值。
解决方案:
动态分块加载:将地图划分为区块(Chunk),根据玩家位置加载周围9个区块(3x3),异步卸载不可见区域。
纹理流式加载:根据距离动态加载不同精度的纹理(Mipmap),远处区域用低分辨率贴图。
资源预加载:玩家移动时,后台预加载前方区块,结合进度条提示提升体验。
内存池管理:常用资源(如草地、树木)常驻内存,冷门资源按需释放。
通用优化原则
数据 vs 渲染分离:确保数据处理(如排序、检测)在后台线程,渲染仅处理结果。
空间换时间:通过缓存、预计算减少实时运算量。
异步化:避免阻塞主线程,利用多线程或协程分解任务。
按需处理:只处理玩家直接交互的内容,忽略不可见或远距离对象。
游戏引擎的渲染管线
游戏引擎的渲染管线(Rendering Pipeline)是决定物体如何从3D数据最终转化为屏幕像素的核心流程。不同引擎的渲染管线细节不同,但核心逻辑相似。
渲染管线核心流程
CPU的核心职责
- 数据准备
收集场景数据(模型、材质、光源位置),处理动画、物理计算。
优化手段:视锥剔除(Frustum Culling)、遮挡剔除(Occlusion Culling)、LOD(根据距离切换模型精度)。 - 减少GPU负担
合并Draw Call:通过静态批处理(Static Batching)或动态批处理(Dynamic Batching)减少渲染指令数量。
提交指令:将处理后的数据(顶点、材质、光源)打包发送给GPU。
一句话总结:CPU是“指挥官”,决定哪些物体需要渲染,并尽量让GPU少干活。
GPU的核心职责
-
顶点处理(Vertex Processing)
通过顶点着色器将模型顶点从本地坐标转换到屏幕坐标(MVP矩阵变换)。
可选处理:曲面细分(Tessellation)增加细节。 -
光栅化(Rasterization)
将三角形转换为像素(片段),插值顶点属性(颜色、UV、法线)。 -
像素处理(Pixel Processing)
通过片段着色器计算每个像素的最终颜色(纹理采样、光照计算、阴影)。
处理深度测试(Z-Buffer)和透明度混合(Alpha Blending)。 -
后处理(Post-Processing)
应用全屏特效(如抗锯齿、Bloom、运动模糊)。
一句话总结:GPU是“画家”,高强度并行计算,负责将数据转化为最终屏幕上的像素。
现代渲染管线技术演进
1. 可编程管线(Programmable Pipeline)
核心思想:允许开发者通过Shader编写顶点/片段处理逻辑。
技术栈:
Shader Graph(Unity):可视化编写Shader(无需手写HLSL)。
Compute Shader:通用GPU计算(如粒子模拟、光线追踪)。
2. 基于物理的渲染(PBR)
核心参数:
基础颜色(Albedo)
金属度(Metallic)
粗糙度(Roughness)
公式示例(Cook-Torrance BRDF):
Specular = (D * F * G) / (4 * NdotL * NdotV)
D: 法线分布函数, F: 菲涅尔反射, G: 几何遮蔽
3. 光线追踪(Ray Tracing)
原理:模拟光线在场景中的物理传播路径。
硬件依赖:NVIDIA RTX系列GPU。
性能优化关键点
1. Draw Call优化
静态合批(Static Batching):合并静态物体的网格和材质。
GPU Instancing:通过一次Draw Call渲染多个相同物体。
2. 带宽优化
纹理压缩:使用ASTC(移动端)或BC7(PC端)压缩格式。
Mipmap:根据距离动态切换纹理分辨率。
3. Shader优化
避免分支:GPU不擅长处理if-else逻辑。
减少纹理采样:合并纹理通道(如将金属度存储在Albedo的Alpha通道)。
传统管线:适合简单场景,但难以应对复杂光照和大量物体。
现代趋势:可编程化(Shader)、物理化(PBR)、并行化(Compute Shader)。
核心原则:在视觉效果和性能之间找到平衡(如《原神》移动端与PC端的画质差异)。
帧同步与状态同步
帧同步
初始化游戏内核心数据,保证局内数据高度一致化
服务器获取到所有客户端的输入,并将所有客户端的输入再发放给每个客户端,但最慢的用户会拖慢所有的进程
帧同步的特点是在客户端可以获取到局内所有的状态,游玩时使用插件就可以获取到这些状态,但现在的游戏会采取一些方法来规避这些问题
优点:可以做一些打击状态清晰的对抗,方便做游戏录屏
缺点:保持一致性很难
状态同步
同步用户的状态信息,如关键性的爆炸,死亡等
每一个玩家提供部分的信息,Server去模拟一个世界,只把与每个用户相同的信息发给他,防作弊的能力好于帧同步
对比项 | 状态同步(State Synchronization) | 帧同步(Lockstep Synchronization ) |
---|---|---|
确定性逻辑 | 非必要 | 必要 |
响应性 | 更好 | 较差 |
网络流量 | 通常较高 | 通常较低 |
开发效率 | 复杂得多 | 易于开发,但调试困难 |
玩家数量 | 支持玩家数量较少 | 支持少量和大量玩家 |
跨平台 | 相对容易 | 相对困难 |
重连 | 相对容易 | 相对困难 |
回放文件大小 | 大 | 小 |
防作弊 | 相对困难 | 相对容易 |
(1) 数据量与带宽消耗
状态同步:
传输状态变化(如坐标、血量),数据量随对象数量和频率增加而增大。
适用场景:小规模实时对战(如MMORPG、回合制游戏)。
劣势:高频状态同步可能导致带宽压力。
帧同步:
传输操作指令(如按键、移动方向),数据量极小(通常每帧几十字节)。
适用场景:高频操作游戏(如MOBA、RTS、格斗游戏)。
优势:带宽占用低,适合大规模单位同步。
(2) 实时性与延迟敏感度
状态同步:
客户端状态依赖服务器下发,网络延迟直接影响体验(如角色移动卡顿)。
优化方案:客户端预测(Prediction) + 插值(Interpolation)缓解延迟问题。
帧同步:
客户端本地计算,延迟表现为操作响应慢,但画面流畅(通过缓冲帧平滑处理)。
优化方案:固定逻辑帧率 + 输入缓冲队列(如《英雄联盟》的“锁60帧”逻辑)。
(3) 反外挂与安全性
状态同步:
逻辑在服务器运行,客户端无法篡改核心数据,反外挂能力强。
劣势:服务器计算压力大(如MMO中大量NPC逻辑)。
帧同步:
客户端本地计算,易受篡改攻击(如加速挂、内存修改)。
防御方案:服务器校验关键行为 + 逻辑混淆(如《皇室战争》的输入校验)。
(4) 断线重连处理
状态同步:
重连后直接同步当前状态,快速恢复(如《原神》副本重连)。
帧同步:
需补发断线期间所有输入帧,重连耗时长(如《王者荣耀》断线需等待数秒)。
Unity中的可视化编程
Unity中的可视化编程(如Bolt、Visual Scripting等插件)的实现原理可以概括为以下几个核心方向:
1. 基于节点的代码抽象与代码生成
可视化编程工具通过节点图(Node Graph)将逻辑操作抽象为可拖拽的节点(如函数调用、条件分支、变量操作等)。每个节点本质上对应一段预定义的代码片段或反射方法。用户连接节点后,插件内部通过代码生成技术将节点图转换为底层的C#脚本或中间语言(IL),最终编译为Unity可执行的二进制代码。例如,Unity官方的Visual Scripting会生成优化后的C#代码,而部分插件可能依赖Roslyn编译器动态生成程序集。
2. 反射与动态绑定机制
Unity的可视化工具依赖C#的反射系统(Reflection)和特性标记(如[SerializeField]
、[ContextMenu]
),动态识别和绑定游戏对象的组件、方法及变量。例如,标记为[Public]
的字段或方法会自动暴露为节点的输入/输出端口,实现与场景对象的实时交互。部分工具还会通过IL Post-Processing(如Mono.Cecil库)在编译后修改代码,增强灵活性。
3. 数据流与状态管理
节点间的连线通过数据驱动模型传递逻辑关系。例如,控制流节点(如If
、For
)通过执行顺序线(Execution Flow)定义逻辑分支,而数据流节点(如数学运算)通过数据类型匹配确保值传递的安全性。插件内部会构建依赖图(Dependency Graph),自动处理变量作用域和生命周期,避免资源泄漏或状态冲突。
4. 与引擎底层的深度集成
可视化工具需要与Unity的组件系统(Component)、事件循环(如Update
、Coroutine
)及序列化机制(Prefab、ScriptableObject)无缝衔接。例如,自定义节点可直接调用Unity的物理引擎接口(如Rigidbody.AddForce
),或通过事件监听场景加载、输入响应等引擎原生行为。部分插件还支持与Shader Graph、Timeline等工具的联动。
5. 性能优化与调试支持
为减少运行时开销,高级工具会通过预编译优化(如静态分支预测)或热点代码替换(将高频逻辑转为原生C#)提升效率。调试功能则依赖于Unity的Editor API,例如在Play Mode下实时显示节点执行状态、变量监视和堆栈追踪,部分工具甚至支持热重载(Hot Reload)以快速迭代逻辑。
总结:Unity的可视化编程并非单纯“拖拽即代码”,而是通过反射绑定、代码生成、引擎集成与性能优化等多层技术,将图形化操作映射为底层可执行的游戏逻辑。其核心目标是降低编程门槛,同时通过与传统C#脚本的互操作性(如混合使用节点与代码)平衡灵活性与性能,但相比Unreal蓝图,其实现更依赖插件层而非引擎原生支持,因此在复杂项目中的调试和优化成本可能更高。
UE蓝图
Unreal Engine的蓝图(Blueprint)可视化编程实现原理,本质上是通过图形化逻辑映射与底层引擎架构深度协作的结果,其核心思路是将用户拖拽的节点网络转化为高效执行的游戏逻辑,同时保持对C++原生代码的无缝兼容。具体实现可归纳为几个层次:
1. 节点图的代码抽象与编译管道
用户拖拽的节点(如函数调用、变量操作、分支判断)并非简单的“图形按钮”,而是引擎内部预定义的逻辑单元模板。每个节点对应C++底层的一段功能代码(如PrintString
节点关联UKismetSystemLibrary::PrintString
函数)。当用户连接节点时,实际在构建一个有向图结构,引擎通过静态类型检查(如验证连线两端的数据类型是否匹配)确保逻辑合法性。完成编辑后,蓝图会经历编译流程:节点图首先转换为抽象语法树(AST),优化冗余操作并验证逻辑完整性,最终生成面向Blueprint虚拟机(BPVM)的字节码,这种中间代码类似汇编指令,可由引擎解释执行。对于高频调用的蓝图逻辑(如角色移动计算),引擎还支持原生化(Nativization),将字节码提前编译为C++代码,消除虚拟机开销。
2. 反射系统与动态绑定的桥梁作用
蓝图与C++的互操作性依赖于Unreal的反射系统。C++中通过UFUNCTION
或UPROPERTY
标记的类成员,会被Unreal Header Tool(UHT)提取元数据并生成反射信息。例如,一个标记为BlueprintCallable
的C++函数,在蓝图中会自动显示为可调用的节点,其输入参数和返回值类型由反射数据动态绑定。这种机制使得蓝图可以直接操作C++对象(如修改Actor的位置属性),甚至继承C++类并覆写虚函数(如Event Tick
)。反射还确保类型安全——若用户试图将浮点数节点连接到字符串输入引脚,编辑器会立即报错,而非等到运行时崩溃。
3. 数据流与对象生命周期的统一管理
蓝图节点间的连线不仅是逻辑顺序的体现,更是数据依赖关系的声明。例如,一个“获取玩家控制器”节点的输出引脚连接到“输入映射”节点时,引擎会隐式传递玩家控制器对象的引用,而无需手动管理内存。蓝图实例(如一个继承自Actor的蓝图类)在运行时拥有独立的内存空间存储变量,其生命周期与所属游戏对象(如Actor)绑定,销毁时自动释放资源。对于异步逻辑(如延迟触发或事件回调),引擎通过**委托(Delegate)和事件分发器(Event Dispatcher)**实现非阻塞操作,底层依赖Unreal的Task Graph系统调度多线程任务。
4. 编辑器集成与实时迭代支持
蓝图的实用性离不开Unreal Editor的深度支持。节点图的序列化存储为.uasset
文件,采用二进制格式以优化读写效率。用户按下“编译”按钮时,编辑器会增量更新字节码并热加载到运行中的游戏实例,实现零重启调试(Hot Reload)。调试功能则通过Blueprint Debugger模块实现,允许设置断点、单步执行并实时监视变量值,其原理是在虚拟机执行字节码时插入调试钩子(Hook),捕获堆栈帧和上下文状态。
总结:蓝图的本质是可视化语法糖,其背后串联了反射元数据、编译技术、虚拟机执行和引擎对象模型,将图形化操作转化为可高效运行的游戏逻辑。与Unity插件方案相比,蓝图作为Unreal的原生功能,与C++的耦合更紧密(如共享内存模型、直接继承C++类),且编译优化更彻底(如原生化编译)。这种设计让开发者既能享受快速原型开发的便利,又能通过混合编程(Blueprint与C++共存)应对高性能需求场景,成为Unreal“AAA级管线”的核心工具之一。
点乘叉乘
在游戏开发中,点乘(Dot Product)和叉乘(Cross Product)是向量运算的核心工具。
1. 点乘(Dot Product)
核心作用:衡量两个向量的方向一致性
几何意义:
点乘的结果是一个标量(数值),表示两个向量之间的“方向相似度”。
如果两个向量方向相同,点乘结果最大(正值)。
如果方向垂直,点乘结果为0。
如果方向相反,点乘结果最小(负值)。
游戏中的典型应用:
判断敌人是否在玩家前方:
玩家面朝方向为向量A,敌人方向为向量B,若A·B > 0,则敌人在前方。
计算光照强度:光线方向与表面法线的点乘决定亮度。
投影长度:将角色的速度向量投影到斜坡的法线方向,计算有效移动速度。
2. 叉乘(Cross Product)
核心作用:判断两个向量的相对方向(顺时针/逆时针)
几何意义:
叉乘的结果是一个垂直于原向量平面的新向量,其方向由右手法则决定。
在2D中,叉乘简化为一个标量(正负表示方向)。
正号:向量B在向量A的逆时针方向。
负号:向量B在向量A的顺时针方向。
游戏中的典型应用:
判断点在线段的哪一侧(用于多边形裁剪或路径规划):
线段AB与点P,计算AB × AP的符号即可。
计算三角形面积:叉乘的模长是两向量构成平行四边形面积的一半。
法线方向:通过两个平面向量叉乘得到平面的法线方向(用于光照或碰撞检测)。
1. 判断点是否在三角形内
(1) 方法:向量叉乘法(Barycentric 坐标法变种)
步骤:
- 设三角形顶点为 ( A )、( B )、( C ),待检测点为 ( P )。
- 计算三个向量叉乘的符号:
• ( \text{sign}_1 = (B - A) \times (P - A) ) 的 z 分量符号(2D中简化为叉乘的符号)。
• ( \text{sign}_2 = (C - B) \times (P - B) ) 的 z 分量符号。
• ( \text{sign}_3 = (A - C) \times (P - C) ) 的 z 分量符号。 - 如果三个符号全部相同(同正或同负),则点 ( P ) 在三角形内;否则在外部。
原理:
叉乘的正负号反映了点 ( P ) 相对于三角形每条边的位置(左侧或右侧)。若所有边对 ( P ) 的叉乘符号一致,说明 ( P ) 在所有边的同一侧,即内部。
2. 叉乘(Cross Product)的几何意义
• 数学定义:
( \mathbf{u} \times \mathbf{v} = |\mathbf{u}||\mathbf{v}|\sinθ \cdot \mathbf{n} ),其中 ( \mathbf{n} ) 是垂直于 ( \mathbf{u} ) 和 ( \mathbf{v} ) 的单位向量(方向由右手定则决定)。
• 几何意义:
- 方向:结果向量垂直于原两向量构成的平面。
- 模长:等于两向量形成的平行四边形的面积。
- 正负:反映两向量的相对方向(顺时针或逆时针)。
应用场景:
• 判断点是否在三角形内(如上述方法)。
• 计算平面法向量(例如三角形面的法向量)。
3. 点乘(Dot Product)的几何意义
• 数学定义:
( \mathbf{u} \cdot \mathbf{v} = |\mathbf{u}||\mathbf{v}|\cosθ )。
• 几何意义:
- 投影长度:点乘结果反映一个向量在另一个向量方向上的投影长度。
- 夹角判断:
◦ 若点乘 > 0:两向量夹角 < 90°(锐角)。
◦ 若点乘 = 0:两向量垂直。
◦ 若点乘 < 0:两向量夹角 > 90°(钝角)。
应用场景:
• 计算光照强度(光源方向与法向量的夹角)。
• 判断物体是否在相机的视野内(视锥体裁剪)。
(2) 其他方法
• 边-边相交检测:检查两个三角形的每一条边是否与对方的边相交。
• 包围盒预筛选:先用 AABB 或包围球快速排除明显不相交的情况。
总结
• 点是否在三角形内:叉乘法利用向量方向性直接判断。
• 叉乘 vs 点乘:叉乘用于方向/面积,点乘用于投影/角度。
• 三角形相交:分离轴定理是高效且通用的方法。
3. 实战技巧:如何快速理解?
点乘 → 方向对齐度
想象两把剑的剑尖指向:
完全对齐(点乘=1):剑尖指向同一方向。
完全相反(点乘=-1):剑尖背对背。
垂直(点乘=0):一把剑横在另一把剑的侧面。
叉乘 → 左右手定则
右手法则(3D):
伸出右手,四指弯曲方向从向量A转向向量B,大拇指指向叉乘结果的方向。
2D简化:
叉乘结果的正负直接告诉你第二个向量在第一个向量的左侧(正)还是右侧(负)。
几何算法
实际应用场景:
是否理解这些几何判断在游戏中的用途,例如:
碰撞检测:判断子弹是否击中敌人(点是否在碰撞体内)。
技能范围:判断玩家是否在AOE范围内(点是否在圆内)。
寻路与导航:判断角色是否在可行走区域(点是否在多边形内)。
1. 点是否在三角形内
考察点:向量叉乘、重心坐标系、三角形面积法。
数学原理:
利用向量叉乘计算三个子三角形的面积。
若三个子面积之和等于原三角形面积,且所有子面积非负,则点在内部。
优化思路:
提前排除点不在三角形包围盒外的情况。
避免重复计算向量叉乘。
2. 点是否在圆内
考察点:距离公式、平方运算优化。
数学原理:
计算点到圆心的距离平方,若小于等于半径平方,则在圆内。
优化思路:
使用平方比较避免开根运算,提升性能。
3. 点是否在线上方(线段的一侧)
考察点:向量叉乘的方向判断。
数学原理:
将点与线段端点组成向量,计算叉乘符号。
符号的正负表示点在线的哪一侧。
应用场景:
判断角色是否跨越了路径边界。
多边形裁剪算法中的内外侧判断。
游戏开发中的性能优化
一、渲染优化
- 层级细节(Level of Detail, LOD)
原理:根据物体与摄像机的距离,动态切换不同细节的模型(高模→中模→低模)。
适用场景:开放世界、复杂场景的静态或动态物体(如树木、建筑)。
工具:Unity的LOD Group组件,Unreal的LOD System。 - 批处理(Batching)
静态批处理(Static Batching)
合并静态物体的网格和材质,减少Draw Calls。
限制:内存占用增加(合并后无法动态修改)。
动态批处理(Dynamic Batching)
自动合并小网格物体(顶点数<300),需相同材质。
限制:仅适用于低复杂度对象。 - 光照优化
烘焙光照(Light Baking)
将静态光照预计算为光照贴图(Lightmap),减少实时计算开销。
光照探针(Light Probes)
为动态物体提供间接光照信息,避免实时全局光照(如Unity的Enlighten/Unreal的Lumen)。 - 纹理优化
纹理压缩
使用ASTC、ETC2、BCn等格式,减少显存占用和带宽压力(移动端必备)。
Mipmap
生成多级纹理链,避免远处物体使用高分辨率纹理。
二、内存优化
- 对象池(Object Pooling)
原理:预先实例化并复用对象(如子弹、粒子),避免频繁Instantiate/Destroy。
适用场景:频繁创建/销毁的物体(如射击游戏、特效)。 - 资源卸载
按需加载/卸载:分块加载场景资源(如Unity的Addressables,Unreal的Streaming Levels)。
异步加载:使用后台线程加载资源,避免主线程卡顿。 - 内存泄漏预防
引用管理:避免循环引用(尤其是C#的event和C++的智能指针)。
工具检测:Unity的Memory Profiler,Unreal的LLM(Low-Level Memory Tracker)。
三、CPU优化
- 多线程与任务并行
Job System(Unity)或Task Graph(Unreal):将计算任务分配到多核CPU(如物理、AI)。
Async/Await:异步处理I/O操作(如文件读写、网络请求)。 - 算法优化
空间划分数据结构:
使用四叉树(2D)、八叉树(3D)、BVH(包围层次)加速碰撞检测或查询。
避免复杂算法:
减少嵌套循环,用哈希表(O(1))替代线性搜索(O(n))。 - 垃圾回收(GC)管理
减少GC压力:
避免频繁分配小对象(如每帧new),使用结构体(值类型)替代类(引用类型)。
手动触发GC:在加载界面等非关键时段调用System.GC.Collect()。
四、GPU优化
- 减少Overdraw(过度绘制)
渲染顺序优化:
先渲染不透明物体(从近到远),再渲染透明物体(从远到近)。
Early-Z测试:
在Shader中启用深度测试,提前丢弃被遮挡的片段。 - Shader优化
简化计算:
避免复杂分支(if/switch),用纹理采样代替实时计算(如预计算光照)。
LOD for Shaders:
为远处物体使用简化版Shader。 - 后处理优化
降低分辨率:
以半分辨率渲染Bloom、Motion Blur等效果(如Unity的Render Scale)。
分帧渲染:
将屏幕空间反射(SSR)、全局光照(GI)分帧计算。
五、网络与I/O优化
- 数据压缩
协议压缩:使用Protobuf、MessagePack替代JSON/XML。
纹理/模型压缩:使用GLTF、Draco压缩减少资源体积。 - 预测与插值(网络同步)
客户端预测:在权威服务器确认前,本地模拟玩家动作(减少延迟感知)。
插值算法:平滑同步其他玩家的移动(如Lerp位置和旋转)。
六、平台特定优化 - 移动端优化
降低分辨率:使用FSR(FidelityFX Super Resolution)或动态分辨率缩放。
节能模式:限制帧率(如30FPS),关闭高功耗功能(如抗锯齿)。 - PC/主机优化
多线程渲染:利用多核CPU分担渲染任务(如Unreal的RHI Thread)。
硬件特性利用:
DirectX 12/Vulkan的多线程渲染,光线追踪(RTX)加速。
七、工具与流程
- 性能分析工具
Unity:Profiler、Frame Debugger。
Unreal:Unreal Insights、GPU Visualizer。 - 自动化测试
性能基准测试:定期运行场景性能测试,跟踪帧率、内存、Draw Calls等指标。
回归测试:验证优化改动是否引入新问题。
哈希表相关
哈希表本质上是数组,常用于快速的查找一个元素是否出现过
通过哈希函数将元素映射到数组(桶)中,通过哈希碰撞解决冲突
• 哈希函数(Hash Function)
将键(Key)转换为数组索引(桶位置),理想情况下应满足:
• 均匀分布:减少哈希碰撞概率。
• 高效计算:时间复杂度为 O(1)
。
• 确定性:同一键始终映射到同一位置。
• 哈希碰撞(Hash Collision)
不同键映射到同一桶时发生碰撞,解决方法:
• 链地址法(Separate Chaining)
每个桶存储链表或红黑树,冲突元素挂载到链表。
◦ 优点:简单,负载因子容忍度高。
◦ 缺点:内存不连续(链表节点分散),缓存不友好。
• 开放寻址法(Open Addressing)
冲突时通过探测(线性、平方、双重哈希)寻找空桶。
◦ 优点:内存连续(所有元素在数组中)。
◦ 缺点:删除需标记“墓碑”(逻辑删除),负载因子低时性能下降快。
2. 主要缺点
• 内存占用高
• 需预分配桶数组,且负载因子(元素数/桶数)通常需保持在 0.5~0.7
以下。
• 链地址法额外存储链表指针,开放寻址法需保留空桶或墓碑标记。
• 遍历性能差
• 无序性:std::unordered_map
遍历顺序不可预测。
• 内存不连续(链地址法):链表节点分散,缓存局部性差。
• 逻辑空洞(开放寻址法):遍历需跳过“墓碑”,效率降低。
• 哈希碰撞导致的性能退化
• 最坏情况下(大量碰撞),操作时间复杂度退化为 O(n)
。
• 动态扩容成本高
• 扩容需重新哈希所有元素,时间复杂度为 O(n)
。
3. 内存连续性分析
实现方式 | 内存连续性 | 适用场景 |
---|---|---|
链地址法 | 桶数组连续,元素通过链表非连续存储 | 冲突频繁场景(如高负载因子) |
开放寻址法 | 桶数组连续,但存在逻辑空洞 | 内存敏感场景,需缓存友好性 |
4. 与数组的对比
特性 | 哈希表(unordered_map ) | 数组(vector ) |
---|---|---|
查找速度 | 平均 O(1) ,最坏 O(n) | O(n) (需遍历) |
插入/删除 | 平均 O(1) ,最坏 O(n) | 尾部 O(1) ,中间 O(n) (需移动) |
内存连续性 | 链地址法不连续,开放寻址法连续 | 严格连续 |
遍历顺序 | 无序 | 严格有序 |
5. 哈希表的扩容机制
哈希表的扩容是解决其容量不足时的核心操作,直接影响性能和内存使用。
扩容的触发条件
当哈希表的负载因子(Load Factor)超过预设阈值时,触发扩容:
• 负载因子 = 当前元素数量 / 桶数量。
• 阈值:
• 链地址法:通常阈值为 0.7~1.0
(如 Java HashMap
默认 0.75
)。
• 开放寻址法:阈值为 0.5~0.7
(逻辑空洞的存在要求更低的阈值)。
扩容的具体步骤
(1) 分配新的桶数组
• 新桶数量通常是原容量的两倍(如从 N
→ 2N
),并选择一个邻近的素数(避免哈希函数分布不均)。
• 示例:原桶数量为 7
,扩容后为 17
(下一个素数)。
• 例外:Google dense_hash_map
直接使用二次幂大小。
(2) 重新哈希(Rehashing)
将所有元素从旧桶移动到新桶:
• 链地址法:
遍历每个旧桶的链表,用新桶大小重新计算哈希值,分配到新桶的链表中。
for (旧桶链表中的每个节点) { int new_index = hash(key) % new_capacity; 插入到新桶[new_index] 的链表中;
}
• 开放寻址法:
遍历所有旧桶(跳过空桶和“墓碑”),重新计算哈希值,直接存入新桶的可用位置。
for (旧桶数组中每个有效元素) { int new_index = hash(key) % new_capacity; 探测新索引直到找到空桶,存入元素;
}
(3) 释放旧桶内存
• 旧桶数组或链表节点被完全释放。
扩容的时间复杂度
• 均摊时间复杂度:O(1)
(均摊到每个插入操作)。
• 单次扩容成本:O(n)
(需遍历所有元素重新哈希)。
• 触发频率:随着容量指数级增长(如 2^1 → 2^2 → 2^3…
),扩容次数为 O(log n)
。
优化策略
为了减少扩容对性能的影响,常见优化方法:
-
预分配容量(Pre-allocation)
通过reserve()
预先分配足够桶数量,避免频繁扩容。std::unordered_map<int, int> map; map.reserve(1024); // 预分配 1024 个桶
-
渐进式扩容(Incremental Resizing)
分摊重新哈希的时间成本(如 Redis 哈希表扩容时新旧桶并存,分步迁移)。 -
使用快速哈希函数
选择计算速度快的哈希函数(如 CRC32、MurmurHash),减少重新哈希的时间。
哈希表的扩容机制是其动态适应数据增长的关键设计:
- 触发条件由负载因子决定,不同实现阈值不同。
- 核心步骤包括新桶分配、重新哈希和旧桶释放。
- 优化建议:预分配容量、选择高效哈希函数。
- 链地址法 vs 开放寻址法:在扩容成本、内存连续性上有显著差异。
哈希表是一种以空间换时间的高效数据结构,核心依赖哈希函数和碰撞解决策略。尽管存在内存占用高、遍历无序等缺点,但其平均 O(1)
的操作复杂度使其在查找密集型场景中不可替代。理解其底层实现(链地址法 vs 开放寻址法)和内存布局特点,有助于在实际开发中合理选择数据结构。
在遍历容器时删除元素的方法
1. 原地交换法(类似“移动零”)
• 核心思路:将所有不需要删除的元素依次移动到到数组后端,最后调整数组大小。
• 优点:时间复杂度 O(n)
,空间复杂度 O(1)
,无需额外内存。
2. 临时数组法
• 核心思路:将保留的元素存入临时数组,遍历后替换原数组。
• 优点:代码直观,适合数据量不大或需保留原数组的场景。
• 缺点:空间复杂度 O(n)
。
3.不递增索引法
• 核心思路
• 控制索引递增:删除元素时不递增索引,否则递增。
• 原地操作:直接修改原数组,无需额外空间。
#include <vector>
#include <iostream>int main() {std::vector<int> vec = {1, 2, 3, 4, 5, 6};// 一次遍历删除所有偶数for (size_t i = 0; i < vec.size();) {if (vec[i] % 2 == 0) {vec.erase(vec.begin() + i); // 删除元素后,索引 i 不递增} else {i++; // 不删除时递增索引}}
}