作者: yylogo

  • 默认和删除函数(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视频笔记 —— 预编译头文件

    视频内容

    刚开始我以为up就是想把所有的系统级头文件合并在一个.h里然后引用,还在想这有什么意义,好看一点?

    看到改CMake直接使用了一个pchheader才知道,这里有东西的!!

    预编译的头文件

    PCH文件是一个预编译头文件(pre-compiled header),它的后缀是PCH,所以也叫PCH文件。编译器会将头文件的内容事先编译成二进制的中间文件,在整个编译过程中,只编译一次,并且有缓存,除非有变化,否则不会重新编译(每个引入的.h和.cpp)文件,从而大大提高编译速度。

    每个源文件只能使用一个预编译的标头(.pch)文件,但是可以在一个项目中使用多个.pch文件。

    几乎所有C/C++编译器都支持预编译头文件的,例如gcc, clang, msvc等…但是不同的编译器对它的支持力度和处理方式有很大差异,并不是非常通用。

    msvc的处理

    /Yc是创建pch文件,必须要通过编译stdafx.cpp才能生成stdafx.pch,使用的时候需要用/Yu来使用,最后链接的时候还需要把stdafx.obj和test.obj都链接上才行,这也是和gcc, clang最大的不同。

    其他

    clang的支持很简单,可以直接通过-c创建,使用的时候通过-include-pch直接使用。

    gcc不支持-include-pch,所以需要从-I的头文件路径中搜索。

    标头单元、模块和预编译头文件

    如微软的文档里所描述的,include是最慢的,其次是预编译头,再快一点的是标头单元,而最快的是模块。

    import std或者import std.compat的速度比#include <vector>要快,不过目前好像只有msvc支持完整,clang到17还是部分支持。

    那说回来,什么是标头单元?
    标头单元是头文件的二进制表示形式。 标头单元以 .ifc 扩展名结尾。相同的格式也用于命名模块。
    使用标头单元比include编译要快的主要原因就是标头单元类似于预编译头文件一样,是提前被编译过的,而且标头单元是不受外部宏定义影响的,因为已经被编译过了。

    不过标准库的标头文件,现在看支持好像还是一般,以mvsc为主。

    参考

    https://tboox.org/cn/2017/07/31/precompiled-header/
    https://learn.microsoft.com/zh-cn/cpp/build/compare-inclusion-methods?view=msvc-170

    End。

  • Hazel视频笔记 – EventSystem

    介绍

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

    事件集中定义

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

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

    Category是位运算

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

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

  • 函数式编程 记录

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

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

  • 读书笔记《现代C++语言核心特性解析》 | 前五章

    介绍

    是一本介绍C++11到C++20特性的书籍。
    越来越多的项目迁移到了更新的标准上,毕竟带来了新的好用的特性,而且性能依然非常的高,需要学习一下。

    不会大而全的记录,主要会是让我有所醒悟的内容。

    基础类型

    整型

    新增了long long表示至少64位的整数,对应的还有LL和ULL的后缀。

    long long x = 65536LL;
    unsigned long long x = 65536ULL;

    整型上限:

    std::numeric_limits<long long>::min()
    std::numeric_limits<long long>::max()
    std::numeric_limits<unsigned long long>::max()

    字符和字符串

    字符集和编码方法

    字符集和编码方法是有区别的,字符集就是所有字符的集合,应该是包含所有的文字对应的图像,而编码方式就是用数字和字符集建立对应关系的方法。
    Unicode有三种编码方式,UTF-8, UTF-16, UTF-32,都是Unicode字符集。而UTF-X是具体的编码方式,UTF-32是最简单的,用4个字节直接存储一个字符,但是很浪费。UTF-16稍微好一点,UTF-8特别差,不好计算长度不好查找字符。

    标准新增类型

    新增了char16_t和char32_t,分别用来对应Unicode字符集的UTF-16和UTF-32两种编码方法。

    • utf8的前缀u8
    • utf16的前缀u
    • utf32的前缀U
    • wchat_t的前缀是L(早期定义的类型)
    char utf8c = u8'a';       // C++17标准
    char16_t utf16c = u'好';
    char32_t utf32c = U'好';

    在C++11标准中u8只能作为字符串字面量的前缀,而无法作为字符的前缀。这个问题直到C++17标准才得以解决,所以上述代码需要C++17的环境来执行编译。

    whcar_t是早期定义的类型,没有严格规定大小,导致Windows是16位,Linux是32位,这种标准不好修改,所以引入新的标准解决这个问题。

    新增的函数,记忆主要是分三个类型:

    • mbr: utf-8,即char
    • c16: utf-16
    • c32: utf-32
      size_t mbrtoc16(char16_t * pc16,const char * s,size_t n,mbstate_t * ps);
      std::mbrtoc32
      std::c16rtomb
      std::c32rtomb

      auto

      主要的用法有两个:

    • 声明变量时2,自动推断类型
    • 声明函数时返回值的占位符(模板推导使用)

    还有几个要点:

    • 从左到右推导
    • 不会收窄类型
    • 总是使用更强的类型
    • 无法声明非静态成员变量
    • 按值推导会忽略cv限定符、引用属性
    • 使用auto和万能引用声明变量,左值会被推导为引用
    • 如果目标对象是数组或者函数,auto会推导为对应的指针类型

    T &&是万能引用声明,会触发引用折叠。

    decltype

    这个初步了解的话,比较清楚地是:

    int i = 1;
    decltype(i) j = 2;      // 推到为int

    但是它的复杂之处就在于它的推导,auto是将cv去掉了,而且不主动用万能引用引发引用折叠是不会有太多引用的问题要考虑的。

    但是decltype的推倒过程是要考虑引用的,它整体的推导规则:
    decltype(e) 其中e的类型是T,推导规则有五条:

    1. 如果e是未加括号的符号表达式或者未加括号的类成员访问,则推出的类型是T;
    2. 如果e是一个函数调用或者仿函数调用,那么推导出返回值的类型;
    3. 如果e是一个类型为T的左值,则decltype(e)是T&
    4. 如果e是一个类型为T的将亡值,则decltype(e)是T&&
    5. 除了以上情况,推导结果为T

    但是实际实验,发现在创造的时候由不符合这个情况,可能是因为编译不过就自动退化了。

    其他资料

    字符编码那些事

    End.

  • 2024年学习记录

    记录

    一方面是持续提升自己,让自己未来在能够接到更重的工作的时候能够胜任;另外一方面也就是持续的学习,保持自己的竞争力,别哪天真的没工作干了。
    作为技术人员,一方面要拥有很核心的技术,深度要深,广度要广,而且要持续跟进最新的知识,有持续的产出,所以,一方面努力学习,一方面努力写代码。

    链接:
    https://cpxwidm62n.feishu.cn/base/HRiqb4hAga8J77smUATcc8qenme?from=from_copylink

  • 梳理python的import

    最近公司出了一个bug,我有点不太懂python循环import会发生什么,也不知道python是怎么处理的,周末顺着文档缕了一遍python的导入系统。

    官方导入系统的文档:https://docs.python.org/zh-cn/3.12/reference/import.html
    本文主要是记录一些细节、自己的理解以及一些注解。

    基本介绍

    import

    import: import语句包含了两个操作:

    1. 它先搜索指定的模块,如果不存在会创建改模块;
    2. 然后将搜索结果绑定到当前作用域中;

    展开来说,就是import首先会调用__import__将模块拿到引用,然后import会处理名字绑定相关的操作,类似于

    import math         # 将math模块绑定到locals()的math
    import abc as a     # 将abc模块绑定到locals()的a
    from abc import d       # 将abc.a绑定到locals()的d上

    __import__

    看完前面的,__import__就很清晰了,它在设计上是仅用来做搜索以及在没搜到时创建模块的功能的,但是虽然不会影响globals()和locals(),但是会修改sys.modules。

    模块和包

    模块:Python只有一种模块对象类型,所有模块都属于该类型,无论是从python还是C++实现的类模块。
    包:可以将模块理解成文件,包是目录,包可以拥有子包,也可以拥有模块。

    包又分为常规包和命名空间包,常规包就是目录有__init__.py的包,命名空间包就可以让一个包内的模块不在同一个目录,甚至

    注:

    • 包是为了帮助组织模块并提供名称层次结构,Python 还引入的概念。
    • 所有的包也都是模块
    • 拥有__path__的模块会被当做是包

    加载模块

    [原文]
    当一个模块说明被找到时,导入机制将在加载该模块时使用它(及其所包含的加载器)。 下面是导入的加载部分所发生过程的简要说明:

    module = None
    if spec.loader is not None and hasattr(spec.loader, 'create_module'):
        # It is assumed 'exec_module' will also be defined on the loader.
        module = spec.loader.create_module(spec)
    if module is None:
        module = ModuleType(spec.name)
    # The import-related module attributes get set here:
    _init_module_attrs(spec, module)
    
    if spec.loader is None:
        # unsupported
        raise ImportError
    if spec.origin is None and spec.submodule_search_locations is not None:
        # namespace package
        sys.modules[spec.name] = module
    elif not hasattr(spec.loader, 'exec_module'):
        module = spec.loader.load_module(spec.name)
    else:
        sys.modules[spec.name] = module
        try:
            spec.loader.exec_module(module)
        except BaseException:
            try:
                del sys.modules[spec.name]
            except KeyError:
                pass
            raise
    return sys.modules[spec.name]

    循环import的问题

    主要文章: https://www.jb51.net/article/51815.htm

    # 文件A.py:
    from B import D
    class C: pass
    
    # 文件B.py
    from A import C
    class D: pass

    这个过程会直接报错,但是如果将from A import C改成import A就不会报错,这是为什么呢?
    可以看到前面的文档里的内容,包括这段开头的文章。
    在import时首先查看是否存在这个模块,如果存在,则直接获取,如果不存在,则先创建一个空的占用,然后再执行loader.execute()

    两个连续的from xx import xx发生的事情就是,在第二次执行from A import C时,会发现sys.modules里面已经有A,那么import做的事情是获取A.C,然后把引用放进globals。

    但是这个A是个空的模块,所以就Trace了。

    End

  • 论抽象还得是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.