第二讲
应具备的基础
-
C++基本语法
-
模板(Template)基础
令你事半功倍
-
数据结构(Data Structures)和算法(Algorithms)概念
令你如鱼得水
书籍:
- 《Algorithms + Data Structures = Programs》—— Niklaus Wirth,1976
源代码的分布(VC, GCC)
所谓标准库,指规格是标准的,接口是标准的,但是不同的编译器可能有不同的实现。
标准库版本,Visual C++
标准库版本,GNU C++
Ubuntu下的C++源码路径:/usr/include/c++
OOP(面向对象编程) vs. GP(泛型编程)
- OOP:Object-Oriented programming
- GP:Generic Programming
C++标准库并不是用面向对象的概念设计出来的,面向对象的概念就是有封装、继承、多态(虚函数)。
标准库是利用泛型编程的概念设计出来的。
- OOP将数据和操作关联到一起;
list
容器中有sort
排序操作,为什么不像vector
或者deque
一样使用全局的排序函数呢?因为标准库的sort
算法用到的迭代器需要一定的条件(RandomAccessIterator),而这个条件是list
提供的迭代器所不能满足的,所以链表不能像vector
或deque
一样使用全局的sort
算法。- 如果容器中有
sort
算法,则排序的时候使用容器中的排序算法;否则使用全局的排序算法。
vector
和deque
容器中没有sort
排序方法,只能使用全局的排序算法,这就是将数据和操作分开了,通过迭代器进行关联。
技术基础:操作符重载and模板(泛化,全特化,偏特化)
阅读C++标准库源码的必要基础
- Operator Overloading 操作符重载
- Templates 模板
Operator Overloading,操作符重载
Class Templates, 类模板
Function Templates,函数模板
Member Templates,成员模板
Specialization,特化
例1:
例2:
例3:
Partial Specialization,偏特化
两个模板参数,绑定其中一个(个数的偏特化);本来泛化可以接受任意类型,但是如果类型是指针,指向的类型还是由 T 决定,就进入struct iterator_traits<T*>
(范围的偏特化)
分配器allocators
标准层面上,分配内存最终都会调用到malloc
,然后各个操作系统调用不同的System API,得到内存。
先谈operator new() 和 malloc()
malloc
分配出来的比你需要的内存多,有很多附加的东西。需要的size越大,overhead(附加的内存)比例就较小;需要的size越小,附加的东西的比例就越大。
VC6 STL对 allocator 的使用
- 使用示例,如下容器使用的分配器是标准库中的
allocator
:
- 标准库中的
allocator
的实现:
说明:
- 分配内存的时候调用的是
allocator
的allocate
函数,调用流程就变成了:allocate
->Allocate
->operator new
->malloc
; - 回收内存的时候调用的是
allocator
的deallocate
函数,调用流程:deallocate
->operator delete
->free
; - 直接使用分配器就是如图中所示的“分配512 ints”,
allocator<int>()
生成一个匿名对象,使用该对象进行函数的调用; - VC的分配器没有做任何独特的设计,只是调用了 C 的
malloc
和free
来分配和释放内存,且接口设计并不方便程序员直接使用,但是容器使用就没问题。
BC5 STL对allocator的使用
- 使用示例,BC5 STL中的一下容器使用的分配器是标准库的
allocator
:
- BC5 中标准库的
allocator
的实现:
说明:和VC一样,allocator
最终也是通过 C 的 malloc
和 free
来分配和释放内存的。
G2.9 STL对 allocator的使用
- G2.9 标准库中的分配器
allocator
的实现:
说明:G2.9 标准库中的allocator
和 VC、BC一样,最终都是调用malloc
和free
进行内存的分配和释放。但是G2.9的STL中并没有使用标准库中的分配器,这个文件并没有被包含在任何STL头文件中,G2.9的STL容器使用的是alloc
分配器。
- G2.9 STL容器使用的分配器是
alloc
:
- G2.9 中的
alloc
的实现的行为模式:
说明:
- 这个
alloc
分配器的主要目的是减少malloc
被调用的次数,因为malloc
会带着额外开销; - 设计了16条链表,每条链表负责不同大小区块的内存,#0负责8bytes大小的区块,#1负责16bytes大小的区块,以此类推…,超过这个分配器能管理的最大的区块128bytes,就仍然要调用
malloc
函数分配; - 所有的容器,当它需要内存的时候都来向这个分配器要内存;容器中元素的大小会被调整为8的倍数;
- 更多内容,可查看C++内存管理机制。
G4.9 STL对allocator的使用
-
G4.9 标准库中的分配器
allocator
的实现:
说明:这个分配器也是直接调用的malloc
和free
进行内存的分配和回收,并没有其他的操作。
- G4.9 STL容器使用的分配器是
std::allocator
:
说明:G4.9 的STL的容器使用的分配器是标准库的allocator
分配器,而没有使用G2.9中的alloc
分配器,为什么呢?那么G2.9中的分配器alloc
在G4.9的版本中还在吗?
- G2.9中的
alloc
分配器在G4.9中还在,只是名称变了,名称现在是__pool_alloc
:
说明:
- 之所以说
__pool_alloc
就是G2.9的alloc
,因为相关的数值都仍然存在:管理8的倍数的区块,最大可以管理128字节的区块,有16条链表; - 这种比较好的分配器仍然是可以使用的,使用方式如图中所示的“用例”,第二个模板参数指定分配器,
__pool_alloc
所在的命名空间为__gun_cxx
;
容器之间的实现关系与分类
容器 — 结构与分类
说明:
-
如图中的注释说明,缩排表达的关系是复合,就是
set
里面有rb_tree
,map
里面有rb_tree
,multiset
/multimap
里面有rb_tree
; -
同理,
heap
中有vector
,priority_queue
中有vector
,priority_queue
中有heap
;stack
和queue
中都有一个deque
; -
图中的左右两侧表示的是容器要操作数据必须有的指针或元素,这个整体的大小,不包括数据,通过
sizeof
计算:
深度探索list
G2.9的list
说明:
list
中只有一个数据成员node
,类型是link_type
,而link_type
就是list_node*
,所以node
就是一个指针,那么sizeof(list) = 4
;__list_node
中有两个指针prev
和next
,以及数据data
,所以这就是一个双向链表,注意这里的prev
和next
指针是void*
类型的,这种写法可以使用,但是必须进行强转型,这种方式不太好,在G4.9中已经进行了改善,这两个指针就指向__list_node
类型;list
中每个元素并不单纯的只有元素本身,还会多耗用两个指针prev
和next
,所以容器list
向它的分配器要内存的时候,就是要“两个指针+数据”这么大的内存,而非只是数据这么多的内存;- 链表是非连续的空间,所以它的
Iterator
不能是指针,因为Iterator
模拟指针,就要能进行++这些操作,但是如果list
的Iterator
进行++
操作不知道指到哪里去了;所以Iteartor
必须足够聪明,当进行++操作的时候知道要指向list
的下一个节点; - 除了
vector
和array
外的所有容器的iterator
都必须是class,它才能成为一个智能指针; - 最后一个节点的下一个节点一定要加一个空白节点(图中的灰色节点),为了符合STL的「前闭后开」区间;
begin()
得到链表的第一个节点,end()
得到链表的最后一个节点的下一个节点,即图中的空白节点;这是实现上的一个小技巧,不但是双向的,而且是环状的。
list’s iterator
- 概览
list
的iterator
:
说明:
iterator
要模拟指针,所以有大量的操作符重载;- 所有的容器中的
iterator
都要做5个typedef
,如上图中的所示的(1)(2)(3)(4)(5);
iteartor
的++
操作的实现:
说明:
i++
叫做postfix form,++i
叫做prefix form,因为无论是前缀还是后缀形式,都只有i
这个参数,C++中为了区分这种情况,规定了operator++()
无参表示前缀,此时的i
已经变成调用这个函数的对象本身了;operator++(int)
有参表示后缀;self& operator++()
函数可以成功的将node
进行移动,指向下一个节点;- 而
self operator++(int)
函数的流程是先记录原值,然后进行操作,最后返回原值。注意:- 此时的记录原值的操作:
self tmp = *this;
并不会调用重载的operator*
函数,因为这行代码先遇到了=
运算符,所以会调用拷贝构造函数,此时的*this
已经变成了拷贝构造函数里面的参数; ++*this
调用了重载的operator++()
函数;
- 此时的记录原值的操作:
- 注意返回值的差别。之所以有差别是向整数的
++
操作看齐:整数里面是不允许进行两次后++
的,所以这里iterator
的operator++(int)
为了阻止它做两次后++
操作,返回值不是引用;整数中是允许做两次前++
的操作,所以iterator
的opeartor++()
返回值是引用。
iterator
的*
和->
操作符的实现
说明:
operator*
就是获得node
指针指向的节点的data
数据;operator->
获取node
指针指向的节点的data
数据的地址;
- 小结
list
是个双向链表,因为每个节点除了有data
,还有next
和prev
指针;- 所有的容器的
iterator
都有两大部分:(1)一些typedef;(2)操作符重载
- G4.9相比G2.9的改进:
说明:
iterator
的模板参数只有一个,容易理解;- G4.9中的指针
prev
和next
是本身的类型,而不再是void*
;
G4.9的list
说明:
- 相比G2.9的
list
,G2.9的list
更加复杂了; - 因为行为模式已经在G2.9中知道了,所以没有必要再去看G4.9了;
- 和 G2.9一样,链表是环状双向的,刻意在环状
list
最后加了一个空白节点,用来符合STL的「前闭后开」区间; - 在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
。
- G4.9的
迭代器的设计原则和Iterator Traits的作用与设计
设计Traits实现希望你放入的数据,能够萃取出你想要的特征。标准库中有好几种Traits,针对type的有type traits;针对characters,就有char traits;针对pointer,有pointer traits,… 。这里,只看iterator traits。
Iterator需要遵循的原则
说明:
iterator
是算法和容器之间的桥梁,这样算法能知道要处理的元素的范围,容器将begin()
和end()
传出去交给算法,算法知道了范围且可以通过iterator进行移动,++或–,将元素一个一个地取出来;- 算法在处理数据的过程中可能需要知道iterator的性质,因为它需要做动作,可能会选择最佳化的动作;
- 举例:有一个
rotate
算法,会想要知道iterator的哪些属性?- 想要知道iterator的分类(
iteartor_traits<_Iter>::iterator_category()
),有的迭代器只能++,或者只能–,有的可以跳着走,得到分类以便可以选取最佳的操作方式; - 想要知道iterator的
difference_type
,两个iterator之间的距离; - 想要知道iterator的
value_type
,指的是迭代器指向的元素的类型,比如在一个容器中放了10个string类型的元素,那么这个value_type
就是string
;
- 想要知道iterator的分类(
- 算法提问,迭代器回答。这样的提问在C++标准库开发过程中设计出 5 种,这 5 种叫做iterator的associated types(相关类型):
iterator_category
difference_type
value_type
reference
pointer
- iterator必须提供这5种相关类型,以便回答算法的提问。
Iterator 必须提供的 5 种 associated types
说明:
- 标准库中用
ptrdiff_t
来表示两个迭代器之间的距离,这个ptrdiff_t
也是C++中定义的,但是如果实际存放的元素的头和尾的距离超过了这个ptrdiff_t
类型表示的范围,那这就失效了; - 因为
list
是个双向链表,所以这里使用了bidirectional_iterator_tag
来表示iterator_category
; - 可以看到,这里并没有traits,那么为什么还要谈到iterator traits呢?因为如果 iterator 不是 class,就不能进行typedef,如果iterator是native pointer,即C++中的指针,它被视为一种退化的 iterator。所以当调用算法的时候,传入的可能是个指针,而不是泛化指针,不是个迭代器,那此时算法怎么提问呢?此时,才需要设计出 traits。
Traits,特性,特征,特质
说明:
- 图中的“萃取机”必须能区分它所收到的iterator,到底是以class设计的iterator还是native pointer的iterator;
说明:
- 因为算法不知道iterator是什么类型,所以不能直接提问,而是间接问。将iterator放入traits,算法问traits:value type是什么?
- 然后traits问iterator或指针:value type是什么?
- 若traits要问的对象是class iterator,则进入图中的①
- 若traits要问的对象是 pointer,则进入②或者③
- 为了应付指针的形式,增加了中间层 iterator traits,利用了偏特化分离出指针和const指针;
- 这一页回答了算法的
value_type
的提问;
完整的iterator_traits
说明:如果是iterator,则进入泛化版本;如果是指针,则进入偏特化版本。算法问traits,当traits发现手上的东西是指针的时候,就由traits替它回答。
各式各样的Traits
深度探索vector
vector
是一种动态增长的数组,当空间不够的时候,要到内存的其他地方开辟空间,并将原来的数据移动到新的空间,这才是扩充,不能在原来的空间进行扩充。
G2.9的vector
说明:
- 当前
vector
的容量是8,目前已经存放了 6 个元素; - 如果已经放了8个元素,要再放第9个元素的时候,就要进行扩充,如图中所示,二倍成长。容器回到内存中去找到另外的空间,要求是当前空间的2倍。当次数较多的时候,申请的空间就越来越大,如果最后找不到2倍大的空间,容器的生命就结束了,不能再放入元素了;
- 只需要三个指针
start
、finish
、end_of_storage
就能控制整个容器,因此,sizeof(vector) = 12
; - 所有的容器,如果带了连续的空间,就必须提供
[]
运算符重载函数; vector
的 二倍成长 到底是怎么回事呢?
vector
的二倍成长的实现
说明:
vector
的成长发生在放入元素的时候,此处即push_back
函数;- 之所以在
insert_aux
函数中也做了finish == end_of_storage
的判断,是因为insert_aux
除了被push_back
函数调用外,还可能被其他函数调用,在那种情况下,就需要做检查;
- 没有备用空间的时候,先记录下原来的
size
,分配原则:如果原大小为0,则分配1;如果原大小不为0,则分配原大小的2倍,前半段用来放置元数据,后半段准备用来放新数据; - 确定了长度之后,使用分配器的
allocate
函数进行内存空间的分配; - 然后将原来vector的内容拷贝到新的vector;
- 为新的元素设定初值;(要放入的第9个元素);
- 因为
insert
操作也会使得空间发生增长,也会调用到这个insert_aux
,所以要把安插点之后的内容也进行拷贝; - 每次成长都会大量调用拷贝构造函数和析构函数(析构原来vector中的元素),需要很大的成本;
G2.9的vector’s iterator
说明:
vector
的空间是连续的,按理说可以直接使用指针作为迭代器,vector
类中的iterator
也的确是指针;- 当算法要提问
iterator
的时候,就通过iterator_traits
进行回答;当前的iterator
是个指针,当它丢给萃取机萃取的时候,就是图中箭头指向的T*
,因此萃取机就会进入偏特化的版本——struct iterator_traits<T*>
;
G4.9的vector
说明:
- G4.9的
vector
也有三个指针,_M_start
、_M_finish
、_M_end_of_storage
,所以sizeof(vector) = 12
;
G4.9的vector’s iterator
说明:
- G4.9的
vector
的iterator
经过层层推导就是T*
外包裹一个iterator adapter,使得能支持 5 种 associated types;
- 算法向iterator提问的时候,将iterator放入萃取机中,因为此时的iterator是个object,所以走图中的灰色细箭头这一条路径;
- 而在iterator内部本身就定义了 5 种 associated types;
- 绕了这么大一圈,最后和G2.9的iterator达到的是一样的效果;
深度探索array
TR1的array
说明:
array
相比vector
更加简单,因为在C和C++语言中本身就存在数组,为什么要将数组包装成一个容器来使用呢?因为变成容器之后,就要遵循容器的规律、规则,即需要提供iterator迭代器,而这个迭代器又要提供五种相关的类型以便于让算法可以询问一些必要的信息,算法才能决定采取哪种最优的动作,如果没有进行这样的包装,array
就被摒弃在六大部件之外,就不能享受算法、仿函数等与其交互的关系。- 上述的是TR1(Technique report 1) 版本,是C++的过渡版本,介于C++1.0和C++2.0之间;
array
不能扩充,所以必须指定大小,如array<int, 10> myArray;
- 没有构造函数,也没有析构函数;
- 因为
array
是连续的空间,所以它的迭代器可以用指针来单纯的指针来表现,不用再设计单独的class;
G4.9的array
说明:
-
数组的写法
int a[100]; //OK int[100] b; //fail typedef int T[100]; T c; //OK
即此处的
_M_elems
变量是个数组;
深度探索forward_list
说明:forward_list
是个单向链表,相比双向链表更加简单,因此此处不再赘述。
深度探索deque、queue和stack
容器deque
G2.9的deque
说明:
deque
是分段连续,deque
是个vector
,其中的每个元素都是一个指针,这些指针分别指向不同的buffer
;- 如果当前空闲的最后一个
buffer
使用完了,要继续push_back
,那么新分配一个buffer
,并将其deque
当前图上的倒数第二个空白位置指向这个buffer即可,这就是往后扩充; - 同理,如果第一个空闲的
buffer
用完了,要继续push_front
,再分配一个buffer,用deque
中的第一个空白位置指向新分配的buffer即可,这就是向前扩充; - 图中的蓝色部分是迭代器,
deque
的迭代器是class,其中包含了cur
、first
、last
和node
四个部分:- 其中的
node
指的就是图中deque
中指向buffer的指针,我们在这里把它暂时称为控制中心。一个迭代器能知道控制中心在哪里,当迭代器要++或–的时候就能够跳到另一个分段,因为分段的控制全部在这里; first
和last
指的是node
所指向的buffer的头和尾(前闭后开),标识出buffer的边界,如果走到了边界,就要跳到下一个buffer;cur
就是当前迭代器指向的元素;
- 其中的
- 几乎所有的容器都维护了两个迭代器
start
和finish
,分别指向头和尾;几乎所有的容器都提供两个函数,begin()
和end()
,其中begin()
传回start
,end()
传回finish
;
说明:
- 图中是G2.9的deque;
- 数据部分的
map
的类型是T**
,占 4 个字节; - 代码中的
iterator
中的数据为下图的deque's iterator
中所示的cur
、first
、last
和node
,都是指针,所以deque’s iterator的大小为 16 字节, - 那么一个
deque
的大小为“两个迭代器 + map + map_size" = 16 * 2 + 4 + 4 = 40 bytes; deque
是个模板类,有三个模板参数,第一个参数表示元素类型,第二个参数是分配器的类型,第三个参数是指每个buffer容纳的元素个数,允许指定buffer容纳的元素个数,默认值为0,deque_buf_size
函数会根据该模板参数决定buffer中能容纳的元素具体个数;
deque<T>::insert()
说明:
- 聪明在于插入数据的时候会判断要插入的位置是离前面比较近还是后面比较近,离哪边近,就推动哪边的元素,因为每次推动元素都要调用构造函数和析构函数,挺花费时间的;
insert_aux
首先检查要插入的点往前和往后,哪边需要移动的元素index
哪边更少;即找到离头还是尾的距离更近,将距离近的哪边的元素进行推动以便放入新值;- 在安插点上设定新的值;
deque如何模拟连续空间
说明:
font()
返回第一个元素,back()
返回最后一个元素,这里是利用finish
进行倒推;size()
就是元素的个数,注意这里的finish - start
,其中迭代器一定是对-
进行了操作符重载;
operator*
就是取值,迭代器取值就是获取迭代器的cur
指向的值;operator-
统计首尾迭代器之间的元素个数;
operator++(int)
调用operator()
,operator--(int)
调用operator--()
,都是只移动一个位置operator++()
就是移动当前元素,移动之后检查是否到达buffer的边界,如果到了下一个边界,就跳到下一个buffer的起点;operator--()
同理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W9WvgOkW-1641201057150)(…/…/…/Library/Application Support/typora-user-images/image-20211223174035925.png)]
operator+=
就是移动多个位置,先判断是否会跨越buffer;如果跨越buffer,要先计算跨越几个,然后退回控制中心,跨越之后再决定有几个要走
operator-=
利用了operator+=
operator[]
将迭代器移动到第n个位置,获得第n个元素
小结:deque的迭代器通过操作符重载可以欺骗使用者自己是连续,这种欺骗是善意的,可以让使用者更好地使用deque。
G4.9的deque
说明:
- G4.9版本相比G2.9版本,又是从一个单一的class变成了复杂的多个class;每个容器的新版本都会设计为如图的这种继承和组合的关系;
- 此时的
sizeof(deque<int>) = 40
,和G2.9版本的大小一样,就是数据成员_M_impl
的大小,即_Deque_impl
类中的数据成员的大小; - G4.9的 deque 的模板参数只有两个,不允许指派buffer size;
_M_map
指向的是控制中心,它是用vector
来存放指向不同buffer的指针的,当它的空间不够的时候,会二倍增长,将当前的vector
拷贝到新的空间的中段,为了让左边和右边有空闲,使得可以向前和向后扩充;
容器queue
说明:
- 可以看到,deque是双向进出,stack是先进后出,queue是先进先出,那么只需要在queue和stack中内含deque,然后封锁住其中某些动作;
- 如图中所示,
queue
类中的数据成员是deque
类型,所有的操作都是转去调用deque
的接口来完成;
容器stack
说明:
- 和
queue
类似,stack
内含了一个deque
,所有的操作都是转调用deque的接口完成。
queue和stack,关于其 iterator 和底层结构
1、stack或queue都不允许遍历,也不提供iterator
说明:
stack
和queue
都可以选择list
或deque
作为底层结构,默认是deque
,如图中所示,选择list
作为底层结构也是可以的,可以成功编译和执行;stack
和queue
都 不允许 遍历,也不提供iterator
,因为stack
的行为是先进后出,queue
的行为是先进先出,如果允许任意插入元素的话就会干扰了这个行为模式,而stack
和queue
的行为是被全世界公认的,所以不允许放元素,而放元素要靠迭代器,所以根本就不提供迭代器,要取东西的时候只能从头或者尾拿;
2、queue不可选择vector作为底层结构,stack可选择vector作为底层结构
说明:
stack
可以选择vector
作为底层结构;queue
不可以选择vector
作为底层结构;部分接口不能转调用vector
,如图中所示的pop()
,不能成功转调用,因为vector
中没有pop_front
这个成员函数,部分失败了;- 通过
queue
测试使用vector
作为底层结构的启示:使用模板的时候,编译器不会预先做全面的检查,用到多少检查多少。
3、stack和queue都不可选择set或map作为底层结构
说明:
stack
和queue
都不可以选择set
或map
作为底层结构,因为转调用的时候,调用不到正确的函数的话,这个结构就剔除了,不能作为候选;- 图中示范了
stack
选择set
作为底层结构出现的的错误,第一行编译通过,是因为上面说过编译器预先不会做前面的检查;而stack<string, map<string>> c;
和queue<string, map<string>> c;
编译无法通是因为map
使用的时候是key和value都要设置,这里使用错误,所以编译无法通过。
深度探索RB_tree
之前谈到的容器都是 Sequence Containers,从本章开始,要讲解关联式容器,它非常有用,因为它查找和插入都很快。关联式容器可以想象成一个小型的数据库,数据库就是希望用key找到value,而关联式容器就带着这样的性质。在标准库中,关联式容器底层使用两种结构作为技术支持——红黑树和哈希表。
红黑树简介
说明:
- Red-Black tree(红黑树)是平衡二叉查找树(balanced binary search tree)中常被使用的一种。平衡二叉查找树的特征:排列规则有利于
search
和insert
,并保持适度平衡——无任何节点过深。 rb_tree
提供 ”遍历“ 操作及iterators
。按正常规则(++ite
) 遍历,便能获得排序状态(sorted)。【注:begin()
记录的是最左的节点,end()
记录最右的节点】- 我们不应使用 rb_tree 的 iterators 改变元素值(因为元素有其严谨排列规则)。编程层面(programming level)并未阻绝此事。如此设计是正确的,因为 rb_tree 即将为 set 和 map 服务(作为其底部支持),而 map 允许 元素的data 被改变,只有元素的key 才是不可被改变的。
- rb_tree 提供两种 insertion 操作:
insert_unique()
和insert_equal()
。前者表示节点的key一定在整个 tree 中独一无二,否则安插失败;后者表示节点的 key 可重复。
G2.9 容器rb_tree
- 标准库中红黑树的实现
说明:
rb_tree
是一个模板类,模板参数:Value
:key 和 data 合成 value,其中的data也可能是其他的数据合起来的;KeyOfValue
:如何取出value中的key;Compare
:比较函数/仿函数;Alloc
:分配器,默认为alloc
;
- 数据部分:
node_count
:rb_tree
中的节点数量;header
:指向rb_tree_node
的指针;key_compare
:key
的大小比较规则;Compare
仿函数,没有数据成员,所以大小为0,任何的编译器,对于大小为0的class,创建出来的对象的size一定为1;
- 所以数据部分一共的大小是 9,但是因为内存对齐,以4的倍数进行对齐,所以 9 要调整为 12;
- 图中的双向链表中的天蓝色节点,是一个虚空节点,为了做「前闭后开」区间,刻意放入的,不是真正的元素;红黑树中的
header
也是类似的,刻意放入的,使得代码实现更加简单;
-
直接使用
rb_tree
示例:rb_tree<int, int, identity<int>, //仿函数,重载了operator()函数,告诉红黑树要如何取得key,GNU C 独有的,不是标准库的一部分 less<int>, //key比较大小的方式,less是标准库的一部分 alloc> myTree;
使用容器rb_tree
G4.9 容器_Rb_tree
说明:
- G4.9版本相比G2.9版本,类结构发生了变化;
- OO思想里面,类中包含一个指针指向另一个类,主体本身不做任何事情,都是通过指针指向的另一个类做事情,这中手法叫做Handle-Body;
set
和map
里都各有一个_Rb_tree
;- 此时的
_Rb_tree
的数据的大小取决于_M_impl
这个数据成员的大小,而_M_impl
类型是_Rb_tree_impl
;Rb_tree_impl
中的数据成员_M_node
的类型是_Rb_tree_node_base
,其中包含了四个数据成员:3 个指针,1个_Rb_tree_color
(enum枚举类型) = 24 bytes;
使用容器_Rb_tree
G4.9版本相比于G2.9部分名称发生了改变,如下红色的部分就是改变的部分:
深度探索set,multiset
-
set/multiset
以rb_tree
为底层结构,因此有「元素自动排序」特性。排序的依据是 key,而set/multiset 元素的 value 和 key 合一:value 就是 key。 -
set/multiset
提供 ”遍历“操作及 iterators。按正常规则(++ite) 遍历,便能获得排序状态(sorted)。 -
我们无法 使用
set/multiset
的 iterators 改变元素值(因为key 有其严谨排列规则)。set/multiset
的 iterator 是其底部的 RB tree 的 const_iterator, 就是为了禁止 user 对元素赋值。【注:讲解 rb_tree 的时候说到的是”不应“,因为Value 中的 data是可以更改的,是合理的;但是这里是”无法“,可见set在设计上就限制了不能修改,之所以不能修改,是因为set的key就是value,如果修改的话,改的就是key,这是不可以的。】 -
set
元素的 key 必须独一无二,因此其insert()
用的是 rb_tree 的insert_unique()
。 -
multiset
元素的 key 可以重复,因此其insert()
用的是 rb_tree 的insert_equal()
。
容器set
说明:
set
的模板参数有三个:Key的类型;Key的大小比较规则,默认值为less<Key>
;分配器,默认值为alloc
;set
中有个红黑树变量t
;- 从
set
中拿 iterator 的时候拿的是rb_tree
的const_iterator
,这个迭代器是不允许对元素进行修改的; set
的所有操作,都转调用底层t
的操作。从这层意义来看,set
未尝不是个 container adapter;- 之前说到 key 和 data 合起来这一整包是 value,从 value 中取出 key 用
identity
,set
里面取出 key 也就需要用identity
;identity
是GNU C中才有的;
VC6 容器set
- VC6 不提供
identity()
,那么其set
和map
如何使用 RB-tree?
说明:
- VC6中自己实现了一个内部类
_Kfn
,写法和 GNU C 中的identity
的实现是一样的,即自己实现。
使用容器multiset
深度探索map,multimap
说明:
- 每个元素即value包含了key 和 data,key不能修改,但是 data 可以修改;
G2.9 的容器map
说明:
select1st
:从value中取出第一个,即取出key;map
拿出key的方式就是select1st
;map
的迭代器就是红黑树的迭代器,红黑树的迭代器并没有禁止任何事情呀?那是如何做到用它不能修改key,但是能修改data的呢?如上例所示,使用者map<int,string>
放入两个类型,被map
包成一个pair
,而这个pair
被当成红黑树的第二个模板参数,map
自动地将key
设置成const
,所以 key 放入之后无论如何都不能被修改,因为它是 const。set
中不能修改 key 是因为使用的迭代器是红黑树的 const_iterator,而map
不允许通过迭代器修改key,是因为包装成pair
的时候将key设置成了 const;select1st
是GNU C独有的;
VC6 的容器map
- VC6 不提供
select1st()
,那么map
如何使用 RB-tree?
说明:
- 自己实现一个和
select1st
功能一样的类_Kfn
,重载operator()
函数,所以是个函数对象/仿函数,将pair
的first
数据传回;
使用容器multimap
容器map,独特的operator[]
说明:
map
的[]
操作:如果key存在,则返回 key 对应的 data;如果key不存在,那么会创建一个pair,使用默认值作为data,当前的key为key;- 使用
lower_bound
查找元素value,如果找到了,则返回一个iterator指向其中第一个元素;如果没有,就返回该元素应该插入的位置,即返回iterator指向第一个「不小于value」的元素。
使用容器map
深度探索hashtable
- 引子
说明:假设有N个object,每个有一个对应的编号,当空间足够的时候,就将object放到对应编号的位置上;当空间不足的时候,object的编号 % 表的长度,此时就可能出现多个object应落在同一个位置,出现了碰撞💥;
- Separate Chaining 解决碰撞
说明:
- 如果发生碰撞,就使用链表串起来,这种方法叫做 Separate Chaining;
- 如图上所示,55、2、108这三个数,模53,结果都是2,所以都落在#2 bucket所指向的这条链表上;
- 如果链表很长,那么搜索速度就很慢,所以就需要一个方法来判断链表是否很长,如果很长就需要打散重新放置;
- 判断链表很长的方法,不涉及到数学,纯由经验所得:如果元素个数比bucket个数多,就需要打散。
- 打散方法:bucket增加到比原来size2倍大的附近的质数,重新计算元素应该落在哪个bucket中。这是GNU C中的实现。
- 例:如上图中所示,一开始放置了 6 个元素,分别落在图中的不同的bucket中,再放入 48个元素,则总量达到54,大于bucket size,所以重新进行哈希,而此时将bucket size增加到 97,因为97是53的2倍大的附近的质数,然后将元素分别模97,得到结果是多少,就落在哪个bucket中;
- GNU C中hashtable的实现
说明:
- 模板参数:
HashFcn
: 每个元素通过什么方法得到编号,是个函数或者仿函数,计算出来的编号叫做hashCode;ExtracKey
:提取出key;EqualKey
:Key比较大小的方式;
- 数据成员:
- 三个函数对象:
hash
、equals
、get_key
,理论值大小都为0,但是因为实际的某些因素,只能为1,所以一共是3bytes; - vector类型的
buckets
,vector
中有3个指针,所以本身是12 bytes; num_elements
:元素个数,4bytes- 所以一共:4 + 12 + 3 = 19bytes,内存对齐,调整为4的倍数,所以为20bytes;
- 三个函数对象:
- GNU C中的串联元素的链表是单向链表;
- 图中有个小错误:
cur
应该指向某个元素,而不是指向bucket; - 迭代器必须有能力在走到链表的尽头的时候回到buckets中,找到下一个bucket;
- 直接使用容器hashtable
说明:
eqstr
规定字符串比较内容,而不是比较指针;- 决定元素的编号的方法是hash-function;本例子中使用的是
hash<const char*>
,处理C风格的字符串,转成编号,这个就是指定如下的特化版本中的__STL_TEMPLATE_NULL struct hash<const char*>
这个特化版本。
- hash-function, hash-code
hash-function
传出来的东西就叫做hash-code
。
hash
方法的泛化和特化版本:
说明:
- 上图接收的都是数值的情况,hash-function就将数值当做编号;
-
上图接收的是字符串的情况,调用
__stl_hash_string
函数生成对应的编号,C++标准库中针对字符串的情况已经设计好了hash-function; -
如果放入的元素不是已经特化的版本,就要自己实现,标准库G2.9中没有提供现成的
hash<std::string>
;
- modulus运算
说明:
- modulus运算就是模运算,计算得到余数。
- 左边的是hashtable中的函数,计算元素要落在哪个bucket中,最后都是通过
hash(key) % n
决定;
- hash-code的计算示例
G2.9 hashtable的使用
G4.9 hashtable的使用
unorder容器概念
- 到了C++11,以
hash_xx
开头的容器都更名为unordered_xx
- 使用容器unordered_set
说明:
-
buckets size一定大于元素数量;
-
图中因为
unordered_set
不能放入重复的数据,随机数的范围是0 ~ 32767,所以unordered_set.size()= 32768
,而unordered_set.bucket_count() = 62233
,即buckets size 比 元素个数大; -
如果使用的是
unordered_multiset
,放入100万个元素,那么buckets size 一定是大于 100万的;