终极文档!!!(技术向)Chapter_0: 简历针对知识点Chapter_1: c++1. 类与结构体1.1. 类成员变量如果不初始化会是什么值?2. const & static关键字2.1. const是否必须初始化?2.2. 常量成员函数是什么?3. 多态(虚函数)√3.1. 虚函数底层实现3.2. 如果一个类有多个父类,虚函数表是怎样的?3.3. 哪些函数不能是 虚函数?4. 构造函数的类型5. STL容器底层5.1. set / map 底层实现?5.2. unordered_set 底层实现?5.3. vector 底层实现6. cast类型转换6.1. 动态转换底层原理7. 函数的参数传递7.1. 值传递 & 引用传递7.2. 什么时候引用类型也无法降低开销?8. 关于指针的那些事儿8.1. 指针 VS 引用8.2. 智能指针?8.3. 详细说说 shared_ptr?8.4. 详细说说 weak_ptr?8.5. 详细说说 unique_ptr?8.6. shared_ptr 和 unique_ptr的转化?9. 栈内存 与 堆内存10. 内存管理10.1. 说说C#和C++分别如何管理内存?10.2. malloc 和 new 的区别?10.3. 说说你知道的C++管理内存的方式?n. C# 模块补充n.0. 与C++对比n.1. 委托和事件n.2. GC机制 √n.3. 抽象类和接口n.4. 封装 继承 多态n.5. foreach 与 forn.6. Dictionary/Hash表底层Chapter_2: 数据结构与算法1. 二叉树1.1. 详谈完全二叉树与平衡二叉树?1.2. 完全二叉树一定是平衡二叉树吗?1.3. 红黑树 和 AVL树的区别?2. 排序算法概述Chapter_3:计算机图形学0. 点乘 与 叉乘1. 光照模型2. PBR渲染原理3. 前向渲染和延迟渲染4. GPU渲染管线5. Unity渲染管线Chapter_4:游戏引擎与性能优化1. UE 与 Unity2. 场景题2.1. FPS游戏中假设子弹数量很多,该如何优化游戏性能?3. 对象池优化3.1. 对象池中存储实例与指针各有何优劣?3.2. 如何存取对象池内对象的时间复杂度都是O(1)的?4. 批处理优化5. 视锥体剔除6. 协程 Coroutine7. 帧同步 与 状态同步8. 生命周期函数Chapter_5:常见游戏算法记录1. 最大频率栈(哈希表+栈)2. AStar 寻路算法Chapter_6: 设计模式与架构1. MVVM架构Chapter_7:计算机网络 / 操作系统1. 进程,线程与协程2. TCP三次握手2.1. 为什么一定要三次握手?2.2. 谈谈SYN泛洪攻击?3. TCP四次挥手3.1. 服务端如何确保客户端正常连接?文章末尾
终极文档!!!(技术向)
前言:本文档针对 个人九月秋招前夕 游戏客户端开发 方向的求职积累。鉴于语言的通用性,此次语言相关的基础知识从实习求职时期的 c# 改为 c++ ,当然,由于 c# 中 事件,委托,协程 等技术为游戏开发中常用技术,故会在单独的板块特别说明!
Chapter_0: 简历针对知识点
C++大纲:
- 基础语法:变量,运算符,语句(条件,循环),函数,数组,指针等。
- OOP编程:类,对象,封装,继承,多态等。
- 异常处理:抛出异常,捕获异常等。
- 标准库:string,vector,map,algorithm等。
- 泛型编程:使用通用的代码适用于不同类型数据,模板,STL等。
- 多线程编程:线程库和同步机制(互斥锁,信号量
框架工具封装细节:
定时器实现细节:使用SortedSet在Update中维护一个始终能取到最小值的容器(距当前最近触发的定时器)。外界仅需在需要时注册 / 注销 定时器即可。
AB包资源管理:加载主包->加载相关依赖->加载目标包体(实例化)
网络模块:
分包,粘包:
-
产生原因:因为TCP为了减少额外开销,采取的是流式传输,所以接收端在一次接收的时候有可能一次接收多个包。
-
处理方式:自定义规则在消息内容前加入 消息ID,消息长度 两个字段来判断是否发生分包粘包。
- 如果长度不够,则发生了分包,将其拷贝到缓存容器并继续接收下一条消息。
- 如果长度超出,则发生了粘包,通过消息ID判断消息类型后进行解析并对消息做业务处理即可。
心跳消息:
- 心跳消息从发送源启动时开始发送,直到发送源关闭,期间发送源会不间断的发送周期性或重复消息。 当接收方在某个消息接收周期内未收到消息,接收方可能会认为发送源已经关闭、出现故障、或者当前不可用。
- 当作一个专门的消息类型处理,周期性像服务器发送,实时监测连接是否正常。
设计模式:
MVC架构设计规则:
- 整体分为三层架构 + 一层工具:视图层(表现)->控制器层(系统)->模型层(数据层)-> 工具层
- 在三层架构设计中,采用面向抽象(接口)编程,降低各层间的耦合度,分层,代码复用等。
- 使用一个专门的架构类来管理各层,各层使用IOC控制反转将自身交给容器进行管理后,注册到架构中。
- 上层 -> 下层 信息传输可以直接调用(查询,变更时使用Command)
- 下层 -> 上层 信息传输需要使用事件
单例模式:一种创建型设计模式,它确保一个类只有一个实例,并提供了一个全局访问点来访问该实例。
工厂模式:提供了一种将对象的实例化过程封装在工厂类中的方式。通过使用工厂模式,可以将对象的创建与使用代码分离,提供一种统一的接口来创建不同类型的对象。复用和方便后续维护与扩展。
简单工厂模式与工厂方法模式:
- 简单工厂模式:中包含判断什么对象的逻辑,增加新类时,需要改动工厂类的逻辑代码。
- 工厂方法模式:通过调用者判断要调用具体的创建方法,增加新产品时,需要扩展新的工厂类和产品类。遵循开闭原则,扩展更方便且耦合度低。
数据持久化解决方案:
- Json字符串
- Xml配置文件
- 二进制
- PlayerPref + Json:在PlayerPref基础上可以存储复杂数据
Chapter_1: c++
依照案例,一切还是从编程语言开始。
1. 类与结构体
在C++语言中,类(class)和结构体(struct)在语法上是非常相似的,但它们有一些区别。下面是它们的主要区别:
-
默认访问权限 :
- 类:默认情况下,类中的成员是私有的(private)。
- 结构体:默认情况下,结构体中的成员是公有的(public)。
-
成员函数:
- 类 / 结构体:可以定义成员函数,包括构造函数、析构函数和其他成员函数。
-
继承:
- 类:支持继承,可以使用关键字
public、protected和private指定继承的访问权限。 - 结构体:C++版本下同样支持继承,使用与类相同的关键字。
- 类:支持继承,可以使用关键字
-
默认初始化:
- 类:如果没有显式提供构造函数,类的对象会使用默认构造函数进行初始化。
- 结构体:如果没有显式提供构造函数,结构体的对象会进行零初始化。
-
值 or 引用类型:
- 类:类是引用类型数据,存储在堆内存中,适合处理逻辑关系复杂的大对象。
- 结构体:结构体是值类型数据,存储在栈内存中,适合处理小对象,比如 点,矩形等......
衍生问题:
1.1. 类成员变量如果不初始化会是什么值?
Q:类成员变量如果不初始化会是什么什么值?
A:如果类的成员变量没有被显式初始化,它们的初始值将取决于它们的类型:
-
内置类型(如整型、浮点型、指针等):
- 在类的默认构造函数中,未显式初始化的内置类型成员变量将具有不确定的值,通常是随机值或垃圾值。
- 如果类的成员变量是静态成员变量,则会被默认初始化为零值(0、0.0、nullptr 等)。
-
类类型(如自定义类、标准库类等):
- 类类型的成员变量会调用它们自己的默认构造函数进行初始化。
- 如果类类型没有默认构造函数或默认构造函数不可访问,则会导致编译错误。
2. const & static关键字
const 和 static 是 C++ 中两个关键字,它们具有不同的用法和含义。以下是它们 的 详细说明 以及与 内存分配 相关的问题:
-
const 关键字:
- 用法:const 用于声明一个常量,即其值在程序执行期间不能被修改。可以应用于变量、函数参数、函数返回类型和成员函数。
- 含义:const 可以确保数据的不可修改性,提高代码的可读性和可维护性。它可以防止无意间修改变量的值,并在编译时进行静态检查,捕获潜在的错误。
- 内存上的分配:const 变量通常被分配在只读数据段(read-only data segment)中,这意味着它们的值在程序执行期间是不可修改的。编译器通常会将 const 变量优化为立即数,而不会为其分配内存。
-
static 关键字:
-
用法:
- 静态变量:在函数内部使用 static 关键字声明的变量是静态变量,它们在函数调用之间保持其值不变。
- 静态成员变量:在类中使用 static 关键字声明的成员变量是静态成员变量,它们属于类本身,而不是类的实例。静态成员变量在所有类的对象之间共享。
- 静态成员函数:在类中使用 static 关键字声明的成员函数是静态成员函数,它们可以直接通过类名调用,而无需创建类的实例。
-
含义:
- 静态变量:静态变量在函数调用之间保持其值,只在首次进入声明所在的函数时进行初始化,之后的调用会保留上一次调用的值。
- 静态成员变量:静态成员变量在所有类的对象之间共享,只有一个副本存在于内存中。
- 静态成员函数:静态成员函数不依赖于类的实例,可以直接通过类名调用,它们不能访问非静态成员变量或非静态成员函数。
-
内存上的分配:
- 静态变量:静态变量通常被分配在静态存储区(static storage area),在程序的整个生命周期内都存在。
- 静态成员变量:静态成员变量在编译时分配内存,与类的实例无关,通常放在数据段或全局数据区。
- 静态成员函数:静态成员函数不与类的实例绑定,因此它们不包含指向类实例的指针,也不占用对象的内存空间。
-
-
const和内存分配:
- const变量通常被视为编译时常量,编译器会尝试将其直接嵌入到使用它的地方,而不是分配内存来存储它。这样可以减少内存的使用,并提高程序的执行效率。对于较大的const对象,编译器可能会在只读数据段中分配内存,并将其初始化为const对象的值。
- 对于指针或引用类型的const变量,它们存储的是地址或引用,并不直接存储变量的值。在这种情况下,const变量本身可能被分配在栈上或全局数据区,而所指向的对象可能在堆上或其他地方。
-
static和内存分配:
- 静态变量(函数内部的静态变量或类的静态成员变量)在程序开始执行时分配内存,并在程序结束时释放。它们的生命周期与程序的整个运行周期相同,即使函数返回或类的对象销毁,静态变量的值也会保持不变。
- 静态成员变量与普通成员变量有所不同。它们在编译时分配内存,并独立于类的每个对象。所有类的对象共享同一个静态成员变量,可以通过类名或对象访问它。
- 静态成员函数没有隐式的this指针,因此它们不直接访问对象的成员变量。由于静态成员函数不与对象绑定,因此它们没有访问非静态成员的权限。
总结:const 关键字用于声明常量,而static关键字用于创建静态变量和静态成员,const通常被视为编译时常量(小)或者 被存储在只读数据段(大),修饰指针或引用时存储其地址或引用。static关键字用于声明静态变量 / 函数,在作为成员时,所有实例共用其值。而作为全局变量修饰时,其生命周期伴随整个程序。 在作为全局变量时,const可在其他不同源文件被访问,但static仅能在当前源文件被访问。
衍生问题:
2.1. const是否必须初始化?
Q:可以声明一个const数,但是不初始化它吗?
A:在C++中,声明一个 const 变量时,必须在声明时进行初始化,否则会导致编译错误。const 变量的值在程序执行期间是不可修改的,因此必须在声明时提供一个初始值。
2.2. 常量成员函数是什么?
Q:如果把const放在成员函数的末尾,代表什么?
A:例子↓:
class MyClass {
public:
void normalMemberFunction(); // 普通成员函数
void normalMemberFunction() const; // 常量成员函数,只有const 类名 obj才能调用。
};
在C++中,将 const 关键字放在成员函数的末尾表示该成员函数是一个常量成员函数(const member function)。
常量成员函数是指在函数声明和定义中使用 const 限定符来修饰的成员函数。常量成员函数承诺不会修改对象的状态,它们只能读取成员变量的值或调用其他常量成员函数。常量成员函数在类的 const 对象上调用时可以被执行,但在非 const 对象上调用时会导致编译错误。
3. 多态(虚函数)√
静态多态: 函数重载,运算符重载等(效率高,无需动态匹配绑定)
动态多态: 以下说明均为 动态多态。
在C++中,多态(Polymorphism)是面向对象编程的一个重要概念,它允许 使用基类的指针或引用来操作派生类的对象 ,以实现同一接口的不同行为。多态通过 动态绑定 在 运行时 确定要调用的具体函数,使得代码更加灵活、可扩展和可维护。
以下是多态的一些关键概念和用法:
-
虚函数(Virtual Function):
- 虚函数是在基类中声明的函数,使用关键字
virtual进行标识。 - 派生类可以对虚函数进行重写(覆盖),在派生类中重新定义函数体。
- 虚函数通过动态绑定在运行时确定要调用的具体函数。
- 虚函数是在基类中声明的函数,使用关键字
-
纯虚函数(Pure Virtual Function)和抽象类(Abstract Class):
- 纯虚函数(抽象函数)是在基类中声明的没有函数体的虚函数,使用
virtual关键字和= 0进行标识。 - 抽象类是包含至少一个纯虚函数的类,它不能被实例化,只能用作其他派生类的基类。
- 纯虚函数(抽象函数)是在基类中声明的没有函数体的虚函数,使用
-
动态绑定(Dynamic Binding)和静态绑定(Static Binding):
- 动态绑定是指在 运行时 根据对象的实际类型决定要调用的函数。
- 静态绑定是在 编译时 根据指针或引用的声明类型决定要调用的函数。
使用多态的好处包括:
- 可以编写通用的代码,适用于多个派生类对象,提高代码的可重用性。
- 可以通过基类指针或引用实现运行时的多态调用,灵活处理不同类型的对象。
- 通过使用虚函数和动态绑定,可以实现基于对象类型的动态行为,更容易扩展和维护代码。
3.1. 虚函数底层实现
在C++中,虚函数的实现依赖于 虚函数表 和 虚函数指针 的机制。
- 虚函数表(vtable): 每个包含虚函数的类都会有一个对应的虚函数表。虚函数表是一个存储着指向虚函数的指针的数组,其中的每个指针指向相应的虚函数。用于在 运行时 动态地绑定到正确的函数实现。
- 虚函数指针(vpointer): 对象的内存布局中会包含一个指向虚函数表的指针,称为虚函数指针。虚函数指针指向对象所属类的虚函数表。
- 动态绑定(Dynamic Binding): 当通过基类的指针或引用调用虚函数时,C++会在运行时确定调用的是哪个具体的虚函数。它会通过虚函数指针找到对象所属类的虚函数表,并根据虚函数表中的偏移量来调用正确的虚函数。
总结:虚函数的实现依赖于 虚函数表 和 虚函数指针 ,他们在 运行时 动态地找到正确的虚函数,并实现 多态 和 动态绑定 。这样可以让程序在处理基类指针或引用时,根据对象的实际类型来调用相应的虚函数,实现了面向对象编程中的重要特性。
总结:多态(Polymorphism)是面向对象编程的一个重要概念,它允许 使用基类的指针或引用来操作派生类的对象。多态通过动态绑定在运行时确定要调用的具体函数,使得代码更加灵活、可扩展和可维护。。
3.2. 如果一个类有多个父类,虚函数表是怎样的?
当一个类有多个父类(多重继承)且这些父类都有虚函数时,每个父类都会有自己的虚函数表。这意味着在多重继承的情况下,一个类会有多个虚函数表。 需要注意的是,当一个类有多个父类时,存在虚函数表的排列和偏移量计算的复杂性。C++会根据继承的顺序和虚函数的声明顺序来安排虚函数表的顺序,以确保正确的虚函数调用。
3.3. 哪些函数不能是 虚函数?
构造 / 析构, 内联, 友元, 静态。
- 构造 / 析构 类型不确定
- 内联 静态绑定 ,违背动态绑定规则 inline
- 友元 不是类的成员,打破封装特征 friend
- 静态 面向类,不具有实例性 static
4. 构造函数的类型
在C++中,一个类可以有以下几种构造的方式:
-
默认构造函数(Default Constructor):
- 默认构造函数没有参数,用于创建对象时不提供任何初始值。
- 如果在类中没有显式定义默认构造函数,编译器会自动生成一个默认构造函数(无操作)。
-
参数化构造函数(Parameterized Constructor):
- 参数化构造函数接受一个或多个参数,用于在创建对象时提供初始值。
- 参数化构造函数可以用于初始化对象的成员变量或执行其他必要的初始化操作。
-
拷贝构造函数(Copy Constructor):
- 拷贝构造函数接受同类型的对象作为参数,用于创建一个新对象并将其初始化为参数对象的副本。
- 拷贝构造函数通常用于对象之间的赋值、传递参数和返回值等场景。
-
移动构造函数(Move Constructor):
- 移动构造函数接受同类型的对象作为右值引用参数(&&),用于在创建新对象时“移动”资源(如动态分配的内存)而不是进行复制。
- 移动构造函数通常用于提高对象的性能和效率,减少不必要的拷贝操作。
-
复制赋值运算符(Copy Assignment Operator):
- 复制赋值运算符重载了
=运算符,用于将一个对象的值复制给另一个对象。 - 复制赋值运算符通常在类中以成员函数的形式实现,并返回一个引用类型。
- 复制赋值运算符重载了
-
移动赋值运算符(Move Assignment Operator):
- 移动赋值运算符重载了
=运算符,用于将一个对象的值“移动”给另一个对象,类似于移动构造函数的功能。 - 移动赋值运算符通常在类中以成员函数的形式实现,并返回一个引用类型。
- 移动赋值运算符重载了
需要注意的是,一个类可以同时拥有多个构造函数,并根据不同的参数进行重载。这样可以提供更多的构造方式,以适应不同的初始化需求。
5. STL容器底层
5.1. set / map 底层实现?
在 C++ 中,std::set 是一个有序的集合容器(有序不重复),它基于红黑树(Red-Black Tree)实现。红黑树是一种 自平衡 的 二叉搜索树 ,具有以下特点:
- 有序性:红黑树中的节点按照特定的顺序排列,每个节点的键值大于其左子树中的所有键值,小于其右子树中的所有键值。
- 自平衡性:红黑树通过维护一组平衡性质来保持树的平衡,这些性质确保了树的高度始终保持在 O(log n) 级别。
插入元素时,std::set 会按照红黑树的规则将新元素插入到适当的位置,并通过调整树的结构来保持平衡性质。具体插入操作的时间复杂度为 O(log n)。
删除元素时,std::set 会根据键值查找到对应的节点,然后根据红黑树的删除算法进行删除操作,并重新调整树的结构以保持平衡性质。删除操作的时间复杂度也为 O(log n)。
查找元素时,std::set 会通过红黑树的搜索算法在树中进行查找,根据键值比较逐级向下搜索,直到找到目标元素或者到达树的叶子节点。查找操作的时间复杂度为 O(log n)。
set总结:C++ 中的 std::set 是基于 红黑树(自平衡性的二叉搜索树) 实现的 有序集合容器 。红黑树作为底层数据结构,提供了高效的插入、删除和查找操作,同时保持了 集合 的有序性。这使得 std::set 在需要有序集合并且对性能要求较高的场景中非常有用。
map总结:C++ 中的 std::map 是基于 红黑树 (自平衡性的二叉搜索树)实现的有序键值对容器 。红黑树作为底层数据结构,提供了高效的插入、删除和查找操作,同时保持了 键 的有序性。这使得 std::map 在需要有序键值对容器且对性能要求较高的场景中非常有用。
5.2. unordered_set 底层实现?
在 C++ 中,std::unordered_set 是一个 无序元素不重复 的集合容器,它基于哈希表(Hash Table)实现。哈希表是一种高效的数据结构,它使用哈希函数将键映射到一个索引位置,并在该位置存储对应的值。
std::unordered_set 的实现原理如下:
- 哈希函数:在
std::unordered_set中,每个元素被插入到哈希表的一个特定位置。为了确定元素在哈希表中的位置,需要使用哈希函数将元素的键值转换为索引。哈希函数应该具有均匀分布的性质,以便将元素分散到哈希表的不同位置。 - 桶(Buckets) :哈希表由一组桶组成,每个桶存储一个或多个元素。桶的数量通常是根据预估的元素数量来设置,以确保较低的哈希冲突率。
- 哈希冲突处理:由于不同的元素可能会映射到相同的索引位置,这就产生了哈希冲突。哈希表使用一些技术来处理冲突,其中最常见的方法是使用链地址法(Chaining)或开放寻址法(Open Addressing)。在链地址法中,每个桶存储一个链表或其他数据结构,用于存储冲突的元素。而在开放寻址法中,当发生冲突时,通过一定规则的探测序列来查找下一个可用的位置。
- 插入元素:插入元素时,首先使用哈希函数计算元素的哈希值,然后确定元素在哈希表中的桶位置。如果桶为空,则直接将元素插入其中;如果桶非空,则根据哈希冲突处理的方法,在桶中的链表或其他结构中插入元素。
- 查找元素:查找元素时,使用哈希函数计算元素的哈希值,并确定元素在哈希表中的桶位置。然后,在相应的桶中查找目标元素。通过哈希表的快速查找能力,可以在平均情况下实现接近常数时间复杂度(O(1))的查找操作。
- 删除元素:删除元素时,首先查找目标元素,并定位到元素所在的桶位置。然后,根据哈希冲突处理的方法,在桶中的链表或其他结构中删除元素。
总结 :C++ 中的 std::unordered_set 是基于 哈希表 实现的 无序 集合容器。插入和查找操作的平均时间复杂度为常数时间 O(1),但也会因为哈希算法的优劣上升为 O(n),根据 哈希函数 将元素的键值转换为索引存储到不同的位置。哈希冲突的处理方式:开放寻址法,链地址法。
5.3. vector 底层实现
在C++中,std:vector 是一个 基于 一段连续的线性内存空间 存储的容器。
其实现核心基于以下三枚迭代器(指针):
- _First:vector容器对象的起始字节位置。
- _Last:当前最后一个元素的末尾字节。
- _End:整个 vector 容器所占用内存空间的末尾字节。
注意:如果vector对象为空,则上述三枚指针也均为null。
使用三枚迭代器的组合,便可以得出vector最广泛使用的API:
templete <class _Ty, class _Alloc = allocator<_Ty>>
class vector{
public:
iterator begin() {return _First;}
iterator end() {return _Last;}
size_type size() const {return size_type(end() - begin());}
size_type capacity() const {(return size_type(_End - _First));}
bool empty() const {return _First == _Last;}
reference operator[](size_type n) {return *(begin() + n);}
reference front() {return *begin();}
reference back() {return *(end()-1);}
}
vector容器扩容:(一般情况是 1.5 倍扩容)
- 完全弃用现有的内存空间,重新申请更大的内存空间;
- 将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
- 最后将旧的内存空间释放。
vector容器插入底层:push_back(), emplace(), insert()。
6. cast类型转换
在 C++ 中,有四种类型转换(cast)操作符可用于进行类型转换。它们分别是:
- 静态转换(Static Cast) :
static_cast是最常用的类型转换操作符,用于在不具备继承关系的类型之间进行转换。它可以进行常规的隐式类型转换,如数值类型之间的转换、指针类型之间的转换以及具有转换构造函数的类之间的转换。静态转换在编译时进行,不提供运行时类型检查,因此需要注意转换的安全性。 - 动态转换(Dynamic Cast) :
dynamic_cast主要用于在具有继承关系的类之间进行转换。它可以进行向上转型(将派生类指针或引用转换为基类指针或引用)和向下转型(将基类指针或引用转换为派生类指针或引用)。动态转换在运行时进行,会进行类型检查,如果转换不合法,则返回空指针(对于指针转换)或抛出std::bad_cast异常(对于引用转换)。 - 常量转换(Const Cast) :
const_cast用于删除变量的const或volatile属性,从而允许对其进行修改。它主要用于消除常量性或易变性,但需要谨慎使用,因为它可能导致未定义行为。常量转换主要用于处理遗留代码或接口中的常量性限制。 - 重新解释转换(Reinterpret Cast) :
reinterpret_cast是最底层的类型转换操作符,它允许将一个指针或引用转换为任意其他类型的指针或引用。重新解释转换的结果是对底层数据的位模式重新解释,没有类型安全检查。这种转换一般用于特殊情况,如将指针转换为整数或将整数转换为指针,或者在底层操作需要的场景中。
总结 :静态转换 发生在编译时,不提供运行时检查,用于 数值类型,指针类型等没有不具备继承关系进行转换。 动态转换 发生在运行时,如果转换不合法会抛出异常,用于基类与派生类之间的互相转换。 常量转换 会删除常量的不可变性。 重新解释转换 用于指针与数据之间的互相转换。
6.1. 动态转换底层原理
dynamic_cast 在进行类型转换时会进行运行时的类型检查,以确保转换的安全性。下面是 dynamic_cast 的安全性保证:
- 继承关系检查:
dynamic_cast主要用于处理具有继承关系的类之间的转换。在进行向下转型(将基类指针或引用转换为派生类指针或引用)时,dynamic_cast会检查基类指针或引用是否真正指向派生类对象,而不是其他类型的对象。如果转换不合法(例如基类指针实际指向的是另一个无关的派生类或基类对象),则dynamic_cast返回空指针(对于指针转换)或抛出std::bad_cast异常(对于引用转换)。 - 虚函数表检查:
dynamic_cast的安全性依赖于类中的虚函数机制。在进行向上转型(将派生类指针或引用转换为基类指针或引用)时,dynamic_cast会利用虚函数表来确定对象的动态类型,并执行转换。这样可以确保在多态情况下进行正确的类型转换。
总结: 基类转派生类(向下转型):动转会自动检查继承关系。派生类转基类(向上转型):虚函数表确定动态类型,从而检查继承关系。
7. 函数的参数传递
7.1. 值传递 & 引用传递
在函数调用中,值传递(Pass-by-Value)和引用传递(Pass-by-Reference)是两种不同的参数传递方式,它们有以下区别:
值传递(Pass-by-Value) :
- 当使用值传递时,函数参数将会被复制到函数的形参中。
- 在函数内部,对形参的修改不会影响到原始的实参。
- 值传递创建了实参的副本,因此函数内部对形参的修改不会影响到原始的实参。
- 值传递适用于参数较小且不需要在函数内部进行修改的情况。
引用传递(Pass-by-Reference) :
- 当使用引用传递时,函数参数将会成为原始实参的别名。
- 在函数内部,对引用参数的修改会直接影响到原始实参。
- 引用传递避免了创建副本的开销,可以直接对原始实参进行操作,提高了效率。
- 引用传递适用于需要在函数内部修改参数值并希望将修改反映到原始实参的情况。
可以根据实际需求选择合适的参数传递方式。如果只需要函数内部读取参数的值或者对参数进行简单的计算,值传递是更常用的方式。如果需要在函数内部修改参数的值并希望将修改反映到原始实参,或者参数较大并希望避免副本的创建,引用传递是更合适的选择。
总结: 值传递:拷贝副本,修改无法映射到实参。适用于参数较小且不用修改的情况。 引用传递: 修改影响原始实参,开销小效率高。适用于参数需要修改的情况。
7.2. 什么时候引用类型也无法降低开销?
引用传递通常可以节省空间,因为它避免了创建参数的副本。然而,有一些情况下引用传递可能无法节省空间:
- 小型数据类型传递: 对于小型的内置数据类型(如整数、字符、布尔值等),它们的大小通常很小(通常是几个字节),在传递过程中创建副本的开销也非常小。在这种情况下,引用传递可能无法明显节省空间。
总结: 当 值传递本身字节存储数 小于 指针的固定字节存储数 时,比如 char, bool 等低字节存储数据。大多数情况下,还是引用传递对空间开销较小,省去了拷贝数据这一步骤。
8. 关于指针的那些事儿
8.1. 指针 VS 引用
引用(Reference)是一个别名或者别称,它是已经存在的变量的另一个名称。引用与原始变量共享相同的内存地址,因此对引用的操作实际上是对原始变量的操作。引用一旦被初始化,就不能再引用其他变量。在使用引用时,无需解引用(dereference)操作,因为引用本身就指向了内存地址。
指针(Pointer)是一个变量,它存储了另一个变量的内存地址。指针可以通过解引用操作来访问指向的内存中存储的值。指针可以被重新赋值,可以指向不同的变量或者被设置为 null(空指针)。指针还可以进行指针运算,例如指针的递增和递减。
下面是引用和指针的一些区别:
- 初始化和赋值:引用在声明时必须被初始化,并且一旦初始化后,它将一直引用同一个变量。指针可以在声明时不初始化,并且可以在任何时候重新赋值。
- 空值(null):引用不允许为空,必须始终引用某个有效的变量。指针可以为空,也就是指向空地址或者被设置为 null。
- 内存操作:引用无需解引用即可访问值,因为引用本身就是变量的别名。指针需要进行解引用操作才能获取指向的变量的值。
- 变量地址:引用没有自己的内存地址,它只是另一个变量的别名。指针有自己的内存地址,并且可以通过取地址运算符(&)获取变量的地址。
- 指针运算:指针可以进行指针运算,例如递增和递减操作。引用不支持指针运算。
总结 :区别从以下四个方面答: 初始化与重新赋值, 访问数据时是否需要解引用, 是否存在自己的内存地址, 是否支持指针运算。
8.2. 智能指针?
智能指针(Smart Pointers)是一种用于管理动态分配的内存的抽象数据类型,它们 提供了自动化的内存管理,以及可以减少内存泄漏和访问已释放内存的问题。
智能指针通过封装原始指针,并提供一些附加功能来实现自动内存管理。它们通常提供以下几个主要功能:
- 自动内存分配和释放 :智能指针在创建时自动分配所需的内存,并在不再需要时自动释放内存。这消除了手动分配和释放内存的需求,减少了内存泄漏的风险。
- 所有权管理 :智能指针可以跟踪特定内存块的所有权,确保在不再需要时正确释放内存。它们可以防止多个指针同时指向同一块内存,从而避免了悬空指针和野指针的问题。
- 生命周期管理 :智能指针可以通过跟踪对象的生命周期来确保内存的正确释放。当没有指针指向某个对象时,智能指针可以自动销毁该对象并释放相关的内存。
常见的智能指针类型包括:
- shared_ptr:允许多个指针共享同一块内存,并且在最后一个指针引用被释放时才释放内存。
- unique_ptr:独占所指向的内存资源,保证只有一个指针可以指向该内存块,并在该指针生命周期结束时释放内存。
- weak_ptr:用于解决 shared_ptr 的循环引用问题,它可以指向 shared_ptr 所管理的内存,但不会增加引用计数。
总结: 智能指针会通过生命周期管理自动分配和释放内存,跟踪特定内存的所有权。
8.3. 详细说说 shared_ptr?
shared_ptr 是 C++ 标准库中的一个智能指针类型,用于共享所有权的动态内存管理。它允许多个指针共享同一块内存,并在最后一个指针引用被释放时才释放内存。shared_ptr 实现了引用计数的机制,用于跟踪有多少个指针共享同一个对象。
但需要注意以下问题:
-
避免循环引用 :如果两个或多个
shared_ptr相互引用,形成循环引用,可能导致内存泄漏。可以使用weak_ptr来解决循环引用问题。 -
避免多线程竞争 :如果多个线程同时访问和修改同一个
shared_ptr对象,可能会引发竞争条件。可以使用互斥锁(mutex)或其他线程同步机制来确保线程安全。底层实现:通常使用两枚指针,一枚指向分配的对象,另一个指向引用计数。当引用计数为0时,释放内存空间。
8.4. 详细说说 weak_ptr?
weak_ptr 是 C++ 标准库中的另一个智能指针类型,用于解决 shared_ptr 的循环引用问题。weak_ptr 允许共享对象,但不会增加引用计数。它提供了对所管理对象的弱引用,可以用于检查对象是否存在,并在需要时获取对应的 shared_ptr。
使用 weak_ptr 的主要目的是在存在循环引用的情况下,避免引发内存泄漏。当一个对象被 shared_ptr 和 weak_ptr 同时引用时,它的引用计数不会受到 weak_ptr 的影响。这样,在所有 shared_ptr 都释放后,weak_ptr 也会正确地检测到对象的释放,而不会导致对象被持续引用。
需要注意以下几点:
- 在使用
weak_ptr之前,需要确保至少有一个shared_ptr指向同一对象。否则,无法通过weak_ptr获取有效的shared_ptr。 - 使用
weak_ptr.lock()获取shared_ptr时,需要进行有效性检查,以确保对象存在。 weak_ptr不会增加引用计数,因此不会影响对象的生命周期。对象的生命周期仍由shared_ptr管理。weak_ptr不能直接访问对象的成员,需要通过lock()获得shared_ptr才能进行对象成员的引用。
8.5. 详细说说 unique_ptr?
unique_ptr 是 C++ 标准库中的另一个智能指针类型,用于独占所有权的动态内存管理。与 shared_ptr 不同,unique_ptr 不允许多个指针共享同一块内存,它是独占的、唯一的指针。当 unique_ptr 离开其作用域时,它所管理的对象会自动被释放。
需要注意以下几点:
unique_ptr是独占所有权的指针,不允许多个指针同时指向同一块内存。- 可以通过移动语义将
unique_ptr所有权转移给另一个unique_ptr。 unique_ptr离开作用域时会自动释放所管理的对象,无需手动释放内存。- 可以使用自定义删除器来指定释放内存的方式。
- 使用
get()函数可以获取指向内存的原始指针,但要谨慎使用,避免手动释放内存。 - 可以使用
reset()函数释放内存并将指针置为空。
使用 unique_ptr 可以更安全地管理动态内存,避免内存泄漏和悬空指针等问题。它在需要独占所有权的场景中非常有用,是一个常用的智能指针类型。
底层实现: 使用一个简单的指针来指向分配的对象。当指针超出范围或重新分配时,会自动删除拥有的对象。
8.6. shared_ptr 和 unique_ptr的转化?
// unique->shared
std::unique_ptr<int> uniquePtr(new int);
std::shared_ptr<int> sharedPtr = std::move(uniquePtr);
// shared->unique
std::shared_ptr<int> sharedPtr(new int);
std::unique_ptr<int> uniquePtr = std::move(sharedPtr);
以上转移过后,原指针不再拥有对象。
9. 栈内存 与 堆内存
在C++中,栈(stack)和堆(heap)是两种用于存储变量和数据的不同内存区域。它们具有不同的分配方式、生命周期和访问方式。
栈:
- 存储:栈用于存储局部变量、函数参数和函数调用的上下文信息。
- 分配方式:栈的内存分配是自动的,由编译器在函数调用和退出时自动管理。
- 生命周期:栈上的变量的生命周期与其所在的作用域(例如函数或代码块)相对应,当作用域结束时,变量会自动释放。
- 访问方式:栈上的数据可以快速访问,因为栈上的内存分配是连续的,变量的地址是预先确定的。
堆:
- 存储:堆用于存储动态分配的内存,例如使用
new或malloc在运行时分配的对象和数据。 - 分配方式:堆的内存分配是手动的,需要显式地请求分配和释放内存。
- 生命周期:堆上分配的内存的生命周期由程序员控制。需要在不再使用时手动释放内存,否则可能会造成内存泄漏。
- 访问方式:堆上的数据访问相对较慢,因为内存分配不是连续的,需要使用指针进行间接访问。
需要注意的是,栈和堆是在不同的内存区域进行分配,它们的大小也有所限制。栈通常比堆小,大小受限于操作系统和编译器的设置。堆的大小通常受限于系统可用内存。因此,栈适合存储较小的局部变量和数据,而堆适合存储较大的对象和动态分配的内存。
10. 内存管理
10.1. 说说C#和C++分别如何管理内存?
为避免内存泄漏,需要确保及时释放不再使用的对象的内存。
如C#中的垃圾回收器(GC)(见C# 知识点区域)。此外还可以使用 IDisposable 接口和 using 语块来手动释放非托管资源。
在一些其他编程语言中,如C++,需要手动管理内存,使用 delete 关键字来释放通过 new 创建的对象的内存。这就需要开发人员负责确保在合适的时机调用 delete 来释放对象的内存,以避免内存泄漏。
总结而言,如果不回收 new 对象的内存,会导致内存泄漏,增加内存占用并可能导致性能下降。及时释放不再使用的对象的内存是保证程序正常运行和资源有效利用的重要操作。
10.2. malloc 和 new 的区别?
malloc 和 new 都用于在编程中动态分配内存。
-
语言:
malloc:malloc是C语言的函数,位于<stdlib.h>头文件中。new:new是C++关键字,用于动态分配内存并创建对象。
-
类型安全:
malloc:malloc返回void*类型的指针,需要手动进行类型转换。new:new返回指定类型的指针,并自动进行类型推断,不需要手动进行类型转换。
-
内存分配大小:
malloc:malloc需要显式指定要分配的内存大小,以字节为单位。new:new根据指定类型的大小自动分配内存。
-
构造函数调用:
malloc:malloc只分配内存空间,不会调用对象的构造函数。new:new不仅会分配内存空间,还会调用对象的构造函数进行初始化。
-
内存足够:
malloc:如果内存分配失败,返回NULL。new:如果内存分配失败,抛出std::bad_alloc异常。
-
内存释放:
malloc:使用free函数释放通过malloc分配的内存。new:使用delete关键字释放通过new分配的内存。
总结: 从以下方面作答:编程语言,类型转换,显式分配大小,构造函数,内存不足,内存释放关键字。
10.3. 说说你知道的C++管理内存的方式?
动态内存管理是在程序运行时动态分配和释放内存的过程,常见的手段包括以下几种:
-
new和delete:- 在C++中,可以使用
new运算符来动态分配内存,用于创建对象。相应地,使用delete运算符来释放通过new分配的内存,销毁对象并释放内存空间。
- 在C++中,可以使用
-
malloc和free:- 在C语言中,可以使用
malloc函数来动态分配内存,它返回一个void*指针,需要手动指定分配的内存大小。使用free函数释放通过malloc分配的内存。
- 在C语言中,可以使用
-
calloc和realloc:calloc函数与malloc类似,但会将分配的内存初始化为零。它接受两个参数,分别是所需的元素数量和每个元素的大小。realloc函数用于重新分配已经分配的内存空间的大小。可以用于调整动态分配的内存块的大小,返回指向重新分配内存的指针。
-
智能指针:
- 在C++中,智能指针是一种封装了动态分配内存的类模板。智能指针可以自动管理动态分配的内存。
-
自定义内存管理:
- 在某些情况下,可以根据特定的需求实现自定义的内存管理机制。例如,使用内存池或对象池来预分配一块内存,并手动管理对象的分配和释放,以提高内存分配和释放的效率。
n. C# 模块补充
n.0. 与C++对比
以下是C++和C#之间的一些区别和各自的优势:
- 语法和语言特性:C++的语法相对较为复杂,需要手动管理内存 ,包括手动分配和释放内存。而C#具有更简单的语法和内存管理,通过 垃圾回收机制自动处理内存,减少了程序员的负担。
- 性能:由于C++直接操作内存并具有更细粒度的控制,因此在性能方面更为出色。C#在运行时需要.NET框架的支持,这可能对性能产生一定影响,尤其是在某些对性能要求较高的场景下。
- 平台支持:C++是一种跨平台语言,可以在多个操作系统上运行。C#最初是为Windows开发的,但现在也可以通过.NET Core在多个平台上运行,包括Windows、Linux和macOS。
- 开发效率:由于C#具有更简单的语法和自动内存管理,开发效率相对较高。C++需要更多的手动操作和细节关注,可能需要更多的代码和调试时间。
编译过程对比:
C#和C++在编译过程上有一些区别。
C#编译流程:
- 源代码:开发者使用C#编写源代码文件,文件扩展名通常是
.cs。 - 编译器:源代码被提交给C#编译器,它将源代码转换成中间语言(Intermediate Language,IL)代码,也称为CIL(Common Intermediate Language)代码。这个阶段主要包括词法分析、语法分析和语义分析等步骤。
- 中间语言代码:生成的中间语言代码被保存在一个扩展名为
.exe或.dll的可执行文件中。这些文件包含IL代码以及相关的元数据,例如类型信息和成员信息。 - 即时编译(Just-In-Time Compilation):在程序运行时,CLR(Common Language Runtime)将IL代码转换成特定平台的本机机器码。这个过程称为即时编译(JIT Compilation),它将中间语言代码编译成可执行代码。
- 执行:生成的本机机器码被执行,程序开始运行。
C++编译流程:
- 源代码:开发者使用C++编写源代码文件,文件扩展名通常是
.cpp。 - 预处理器:源代码被提交给预处理器,它会处理包含预处理指令(如宏定义和条件编译等)的源代码。预处理器会对源代码进行处理并生成预处理后的代码。
- 编译器:预处理后的代码被提交给C++编译器(如GNU Compiler Collection或Microsoft Visual C++等)。编译器将C++源代码转换成汇编语言代码。
- 汇编器:汇编器将汇编语言代码转换成机器码,生成一个目标文件(通常具有
.obj或.o的文件扩展名)。 - 链接器:目标文件和其他必要的库文件(如标准库)被提交给链接器。链接器将它们合并成一个可执行文件(如
.exe文件)。 - 执行:生成的可执行文件被操作系统加载和执行,程序开始运行。
需要注意的是,C#使用CLR(Common Language Runtime)作为运行时环境,而C++是直接生成本机机器码。这使得C#具有更高的可移植性和平台无关性,而C++在编译时更接近底层硬件,并提供了更高的性能和控制能力。
总结 : C#: cs -> dll / exe -> 本机可执行代码。C++:cpp -> obj(汇编) -> exe。C# 具有更高的可移植性。
n.1. 委托和事件
委托(Delegate) 是一种类型,它可以用来表示对一个或多个方法的引用。委托提供了一种将方法作为参数传递、存储和调用的机制,使得方法的调用可以更加灵活和动态。委托可以用来实现回调函数、事件处理以及多播委托等功能。
事件(Event) 是一种特殊类型的委托,它定义了在特定条件下触发的行为。C# 提供了一种方便的方式来实现方法的回调和事件的处理,以及实现发布和订阅模式。通过委托和事件,可以实现 解耦 和 灵活性 的 增加,使得代码具有更好的 可维护性和可扩展性 。
为了避免内存泄漏,当不再需要订阅事件时,应及时取消订阅。这样可以防止订阅者对象被事件持有,导致无法被垃圾回收。
总结:委托和事件提供了一种灵活和可扩展的方式来实现 方法的回调 和 事件的发布与订阅 。通过委托和事件,可以实现 代码的解耦和增强代码的可维护性 。事件机制 有加必有减!!! ,事件机制可以理解为 封装的委托,仅对自己提供 Add, Remove, Trigger等功能,而委托可以被外界调用。
n.2. GC机制 √
在C#中,垃圾回收(Garbage Collection,GC)是一种自动内存管理机制:
-
托管堆(Managed Heap):C#中的对象存储在托管堆中。托管堆是一块内存区域,用于动态分配和管理对象。堆内存由GC负责分配和回收。
-
GC的触发时机:
- 当分配新对象时,如果没有足够的内存空间,则会触发垃圾回收以释放不再使用的对象。
- 也可以根据开发人员手动调用
GC.Collect()触发垃圾回收,但不建议。
使用了分代标记机制(Generational Marking)来提高垃圾回收的效率。
分代:
C#的垃圾回收器将堆内存划分为不同的代(Generation),通常将对象分为三个代:0代、1代和2代。新分配的对象首先放置在0代中。当垃圾回收器执行标记-清除操作时,只会扫描特定的代,而不是整个堆。
具体的分代回收过程如下:
- 0代(Generation 0):0代包含最新分配的对象。当进行垃圾回收时,只会扫描0代。大多数对象都很快就会被回收,只有少数对象会存活到下一代。
- 1代(Generation 1):当进行垃圾回收时,会扫描0代和1代。一些中等生存时间的对象可能会在这里被回收。
- 2代(Generation 2):当进行垃圾回收时,会扫描0代、1代和2代。2代通常包含生存时间较长的对象。
标记:
遍历 并标记正在活跃中的对象,遍历完成后 清除 未被标记的对象,此刻会产生内存零散化,对其空间进行 压缩 ,最后整体迁移到更高一代。
避免GC的途径:
- 多利用公共对象(缓存机制)。
- 定义容器时,预估容器的容量大小,避免频繁扩容。
- 对象池思想,加强对对象的复用。
- 减少拆装箱(多使用泛型)。
- 字符串频繁修改优化,注意new对象的频率以及项目打包前删掉所有Log等等。
Debug.Log耗费性能的原因:所有类型的输出只提供了object参数版本,Debug总是要输出值类型的,这样就一定会出现boxing装箱的操作,如果我们在Update中去做这样的Log,那么每一帧都会在堆上分配内存,会产生内存碎片,容易引起GC。
总结: 分代标记算法的叙述。缓存机制,容器优化,字符串优化,减少打印等等。
n.3. 抽象类和接口
-
实例化:
- 接口无法被实例化。
- 抽象类只能被间接实例化(实例化其派生类自动实例化父类)。
-
成员限制:
- 接口只能包含抽象成员(完全抽象),其成员仅能是 公共且非静态的。
- 抽象类可以包含抽象成员或者实现成员(不完全抽象)。
-
多继承:
- 接口可以实现多继承。
- 抽象类仅能被单继承。
-
使用场景:
- 接口 注重代码的扩展性和可维护性,接口只关心对象之间的交互方法,而不关心对象具体对应的类,是 程序的设计规范。(高扩展,低耦合)
- 抽象类 当我们有许多类存在重复部分,我们可以将 共同特征 其提取出来,希望这个基类不能被实例化,但可以在其中提供一些实现代码。
n.4. 封装 继承 多态
- 封装:将数据和行为相结合,通过行为约束代码修改数据的程度,增强数据安全性。(属性是C#封装特效的最好实例)。分为 数据封装(仅能通过对应接口操作) / 方法封装(实现细节不可见)
- 继承:提高代码复用度,增强软件可维护的重要手段,符合开闭原则(见设计模式)。继承就是把子类的共性聚集起来提取出一个父类,C#的继承是单继承,但具有传承性(这一点上与Java一致)。
- 多态:父类引用指向子类对象(向上转型),重写和重载 是实现多态的两种主要方式。同名的方法在不同环境下,会自适应转换为不同的逻辑表现。(例子:猫在走猫步,鸟儿在飞翔......)。
n.5. foreach 与 for
foreach 底层逻辑(迭代器):
List<int> arr = new List<int>(1,2);
IEnumerator<int> enumerator = arr.GetEnumerator(); // 获取列表迭代器
while (enumerator.MoveNext()){
enumerator.Current; // 当前遍历到的元素
}
核心区别:
for 循环可以用于任何行为(增删改查),而迭代器解决方案foreach仅可用来遍历,遍历期间无法修改目标容器。
n.6. Dictionary/Hash表底层
Hash算法:
- 相同的数据进行Hash运算,其结果一定相同。
- 不同数据进行Hash运算,结果也可能相同(哈希冲突)
- Hash运算不可逆,反映到字典即无法通过value->key
常见的哈希算法:
- 直接寻址法:取 数据 的某个线性函数进行映射。比如H(key) = a * key + b(a,b 均为常数)
- 平方取中法:取 数据 平方后的中间几位作为散列地址。
- 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。(很大程度避免哈希冲突)
Hash桶:
首先,哈希表的原理是 Key -> HashCode -> Value。但 HashCode 一般都非常大,以此我们将生成的HashCode分段来映射,每一段为一个桶 Bucket。最常见的 Bucket 即: bucketIndex = Hash(key1) % n,n为桶的数量。
Hash冲突解决算法:
- 拉链法:产生冲突的元素再建立一个单链表,并将其头指针存储到Hash对应桶的位置,这样定位到Hash表后可通过遍历单链表来查找元素。
- 再Hash法:使用其他Hash函数继续生成Hash,直到不重复为止。
Chapter_2: 数据结构与算法
1. 二叉树
1.1. 详谈完全二叉树与平衡二叉树?
完全二叉树(Complete Binary Tree)是一种特殊的二叉树,其中除了最后一层外,其他层的节点都必须填满,并且最后一层的节点从左到右连续排列,不能有间隔。简单来说,完全二叉树是一棵按照从上到下、从左到右的顺序依次填充节点的二叉树。
以下是完全二叉树的几个特点:
-
所有的叶子节点都集中在二叉树的最后两层。
-
如果一个节点的右子树不为空,则它的左子树必须不为空。
-
对于编号为 i 的节点:
- 如果 i > 1,则其父节点的编号为 i / 2。
- 如果 2i <= n,则其左子节点的编号为 2i。
- 如果 2i+1 <= n,则其右子节点的编号为 2i+1。 这里的 n 是二叉树中节点的总数。
举例来说,下面是一个完全二叉树的示例:
1
/ \
2 3
/ \ /
4 5 6
相比于完全二叉树,平衡二叉树(Balanced Binary Tree)是一种更为严格的二叉树结构。平衡二叉树要求左右子树的高度差不超过一个固定的阈值,通常是1。也就是说,平衡二叉树的每个节点的左子树和右子树的高度之差的绝对值不超过1。
平衡二叉树的设计目的是为了提高二叉树的查找、插入和删除操作的效率。由于平衡二叉树的高度差较小,查找操作的时间复杂度能够保持在 O(log n) 的级别。
常见的平衡二叉树有AVL树、红黑树等。这些平衡二叉树在插入或删除节点时会通过旋转操作来保持树的平衡性。
总结:
- 完全二叉树是一种按照从上到下、从左到右的顺序依次填充节点的二叉树。
- 平衡二叉树是一种左右子树高度差不超过一个固定阈值的二叉树,用于提高查找、插入和删除操作的效率。
1.2. 完全二叉树一定是平衡二叉树吗?
A:从平衡因子定义看,完全二叉树任一结点的平衡因子的绝对值确实是小于等于1。 但是,平衡二叉树本质上是二叉排序树,完全二叉树不一定是排序树。 故不能说完全二叉树是平衡二叉树。
1.3. 红黑树 和 AVL树的区别?
A:红黑树(Red-Black Tree)和 AVL 树都是自平衡的二叉搜索树,用于在动态数据集上进行高效的插入、删除和查找操作。它们之间的主要区别如下:
- 平衡性维护方式:红黑树中最重要的性质是红黑树的节点被着色为红色或黑色,并通过一些规则确保了树的平衡。而 AVL 树通过 维护平衡因子(balance factor)来保持树的平衡,平衡因子是指每个节点的左子树高度和右子树高度之差。AVL 树要求每个节点的平衡因子在 {-1, 0, 1} 的范围内,从而保持树的平衡。
- 平衡性调整操作:在红黑树中,通过旋转和节点着色来调整树的结构,以维持平衡性质。旋转操作包括左旋和右旋,通过改变节点之间的链接关系来调整树的平衡。着色操作则是将节点标记为红色或黑色,通过调整节点的颜色来维护平衡性质。而在 AVL 树中,除了旋转操作之外,还需要进行节点的重新平衡,具体包括左旋、右旋和双旋等操作。
- 平衡性维护代价:由于 AVL 树对平衡性的要求更加严格,因此在插入和删除操作后,可能需要进行多次的平衡调整,以满足平衡因子的要求。相比之下,红黑树的平衡性要求较为宽松,因此在插入和删除操作后,需要的平衡调整次数相对较少。
- 性能特点:由于 AVL 树的平衡性要求更加严格,所以在某些情况下,AVL 树相对于红黑树可能需要更多的平衡调整操作。因此,在频繁的插入和删除操作场景中,红黑树的性能可能优于 AVL 树。然而,在读取操作(如查找)较为频繁的场景中,由于 AVL 树的平衡更加严格,其查询性能可能略优于红黑树。
总结: 红黑树 和 AVL 树 都是 自平衡的二叉搜索树 ,用于高效地处理动态数据集。红黑树通过 着色(红色节点 / 黑色节点)和 旋转(左旋 / 右旋)操作维护平衡性质,而 AVL 树通过 平衡因子 (子树高度查)和 旋转 操作(左旋 / 右旋 / 双旋)。性能方面,红黑树适合处理 频繁增删改 的情况,而AVL树更适合 频繁查找 的情况。
2. 排序算法概述
常见的排序算法包括以下几种:
- 冒泡排序(Bubble Sort):比较相邻的两个元素,如果顺序不对则交换,重复多轮直到排序完成。
- 插入排序(Insertion Sort):将未排序部分的元素逐个插入到已排序部分的合适位置,重复直到所有元素有序。
- 选择排序(Selection Sort):在未排序部分中选择最小(或最大)的元素,并将其放置在已排序部分的末尾,重复直到排序完成。
- 快速排序(Quick Sort):(分治策略)选择一个基准元素,将小于基准的元素放在左侧,大于基准的元素放在右侧,对左右两部分递归地进行快速排序。
- 归并排序(Merge Sort):(空间换时间)将待排序数组分成两部分,分别进行归并排序,然后将排序好的两部分合并成一个有序数组。
- 堆排序(Heap Sort):构建最大(或最小)堆,然后依次取出堆顶元素并进行调整,直到所有元素有序。
- 希尔排序(Shell Sort):将待排序的数组按一定间隔分组,对每组进行插入排序,逐渐减小间隔直到为1,最后进行一次完全的插入排序。
- 计数排序(Counting Sort):统计每个元素出现的次数,然后根据统计结果将元素放回原数组,实现排序。
- 桶排序(Bucket Sort):将待排序元素分配到不同的桶中,对每个桶进行排序,最后按顺序将各个桶中的元素合并得到有序结果。
- 基数排序(Radix Sort):根据元素的位数,将待排序元素按照位数的值依次进行排序,从最低位到最高位依次进行排序。
详细信息表格:
| 排序类型 | 平均时间复杂度 | 最佳情况 | 最坏情况 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
| 希尔排序 | O(nlogn) | O(nlogn²) | O(nlogn²) | O(1) | 不稳定 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
| 桶排序 | O(n+k) | O(n+k) | O(n²) | O(n+k) | 稳定 |
| 基数排序 | O(n × k) | O(n × k) | O(n × k) | O(n+k) | 稳定 |
Chapter_3:计算机图形学
0. 点乘 与 叉乘
-
向量点乘(Dot Product): 点乘的结果是一个标量(数量),定义如下:
对于两个三维向量 A = (A1, A2, A3) 和 B = (B1, B2, B3),它们的点乘可以表示为: A · B = A1 * B1 + A2 * B2 + A3 * B3 = |A||B|cosθ
在图形学和游戏中,向量点乘有多种应用,包括:
- 计算两个向量之间的夹角。
- 判断两个向量是否正交(垂直)。
- 判断两个向量之间的相对方向(同向或反向)。
-
向量叉乘(Cross Product): 结果是另一个向量。叉乘的结果是垂直于原始向量的向量。具体定义如下:
对于两个三维向量 A = (A1, A2, A3) 和 B = (B1, B2, B3),它们的叉乘可以表示为: A × B = (A2 * B3 - A3 * B2, A3 * B1 - A1 * B3, A1 * B2 - A2 * B1) = |A||B|sinθ,方向判定采用 右手螺旋定则。
左右手坐标系的定义:当
x × y = z => 右手坐标系。在图形学和游戏中,向量叉乘也有多种应用,包括:
- 判断 a,b 向量的 左右关系。
- 判断 某点在面的 内外关系。
1. 光照模型
当谈到光照模型时,我们常常涉及以下几种常见的光照模型:Lambert光照模型、高光反射模型、Blinn-Phong光照模型 。下面我会为你详细介绍每个光照模型的工作原理和特点。
-
Lambert光照模型(Lambertian Reflection):(漫反射)
- Lambert光照模型是最简单的光照模型之一,基于兰伯特定律,它假设物体表面在所有方向上均匀地反射光线,不考虑光的衰减和反射方向。
- Lambert光照模型仅使用漫反射项来计算光照强度,它使用入射光线的方向与物体表面法线之间的夹角来确定物体表面的明暗程度。
- Lambert光照模型适用于描述非金属材质的光照效果,例如石头、木材等。
-
高光反射模型(Specular Reflection):(镜面反射)
- 高光反射模型用于描述物体表面的镜面反射特性,当光线直接照射到物体表面时,会产生明亮的高光。
- 高光反射模型考虑了入射光线的方向、物体表面法线和观察者的位置,通过计算反射方向与入射方向的夹角来确定高光的亮度和位置。
- 高光反射模型通常使用镜面反射项来计算高光的强度,并且具有关于观察者位置的指数衰减特性。
- 高光反射模型适用于描述金属或光泽表面的光照效果,如金属、塑料、玻璃等。
-
Blinn-Phong光照模型:(漫反射 + 镜面反射 + 全局光照)
- Blinn-Phong光照模型综合了Lambert光照和高光反射,是一种常用的光照模型。
- Blinn-Phong光照模型使用漫反射项计算由环境光产生的明暗程度,使用镜面反射项计算由光源产生的高光效果。
- Blinn-Phong光照模型通过插值计算顶点法线来实现平滑的光照效果,它使用半程向量(half-vector)来近似计算反射方向和入射方向的夹角。
2. PBR渲染原理
物理基于渲染(Physically Based Rendering ,PBR)是一种基于物理原理 的渲染技术,通过模拟 光线的反射,折射,散射 等光学行为,以及 物理材质,以获得逼真的渲染效果。
-
材质定义:
- 反射率 决定了物体表面的颜色;金属度 定义了物体是金属还是非金属;粗糙度 描述了表面的光滑程度;法线贴图 用于增强表面的细节和凹凸感。
-
光照模型:
- 基于 菲涅尔反射的 BRDF(双向反射分布函数)。描述光纤从一个方向摄入物体表面到另一个方向的分布情况。Blinn-Phong 光照模型 (见上述描述)。
-
环境光照:
- 全局照明技术 通过考虑光线在场景中的间接传播和反射,使渲染结果更真实、自然。
-
HDR渲染:
- 高动态范围(HDR)渲染,捕捉到更广泛的亮度范围。
-
着色模型:
- Flat Shading: 通过三角形法线对几何体面上的 每个三角面片 进行着色操作。
- Gouraud Shading: 通过三角面片的 每个顶点 的法线方向进行着色,并通过插值运算对三角形内部进行着色。
- Phong Shading: 通过三角面片上的 每个像素 进行着色。
3. 前向渲染和延迟渲染
前向渲染(Forward Rendering)和 延迟渲染(Deferred Rendering)是两种常见的渲染管线技术,它们在光照和渲染过程上有一些关键的区别。下面是它们的主要区别:
前向渲染:
- 光照计算:在前向渲染中,光照计算发生在每个像素上。每个光源都会对场景中的每个像素进行计算,然后将其贡献到最终的颜色中。
- 渲染顺序:前向渲染按照对象的绘制顺序进行渲染。每个对象都会进行完整的渲染,包括几何体的绘制、光照计算和像素着色等。
- 透明物体:前向渲染在处理透明物体时存在困难,因为它需要按照绘制顺序进行混合,并且可能会导致复杂的排序和重叠问题。
- 内存占用:前向渲染需要存储每个像素的颜色、深度和法线等信息,因此在大型场景中占用的内存较大。
延迟渲染:
- 光照计算:在延迟渲染中,光照计算发生在屏幕空间的片段级别。首先将场景的几何信息存储在一个叫做G缓冲(G-buffer)的缓冲区中,然后在光照阶段使用G缓冲进行光照计算。
- 渲染顺序:延迟渲染将几何体的渲染和光照计算分离开来。首先绘制几何体并将其信息存储在G缓冲中,然后进行光照计算,最后将光照结果与几何体的颜色进行合成。
- 透明物体:延迟渲染在处理透明物体时相对更容易,因为它可以使用深度排序和透明度排序来正确地渲染透明物体。
- 内存占用:延迟渲染需要额外的G缓冲来存储几何信息,这可能导致较大的内存占用,尤其是在复杂场景中。
选择前向渲染还是延迟渲染取决于具体的应用需求和场景。前向渲染适用于小型场景和少量光源的情况,而延迟渲染适用于大型场景、复杂光照和大量透明物体。
4. GPU渲染管线
GPU渲染管线 (Graphics Processing Unit Rendering Pipeline)是计算机图形学中用于生成图像的流程和阶段集合。它是GPU(图形处理单元)执行图形渲染任务的基本流程。渲染管线将输入的 3D场景 描述转换为最终的 2D图像 。
下面是GPU渲染管线的基本流程:
-
顶点处理 阶段(Vertex Processing Stage):
- 顶点输入:从CPU传输顶点数据(网格和纹理)到GPU。
- 顶点着色器(Vertex Shader):对每个顶点进行处理,进行变换(例如,模型、视图和投影变换)、应用材质属性、执行动态顶点变形等。
-
几何处理 阶段(Geometry Processing Stage):
- 图元装配(Primitive Assembly):将顶点组装成图元(如点、线、三角形)。
- 几何着色器(Geometry Shader):对每个图元进行处理,可以生成新的几何图元、执行曲面细分、执行几何操作等。
-
光栅化 阶段(Rasterization Stage):
- 光栅化:将图元转换为屏幕上的片段(或称为像素片段)。
- 裁剪(Clipping):根据视锥体剪裁可见的片段。
-
片段处理 阶段(Fragment Processing Stage):
- 片段着色器(Fragment Shader):对每个片段进行处理,进行光照计算、纹理采样、阴影计算等。
- 深度测试(Depth Testing):比较片段的深度值与深度缓冲区中的值,决定片段是否可见。
- 模板测试(Stencil Testing):根据模板缓冲区中的值进行片段的遮罩和裁剪。
- 色彩混合(Color Blending):将片段的颜色与帧缓冲区中的颜色进行混合。
-
输出合成 阶段(Output Merger Stage):
- 帧缓冲写入(Framebuffer Writing):将最终的片段颜色值写入帧缓冲区。
- 输出到屏幕:将帧缓冲区的内容显示在屏幕上。
总结 :
- 数据加载 :CPU打包 顶点数据(顶点坐标,贴图坐标,法线......),摄像机(视锥,景深......),光照数据从硬盘加载到内存再将网格和纹理数据加载到显存(GPU读取显存速度快)。定义渲染状态后调用DC(仅指向图元列表,不包含任何材质信息)
- GPU 顶点处理(MVP变换(世界空间>相机空间->裁切空间)或其他变换(顶点动画等))。(二向箔doge)
- GPU 光栅化 裁切像素栅格化,并剔除无需渲染的部分。
- GPU 片段处理(各类参数的计算进行着色)。
- GPU 后处理,存入缓冲区,做一些全局处理(抗锯齿,加滤镜等)后将缓冲区内容显示再屏幕上。
5. Unity渲染管线
Unity渲染管线 包括 内置渲染管线,通用渲染管线(URP)和 高清渲染管线(HDRP) 或者 可编程渲染管线(SRP)。
渲染的通用流程:
- 剔除culling
- 渲染rendering
- 后处理postprocessing
Chapter_4:游戏引擎与性能优化
1. UE 与 Unity
当涉及到游戏引擎时,Unity和Unreal Engine都是业界领先的选择,具备各自独特的特点和优势。
Unity是一款跨平台的游戏引擎,其主要特点包括:
- 易于上手和学习:Unity提供了直观的可视化编辑器,使得初学者和新手开发者能够相对容易地入门。它还有广泛的教程和文档资源,帮助开发者快速上手。
- 快速迭代开发:Unity注重快速迭代和迭代开发,可以快速原型设计和迅速迭代。它提供了实时预览功能,允许开发者在编辑器中实时查看游戏的变化,加快了开发周期。
- 跨平台支持:Unity支持多个平台,包括PC、主机、移动设备和Web等。通过编写一套代码,开发者可以将游戏轻松发布到不同的平台上。
- 强大的社区和生态系统:Unity拥有庞大活跃的社区,开发者可以从中获取支持、分享经验和参与讨论。Unity还有丰富的插件生态系统,可以扩展引擎功能。
相比之下,Unreal Engine也有其独特的特点:
- 图形和渲染质量:Unreal Engine以其强大的图形渲染能力而闻名,能够提供高质量的视觉效果。它支持先进的渲染技术,如实时光线追踪和全局光照。
- 物理模拟和动画系统:Unreal Engine具备强大的物理模拟和动画系统,能够实现逼真的物理效果和流畅的角色动画。它提供了多种工具和节点系统,支持复杂的物理模拟和动画制作。
- 蓝图系统:Unreal Engine引入了蓝图系统,这是一种可视化脚本工具,允许开发者通过拖放节点和连接线来创建游戏逻辑,而无需编写代码。这使得非程序员也能够参与游戏逻辑的制作。
- AAA级游戏开发:Unreal Engine广泛用于AAA级游戏的开发,如《堡垒之夜》和《地铁离去》等。它提供了全面的工具套件,包括场景编辑器、材质编辑器、动画编辑器等,使开发者能够创建复杂且高度定制的游戏内容。
总的来说,Unity和Unreal Engine都是强大的游戏引擎,它们在以下方面有所不同:
- 编程语言和脚本支持:Unity使用C#作为主要的编程语言,它是一种现代、强类型的语言,易于学习和使用。而Unreal Engine则支持C++编程,这对于需要更高级的控制和性能优化的项目来说非常有优势。
- 定制性和灵活性:Unreal Engine相对于Unity更加注重灵活性和定制性。它提供了底层的访问权限,允许开发者对引擎进行深度定制,以满足特定项目的需求。这使得Unreal Engine在开发大型、复杂的游戏时具有优势。
- 开发成本:Unity在一些方面具有较低的开发成本。它的学习曲线相对较浅,上手速度快,对于初学者和小型团队来说更容易上手。同时,Unity的基础功能是免费提供的,而Unreal Engine则采用了收费许可模型,需要支付一定的许可费用。
- 适用项目类型:Unity在移动游戏和小型独立游戏的开发方面非常强大。它的轻量级设计和跨平台支持使得在移动设备上开发游戏变得更加便捷。Unreal Engine则更适合开发大型、高质量的游戏和虚拟现实项目,如高度逼真的图形、复杂的物理模拟和大规模的开放世界。
最终,选择使用哪个游戏引擎取决于项目的需求、开发团队的技术能力和时间预算。无论选择Unity还是Unreal Engine,都能够提供强大的工具和功能,帮助开发者创造出令人惊叹的游戏体验。
2. 场景题
2.1. FPS游戏中假设子弹数量很多,该如何优化游戏性能?
要优化游戏性能以处理大量子弹的情况,可以考虑以下方法:
- 减少子弹和敌人之间的实际碰撞检测:避免对每颗子弹和每个敌人进行实际的碰撞检测。可以使用空间分区技术(如网格或四叉树)将游戏世界划分为多个区域,然后只对位于相同区域的子弹和敌人进行碰撞检测。
- 使用碰撞体积的近似表示:使用碰撞体积的近似表示,例如简化的几何形状(如球体、包围盒)来代表子弹和敌人的碰撞体积。这样可以减少实际的碰撞检测计算量,从而提高性能。
- 引入碰撞预测:根据子弹的速度和敌人的位置,通过预测敌人和子弹的轨迹,可以估算出碰撞可能发生的时间窗口。然后只在这个时间窗口内进行实际的碰撞检测,以避免对所有子弹和敌人进行连续的实时检测。
- 对象池和对象重用:避免频繁地创建和销毁大量的子弹和敌人对象,可以使用对象池和对象重用的技术。通过预先创建一定数量的对象,并在需要时重用它们,可以减少对象创建和销毁的开销,提高性能。
- 优化碰撞检测算法:对于实际的碰撞检测算法,可以采用一些高效的技术和优化方法,例如空间分区算法(如BVH、KD-Tree)或基于流水线的算法。这些算法可以加速碰撞检测计算,并减少不必要的检测。
- 使用多线程或并行计算:利用多线程或并行计算技术,可以将碰撞检测任务分配给多个处理器核心进行并行计算,以提高整体的计算性能。
- 针对特定平台进行优化:针对目标平台的特性进行优化,例如使用平台特定的图形API、编译器优化选项和硬件加速功能,以提高游戏性能。
总结 :总体思路从 空间划分,近似判定,碰撞预测(时间维度),对象池技术,多线程 / 并行计算 等方面依次优化即可。
3. 对象池优化
3.1. 对象池中存储实例与指针各有何优劣?
存储实例还是存储指针,取决于具体的使用情况和设计需求。下面是一些考虑因素:
1. 存储实例:
- 简单性: 存储实例可以更直观和简单,无需手动管理指针的生命周期。
- 内存连续性: 存储实例可以提供更好的内存连续性,从而有助于提高数据访问效率。
- 对象语义: 存储实例可以保留对象的完整语义,并允许直接访问对象的成员函数和数据。
2. 存储指针:
- 灵活性: 存储指针可以提供更大的灵活性,允许动态地创建和销毁对象,并通过指针进行引用。
- 对象的生命周期: 如果对象的生命周期需要在容器之外进行管理,存储指针可以更好地控制对象的创建和销毁时机。
- 对象所有权: 存储指针可以支持共享对象或转移对象所有权的场景。
总结:存储 实例 :对象的 生命周期无需动态管理 ,并且 对象较小 且需要 频繁访问 。存储指针 : 对象的 生命周期需要动态管理,或者 对象较大 且 创建和销毁频率较高。
3.2. 如何存取对象池内对象的时间复杂度都是O(1)的?
要让存取对象池内对象的时间复杂度都为 O(1),可以采用以下方法:
- 使用索引或哈希表: 维护一个索引或哈希表,将对象的标识符或关键属性作为键,将对象本身或对象指针作为值存储在索引中。这样可以通过对象的标识符或关键属性快速定位到对象,实现 O(1) 的存取时间复杂度。
- 使用固定大小的数组: 将对象存储在固定大小的数组中,数组的下标可以作为对象的索引。通过将对象的索引与数组下标关联,可以直接访问对象,实现 O(1) 的存取时间复杂度。需要注意的是,如果数组已满,可能需要进行对象的迁移和重新分配。
总结:解题思路锁定在 索引,无论是以 下标 为索引的 静态数组,还是 以 键值 为索引的 哈希表,都可以满足时间复杂度为O(1)的条件。
4. 批处理优化
批处理是一种优化技术,可以显著减少渲染管线中的开销。在Unity中,批处理主要用于减少Draw Call的数量,以提高性能。下面是一些常见的批处理优化方案:
- 静态批处理(Static Batching):静态批处理是将多个静态物体合并成一个大的网格,以减少渲染调用。这适用于那些不需要经常改变的物体,如静态地形或静态背景物体。Unity会在编译时自动执行静态批处理,只需确保物体被标记为静态。
- 动态批处理(Dynamic Batching):动态批处理用于合并多个动态物体的渲染。Unity会自动执行动态批处理,将使用相同材质和渲染属性的物体合并成一个批次。为了实现动态批处理,确保物体使用相同的材质和渲染设置,并且满足Unity的合并条件(例如顶点数限制)。
- GPU实例化(GPU Instancing):GPU实例化是一种技术,允许使用相同的网格和材质来渲染多个实例。这种方法特别适用于需要大量重复的物体,如草地、树木或粒子效果。通过使用GPU实例化,可以减少Draw Call的数量,并在GPU上高效渲染多个实例。
当进行批处理优化时,还有一些额外的注意事项和技巧:
- 避免动态材质属性:动态材质属性会破坏批处理的效果,因为每个物体的材质属性都不同,导致无法合并渲染。尽量避免使用动态属性,或者将其转化为静态属性,以实现批处理优化。
- 合理设置合批阈值:在Unity中,可以通过调整合批阈值来控制何时触发批处理。合批阈值决定了多少个物体可以被合并为一个批次。根据场景的复杂性和硬件的性能,调整合适的合批阈值,以在减少Draw Call和保持性能之间取得平衡。
- 高效使用合并网格(CombineMeshes):Unity提供了CombineMeshes函数,可以在运行时将多个网格合并成一个。使用CombineMeshes函数可以避免手动编写合并逻辑,节省开发时间。然而,要注意在合并网格时保持材质和渲染设置的一致性,以确保合并后的网格可以进行批处理渲染。
- 批处理调试:在进行批处理优化时,可以使用Unity的Profiler工具来检查批处理的效果和性能。Profiler可以帮助你识别哪些物体成功合并,哪些物体无法进行批处理等。通过观察Profiler的结果,可以调整和优化批处理设置,以获得更好的性能表现。
- 使用GPU Instancer插件:GPU Instancer是一个强大的Unity插件,可以简化GPU实例化的使用和管理。它提供了易于使用的编辑器工具,可以帮助你快速配置和管理大量实例化物体,从而实现更高效的批处理渲染。
总结 :批处理是一种重要的优化技术,可以大幅减少Draw Call的数量。通过 静态批处理、动态批处理、GPU实例化和手动合并等方法,可以有效地减少渲染开销,提升游戏的帧率和流畅度。在应用批处理优化时,需要根据具体场景和需求进行权衡和调整,以获得最佳的性能改进。
5. 视锥体剔除
视锥剔除(Frustum Culling)是一种在渲染过程中进行的优化技术。视锥剔除的基本原理是利用相机的视锥体来确定物体是否在相机的可视范围内。相机的视锥体是一个截头锥体,其顶点位于相机位置,面通过相机的近裁剪面和远裁剪面,并延伸到无限远。所有在视锥体外的物体都可以被剔除。但有时候相机的视锥体开销会比原本渲染开销大,综合考虑使用即可。
6. 协程 Coroutine
定义:协程就是一种特殊的函数,它可以主动的请求暂停自身并提交一个唤醒条件,Unity会在唤醒条件满足的时候去重新唤醒协程。保证函数的 分时分段 执行。
应用场景:
1. 一些简单的动画效果。
- 打字机效果。
- 异步加载资源。
底层原理:
组成: 协程 + 协程调度器
C#的迭代器函数:是一个协程,你可以使用yield来暂停,使用MoveNext()来继续执行。 当一个方法的返回值写成了IEnumerator类型,他就会自动被解析成迭代器方法,你调用此方法的时候不会真的运行,而是会返回一个迭代器,需要用MoveNext()来真正的运行。
Unity中的协程是无栈协程,它不会维护整个函数调用栈,仅仅是保存一个栈帧。
7. 帧同步 与 状态同步
概念:多个客户端表现 / 数据保持 一致。
核心差别:
- 战斗逻辑:状态同步 在 服务端(MMO),帧同步 在 客户端。
- 通信交互:状态同步下,客户端更像是服务端数据的表现层,一些数据传给客户端,客户端在界面上显示数值。帧同步下,服务端只做转发操作,不做任何逻辑处理。
- 流量:状态同步消耗 > 帧同步,帧同步仅需要转发,而状态同步每次改变都要同步一次属性。
- 安全性:状态同步 > 帧同步。服务端做战斗逻辑的作弊难度比客户端做逻辑难度高很多。
- 服务器压力: 状态同步 > 帧同步。帧同步无需做过多逻辑运算仅负责转发。
- 应用实例:帧同步适合做 回放 & 观战系统,保存每个人的操作即可,状态同步则适合做 断线重连,只需将断网前上次状态数据打包发送给客户端即可。
8. 生命周期函数
Chapter_5:常见游戏算法记录
1. 最大频率栈(哈希表+栈)
# include <iostream>
# include <unordered_map>
# include <stack>
using namespace std;
class MaxFrequencyStack {
private:
unordered_map<int, int> freq_map; // 元素 -> 频率
unordered_map<int, stack<int>> stack_map; // 频率 -> 栈
int max_frequency; // 记录最大频率
public:
MaxFrequencyStack() {
max_frequency = 0;
}
void push(int x) {
freq_map[x]++; // 频率+1
max_frequency = max(max_frequency, freq_map[x]);
stack_map[freq_map[x]].push(x);
}
int pop() {
if (max_frequency == 0) return -1;
int x = stack_map[max_frequency].top(); // 拿到最大频率元素
stack_map[max_frequency].pop() ;
freq_map[x]--;
if (stack_map[max_frequency].empty()) max_frequency--;
return x;
}
int get_max_frequency() {
return max_frequency;
}
};
2. AStar 寻路算法
算法预处理:
- 地图栅格化,每个正方形格子中央成为节点。
- 确定 起始点 与 目标点。
- 定义 开启列表 与 关闭列表。open_list存放待考察的节点, close_list存放考察过的节点。初始将起点放入关闭列表中。
- 父节点周围八个节点分别存入 开启列表 。
核心思想:
Dijkstra算法(广搜):很好的找到了最短路径,但走了太多没用的路。
贪心算法:找到了终点,但不一定是最短。
将上述思想进行综合并引入 寻路消耗 的概念:
寻路消耗函数:f(n) = g(n) + h(n)
- g(n) 是在状态空间中 从初始节点 当前节点n的实际代价(欧氏距离)
- h(n) 是当前节点n 到 目标节点的估计代价(曼哈顿距离)
- 换句话说,g(n) 代表了 广搜 的优先趋势,更容易找到最短路径。
Chapter_6: 设计模式与架构
1. MVVM架构
MVVM(Model-View-ViewModel)是一种软件架构模式,用于设计和开发用户界面(UI)应用程序。它的目标是将应用程序的逻辑与界面的表示分离,提供更好的可维护性、可测试性和可扩展性。
MVVM模式由以下三个核心组件组成:
- 模型(Model):表示应用程序的数据和业务逻辑。模型通常是独立于界面的,它封装了数据源、数据访问和数据处理等功能。
- 视图(View):;责呈现模型数据给用户并接收用户的输入。视图通常是界面元素的集合(UI),如窗口、页面、控件等。
- 视图模型(ViewModel):作为模型和视图之间的中介,它将模型的数据和操作转换为视图可以理解和展示的形式。视图模型向视图公开命令(Command)、属性(Property)和事件(Event) ,并提供数据绑定机制,以便在模型数据发生变化时自动更新视图。
MVVM的关键概念是数据绑定(Data Binding) ,它使得模型和视图之间的数据同步变得简单和自动化。通过数据绑定,当模型的数据发生变化时,视图会自动更新,而用户的操作也可以直接反映到模型中。
在MVVM中,视图和视图模型之间的交互主要通过以下方式实现:
- 数据绑定:视图的控件属性可以绑定到视图模型的属性,以便在数据变化时自动更新视图。
- 命令绑定:视图的命令(按钮点击、菜单选项等)可以绑定到视图模型的命令,以便在用户操作时执行相应的逻辑。(命令模式)
- 事件通知:视图模型可以通过事件或委托机制通知视图发生的重要事件,以便视图可以采取相应的操作。(观察者模式)
Chapter_7:计算机网络 / 操作系统
1. 进程,线程与协程
进程是操作系统资源分配的基本单位,每个进程有独自的指令和数据 ,程序(外存中的二进制流)=> 进程(内存中的指令和数据)
线程是处理器调度与执行的基本单位:
- 每个线程和一个函数调用栈绑定(一一对应),但共享 进程的堆,数据区和代码区(存储指令)。
- 时间片轮转法:操作系统不停的在不同线程间来回切换营造一个并行的效果。
- 线程调度是 抢占式 的,理解为多个线程去抢占CPU的控制权。
协程是一个程序员自己规定的可以分时分段执行的函数:
- 协程可以使用yield 和 resume操作对其挂起 or 恢复。
- 协程调度是 非抢占式 的,需要用yield释放CPU控制权,且运行过程中不会被系统中断所打断。
2. TCP三次握手
过程详解:
第一次握手:客户端向服务端发送 同步报文(SYN=1) + 选择一个随机数 seq = x 初始序列号 ,随后进入 SYN_SENT 状态,等待服务端确认。
第二次握手:服务端向客户端发送 同步确认报文(SYN=1,ACK=1) + 选择一个随机数 seq = y初始序列号,确认号为 ack=x+1 ,随后进入 SYN_RECV 状态。
第三次握手:客户端发送 确认报文(ACK=1) + 序列号为 seq=x+1,确认号为 ack=y+1。从而双端进入 ESTABLISHED 状态。
2.1. 为什么一定要三次握手?
- 防止已经过期的连接请求突然又传到服务器,造成服务器的资源浪费。
- 三次握手才能让双方都确认彼此的发送与接收能力是否正常。
- 告知对方自己的初始序列号值,并确认对方的初始序列号值。
2.2. 谈谈SYN泛洪攻击?
发生原理:TCP第三次握手成功之前,服务端在收到客户端确认报文之前会不断重发请求直至超时。
检测方式:服务器上看到大量半连接状态,且源IP随机时,基本可以断定是SYN攻击。
防范手段:
- 防火墙,路由器等网关防护。
- 加固 TCP/IP 协议,增加最大半连接数,缩短超时时间等。