分类: 技术

  • C++ 11/17强枚举类型读书笔记

    强枚举类型

    原来继承自C语言的枚举类型在C++之父看来是一个奇怪且半生不熟的概念。

    枚举的弊端

    虽然枚举类型可以避免A类型赋值给B类型,但是:

    • 可以直接跨枚举比较
    • 可以直接转化为int
    • 同名的枚举值是冲突的

    虽然有很多缺点,但依然是建议使用枚举而不是const int来做枚举,那样问题只会更多。

    强枚举类型

    C++11标准增加了强枚举类型,为了保证老代码的兼容性,同时也兼容了旧的特性,新增的枚举类型具有三个特性:

    • 枚举标识符属于强枚举类型的作用域。
    • 枚举标识符不会隐式转换为整型。
    • 能指定强枚举类型的底层类型,底层类型默认为int类型。
      基本上就是让枚举不是一个int的别名,而是一个独立的完全定义,具备完全语义的类型,同时解决了枚举值作用域的问题。

    为了兼容旧的逻辑,所以使用了新的标识符,从enum替换成了enum class。

    列表初始化有底层类型枚举对象

    这一段真的很难理解,为什么C++标准要搞这种东西,即使书里面有一些解释,我还是觉得不太理解…
    首先强枚举类型支持由int作为参数的列表初始化:

    enum class Color {
     Red,
     Green,
     Blue
    };
    int main()
    {
     Color c{5};
     Color c1 = 5;
     Color c2 = {5};
     Color c3(5);
    };

    这个例子真的震惊到我,Color的范围不是[0~2]吗,怎么就能赋值成5?
    而且{5}和(5)的差别是列表初始化构造函数和参数构造函数,为什么要有这种奇怪的特性?C++真的越来越折磨人。。。
    说是为了定义一种特殊的整数类型,同时这个整数类型不能跟其他的整数互相转换,强枚举类型符合这个特性,所以通过列表初始化构造函数让强枚举类型变成了一种非通用整数类型的整数类型。
    C++真的越来越折磨人。。。

    用using打开强枚举类型

    可以使用using namesapce;的方式,省略掉强枚举类型的前缀,直接在上下文使用枚举值。

    enum class Color {
     Red,
     Green,
     Blue
    }
    const char* ColorToString(Color c)
    {
     switch(c)
     {
      case Color::Red: return"Red";
      case Color::Green: return"Green”
      case Color::Blue: return"Blue";
      default:
        return"none";
      }
    }

    可以改成:

    enum class Color {
     Red,
     Green,
     Blue
    }
    const char* ColorToString(Color c)
    {
     switch(c)
     {
      using Color;
      case Red: return"Red";
      case Green: return"Green”
      case Blue: return"Blue";
      default:
        return"none";
      }
    }

    End.

  • 默认和删除函数(C++11)笔记

    类的特殊成员函数

    在C++中定义一个类,会默认生成以下6个成员函数:

    • 默认构造函数
    • 析构函数
    • 复制构造函数
    • 复制赋值运算符函数
    • 移动构造函数(C++11 新增)
    • 移动赋值运算符函数(C++11 新增)

    这些功能很实用,在我们直接定义一个类之后,能够直接互相拷贝,而不用为了编译器语法的原因一定要写一遍这些函数。

    但是它也有一些潜规则:

    • 声明任何构造函数都会抑制默认构造函数的添加。
    • 一般用自定义的构造函数替代默认构造函数,类就会转化为非平凡类型。

    非平凡类

    什么是平凡类什么是非平凡类呢?
    这里的概念是出自C语言,C语言想要复制一个对象最快速的方法是直接把整个对象的内存从a复制到b,而不用考虑其他的情况。

    但是对C++的类,可能就不能通过直接复制对象的内存来实现复制,因为类可能定义了构造函数、复制构造函数,要在直接的内存copy之前或者之后做一些逻辑,甚至可能是很重的逻辑,那么对象的拷贝就只能通过复制构造函数来做了。

    因此,平凡类和非平凡类的最大差别是:是否能直接copy类的内存块来实现完整的复制,想做到这个要求这个类在构造的时候和复制的时候不需要做额外的逻辑,但是编译器无法确认你是否有做了额外的逻辑,所以编译器只能定义,显式(explict)定义了四种函数:

    • 构造函数
    • 复制构造函数
    • 复制赋值函数
    • 析构函数
      有另外定义就是非平凡类,但是如果定义了使用=default,也算是默认的,所以不会改变平凡类的性质。

    从我这个角度去分析这个问题应该会简单一点。

    POD是指完全跟C语言的struct兼容,需要是平凡类和标准布局,这俩分别对应的是行为定义和内存定义。

    显示默认和显示删除

    C++11标准提供了一种简单的方法能够有效地控制默认特殊函数的添加和删除,语法很简单,就是在尾部添加=default和=delete。

    这里=default可以在.cpp中去制定,但是=delete必须在.h中指定,不过这个也很好理解。

    显示删除除了在这些地方,还可以使用在普通函数上,可能是给一些库使用,保留旧的函数签名,但是链接新版本时会失败,虽然听起来还是没什么用。

    然后就是可以显式的删除new函数和析构函数,而且这两者的表现完全相反,new操作符被删除之后,就不能通过new创建了,只能通过自动变量、静态变量等方法创建;而析构函数删除后,则无法调用delete函数,同时也无法从自动变量、全局变量中创建。

    参考了一下维基百科里C++的new,原来C++ new一个过程是先用operator new创建内存,然后调用构造函数再返回指针,而new 操作还支持在已经有的内存上直接new一个对象……

    参考

    https://zh.wikipedia.org/wiki/POD_(%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1)
    https://zh.wikipedia.org/wiki/New_(C%2B%2B)

    End。

  • Hazel视频笔记 – EventSystem

    介绍

    最近开始听Youtube上一个大佬自研引擎的开发全过程,本篇是我做的笔记。

    事件集中定义

    这篇视频介绍了他对于EventSystem的初步规划和开发,首先我因为用python很多,很习惯于不提前把一切事件都定义好,EventSystem就应该是外部可以定义Event,并且可以发布Event。

    但是他这里做的事情是,定义好了所有的Event,有好有坏,好处呢是所有的事件集中在Event.h的里面,但也有坏处的,坏处就是如果我想增加事件必须要修改引擎的代码,或者要新开发一个Delegate的系统。

    Category是位运算

    博主说为了可以快速区分这个事件是不是一个鼠标事件,就将所有的鼠标事件都集中在同一个Category里,感觉是一个不够抽象的设计。

    如果是我来做,我应该会定义一个Category的抽象类,然后把所有的EventType都放进去,效率可能会比博主的这个方案低一点,但是在外部就可以自由的定义哪些组事件放在一起,个人感觉会比直接定死一批Category要好。

  • 函数式编程 记录

    函数式编程范式则认为:函数也是一种变量,函数可以作为另一个函数的参数!

    通常来说,软件应当追求低耦合度,适度解耦的软件能更快适应需求变化。但过度的低耦合也会导致代码过于分散,不易阅读和修改,甚至可能起到反效果。

  • 论抽象还得是C++

    最近刷leetcode挺多的,在学算法的同时,还通过其他人的代码学了不少C++新标准的东西。
    今天看到Split和Join,轮抽象还得是C++啊。
    实现Split是很多语言都有的基础功能,C++迟迟没有推出,是因为有杠精觉得为什么一定要用std::vector呢?不能用std::list吗?
    然后这次标准推出了一个新的抽象对象,View。

    类似于python迭代器的一种抽象,包含了一个Ranges的一些信息,但是又没实例化。

    论抽象还得是C++

  • 游戏中AOI的思考

    AOI的概念

    AOI(Area Of Interest),通常是指服务端对玩家感兴趣领域的划分的技术。
    这个技术的应用场景是服务端存在非常多的单位,客户端不需要渲染所有的单位,服务端也可以避免将所有单位的信息广播,所以其目的是降低客户端的渲染压力、减小服务端的带宽压力。

    这个问题的指导思想就是:世界很大,我只看眼前,我只关注周围的单位,太远的单位我并不关心。

    好久之前有一个小朋友问过我,背那些算法和数据结构的八股文有什么意义,我当时跟他说,这是你翻身的武器,我们不要文凭,不要出身,只要你能背下八股文就能给你高薪的工作。
    同时他们非常有用,在你深入做底层问题的时候,本质上就是面对CPU、内存、数据结构和算法,早就是脱离于语言存在的东西。

    问题简化

    AOI问题是一个实际应用中常见的优化问题,把它抽象成一个算法题的话应该是这样。你要维持一个数据结构,能够快速地获取周边足够近的n个单位,同时能够快速地修改所有单位的坐标。

    目标就很明确了,需要一个快速获取某个单位或者某个坐标周围足够近的单位,这个足够近是项目自己定义的,它可以是距离,也可以是某种近似。

    这里提到了两种思路,也代表了坐标计算的两种思想,既可以围绕单位做数据结构,也可以围绕坐标做数据结构,他们带来的优劣也会很明显体现在单位和坐标上。

    基于单位设计数据结构,那就应对大量的单位就是难点;基于坐标设计数据结构,那核心要解决的难点就是如何应对更大的坐标和世界。

    常见解法1: 遍历

    直接遍历游戏中的所有对象,然后比对距离就可以了。

    其实moba、fps的场景不太关注AOI,或者说关注的方式是有不同的,它们关注的更多不是能不能见,而是要见哪些数据,这篇我先不展开讨论,有机会再展开聊。

    在这种少量玩家开房间的游戏场景,如果有AOI更多也是为了防作弊,毕竟客户端不会有大量的渲染压力,降低渲染压力才是这个问题的核心目的。

    但对于同屏上百上千的单位的MMORPG游戏而言,这个方案肯定是会卡死服务端的。

    常见解法2: 九宫格

    这个是围绕坐标设计的数据结构,思路很简单,将整个世界按照固定的格子大小,均匀地划分成很多个格子,然后维护每个格子里的单位列表。

    单位进出和查找都是O(1)的,它很简单而且高效,但缺点也很明显,基于格子设计的问题就会出在格子上:

    缺点分析

    首先的问题是,应对大世界的地图占用内存过高,而且内存使用率低,可能有大量的格子是空的;
    当然这里可以用稀疏矩阵去维护这个格子,但是这样进出和查找的时间复杂度就可能是O(n)了。

    本来还想提一下格子内单位数量过多,但是这个问题好像是所有AOI都会遇到的通用问题,即使做了AOI,同一个城镇甚至同一个NPC前面可能站着上百上千个玩家,这个时候任何AOI算法都不生效的,只能去做分层。

    所以不算这个算法特有的缺点。

    常见解法3: 十字链表

    这个是围绕单位设计的数据机构,每个单位会存在两条x-y方向的链表上,能够非常快速地遍历在关注单位周边的单位,查找节点很快是因为可以用O(1)的复杂度获取一个最近的单位。

    就像前面说的,这个算法更多的是收到但数量影响,当单位数量非常多的时候,坐标修改可能涉及到频繁地链表进出的计算。

    常见解法4: 灯塔算法

    灯塔算法算是结合了遍历和九宫格的算法,如果只有一个灯塔它就退化成了暴力的遍历算法,如果按照均匀地划分成九宫格,它就退化成了九宫格。

    相比九宫格是固定格子的划分,灯塔是可以立一个灯塔圈定一个范围,所有进入这个范围的单位都需要在灯塔登记一下,那玩家AOI的区域就需要找附近几个灯塔,并且遍历里面的所有玩家。

    优点:结合了坐标+单位,变得很简单,并且在部分场景能够很好的运行
    缺点:结合了坐标+单位,即跟场景大小有关,也跟单位数量有关,这里需要很细致的优化

    必要解法: 分层

    就像在九宫格的缺点分析里提到的,无论是哪种AOI算法,它都无法解决一个npc面前有成百上千玩家的应用场景,所以分层是所有算法之上都要做的事情。

    总结

    就写这些吧,后面慢慢补充。

    参考

    https://www.modb.pro/db/177776
    https://blog.csdn.net/haha1fan/article/details/129122406
    https://blog.codingnow.com/2012/03/dev_note_13.html
    https://iyichen.xyz/2020/04/talk-about-aoi
    https://wykxwyc.github.io/2022/04/23/How-AOI-Work-in-Games/

    End.

  • 树状数组学习笔记

    树状数组

    推荐文章:https://leetcode.cn/problems/range-sum-query-mutable/solutions/2524481/dai-ni-fa-ming-shu-zhuang-shu-zu-fu-shu-lyfll/
    推荐视频:https://www.bilibili.com/video/BV1ce411u7qP/?vd_source=9916020e44ecb0118b4b1ad9cd262997

    总体功能上来说就是一个快速对区间进行求和和修改的数据结构,推荐文章里讲的真的很好,想要查的快多做缓存能到O(1)的效率,但是修改就会是O(n)的效率,那树状数组就是综合查找和修改的一个数据结构,能做到O(logn)的效率。
    具体的算法细节就不再多赘述
    是一个真的很好用的工具!

    我专项的做了一些训练,出题的人还是挺厉害的,树状数组本身是求下标之间数量和的结构,但题目里的使用场景被扩充到了求某一些值之间的数量。

    具体变换

    求某些值之间的数量

    题目:将元素分配到两个数组中 II

    这个题目是一个模拟题,但是题目核心的复杂度在greaterCount函数的实现,如果直接遍历O(n)的复杂度,那么这个题目最大的复杂度就去到了O(n^2),肯定是不够快的。

    那有什么结构能够快速实现数组内大于等于某个数量的查询和插入呢?

    答案:树状数组。

    离散化
    那要怎么做呢?将数组内的值作为横坐标,每个数据的纵坐标都是1,也就是创造了一个记录值数量的结构,那么对[1, 5]求和就能够表示:在原数组内值为1~5之间的总数,这就能够满足原问题了,但这会引入一个新的问题:

    3 <= n <= 10^5
    1 <= nums[i] <= 10^9

    那就是树状数组的坐标会变得非常大,这时就有另外一个办法,我们将原数组进行一次离散化的压缩,例如:
    100, 701, 504, 10201
    可以被压缩成:1,3,2,4,其中需要记录1~100, 2~504, 3~701, 4~10201
    它也可以被压缩成:1,2,3,4,其中1~100, 2~701, 3~504, 4~10201
    这二者有什么差别吗?前者保留了映射数组的大小关系,但代价是一次O(nlogn)的排序,我们需要的当然是前者,但后者也是离散化。

    >, >=, <和<=

    题目:通过指令创建有序数组

    树状数组很像是一个公式,它的公式本身只能求解<=值的数量,但是如何扩展到另外四个维度呢?

    小于等于: 原本公式化的Get(v)即可;
    小于: 原本公式化的Get(v-1)即可;
    大于: 当前总数 – Get(v)
    大于等于: 大于 + num[v]

    置换

    题目: 统计数组中好三元组数目

    原本的题目是比较复杂的,明显是需要遍历A数组,找到三个数(a,b,c),然后在B数组中寻找a,b,c是否存在,且仍然保序。

    置换是一个排列到另一个排列的双射。所有全排列的数组之间求前后顺序相关的题目都可以考虑这个方案。
    置换即直接将AB数组做一个关于A数组的坐标映射:

    A: [2,0,1,3], B: [0,1,2,3]
    映射成:
    A: [1,2,3,4], B: [2,3,1,4]
    
    A: [4,0,1,3,2], B: [4,1,0,2,3]
    映射成:
    A: [1,2,3,4,5], B: [1,3,2,5,4]

    因为做了置换,问题就从要在A, B中寻找一个三元数,变成了在B数组中找三个升序排列的子数组总共有多少个。
    如何遍历呢?
    三元数是(a, b, c),只需要在B数组中遍历b就可以,然后在左边寻找有小于b的数量x,右边是否有大于b的数量y,sum(x * y)就是结果。
    接下来的问题就是如何快速求解左边小于b, 右边大于b的数量呢?
    依然是遍历,从左到右维护一个树状数组的结构,在遍历过程中就可以快速求解<x的数量,那如何求右边是否有>x的数量呢?因为是全排列,总共会有(n-x)个大于x的数量,那左边有l个的话,右边就有n-x-l个,综上。

    额外的知识

    离散化

    离散化的方案其实很简单,就是排序之后做一个映射,但是可以直接对有序数组进行遍历,无需关心是否是完全连续,只需要保证离散化之后的坐标保留值本身的大小关系,代码如下:

    sort(sortedNums.begin(), sortedNums.end());
    unordered_map<int, int> indexMap;
    for(int i = 0; i < sortedNums.size(); i++)
    {
        indexMap[sortedNums[i]] = i + 1;
    }

    lower_bound

    看到其他人的题解里用到了std::lower_bound,深入看了一下,参考文章。

    除了lower_bound,标准库还包含upper_bound, equal_range和binary_search这4个查找函数,底层都是二分查找。

    在那些同学的实现里,就是用这个替代前一小节的indexMap的映射关系。我的实现是用O(n)的时间建立了一个unordered_map,换取每次O(1)的读取。而他们的实现是不用O(n)的时间以及空间建立额外的哈斯表(unordered_map)或者红黑树(map),直接用排序的数组进行二分查找。

    这里C++标准库提供了四种查找:
    lower_bound: 左到右查找第一个大于等于目标值的元素的迭代器
    upper_bound: 左到右查找第一个大于目标值的元素的迭代器
    equal_range: 从左到右找出等于目标值的迭代器范围, [first, last)
    binary_search: 只返回是否存在,不返回迭代器

    同时我还挺惊讶的,直接用lower_bound似乎比用unordered_map要更快?是因为反复扩充hash表吗?

    vector::unique, vector::remove, vector::erase

    这里是需要注意的!
    vector::remove和vector::unique都对vector数组进行了改动,将待删除数组挪到了数组末尾,但并没有执行删除操作。可能是考虑到类似于栈的top和pop的读和删除的操作分离。

    所以想要实际删除需要:

    std::erase(std::unique(v.begin(), v.end()), v.end());
    std::erase(std::remove(v.begin(), v.end(), val), v.end());

    拆开的目的应该就是在删除之前还能保留一定读的能力。

    本节参考:
    https://blog.csdn.net/Vcrossover/article/details/106243627
    https://cloud.tencent.com/developer/article/1022341
    https://en.cppreference.com/w/cpp/algorithm/unique

    End.

  • 修复了虚幻旋转的bug

    昨天通宵定位修复了之前demo移动有问题的bug,还挺开心的,一个可能是因为误差导致的悬空,另外一个是官方的bug,也是我改了bug的同事还给官方提供了一个request,希望能过。

  • 了解一下boost的fiber

    fiber是纤程,用户级纤程,在用户层提供调度管理器,可以在系统线程上切换纤程,它主要的好处:

    • 切换非常快,按照boost的数据,快100倍[1];
    • 纤程访问父线程的数据不需要加锁,因为不具备竞争;
    • 基于完全同步的写法来实现的fiber阻塞和切换;

    引用

    1. https://www.boost.org/doc/libs/1_83_0/libs/fiber/doc/html/fiber/performance.html
    2. https://blog.csdn.net/hezhanran/article/details/110792367
    3. https://agraphicsguynotes.com/posts/fiber_in_cpp_understanding_the_basics/