【侯捷】C++STL标准库与泛型编程(第二讲)

第二讲

应具备的基础

  • C++基本语法

  • 模板(Template)基础

    令你事半功倍

  • 数据结构(Data Structures)和算法(Algorithms)概念

    令你如鱼得水

书籍:

  • 《Algorithms + Data Structures = Programs》—— Niklaus Wirth,1976

源代码的分布(VC, GCC)

所谓标准库,指规格是标准的,接口是标准的,但是不同的编译器可能有不同的实现。

标准库版本,Visual C++

image-20211223170424184

image-20211223170541698

标准库版本,GNU C++

image-20211223170608518

image-20211223170629180

Ubuntu下的C++源码路径:/usr/include/c++

OOP(面向对象编程) vs. GP(泛型编程)

  • OOP:Object-Oriented programming
  • GP:Generic Programming

C++标准库并不是用面向对象的概念设计出来的,面向对象的概念就是有封装、继承、多态(虚函数)。

标准库是利用泛型编程的概念设计出来的。

image-20211223170724688

  • OOP将数据和操作关联到一起;
  • list容器中有sort排序操作,为什么不像vector或者deque一样使用全局的排序函数呢?因为标准库的sort算法用到的迭代器需要一定的条件(RandomAccessIterator),而这个条件是list提供的迭代器所不能满足的,所以链表不能像vectordeque 一样使用全局的 sort 算法。
  • 如果容器中有sort 算法,则排序的时候使用容器中的排序算法;否则使用全局的排序算法。

image-20211223170743188

  • vectordeque 容器中没有sort排序方法,只能使用全局的排序算法,这就是将数据和操作分开了,通过迭代器进行关联。

image-20211223170849235

image-20211223170905933

技术基础:操作符重载and模板(泛化,全特化,偏特化)

阅读C++标准库源码的必要基础

  • Operator Overloading 操作符重载
  • Templates 模板

Operator Overloading,操作符重载

image-20211223171159604

image-20211223171217055

image-20211223171327289

Class Templates, 类模板

image-20211223171355498

Function Templates,函数模板

image-20211223171421293

Member Templates,成员模板

image-20211223171443931

Specialization,特化

例1:

image-20211223171538209

例2:

image-20211223171552900

例3:

image-20211223171606981

Partial Specialization,偏特化

两个模板参数,绑定其中一个(个数的偏特化);本来泛化可以接受任意类型,但是如果类型是指针,指向的类型还是由 T 决定,就进入struct iterator_traits<T*>(范围的偏特化)

image-20211223171636483

分配器allocators

标准层面上,分配内存最终都会调用到malloc,然后各个操作系统调用不同的System API,得到内存。

先谈operator new() 和 malloc()

image-20211223171746758

malloc 分配出来的比你需要的内存多,有很多附加的东西。需要的size越大,overhead(附加的内存)比例就较小;需要的size越小,附加的东西的比例就越大。

VC6 STL对 allocator 的使用

  • 使用示例,如下容器使用的分配器是标准库中的allocator

image-20211223171816435

  • 标准库中的allocator的实现:

image-20211223171835762

说明:

  1. 分配内存的时候调用的是allocatorallocate函数,调用流程就变成了:allocate -> Allocate -> operator new -> malloc
  2. 回收内存的时候调用的是allocatordeallocate函数,调用流程:deallocate -> operator delete -> free
  3. 直接使用分配器就是如图中所示的“分配512 ints”,allocator<int>()生成一个匿名对象,使用该对象进行函数的调用;
  4. VC的分配器没有做任何独特的设计,只是调用了 C 的mallocfree来分配和释放内存,且接口设计并不方便程序员直接使用,但是容器使用就没问题。

BC5 STL对allocator的使用

  • 使用示例,BC5 STL中的一下容器使用的分配器是标准库的allocator

image-20211223171900497

  • BC5 中标准库的allocator的实现:

image-20211223171913193

说明:和VC一样,allocator最终也是通过 C 的 mallocfree来分配和释放内存的。

G2.9 STL对 allocator的使用

  • G2.9 标准库中的分配器allocator的实现:

image-20211223172016648

说明:G2.9 标准库中的allocator 和 VC、BC一样,最终都是调用mallocfree进行内存的分配和释放。但是G2.9的STL中并没有使用标准库中的分配器,这个文件并没有被包含在任何STL头文件中,G2.9的STL容器使用的是alloc分配器

  • G2.9 STL容器使用的分配器是alloc

image-20211223172005073

  • G2.9 中的 alloc 的实现的行为模式:

image-20211223172032944

说明:

  1. 这个alloc分配器的主要目的是减少malloc被调用的次数,因为malloc会带着额外开销;
  2. 设计了16条链表,每条链表负责不同大小区块的内存,#0负责8bytes大小的区块,#1负责16bytes大小的区块,以此类推…,超过这个分配器能管理的最大的区块128bytes,就仍然要调用malloc函数分配;
  3. 所有的容器,当它需要内存的时候都来向这个分配器要内存;容器中元素的大小会被调整为8的倍数;
  4. 更多内容,可查看C++内存管理机制

G4.9 STL对allocator的使用

  • G4.9 标准库中的分配器allocator的实现:

    image-20211223172104403

说明:这个分配器也是直接调用的mallocfree进行内存的分配和回收,并没有其他的操作。

  • G4.9 STL容器使用的分配器是std::allocator

image-20211223172052689

说明:G4.9 的STL的容器使用的分配器是标准库的allocator分配器,而没有使用G2.9中的alloc分配器,为什么呢?那么G2.9中的分配器alloc在G4.9的版本中还在吗?

  • G2.9中的alloc分配器在G4.9中还在,只是名称变了,名称现在是__pool_alloc

image-20211223172142223

说明:

  1. 之所以说__pool_alloc就是G2.9的alloc,因为相关的数值都仍然存在:管理8的倍数的区块,最大可以管理128字节的区块,有16条链表;
  2. 这种比较好的分配器仍然是可以使用的,使用方式如图中所示的“用例”,第二个模板参数指定分配器,__pool_alloc所在的命名空间为__gun_cxx

容器之间的实现关系与分类

容器 — 结构与分类

image-20211223172246005

image-20211223172300440

说明:

  1. 如图中的注释说明,缩排表达的关系是复合,就是set 里面有rb_treemap里面有rb_treemultiset/multimap里面有rb_tree

  2. 同理,heap中有vectorpriority_queue中有vectorpriority_queue中有heapstackqueue中都有一个deque

  3. 图中的左右两侧表示的是容器要操作数据必须有的指针或元素,这个整体的大小,不包括数据,通过sizeof计算:

    image-20211223172215059

深度探索list

G2.9的list

image-20211223172658562

说明:

  1. list中只有一个数据成员node,类型是link_type,而link_type就是list_node*,所以node就是一个指针,那么sizeof(list) = 4;
  2. __list_node中有两个指针prevnext,以及数据data,所以这就是一个双向链表,注意这里的prevnext指针是void*类型的,这种写法可以使用,但是必须进行强转型,这种方式不太好,在G4.9中已经进行了改善,这两个指针就指向__list_node类型;
  3. list中每个元素并不单纯的只有元素本身,还会多耗用两个指针prevnext,所以容器list向它的分配器要内存的时候,就是要“两个指针+数据”这么大的内存,而非只是数据这么多的内存;
  4. 链表是非连续的空间,所以它的Iterator不能是指针,因为Iterator模拟指针,就要能进行++这些操作,但是如果listIterator进行++ 操作不知道指到哪里去了;所以Iteartor必须足够聪明,当进行++操作的时候知道要指向list的下一个节点;
  5. 除了vectorarray外的所有容器的iterator都必须是class,它才能成为一个智能指针;
  6. 最后一个节点的下一个节点一定要加一个空白节点(图中的灰色节点),为了符合STL的「前闭后开」区间;begin()得到链表的第一个节点,end()得到链表的最后一个节点的下一个节点,即图中的空白节点;这是实现上的一个小技巧,不但是双向的,而且是环状的。

list’s iterator

  • 概览listiterator

image-20211223172725770

说明:

  1. iterator要模拟指针,所以有大量的操作符重载;
  2. 所有的容器中的iterator都要做5个typedef,如上图中的所示的(1)(2)(3)(4)(5);
  • iteartor++操作的实现:

image-20211223172747899

说明:

  1. i++叫做postfix form,++i叫做prefix form,因为无论是前缀还是后缀形式,都只有i 这个参数,C++中为了区分这种情况,规定了operator++()无参表示前缀,此时的i已经变成调用这个函数的对象本身了;operator++(int)有参表示后缀;
  2. self& operator++()函数可以成功的将node进行移动,指向下一个节点;
  3. self operator++(int)函数的流程是先记录原值,然后进行操作,最后返回原值。注意:
    • 此时的记录原值的操作:self tmp = *this;并不会调用重载的operator*函数,因为这行代码先遇到了=运算符,所以会调用拷贝构造函数,此时的*this已经变成了拷贝构造函数里面的参数;
    • ++*this 调用了重载的operator++()函数;
  4. 注意返回值的差别。之所以有差别是向整数的++操作看齐:整数里面是不允许进行两次后++的,所以这里iteratoroperator++(int)为了阻止它做两次后++操作,返回值不是引用;整数中是允许做两次前++的操作,所以iteratoropeartor++()返回值是引用。
  • iterator*->操作符的实现

image-20211223172817767

说明:

  1. operator* 就是获得node指针指向的节点的data数据;
  2. operator->获取node指针指向的节点的data数据的地址;
  • 小结
    1. list是个双向链表,因为每个节点除了有data,还有nextprev指针;
    2. 所有的容器的iterator都有两大部分:(1)一些typedef;(2)操作符重载
  • G4.9相比G2.9的改进:

image-20211223172833959

说明:

  1. iterator的模板参数只有一个,容易理解;
  2. G4.9中的指针prevnext是本身的类型,而不再是void*;

G4.9的list

image-20211223172940210

说明:

  1. 相比G2.9的list,G2.9的list更加复杂了;
  2. 因为行为模式已经在G2.9中知道了,所以没有必要再去看G4.9了;
  3. 和 G2.9一样,链表是环状双向的,刻意在环状list最后加了一个空白节点,用来符合STL的「前闭后开」区间;
  4. 在G2.9的list图中看到了,sizeof(list)在G2.9中是4,因为只有一个指针;而在G4.9中是8,为什么是8呢?
    • G4.9的list中本身没有数据,所以size = 0;但是它有父类_List_base,所以父类多大,它就多大;
    • _List_base中的数据为_M_impl,所以这个数据多大,_List_base就多大;
    • _M_impl类型为_List_impl,而_List_impl中的数据类型是_List_node_base;
    • _List_node_base中有两个指针,所以sizeof(list) = 8

迭代器的设计原则和Iterator Traits的作用与设计

设计Traits实现希望你放入的数据,能够萃取出你想要的特征。标准库中有好几种Traits,针对type的有type traits;针对characters,就有char traits;针对pointer,有pointer traits,… 。这里,只看iterator traits。

Iterator需要遵循的原则

image-20211223173005100

说明:

  1. iterator是算法和容器之间的桥梁,这样算法能知道要处理的元素的范围,容器将begin()end() 传出去交给算法,算法知道了范围且可以通过iterator进行移动,++或–,将元素一个一个地取出来;
  2. 算法在处理数据的过程中可能需要知道iterator的性质,因为它需要做动作,可能会选择最佳化的动作;
  3. 举例:有一个rotate算法,会想要知道iterator的哪些属性?
    • 想要知道iterator的分类(iteartor_traits<_Iter>::iterator_category()),有的迭代器只能++,或者只能–,有的可以跳着走,得到分类以便可以选取最佳的操作方式;
    • 想要知道iterator的difference_type,两个iterator之间的距离;
    • 想要知道iterator的value_type,指的是迭代器指向的元素的类型,比如在一个容器中放了10个string类型的元素,那么这个value_type就是string
  4. 算法提问,迭代器回答。这样的提问在C++标准库开发过程中设计出 5 种,这 5 种叫做iterator的associated types(相关类型):
    • iterator_category
    • difference_type
    • value_type
    • reference
    • pointer
  5. iterator必须提供这5种相关类型,以便回答算法的提问。

Iterator 必须提供的 5 种 associated types

image-20211223173043306

说明:

  1. 标准库中用ptrdiff_t来表示两个迭代器之间的距离,这个ptrdiff_t也是C++中定义的,但是如果实际存放的元素的头和尾的距离超过了这个ptrdiff_t类型表示的范围,那这就失效了;
  2. 因为list是个双向链表,所以这里使用了bidirectional_iterator_tag来表示iterator_category;
  3. 可以看到,这里并没有traits,那么为什么还要谈到iterator traits呢?因为如果 iterator 不是 class,就不能进行typedef,如果iterator是native pointer,即C++中的指针,它被视为一种退化的 iterator。所以当调用算法的时候,传入的可能是个指针,而不是泛化指针,不是个迭代器,那此时算法怎么提问呢?此时,才需要设计出 traits。

Traits,特性,特征,特质

image-20211223173113966

说明:

  1. 图中的“萃取机”必须能区分它所收到的iterator,到底是以class设计的iterator还是native pointer的iterator;

image-20211223173140798

说明:

  1. 因为算法不知道iterator是什么类型,所以不能直接提问,而是间接问。将iterator放入traits,算法问traits:value type是什么?
  2. 然后traits问iterator或指针:value type是什么?
    • 若traits要问的对象是class iterator,则进入图中的①
    • 若traits要问的对象是 pointer,则进入②或者③
  3. 为了应付指针的形式,增加了中间层 iterator traits,利用了偏特化分离出指针和const指针;
  4. 这一页回答了算法的value_type的提问;

完整的iterator_traits

image-20211223173207147

说明:如果是iterator,则进入泛化版本;如果是指针,则进入偏特化版本。算法问traits,当traits发现手上的东西是指针的时候,就由traits替它回答。

各式各样的Traits

image-20211223173227595

深度探索vector

vector是一种动态增长的数组,当空间不够的时候,要到内存的其他地方开辟空间,并将原来的数据移动到新的空间,这才是扩充,不能在原来的空间进行扩充。

G2.9的vector

image-20211223173351711

说明:

  1. 当前vector的容量是8,目前已经存放了 6 个元素;
  2. 如果已经放了8个元素,要再放第9个元素的时候,就要进行扩充,如图中所示,二倍成长。容器回到内存中去找到另外的空间,要求是当前空间的2倍。当次数较多的时候,申请的空间就越来越大,如果最后找不到2倍大的空间,容器的生命就结束了,不能再放入元素了;
  3. 只需要三个指针startfinishend_of_storage就能控制整个容器,因此,sizeof(vector) = 12;
  4. 所有的容器,如果带了连续的空间,就必须提供[]运算符重载函数;
  5. vector二倍成长 到底是怎么回事呢?
  • vector的二倍成长的实现

image-20211223173412378

说明:

  1. vector的成长发生在放入元素的时候,此处即push_back函数;
  2. 之所以在insert_aux函数中也做了finish == end_of_storage的判断,是因为insert_aux除了被push_back函数调用外,还可能被其他函数调用,在那种情况下,就需要做检查;

image-20211223173425324

  1. 没有备用空间的时候,先记录下原来的size,分配原则:如果原大小为0,则分配1;如果原大小不为0,则分配原大小的2倍,前半段用来放置元数据,后半段准备用来放新数据;
  2. 确定了长度之后,使用分配器的allocate函数进行内存空间的分配;
  3. 然后将原来vector的内容拷贝到新的vector;
  4. 为新的元素设定初值;(要放入的第9个元素);
  5. 因为insert操作也会使得空间发生增长,也会调用到这个insert_aux,所以要把安插点之后的内容也进行拷贝;
  6. 每次成长都会大量调用拷贝构造函数和析构函数(析构原来vector中的元素),需要很大的成本;

G2.9的vector’s iterator

image-20211223173449114

说明:

  1. vector的空间是连续的,按理说可以直接使用指针作为迭代器,vector类中的iterator也的确是指针;
  2. 当算法要提问iterator的时候,就通过iterator_traits进行回答;当前的iterator是个指针,当它丢给萃取机萃取的时候,就是图中箭头指向的T*,因此萃取机就会进入偏特化的版本——struct iterator_traits<T*>

G4.9的vector

image-20211223173524053

说明:

  1. G4.9的vector也有三个指针,_M_start_M_finish_M_end_of_storage,所以 sizeof(vector) = 12

G4.9的vector’s iterator

image-20211223173605425

说明:

  1. G4.9的vectoriterator经过层层推导就是T*外包裹一个iterator adapter,使得能支持 5 种 associated types;

image-20211223173620723

  1. 算法向iterator提问的时候,将iterator放入萃取机中,因为此时的iterator是个object,所以走图中的灰色细箭头这一条路径;
  2. 而在iterator内部本身就定义了 5 种 associated types;
  3. 绕了这么大一圈,最后和G2.9的iterator达到的是一样的效果;

深度探索array

TR1的array

image-20211223173650236

说明:

  1. array相比vector更加简单,因为在C和C++语言中本身就存在数组,为什么要将数组包装成一个容器来使用呢?因为变成容器之后,就要遵循容器的规律、规则,即需要提供iterator迭代器,而这个迭代器又要提供五种相关的类型以便于让算法可以询问一些必要的信息,算法才能决定采取哪种最优的动作,如果没有进行这样的包装,array就被摒弃在六大部件之外,就不能享受算法、仿函数等与其交互的关系。
  2. 上述的是TR1(Technique report 1) 版本,是C++的过渡版本,介于C++1.0和C++2.0之间;
  3. array不能扩充,所以必须指定大小,如array<int, 10> myArray;
  4. 没有构造函数,也没有析构函数;
  5. 因为array是连续的空间,所以它的迭代器可以用指针来单纯的指针来表现,不用再设计单独的class;

G4.9的array

image-20211223173701515

说明:

  1. 数组的写法

    int a[100]; //OK
    int[100] b; //fail
    typedef int T[100];
    T c; //OK
    

    即此处的_M_elems变量是个数组;

深度探索forward_list

image-20211223173727443

说明:forward_list是个单向链表,相比双向链表更加简单,因此此处不再赘述。

深度探索deque、queue和stack

容器deque

G2.9的deque

image-20211223173749834

说明:

  1. deque是分段连续,deque是个vector,其中的每个元素都是一个指针,这些指针分别指向不同的buffer;
  2. 如果当前空闲的最后一个buffer使用完了,要继续push_back,那么新分配一个buffer,并将其deque当前图上的倒数第二个空白位置指向这个buffer即可,这就是往后扩充
  3. 同理,如果第一个空闲的buffer用完了,要继续push_front,再分配一个buffer,用deque中的第一个空白位置指向新分配的buffer即可,这就是向前扩充
  4. 图中的蓝色部分是迭代器,deque的迭代器是class,其中包含了curfirstlastnode四个部分:
    • 其中的node指的就是图中deque中指向buffer的指针,我们在这里把它暂时称为控制中心。一个迭代器能知道控制中心在哪里,当迭代器要++或–的时候就能够跳到另一个分段,因为分段的控制全部在这里;
    • firstlast指的是node所指向的buffer的头和尾(前闭后开),标识出buffer的边界,如果走到了边界,就要跳到下一个buffer;
    • cur就是当前迭代器指向的元素;
  5. 几乎所有的容器都维护了两个迭代器startfinish,分别指向头和尾;几乎所有的容器都提供两个函数,begin()end(),其中begin()传回startend()传回finish

image-20211223173817837

说明:

  1. 图中是G2.9的deque;
  2. 数据部分的map的类型是T**,占 4 个字节;
  3. 代码中的iterator中的数据为下图的deque's iterator中所示的curfirstlastnode,都是指针,所以deque’s iterator的大小为 16 字节,
  4. 那么一个deque的大小为“两个迭代器 + map + map_size" = 16 * 2 + 4 + 4 = 40 bytes;
  5. deque是个模板类,有三个模板参数,第一个参数表示元素类型,第二个参数是分配器的类型,第三个参数是指每个buffer容纳的元素个数,允许指定buffer容纳的元素个数,默认值为0,deque_buf_size函数会根据该模板参数决定buffer中能容纳的元素具体个数;

image-20211223173842824

deque<T>::insert()

image-20211223173919849

说明:

  1. 聪明在于插入数据的时候会判断要插入的位置是离前面比较近还是后面比较近,离哪边近,就推动哪边的元素,因为每次推动元素都要调用构造函数和析构函数,挺花费时间的;

image-20211223173935419

  1. insert_aux首先检查要插入的点往前和往后,哪边需要移动的元素index哪边更少;即找到离头还是尾的距离更近,将距离近的哪边的元素进行推动以便放入新值;
  2. 在安插点上设定新的值;
deque如何模拟连续空间

image-20211223173958490

说明:

  1. font()返回第一个元素,back()返回最后一个元素,这里是利用finish进行倒推;
  2. size() 就是元素的个数,注意这里的finish - start,其中迭代器一定是对-进行了操作符重载;

image-20211223174013620

  1. operator*就是取值,迭代器取值就是获取迭代器的cur指向的值;
  2. operator-统计首尾迭代器之间的元素个数;

image-20211223174025553

  1. operator++(int)调用operator()operator--(int)调用operator--(),都是只移动一个位置
  2. operator++()就是移动当前元素,移动之后检查是否到达buffer的边界,如果到了下一个边界,就跳到下一个buffer的起点;operator--()同理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W9WvgOkW-1641201057150)(…/…/…/Library/Application Support/typora-user-images/image-20211223174035925.png)]

  1. operator+=就是移动多个位置,先判断是否会跨越buffer;如果跨越buffer,要先计算跨越几个,然后退回控制中心,跨越之后再决定有几个要走

image-20211223174046616

  1. operator-=利用了operator+=
  2. operator[]将迭代器移动到第n个位置,获得第n个元素

小结:deque的迭代器通过操作符重载可以欺骗使用者自己是连续,这种欺骗是善意的,可以让使用者更好地使用deque。

G4.9的deque

image-20211223174128722

image-20211223174149104

说明:

  1. G4.9版本相比G2.9版本,又是从一个单一的class变成了复杂的多个class;每个容器的新版本都会设计为如图的这种继承和组合的关系;
  2. 此时的 sizeof(deque<int>) = 40,和G2.9版本的大小一样,就是数据成员_M_impl的大小,即_Deque_impl类中的数据成员的大小;
  3. G4.9的 deque 的模板参数只有两个,不允许指派buffer size;
  4. _M_map指向的是控制中心,它是用vector来存放指向不同buffer的指针的,当它的空间不够的时候,会二倍增长,将当前的vector拷贝到新的空间的中段,为了让左边和右边有空闲,使得可以向前和向后扩充;

容器queue

image-20211223174231839

说明:

  1. 可以看到,deque是双向进出,stack是先进后出,queue是先进先出,那么只需要在queue和stack中内含deque,然后封锁住其中某些动作;
  2. 如图中所示,queue类中的数据成员是deque类型,所有的操作都是转去调用deque的接口来完成;

容器stack

image-20211223174255541

说明:

  1. queue类似,stack 内含了一个 deque,所有的操作都是转调用deque的接口完成。

queue和stack,关于其 iterator 和底层结构

1、stack或queue都不允许遍历,也不提供iterator

image-20211223174332980

说明:

  1. stackqueue 都可以选择 listdeque 作为底层结构,默认是 deque,如图中所示,选择 list 作为底层结构也是可以的,可以成功编译和执行;
  2. stackqueue不允许 遍历,也不提供 iterator,因为stack的行为是先进后出,queue的行为是先进先出,如果允许任意插入元素的话就会干扰了这个行为模式,而stackqueue的行为是被全世界公认的,所以不允许放元素,而放元素要靠迭代器,所以根本就不提供迭代器,要取东西的时候只能从头或者尾拿;
2、queue不可选择vector作为底层结构,stack可选择vector作为底层结构

image-20211223174346490

说明:

  1. stack 可以选择vector作为底层结构;
  2. queue不可以选择vector作为底层结构;部分接口不能转调用vector,如图中所示的pop(),不能成功转调用,因为vector中没有pop_front这个成员函数,部分失败了;
  3. 通过queue测试使用vector作为底层结构的启示:使用模板的时候,编译器不会预先做全面的检查,用到多少检查多少。
3、stack和queue都不可选择set或map作为底层结构

image-20211223174359305

说明:

  1. stackqueue 都不可以选择 setmap作为底层结构,因为转调用的时候,调用不到正确的函数的话,这个结构就剔除了,不能作为候选;
  2. 图中示范了stack选择set作为底层结构出现的的错误,第一行编译通过,是因为上面说过编译器预先不会做前面的检查;而stack<string, map<string>> c;queue<string, map<string>> c; 编译无法通是因为map使用的时候是key和value都要设置,这里使用错误,所以编译无法通过。

深度探索RB_tree

之前谈到的容器都是 Sequence Containers,从本章开始,要讲解关联式容器,它非常有用,因为它查找和插入都很快。关联式容器可以想象成一个小型的数据库,数据库就是希望用key找到value,而关联式容器就带着这样的性质。在标准库中,关联式容器底层使用两种结构作为技术支持——红黑树和哈希表。

红黑树简介

image-20211223174526646

说明:

  1. Red-Black tree(红黑树)是平衡二叉查找树(balanced binary search tree)中常被使用的一种。平衡二叉查找树的特征:排列规则有利于 searchinsert,并保持适度平衡——无任何节点过深。
  2. rb_tree 提供 ”遍历“ 操作及 iterators。按正常规则(++ite) 遍历,便能获得排序状态(sorted)。【注:begin()记录的是最左的节点,end()记录最右的节点】
  3. 我们不应使用 rb_tree 的 iterators 改变元素值(因为元素有其严谨排列规则)。编程层面(programming level)并未阻绝此事。如此设计是正确的,因为 rb_tree 即将为 set 和 map 服务(作为其底部支持),而 map 允许 元素的data 被改变,只有元素的key 才是不可被改变的。
  4. rb_tree 提供两种 insertion 操作: insert_unique()insert_equal()。前者表示节点的key一定在整个 tree 中独一无二,否则安插失败;后者表示节点的 key 可重复。

G2.9 容器rb_tree

  • 标准库中红黑树的实现

image-20211223174546788

说明:

  1. rb_tree是一个模板类,模板参数:
    • Value:key 和 data 合成 value,其中的data也可能是其他的数据合起来的;
    • KeyOfValue:如何取出value中的key;
    • Compare:比较函数/仿函数;
    • Alloc:分配器,默认为alloc
  2. 数据部分:
    • node_countrb_tree中的节点数量;
    • header:指向rb_tree_node的指针;
    • key_comparekey的大小比较规则;Compare仿函数,没有数据成员,所以大小为0,任何的编译器,对于大小为0的class,创建出来的对象的size一定为1;
  3. 所以数据部分一共的大小是 9,但是因为内存对齐,以4的倍数进行对齐,所以 9 要调整为 12;
  4. 图中的双向链表中的天蓝色节点,是一个虚空节点,为了做「前闭后开」区间,刻意放入的,不是真正的元素;红黑树中的header也是类似的,刻意放入的,使得代码实现更加简单;

image-20211223174608781

  1. 直接使用rb_tree示例:

    rb_tree<int, 
    		int, 
    		identity<int>, //仿函数,重载了operator()函数,告诉红黑树要如何取得key,GNU C 独有的,不是标准库的一部分
    		less<int>, //key比较大小的方式,less是标准库的一部分
    		alloc> 
    myTree;
    

使用容器rb_tree

image-20211223174628938

G4.9 容器_Rb_tree

image-20211223174759972

说明:

  1. G4.9版本相比G2.9版本,类结构发生了变化;
  2. OO思想里面,类中包含一个指针指向另一个类,主体本身不做任何事情,都是通过指针指向的另一个类做事情,这中手法叫做Handle-Body;
  3. setmap里都各有一个_Rb_tree
  4. 此时的_Rb_tree的数据的大小取决于_M_impl这个数据成员的大小,而_M_impl类型是_Rb_tree_implRb_tree_impl中的数据成员_M_node的类型是_Rb_tree_node_base,其中包含了四个数据成员:3 个指针,1个_Rb_tree_color(enum枚举类型) = 24 bytes;

使用容器_Rb_tree

G4.9版本相比于G2.9部分名称发生了改变,如下红色的部分就是改变的部分:

image-20211223174714367

image-20211223174728444

深度探索set,multiset

image-20211223174824878

  1. set/multisetrb_tree为底层结构,因此有「元素自动排序」特性。排序的依据是 key,而set/multiset 元素的 value 和 key 合一:value 就是 key

  2. set/multiset 提供 ”遍历“操作及 iterators。按正常规则(++ite) 遍历,便能获得排序状态(sorted)。

  3. 我们无法 使用 set/multiset 的 iterators 改变元素值(因为key 有其严谨排列规则)。set/multiset 的 iterator 是其底部的 RB tree 的 const_iterator, 就是为了禁止 user 对元素赋值。【注:讲解 rb_tree 的时候说到的是”不应“,因为Value 中的 data是可以更改的,是合理的;但是这里是”无法“,可见set在设计上就限制了不能修改,之所以不能修改,是因为set的key就是value,如果修改的话,改的就是key,这是不可以的。】

  4. set 元素的 key 必须独一无二,因此其 insert() 用的是 rb_tree 的 insert_unique()

  5. multiset 元素的 key 可以重复,因此其insert() 用的是 rb_tree 的 insert_equal()

容器set

image-20211223174840212

说明:

  1. set的模板参数有三个:Key的类型;Key的大小比较规则,默认值为less<Key>;分配器,默认值为alloc
  2. set中有个红黑树变量t
  3. set中拿 iterator 的时候拿的是 rb_treeconst_iterator,这个迭代器是不允许对元素进行修改的;
  4. set的所有操作,都转调用底层 t 的操作。从这层意义来看,set 未尝不是个 container adapter;
  5. 之前说到 key 和 data 合起来这一整包是 value,从 value 中取出 key 用identityset 里面取出 key 也就需要用 identityidentity 是GNU C中才有的;
VC6 容器set
  • VC6 不提供 identity(),那么其 setmap 如何使用 RB-tree?

image-20211223174903260

说明:

  1. VC6中自己实现了一个内部类_Kfn,写法和 GNU C 中的identity 的实现是一样的,即自己实现。
使用容器multiset

image-20211223174935649

深度探索map,multimap

image-20211223175007999

说明:

  1. 每个元素即value包含了key 和 data,key不能修改,但是 data 可以修改;
G2.9 的容器map

image-20211223175024027

说明:

  1. select1st:从value中取出第一个,即取出key;map拿出key的方式就是select1st;
  2. map的迭代器就是红黑树的迭代器,红黑树的迭代器并没有禁止任何事情呀?那是如何做到用它不能修改key,但是能修改data的呢?如上例所示,使用者map<int,string>放入两个类型,被map包成一个 pair,而这个pair被当成红黑树的第二个模板参数,map自动地将key设置成 const,所以 key 放入之后无论如何都不能被修改,因为它是 const。set 中不能修改 key 是因为使用的迭代器是红黑树的 const_iterator,而map不允许通过迭代器修改key,是因为包装成 pair的时候将key设置成了 const;
  3. select1st是GNU C独有的;
VC6 的容器map
  • VC6 不提供 select1st(),那么 map 如何使用 RB-tree?

image-20211223175043995

说明:

  1. 自己实现一个和select1st功能一样的类_Kfn,重载operator() 函数,所以是个函数对象/仿函数,将pairfirst 数据传回;
使用容器multimap

image-20211223175103875

容器map,独特的operator[]

image-20211223175129044

说明:

  1. map[]操作:如果key存在,则返回 key 对应的 data;如果key不存在,那么会创建一个pair,使用默认值作为data,当前的key为key;
  2. 使用lower_bound查找元素value,如果找到了,则返回一个iterator指向其中第一个元素;如果没有,就返回该元素应该插入的位置,即返回iterator指向第一个「不小于value」的元素。
使用容器map

image-20211223175148989

深度探索hashtable

  • 引子

image-20211223175206845

说明:假设有N个object,每个有一个对应的编号,当空间足够的时候,就将object放到对应编号的位置上;当空间不足的时候,object的编号 % 表的长度,此时就可能出现多个object应落在同一个位置,出现了碰撞💥

  • Separate Chaining 解决碰撞

image-20211223175221195

说明:

  1. 如果发生碰撞,就使用链表串起来,这种方法叫做 Separate Chaining;
  2. 如图上所示,55、2、108这三个数,模53,结果都是2,所以都落在#2 bucket所指向的这条链表上;
  3. 如果链表很长,那么搜索速度就很慢,所以就需要一个方法来判断链表是否很长,如果很长就需要打散重新放置;
  4. 判断链表很长的方法,不涉及到数学,纯由经验所得:如果元素个数比bucket个数多,就需要打散。
  5. 打散方法:bucket增加到比原来size2倍大的附近的质数,重新计算元素应该落在哪个bucket中。这是GNU C中的实现。
  6. 例:如上图中所示,一开始放置了 6 个元素,分别落在图中的不同的bucket中,再放入 48个元素,则总量达到54,大于bucket size,所以重新进行哈希,而此时将bucket size增加到 97,因为97是53的2倍大的附近的质数,然后将元素分别模97,得到结果是多少,就落在哪个bucket中;
  • GNU C中hashtable的实现

image-20211223175310483

说明:

  1. 模板参数:
    • HashFcn : 每个元素通过什么方法得到编号,是个函数或者仿函数,计算出来的编号叫做hashCode;
    • ExtracKey:提取出key;
    • EqualKey:Key比较大小的方式;
  2. 数据成员:
    • 三个函数对象:hashequalsget_key ,理论值大小都为0,但是因为实际的某些因素,只能为1,所以一共是3bytes;
    • vector类型的bucketsvector中有3个指针,所以本身是12 bytes;
    • num_elements:元素个数,4bytes
    • 所以一共:4 + 12 + 3 = 19bytes,内存对齐,调整为4的倍数,所以为20bytes;
  3. GNU C中的串联元素的链表是单向链表;
  4. 图中有个小错误: cur 应该指向某个元素,而不是指向bucket;
  5. 迭代器必须有能力在走到链表的尽头的时候回到buckets中,找到下一个bucket;
  • 直接使用容器hashtable

image-20211223175326136

说明:

  1. eqstr规定字符串比较内容,而不是比较指针;
  2. 决定元素的编号的方法是hash-function;本例子中使用的是hash<const char*>,处理C风格的字符串,转成编号,这个就是指定如下的特化版本中的__STL_TEMPLATE_NULL struct hash<const char*> 这个特化版本。
  • hash-function, hash-code

hash-function传出来的东西就叫做hash-code

hash方法的泛化和特化版本:

image-20211223175355949

说明:

  1. 上图接收的都是数值的情况,hash-function就将数值当做编号;

image-20211223175423626

  1. 上图接收的是字符串的情况,调用__stl_hash_string函数生成对应的编号,C++标准库中针对字符串的情况已经设计好了hash-function;

  2. 如果放入的元素不是已经特化的版本,就要自己实现,标准库G2.9中没有提供现成的hash<std::string>

  • modulus运算

image-20211223175442749

说明:

  1. modulus运算就是模运算,计算得到余数。
  2. 左边的是hashtable中的函数,计算元素要落在哪个bucket中,最后都是通过hash(key) % n 决定;
  • hash-code的计算示例

image-20211223175456957

G2.9 hashtable的使用

image-20211223175516300

G4.9 hashtable的使用

image-20211223175543473

image-20211223175621996

unorder容器概念

  • 到了C++11,以hash_xx开头的容器都更名为unordered_xx

image-20211223175640216

image-20211223175650302

  • 使用容器unordered_set

image-20211223175711678

image-20211223175735873

说明:

  1. buckets size一定大于元素数量;

  2. 图中因为unordered_set不能放入重复的数据,随机数的范围是0 ~ 32767,所以unordered_set.size()= 32768,而unordered_set.bucket_count() = 62233,即buckets size 比 元素个数大;

  3. 如果使用的是unordered_multiset,放入100万个元素,那么buckets size 一定是大于 100万的;

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值