写在前面
记录一下《C++ STL源码剖析》中的要点。
一、STL六大组件
- 容器(container):
- 各种数据结构,用于存放数据;
- class template 类泛型;
- 如
vector
,list
,deque
,set
,map
;
- 算法(algorithm):
- function template 函数泛型;
- 如
sort
,search
,copy
,erase
;
- 迭代器(iterator):
- 容器和算法之间的胶合剂;
- pointer template 指针泛型;
- 如
*
,->
,++
,--
;
- 仿函数(functor):
- 类似函数,可以作为算法的某种策略;
- operator()的template 小括号泛型;
- 配接器(adapter):
- 修饰容器、仿函数、迭代器的接口;
- 如
queue
,stack
;
- 空间配置器(allocator):
- 负责空间配置和管理;
- 实现了空间管理的template 空间管理类泛型;
二、空间配置器
这里是指SGI版本的STL实现,即GCC中的STL实现。
2.1 一般对象的申请和释放过程
2.1.1 新建一个对象
Foo *pf = new Foo;
- (1) 调用了
operator new
配置内存; - (2) 调用了
Foo::Foo()
构建对象内容;
2.1.2 删除一个对象:
delete pf;
- (1) 调用
Foo::~Foo()
将对象析构; - (2) 调用
operator delete
释放内存;
2.2 STL中对象的内存申请和释放
主要涉及如下四个函数:
construct()
函数;destroy()
函数;allocate()
函数;deallocate()
函数;
2.2.1 construct()
函数
- 直接调用
new
,同时完成空间的分配和根据值对对象的构造;
2.2.2 destroy()
函数:
-
有两种方式:
-
第一种是只传入一个指针,则删除这个指针;
-
第二种是传入一头一尾两个迭代器,则删除这两个迭代器之间的元素;
-
删除的元素范围是
[first, last)
; -
但也不是范围内的元素都会被删除,删除之前还要看一下这些元素的析构函数是不是无所谓的(trivial destructor),如果是无所谓的就不逐个执行,这样可以提高运行效率;
以上两个函数执行的逻辑示意图如下:
2.2.3 allocate()
函数
-
SGI的STL版本将内存的分配和对象的构造两个步骤拆分开来了,其实是为了能够在更细的粒度上管理内存空间;
-
allocate()
函数只负责内存的分配环节,设计的动机如下:- 向system heap要求空间;
- 考虑多线程状态;
- 考虑内存不足时的应变措施;
- 考虑过多“小型区块”可能造成的内存碎片(fragment)问题;
-
为此,SGI设计了双层级配置器,避免空间分配过程中可能出现的大量内存破碎问题。
-
双层级配置器的结构示意图如下:
-
第一层配置器的要点如下:
-
用
malloc()
,free()
和realloc()
等C风格的函数执行实际的内存管理,而不是直接使用new
;
-
如果
allocate()
和reallocate()
用malloc()
和realloc()
申请空间时内存不足,则调用oom_malloc()
和oom_realloc
,来不断循环尝试用某个预先写好的“内存不足处理例程”获取足够的内存;
-
但如果“内存不足处理例程”并没有实现,则会直接抛出异常或者中止程序;
-
-
第二级配置器要点如下:
-
只处理申请
128 bytes
及以下空间的操作; -
维护16个free-lists,每个list空间大小分别8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 (16*8);
-
如果申请的空间不是8的倍数,则会自动将申请空间上调到8的倍数,这是对应了free-lists的空间;
-
节点为
union
类型,结构如下:
-
(1) 如果free-lists中有空间,则将这个空间返回,如下图:
-
(2) 如果free-lists中对应大小的区块不足,则用
refill()
函数向内存池中获取20个(默认)新区块,再返回其中一个区块;refill()
是通过调用chunk_alloc()
函数从内存池中获取新区块的;
-
(3) 如果内存池中不足20个新区块,则
chunk_alloc()
函数会取尽量多的新区块;
-
(4) 如果内存池中连1个新区块都凑不齐,则将内存池中剩余的空间组成一个区块(大小不一定对应8的倍数),放入free-lists中;
-
此时
start_free
是内存池空余内存的起始地址,my_free_list
是free-lists数组中某个节点的地址,以start_free
为起点的空间不足my_free_list
所代表的区块list的常规空间大小,但仍放入其中,避免浪费空间;
-
然后还要向heap空间申请空间补充内存池,申请空间使用
malloc
函数;
-
(5) 如果heap空间不足,则反过来再查free-lists是否还有空间,查询从当前的
free-lists[size]
开始,一直查到free-lists[15]
; -
如果有空闲的区块,则把这个区块从free-lists取出并加入内存池,然后重新分配到free-lists中;
-
如果free-lists中也没有更大的区块可以用了,则调用第一级配置器,从而抛出异常;
-
调用第一级配置器主要是想通过
oom_malloc()
和oom_realloc
来尝试获得内存;
-
-
总之,通过划分两级配置器,可以尽最大的努力获得空间的最大利用率;
2.2.4 deallocate()
函数
- 和
allocate()
函数一样,deallocate()
函数的实现也是依赖于两级配置器; - 第一层配置器的要点如下:
- 如果释放的空间超过128 bytes,则调用第一级配置器释放;
- 释放空间直接使用
free()
函数;
- 第二层配置器的要点如下:
- 释放的空间少于128 bytes,则回收到free-lists中;
- 回收的过程如下图:
- 释放的空间少于128 bytes,则回收到free-lists中;
最后是以上两个函数加上内存池的完整示意图:
2.3 内存构造函数
按照前面的逻辑,SGI将对象的空间分配和对象的值构造两者分开了,因此在allocate()
函数的基础上,还需要有一些配套的函数来实现对象值的构造,这些函数是:
uninitialized_copy()
函数;uninitialized_fill()
函数;uninitialized_fill_n()
函数;
2.3.1 uninitialized_copy()
函数
- 用于给区间中的各个内存空间依次拷贝对象的值;
- 遵循
commit or rollback
原则,即要么全部拷贝成功,要么全部不要拷贝; - 调用的是
construct()
函数;
[result, result+(last-first))
是输出的范围,也是要复制的空间;i
遍历输入的序列,也就是要复制的原始对象;- 然后调用
construct(&*(result+(i-first)), *i)
执行复制;
2.3.2 uninitialized_fill()
函数
- 用于将传入的参数填充到区间中的各个内存空间上;
- 遵循
commit or rollback
原则,即要么全部拷贝成功,要么全部不要拷贝; - 调用的是
construct()
函数;
[first, last)
是要填充的范围;i
遍历输出的序列,也就是要填充的对象;- 然后调用
construct(&*i, x)
执行填充;
2.3.3 uninitialized_fill_n()
函数;
- 用于将连续
n
个元素填充传入的参数; - 遵循
commit or rollback
原则,即要么全部拷贝成功,要么全部不要拷贝; - 调用的是
construct()
函数;
[first, first+n)
是要填充的范围;i
遍历输出的序列,也就是要填充的对象;- 然后调用
construct(&*i, x)
执行填充;
三、迭代器
作用是提供一种方法,使它能够依次遍历某个容器所含的各个元素,又不用暴露该容器的内部细节。
3.1 auto_ptr
- 迭代器是一种行为类似指针的对象;
- 最重要的是对
operator*
和operator->
进行重载; - 因此首先介绍用于包装原生指针的
auto_ptr
对象; - 迭代器虽然设计是为了为STL容器封装指针功能,但也应当能够兼容原生指针;
- 相当于是在
auto_ptr
的基础上扩展,从封装原生指针扩展到为不同容器封装指针功能;
3.1.1 auto_ptr的使用
- 声明如下:
auto_ptr<指向对象的类型> ptr_name(new 指向的对象);
- 使用的方法和原生指针完全一致;
3.1.2 auto_ptr的实现
- 成员变量、构造函数和析构函数:
template <class _Tp> class auto_ptr {
private:
// 成员变量,即原生指针
_Tp* _M_ptr;
public:
// 绑定类型
typedef _Tp element_type;
// 构造函数 [1] => 初始化为空指针
explicit auto_ptr(_Tp* __p = 0) __STL_NOTHROW : _M_ptr(__p) {}
// 构造函数 [2] => 初始化为一个现有的指针
auto_ptr(auto_ptr& __a) __STL_NOTHROW : _M_ptr(__a.release()) {}
// 构造函数 [3] => 初始化为一个现有的泛型指针
#ifdef __STL_MEMBER_TEMPLATES
template <class _Tp1> auto_ptr(auto_ptr<_Tp1>& __a) __STL_NOTHROW
: _M_ptr(__a.release()) {}
#endif /* __STL_MEMBER_TEMPLATES */
// 析构函数
// 删除指针指向的内存空间
~auto_ptr() { delete _M_ptr; }
// ...
};
- 重载
operator=
:
// 重载operator= [1] => 复制一个现有的指针
auto_ptr& operator=(auto_ptr& __a) __STL_NOTHROW {
if (&__a != this) {
delete _M_ptr;
_M_ptr = __a.release();
}
return *this;
}
// 重载operator= [2] => 复制一个现有的泛型指针
#ifdef __STL_MEMBER_TEMPLATES
template <class _Tp1>
auto_ptr& operator=(auto_ptr<_Tp1>& __a) __STL_NOTHROW {
if (__a.get() != this->get()) {
delete _M_ptr;
_M_ptr = __a.release();
}
return *this;
}
#endif /* __STL_MEMBER_TEMPLATES */
- 重载
operator*
:
// 取自身指针指向内存地址的值
_Tp& operator*() const __STL_NOTHROW {
return *_M_ptr;
}
- 重载
operator->
:
// 返回自身指针
_Tp* operator->() const __STL_NOTHROW {
return _M_ptr;
}
_Tp* get() const __STL_NOTHROW {
return _M_ptr;
}
- 实现
release
和reset
函数:
// 把自身指针置空,同时返回自身指针
_Tp* release() __STL_NOTHROW {
_Tp* __tmp = _M_ptr;
_M_ptr = 0;
return __tmp;
}
// 修改指针的指向
void reset(_Tp* __p = 0) __STL_NOTHROW {
if (__p != _M_ptr) {
delete _M_ptr;
_M_ptr = __p;
}
}
- 以上操作只有在取值
operator*
和析构的时候才会操作指针指向内存地址的值,其他操作均是操作指针本身的值;
3.2 迭代器需要实现和重载的操作
-
构造函数;
- 指针赋值;
- 因为指针指向的空间并非由指针自动创建,故无需分配空间;
- 由于无需回收空间,故也无需实现析构函数;
-
operator*()
函数;- 取指针指向的空间的值;
- 当返回值的类型是
Item&
即引用时,该返回值可以充当左值和右值; - 当返回值的类型是
Item
时,该返回值只能充当右值,返回时是值复制返回,会产生临时变量; - 左值是
*ptr = xxx;
的形式,右值是xxx = *ptr
的形式;
-
operator->()
函数;- 取指针本身的值;
- 取指针本身的值;
-
operator++()
函数;- 包括前置++和后置++两种;
- 包括前置++和后置++两种;
-
operator==()
函数和operator!=()
函数;- 判断指针本身是否相等,也就是说指向的空间地址是否相等,或者说指向的是不是同一个空间;
- 判断指针本身是否相等,也就是说指向的空间地址是否相等,或者说指向的是不是同一个空间;
3.3 迭代器相应类型
- 目的是让迭代器在泛型实现的具体运行过程中能够获知泛型具体的类型;
- 迭代器是由以泛型实现的不同容器提供的,用以处理泛型对应类型的数据的类似指针的对象;
- 因此通过迭代器起码要获得两个信息:
- 迭代器是什么类型的容器提供的;
- 迭代器指向的容器Item中存放的数据是什么类型的;
- 这些信息在迭代器的处理过程中都有可能用到,或者需要迭代器向外提供给算法使用,也就是充当容器和算法之间的桥梁;
- 一般而言,要获取的信息如下:
value_type
;difference_type
;pointer
;reference
;iterator_category
;
- 这五种信息中,仅
iterator_category
是和容器类型相关,其他四种信息均用于表明迭代器指向的空间所存放的数据的类型信息;
3.3.1 Traits编程思想
- Traits即特性萃取机,用于获取迭代器的特性,也就是迭代器的相应类型信息;
- 它出现的目的是为了兼容迭代器对象和原生C风格类型;
- 对于迭代器对象,可以执行迭代器的信息获取方式(C++风格);
- 对于原生指针,需要手动为指针指派信息(C风格);
- 除了原生指针,可能还会有别的情况,而Traits也需要兼容(其他C风格);
- 实现Traits的方式是
partial specialization
偏特化;- 即针对任何template参数更进一步的条件限制所设计出来的一个特化版本;
- 实现的方式是在泛型的类
class
或者结构struct
后面限定泛型的形式,从而进一步区分处理;
3.3.2 泛型的类型声明
- 目的是通过解析迭代器指向的泛型,获取和泛型相关的类型信息,供自身的函数或者外界的函数进一步调用;
- 算法如果要调用泛型实现的容器数据,就必须要得知泛型相关的信息,由于迭代器充当了算法访问泛型的中间工具,所以迭代器就要负责获取这些信息;
- 声明参数类型
- 将依赖泛型的类型直接绑定到别名上;
- 对应容器的泛型类型绑定;
/*第一种用法:只能获取泛型本身的信息*/
template<class T>
struct MyIter {
typedef T value_type; // 将泛型类型直接绑定到别名
T* ptr;
MyIter(T* p=0):ptr(p) {}
// ...
};
/*第二种用法:可以通过取泛型的成员变量获取更多的信息*/
template<class T>
struct MyIter {
typedef typename T::value_type value_type; // 将依赖于泛型的类型绑定到别名
T* ptr;
MyIter(T* p=0):ptr(p) {}
// ...
};
- 声明返回值类型
- 声明返回值为依赖泛型的类型;
- 对应函数的泛型类型绑定;
/*第一种用法:只能返回泛型本身的类型*/
template<class I>
I func(I ite) {
return ite;
}
/*第二种用法:可以通过取泛型的成员变量以返回更多的类型*/
template<class I>
typename I::value_type func(I ite) {
return *ite;
}
3.3.3 Traits实现
- 实现Traits用到了上面讨论的两种方法:
- 兼容C++风格对象(迭代器对象)和C风格类型(原生指针)是通过偏特化实现;
- 提取类型信息是通过泛型编程的类型声明实现的;
- 实现相当于是封装了一个
iterator_traits
泛型来进行萃取的功能; - 常用的需要按照Traits萃取的信息如下:
value_tyoe
- 指迭代器所指对象(空间)的类型;
-
(1) 容器的迭代器:直接调用容器/类对象的成员变量即可;
-
(2) 原生指针:用偏特化萃取;
-
(3) 原生常量指针:用偏特化萃取;
-
difference_type
- 指两个迭代器之间的距离,也是用来表示一个容器的最大容量的类型;
-
(1) 容器的迭代器:直接调用容器/类对象的成员变量即可;
-
(2) 原生指针:用偏特化萃取;
-
reference
- 指迭代器所指对象(空间)的引用类型;
reference_type
作为返回值类型,可以做左值和右值;value_type
作为返回值类型是值传递,需要进行对象复制,产生临时变量,只能做右值;- Traits实现在下面一小节;
pointer
- 指迭代器的指针类型;
-
(1) 容器的迭代器:直接调用容器/类对象的成员变量即可;
-
(2) 原生指针:用偏特化萃取;
-
(3) 原生常量指针:用偏特化萃取;
-
iterator_category
-
即对应的容器所属类型;
-
是对迭代器对应容器的存储方式的说明;
-
目的是增强算法的效率,因为算法可以根据迭代器的类型来进行不同的数据访问操作实现;
-
总共有5种类型;
input_iterator
;- 迭代器指向的对象是只读;
output_iterator
;- 迭代器指向的对象是只写;
forward_iterator
;- 迭代器指向的对象可读可写;
bidirectional_iterator
;- 迭代器指向的对象可读可写且可以双向移动,但只能顺次移动;
random_access_iterator
;- 迭代器指向的对象可读可写,可以双向移动,且可以随机移动;
-
这5种类型的关系如下:
-
高层级的类型是对低层级类型的特殊化,高层级类型一定是低层级类型;高层级类型的泛化能力下降,但是效率会提高,所以能够用高层级类型就一定会用高层级类型;
-
因为5种类型之间有泛化和特殊化的关系,所以定义的时候用了继承,如下:
-
继承实现的好处在于,只要实现了基类的处理方法,如果高层级所对应的方法找不到,会自动调用低层级的方法,这样越高层级的可兜底的方法就越多,虽然使用兜底的方法会降低了调用时的效率,但大大提高了调用时的容错率;
-
迭代器中的Traits实现如下:
-
(1) 容器的迭代器:直接调用容器/类对象的成员变量即可;
-
(2) 原生指针:用偏特化萃取;
-
(3) 原生常量指针:用偏特化萃取;
-
-
使用
iterator_category
的一个例子如下,这个例子实现了advance()
,用于迭代器的前后移动,针对不同类型的迭代器,实现了不同的泛型方法;
-
advance()
上层对外接口实现如下,通过iterator_category
调用上面实现的不同泛型方法;
- 注意,上面
...class InputIterator, ...
中的InputIterator
是遵循了STL算法的命名规则的写法,即以算法所能接受的最初级类型来为其迭代器类型参数命名;
3.4 使用方式
-
迭代器和容器搭配使用的方式如下:
-
定义的方式为
xxx<xxx>::iterator it
,需要说明容器类的具体类型; -
xxx.begin()
和xxx.end()
返回的是迭代器类型; -
通常是作为STL的算法调用参数使用;
四、序列式容器
4.1 vector
- 相当于是动态数组,分配和维护的是连续线性空间;
- 可以随机访问其中的元素;
4.1.1 数据结构
-
vector
的数据结构如下:
-
基于三个迭代器的一些获取信息函数实现如下:
-
三个迭代器在
vector
中的分布如下:
-
start
指向vector
的首元素; -
finish
指向vector
的尾元素之后的第一个元素,即size()
返回容量外的第一个元素,相当于是已用空间的边界(不含); -
end_of_storage
指向capacity()
返回最大容量后的第一个元素,相当于是最大可用空间边界(不含);
4.1.2 迭代器类型
vector
的迭代器用类型对应的普通指针即可,定义如下:
- 例如,
vector<int>::iterator
的本质是int *
; - 迭代器类型是
random_access_iterator
,参看三、3.3.3部分;
4.1.3 内存分配和管理
- 构造函数:
- 先分配空间,然后按照初值调用
uninitialized_fill_n
函数填充值; - 最后将三个迭代器放到合适的位置;
- 注意此时的
capacity()
就是申请的n
,和finish
指向相同,并没有自动增加可用空间;
push_back()
函数:
- 用于在
vector
的末尾增加一个元素; - 插入的过程如下:
- 如果空间够,则直接在
finish
处插入元素,同时++finish
; - 如果空间不够,则申请原来的两倍空间,把原来的
start
到finish
和finish
到end_of_storage
的元素都复制到新空间中; - 如果原来的空间为0,则新申请的空间是1而不是两倍;
- 注意,此时三个迭代器都会重新指向新空间,因此原来的所有迭代器指向均失效;
- 最后还要释放原来的空间;
- 如果空间够,则直接在
- 为什么空间是原来两倍就一定能装下呢?
- 主要是因为
push_back()
每次仅增加一个元素;
- 扩增空间的过程可以总结为:重新配置、移动数据、释放原空间;
- 因此使用
push_back()
函数的时候一定要注意原来的迭代器的指向问题;
4.1.4 一些常用函数
- pop_back()函数:
- 用于把尾端的元素去掉;
- 同时调整
finish
的位置;
- erase()函数:
- 用于清除某个位置的元素;
- 或者用于清除某个区间上的元素;
- 均是用
copy()
将后面的元素整体迁移覆盖要擦除的元素,同时移动finish
指针; - 覆盖的示意图如下:
- insert()函数:
- 用于插入一段序列;
- 如果插入点后的元素个数【3】大于新增元素【2】,则:
- (1) 将
finish
前的新增元素个数【2】的元素挪到最后面,同时finish
后移; - (2) 将剩下的
position
后面的元素【1】往后压,空出位置给插入的元素; - (3) 将插入的元素填充到空开的位置中;
- (1) 将
- 如果插入点后的元素个数【2】小于等于新增元素【3】,则:
- (1) 先用插入元素填充
finish
后的元素,让它的个数等于新增元素个数【3】,同时finish
后移; - (2) 将
position
后的元素【2】挪到最后面,空出位置给剩下的要插入元素,同时finish
后移; - (3) 将插入的元素填充到空开的位置中;
- (1) 先用插入元素填充
- 如果
finish
后的备用空间【2】小于插入元素的空间【3】,则:- (1) 先扩展空间,令其是原来的两倍,或者是增加插入元素所需的空间,因为有可能两倍还是放不下的;
- (2) 然后再分段将原空间的元素拷贝到新空间;
- (3) 先拷贝插入前的元素,再填充要插入的元素,最后拷贝插入后的元素;
- 其实之所以要把这个过程弄得这么复杂,主要是为了:
- 充分利用
finish
指针; - 充分利用已有的
fill()
函数和copy()
函数; - 未赋初值的空间单独处理,已赋初值的空间覆盖也单独处理;
- 而且操作过程中
vector
赋值始终保持连续;
- 充分利用
- 实现的代码如下:
4.2 list
- 相当于双向环状链表;
- 不可以随机访问,但插入删除操作时间复杂度低至O(1),而且省空间;
4.2.1 数据结构
-
(1) 每个节点的结构如下:
-
是一个双向链表;
-
(2) 整个双向链表的结构如下:
-
此时的
list_node
是节点类型,link_type
是指向节点的指针类型; -
是一个环状双向链表:
-
node
指针指向的是环状双向链表的最后一个节点list.end()
(不含),是一个空白节点,即伪头节点(亦是伪尾节点); -
注意:
- 链表头
front
元素是node->next
; - 链表尾
back
元素是node->pre
; - 这两个元素均不是由
node
指针指向;
- 链表头
4.2.2 迭代器类型
- 用节点类型的指针的封装充当迭代器即可,即类型是
link_type
或者说是list_node*
的封装; - 定义如下:
-
迭代器类型是
bidirectional_iterator
,可以双向移动,但不能进行随机读取; -
增加元素、删除元素的操作不会令原来的迭代器失效;
-
赋值和取值函数重载:
-
自增和自减函数重载:
-
self& operator++()
相当于++i
,先自增再返回; -
self operator++(int)
相当于i++
,先返回再自增;
-
其他的一些获取信息的函数如下:
4.2.3 内存分配和管理
- 其实就是类似于双向链表的空间管理;
- 4个基本的空间管理函数如下:
- 构造函数:
- 构建一个空白节点,这个节点将一直和
node
指针绑定,不会赋值;
- 初始的示意图如下:
- push_back()函数:
- 用于在尾部插入一个节点;
-
用到的
insert()
函数实现如下: -
插入前是
pre, positon, next
,插入后是pre, tmp, position, next
; -
因此是在
position
之前插入;
-
一个例子如下所示:
4.2.4 一些常用函数
- 两个push函数:
- erase()函数:
- 两个pop函数:
- clear()函数:
- 从
node->next
出发,一直遍历并销毁到node
; - 最后仅保留
node
节点;
- transfer()函数:
- 用于将某一段链表移动到
position
之前;
- 移动过程需要完成:
- 将
first
到last
(不含)的链表和原链表断开; - 然后修复原链表;
- 最后将
first
到last
(不含)的链表放到position
之前;
- 将
- 是一段比较复杂的指针操作;
- 移动的示意图如下:
4.3 deque
- 相当于双向数组,是双向开口的连续线性空间;
- 可以随机访问;
- 相比
vector
增加了双向空间增长和删除,相比list
实现了随机访问,而且使用的是连续的空间,因此在实现上远比vector
或者list
复杂; - 是一种隐藏了底层细节的伪双向连续空间,在底层是由一段一段的定量连续空间(称为缓冲区)组成,增加映射控制(连续的指向缓冲区的指针数组)连接各个连续空间,从而维持了整体连续的假象;
- 缓冲区的作用在于,当数据向两端生长但空间不够的时候,仅扩展指针数组即可(也就是申请新空间,将指针数组整体拷贝,再释放旧空间),而不需要移动所有实际的数据,这将极大提高空间扩展的效率;
4.3.1 数据结构
- (1) 中央映射的数据结构
- 中央映射充当中控器,负责串联各个缓冲区,结构定义变量
map
,如下:
-
也就是定义一个指针数组,数组中的各个指针均指向一块缓冲区;
-
然后用变量
map
指向这个指针数组的头节点; -
结构示意图如下:
-
(2) 指向缓冲区的头尾指针:
-
除了
map
之外,还需要用两个迭代器指向缓冲区头尾,分别是变量start
和finish
,如下:
-
迭代器的结构见下节;
4.3.2 迭代器类型
- 用一个新的结构作为迭代器,其中封装了指向缓冲区关键位置的3个指针和指向中控器指针的指针;
- 相当于是对指向缓冲区的指针的封装;
- 但实际指向的元素仅有一个,就是指缓冲区中
cur
指针指向的那个元素,是某个实际数据元素,而不是整个缓冲区; - 因此
start
和finish
迭代器的含义是start.cur
和finish.cur
,分别指向deque
的头元素和尾元素; - 定义如下:
- 迭代器的结构示意图如下:
- 迭代器使用的一个例子如下:
- 每个缓冲区可以放8个int类型,共用了三个缓冲区;
- 有三个迭代器,分别负责三个缓冲区,其中一个是
deque
的start
(起始点),一个是finish
(终止点);
- 注意:
map
指针数组的缓冲区指针是有序的,即左侧指针指向的缓冲区一定在右侧指针指向的缓冲区前面;map
的缓冲区使用从数组中央开始,向两边展开使用,而不是从头开始使用;- 如果指针的使用超过了
map
数组的两头,则需要重新分配一个更大的map
数组,并把原map
拷贝过去; - 迭代器的
cur
指针是指向真正的当前访问元素;
- 核心:迭代器如何指向不同的缓冲区?
- 是通过
set_node()
函数用于迭代器在不同的缓冲区之间跳转; set_node()
函数是实现迭代器运算符重载的关键,它通过对map
数组指针的遍历,实现跳转到下一指针指向的缓冲区(*new_node
)的功能,其实现如下:
- 在此基础上,运算符的重载实现主要是,先判断所要处理的元素是在当前迭代器指向的缓冲区还是其他的缓冲区中,然后通过移动迭代器,用它的3个指针找到元素,并完成元素的处理;
- 一些迭代器的运算符重载函数如下:
- 一些获取信息的函数实现如下:
4.3.3 内存分配和管理
- 构造函数:
- 构造函数如下:
fill_initialize()
函数用于分配空间并设置元素初值,包括:- 为
deque
申请空间; - 为每个节点的缓冲区中的元素设置初值,因为缓冲区才是真正存放数据的地方;
- 为
create_map_and_nodes()
函数用于为deque
申请空间,包括:- 计算
map
数组大小,即需要节点数+2,最少为8; - 需要节点数用总元素除以每个缓冲区能放下的元素的结果上取整即可,刚好整除则仍+1;
- 使用
map
数组中间的指针,然后为每个指针申请缓冲区内存; - 将计算得到的
nstart
指针和nfinish
指针赋予迭代器start
和finish
;
- 计算
- push_back()函数:
- 如果
finish
所在的缓冲区仍有一个以上空间,则直接增加即可; - 否则,在增加元素后(此时缓冲区已满),还需要新开一个缓冲区,然后将
finish
迭代器跳到该缓冲区中;
- 新开缓冲区的示意图如下,此时
finish
指向的缓冲区全空:
- reserve_map_at_back()函数:
- 用来判断当前
map
数组是否需要扩增; - 如果需要增加的节点放不下了,就扩增,实现如下:
reallocate_map()
函数用于扩增:- 如果
map
数组比较空(因为是从中间向两边填充的,所以有可能出现仅用了一边的空间,导致此时map
数组的节点利用率不高),则将已经使用的节点(即map
数组中的指针元素)往数组中间挪动即可,无需申请新的空间; - 否则,申请一个新空间,将空间增加到原来的两倍或者增加所需的节点数(因为两倍可能还是放不下)再+2,将旧空间内容拷贝到新空间后释放旧空间;
- 最后还要更新迭代器
start
和finish
;
- 如果
4.3.4 一些常用函数
pop_back()函数
:
- 用于移除末尾的元素;
- 移除元素时,若
finish.cur == finish.first
,(也就是说移除之前缓冲区就为空),则需要消除缓冲区,并回退到上一个缓冲区,再进行元素的移除,此时移除的是上一个缓冲区的最后一个元素; - 实现思路是和
push_back()
相吻合的,也就是说允许有完全空的缓冲区出现,而且两端无论何时都需要有空余的空间,实现如下:
- clear()函数:
- 清空所有缓冲区,仅保留一个缓冲区(也就是起始条件中的
start.node+1
); - 实现如下:
- erase()函数:
- 清除缓冲区中的某个元素;
- 也就是说清除的对象是实际的元素,由于是双向队列,因此清除之后要尽量让剩下的元素往数组中间靠;
- 之所以能够用
copy()
和copy_backward()
函数来完成复杂的支离破碎的缓冲区之间的移动,是因为迭代器已经定义和实现好迭代器前后移动的代码了,因此在高层算法的使用上,deque
和其他的容器无异; - 这里的
pos
实际上是指pos.cur
指针所指的元素; - 实现如下:
- insert()函数:
- 用于在
position.cur
处插入一个元素; - 和
erase()
函数一样,之所以不需要显式给出复杂的缓冲区之间的操作,是因为使用了copy()
和copy_backward()
函数,因此在逻辑上可以完全按照完整的双向连续空间来处理,无需理会繁杂的中控器和缓冲区管理;
4.4 stack 【deque的adapter】
- 相当于是仅尾端开口的
deque
; - 不能随机访问;
- 在逻辑上是单向开口的连续线性空间;
- 没有迭代器;
4.4.1 数据结构
- 在实现上完全是在
deque
的基础上进一步封装; - 并不是继承
deque
,而是拥有一个deque
的成员变量,这样确实能够让这个封装关系不那么复杂而且合乎逻辑,因为从逻辑来说,stack
和deque
是同级的而不是父子关系; - 仅对外暴露
deque
的某些功能,而且均进行了二次封装; - 实现如下:
4.4.2 一些常用的函数
- 一些常用的函数实现如下:
4.5 queue 【deque的adapter】
- 相当于是两端开口的
deque
,但数据只能从尾端入,从首端出; - 不能随机访问;
- 在逻辑上是单向开口的连续线性空间;
- 没有迭代器;
4.5.1 数据结构
- 在实现上完全是在
deque
的基础上进一步封装; - 并不是继承
deque
,而是拥有一个deque
的成员变量; - 仅对外暴露
deque
的某些功能,而且均进行了二次封装; - 实现如下:
4.5.2 一些常用的函数
- 一些常用的函数实现如下:
4.6 priority_queue 【vector的adapter】
- 相当于是一个大顶堆;
- 底层是用
vector
来实现; - 没有迭代器;
- 在实现的过程中使用了4个STL标准的大顶堆操作函数;
4.6.1 数据结构
-
堆的结构是一个完全二叉树;
-
完全二叉树可以用数组来表示;
-
root
节点是最值,且位于数组的第一个元素; -
数组保存的顺序相当于是完成二叉树的广度优先遍历;
-
因此,
priority_queue
封装了一个vector
成员变量,如下所示:
4.6.2 和heap相关的4个标准函数
- 这些STL标准函数均是针对最大堆来实现的;
- push_heap()函数:
- 要求原来是一个大顶堆;
- 将处于
vector
末尾的新加入元素上浮,以满足最大堆的性质; - 根节点是
first
,新插入的元素在last
;
- 一个例子如下:
- pop_heap()函数:
- 要求原来是一个大顶堆;
- 先把最大值换到
vector
的最尾端(并未取走); - 然后将新换上来的
root
下沉,直到满足大顶堆的性质; - 最后除
vector
最后一个元素外,其余元素满足大顶堆; - 实现如下:
adjust_heap()
函数用于将根节点下沉,以满足最大值性质;- 一个例子如下:
- sort_heap()函数:
- 实现堆排序;
- 要求原来的数组是一个大顶堆;
- 其实就是多次执行
pop_heap()
函数,因为每次执行都会将最大值放到vector
表示最大堆部分的末尾; - 实现如下:
- make_heap()函数:
- 用于将一个
vector
变成符合大顶堆性质的完全二叉树; - 是从下往上建堆,第一个处理的元素是最后一个非叶节点,一直处理到根节点;
- 对每个非叶节点,执行类似于
pop_heap()
的节点下沉操作,使得以当前节点为根节点的子树满足大顶堆性质; - 实现如下:
4.6.3 一些常用的函数
priority_queue
的函数实现基本就是直接封装和heap
相关的函数,来实现一个完整的大顶堆的功能;- 其中,堆主要的函数是:
push()
函数:往堆中添加元素,先用vector.push_back()
往数组中添加元素,再调用的是push_heap()
函数;pop()
函数:往堆中删除最大值元素,调用的是pop_heap()
函数,然后再用vector.pop_back()
弹出末尾的元素;
- 常用的函数实现如下:
4.7 forward_list
- 相当于单向链表;
- 在SGI标准中,这种容器被命名为
slist
,这里的标题是采用了更为常用的C++11标准容器命名; - 由于只能单向遍历,因此操作最好发生在头节点附近,否则将需要从头遍历整个链表以到达操作点;
4.7.1 数据结构
- (1) 首先是定义一个节点基本类型
__slist_node_base
,里面仅包含一个next
指针,定义如下:
- (2) 然后是节点的数据结构
- 继承于
__slist_node_base
类,也就是包含一个next
指针; - 同时新增加一个数据变量;
- 定义如下:
-
继承的示意图如下:
-
(3) slist的数据结构
-
整个单向链表的数据结构定义如下:
-
仅需包含一个伪头节点
head
;
- 基于上述数据结构定义的插入节点函数实现如下:
- 插入前:
->pre
; - 插入后:
->pre->new
;
- 统计链表大小节点数量函数实现如下:
4.7.2 迭代器类型
slist
的迭代器可以看作是对指向节点的指针的指针,即指向__slist_node_base
的指针;- 从这里就可以看出前面对节点结构的继承定义的好处,就是将节点指针的定义和节点的定义分开了,那么在别的地方就可以更加灵活地使用,解耦性更强,虽然会增加理解的难度;
- 迭代器的定义也是用了继承的结构;
- 结构定义:
-
迭代器示意图如下:
-
由于
__slist_node
是__slist_node_base
的子类,因此通过迭代器的node
指针取值的时候需要将父类指针强制转换为子类指针,才能调用子类对象的成员变量; -
迭代器的一些函数实现如下:
4.7.3 一些常用的函数
- 正如前面提到的,单向链表通常只能操作头部的元素,处理其他元素的时间复杂度都太高了;
- 因此功能函数只有3个,即取头部元素,从头部插入元素,从头部取走元素;
- 从功能上来说,其实是很适合用来做
stack
的底层容器的,相当于是一个倒转的stack
; - 一些常用的函数实现如下:
- 由于并没有伪尾节点,因此判断到达链表末尾的条件是将指针和空指针(
0
或者NULL
)进行比较; - 所以
end()
函数实际上是返回了用空指针初始化的迭代器,指向空指针;
五、关联式容器
- 关联式容器的特征是:每个元素都是一个键值对,包含
key
和value
; - 实现的底层技术有两种,一种是红黑树(Red-Black Tree),另一种是哈希表(Hash Table);
- 红黑树具有对数平均时间,即O(logN),因为底层用的是指针形式的二叉搜索树;
- 哈希表具有常数平均时间,即O(1),因为底层用的是可以随机访问的索引数组;
- 从效率上看,哈希表的效率更高;
5.1 树的相关知识
- 二叉树:
- 特点:
- 任何节点最多只能有两个子节点;
- 二叉搜索树:
- 特点:
- 任何节点的值一定大于其左子树中节点的值,一定小于其右子树中节点的值;
- 插入操作如下:
- 删除操作如下:
- AVL树:
- 是平衡二叉搜索树;
- 特点:
- 任何节点的左右子树高度相差最多1;
- 任何节点的左右子树高度相差最多1;
- 红黑树:
-
是平衡二叉搜索树;
-
特点:
- 每个节点只能是红色或者黑色;
- 根节点是黑色;
- 红色节点的子节点一定是黑色;
- 任一节点到NULL的路径上所含的黑色节点数量一定相同;
-
STL中实现了红黑树的完整容器,但并不暴露,而是作为其他关联式容器的底层容器;
-
使用的是指针形式二叉树来实现红黑树;
-
由于红黑树真的很复杂,无论是原理还是实现都很复杂(究极麻烦的分类讨论和指针操作),这里就无力再赘述了(>﹏<),可以参考原书中的内容;
5.2 set 【rb_tree的adapter】
- 集合,所有元素会根据元素的键值自动排序;
- 不允许两个元素有相同的键值;
key
和value
是统一,就保存一个值,既是键(可被索引和排序)也是值(可被取出使用);
5.2.1 数据结构
- 包含了一个
re_tree
的成员变量; - 数据结构定义如下:
5.2.2 迭代器类型
- 是只读类型的迭代器,直接使用了红黑树的迭代器类型;
- 可以通过迭代器访问元素,但不能通过迭代器修改值;
5.2.3 一些常用的函数
- 基本上就是调用红黑树的方法;
- 实现如下:
find()
函数是比较常用的函数:
5.3 map 【rb_tree的adapter】
- 键值对映射,所有元素会根据元素的键值自动排序;
- 不允许两个元素有相同的键值;
key
和value
是分开的,键用于被索引和排序,值用于被取出使用;- 用
pair
数据结构实现键值对;
5.2.1 数据结构
- (1) 键值对的数据结构
- 包含两个成员变量,
first
作为键,second
作为值; - 实现如下:
- 包含了一个
re_tree
的成员变量; - 数据结构定义如下:
5.2.2 迭代器类型
- 迭代器不只是只读类型,限制比
set
容器的少; - 可以通过迭代器访问元素并修改
value
,但不能修改key
;
5.2.3 一些常用的函数
- 一些常用的函数实现如下:
- 一些常用的map操作:
5.4 multiset和multimap【rb_tree的adapter】
- 分别是在
set
和map
的基础上增加了允许键值重复; - 插入操作用的是红黑树实现的
insert_equal()
而不是insert_unique()
,因此可以实现键值的重复;
5.5 哈希表的相关知识
- 哈希表的原理其实就是用某种映射函数(hash function,散列函数),将
key
的大数映射成小数,然后就可以放到一个小索引里面了; - 小索引可以用数组实现,因而能够进行随机访问;
- 可以提供常数时间的查找、插入和删除效率;
- 哈希表在STL中有完整实现的容器
hashtable
,但并不向外暴露,而是作为其他关联式容器的底层容器使用;
5.5.1 哈希碰撞和5种处理碰撞的方法
- 哈希碰撞:即有不同的元素(在这里指
key
)被映射到同一个索引数组位置上; - 解决哈希碰撞的方式主要如下:
- 线性探测:
- 插入策略:如果当前索引位置已经有
key
了,就从当前的位置开始,一直往下找,直到找到一个空闲的位置来放key
; - 也就是下一个查找的地址为:
i = i + step
,step = step + 1
; - 这个查找空闲位置的过程是循环的,查到数组尾端之后,就转到头部继续查找,直到找到空位位置;
- 一个例子如下:
- 查找策略:和插入策略类似,如果索引位置不是要找的
key
,则往下循环查找,直至找到; - 删除策略:必须采用惰性删除,也就是只标记删除,实际并未删除,要等到rehashing的时候再删除;
- 一些讨论:
- 假设碰撞随机出现,则平均处理碰撞的时间是O(0.5N),因为可能要查找一半的数组,最坏的情况下是O(N);
- 如果有连片的碰撞,也就是连续的索引空间被占据,则平均处理碰撞的时间还要比O(0.5N)要高,也就是主集团(primary clustering)问题;
- 最多能存储的元素数量不能超过索引数组的大小,不像红黑树那样可以动态无限增长;
- 存储空间满了之后需要申请新空间并迁移数据,同时进行rehashing操作;
- 二次探测:
- 在线性探测的基础上解决主集团问题;
- 插入策略:如果当前位置
i
已有元素发生碰撞,则探查的下一个位置不是i+1
,而是i+1^2, i+2^2, i+3^2
,直到找到空闲的位置; - 也就是下一个查找的地址为:
i = i + step^2
,step = step + 1
; - 一个例子如下:
- 一些讨论:
- 如果负载系数在0.5以下(即索引数组只有少于一半被使用),且索引数组的大小是质数,则可以确定碰撞时探测的次数不多于2;
- 如果索引数组空间不够了,则需要将空间扩展到一个大约是两倍的质数,并且同时执行rehashing操作,和线性探测类似;
- 双重散列(double hashing):
- 增加一个hash函数处理碰撞;
- 处理碰撞的效果优于前两种方法;
- 也就是下一个查找的地址为:
i = i + step*hash(key)
,step = step + 1
; - 其中
hash(key)
是另外一个hash函数;
- 多哈希法:
- 就是用多个hash函数计算
key
对应的索引数组位置; - 插入策略:如果当前位置
i
已有元素发生碰撞,则用下一个hash函数计算位置,再探测是否有碰撞; - 如果hash函数设计得够好,或者数量够多,则总会找到一个空位不发生碰撞的;
- 开链法(separate chaining):
- 前面的四种方式都是开放地址法,核心思路还是在碰撞时再找一个空位;
- 因此受索引数组大小的限制,在空间不够的时候均需要扩增空间;
- 开链法不需要扩增空间,它使用的是数组和链表相结合的方式处理碰撞;
- 其结构由一个桶数组和每个桶引出的节点链表组成,示意图如下:
- 具有动态扩充的能力;
- 是STL中采用的处理碰撞方式;
5.5.2 哈希表数据结构
-
(1) 桶数组的底层是用
vector
实现; -
(2) 链表用另外定义的
__hashtable_node
实现,如下:
-
示意图如下:
-
(3) 哈希表的数据结构
-
就保存一个
__hashtable_node
的指针类型的数组; -
数组中的每个指针均作为桶的起始指针;
-
同时记录当前全部的节点数量;
-
实现如下:
- 关于桶数组大小的一些说明,仍用质数作为桶数组的大小:
5.5.3 哈希表的迭代器类型
- 是
forward_iterator
类型,只能前进,不能后退; - 包括一个指向桶(同一索引下的链表)中的某个节点的指针
cur
和指向整个哈希表对象的指针ht
; - 结构定义如下:
- 重载的一些函数实现如下:
- 前置++和后置++函数的重载如下:
- 如果当前桶还有元素,就通过链表的指针跳到下一个元素;
- 否则,则从指针数组的当前桶指针
bucket
开始往后遍历,直到找到一个非空的桶,然后把cur
指针指向这个桶链表的第一个节点;
bkt_num()
函数用于计算某个key
的哈希值并返回它在指针数组中的位置,实现如下:
5.5.4 哈希表的内存分配和管理
-
初始的时候用一个质数初始化指针数组的大小;
-
如果感觉指针数组不够,就会扩增指针数组的空间,过程仍然是:申请新空间,复制原有数据,释放旧空间;
-
判断指针数组不够的标准是:如果已放入哈希表中的元素个数大于指针数组的大小,则扩增指针数组;
-
复制原有数据的操作如下:
- 依次遍历旧指针数组中的每个桶;
- 逐个处理桶中的链表节点,首先计算该节点应该放到新桶指针数组中的哪个桶,然后修改它的指针,把它放到新桶的第一个节点位置上;
- 操作过程示意图如下:
-
其余的关于
hashtable
的详细定义这里就不再赘述了,实现是比较像deque
的实现的; -
只不过
hashtable
是引入了哈希函数作为指针数组的定位,而不是用下标定义,在实现上还比deque
简单一点点,因为指向的区域是链表而不是固定连续的空间(即deque
的缓冲区),可以动态增长; -
但
hashtable
在扩增指针数组的时候需要rehashing重新分配每个桶内的链表,而不仅仅是修改头部指针即可;
5.6 unordered_set 【hashtable的adapter】
- 和
set
的功能基本相同; - 内部没有自动排序功能;
- 操作效率比
set
更高,可达常数时间复杂度; - 在SGI标准中,这种容器被命名为
hash_set
,这里的标题是采用了更为常用的C++11标准容器命名;
5.6.1 数据结构
- 包含了一个
hashtable
的成员变量; - 结构定义如下:
5.6.2 一些常用的函数
- 一些常用的函数实现如下:
- 基本上就是调用
hashtable
的函数; - 初始的桶指针数组大小默认初始传参为
100
,最近的质数为193
,也就是说最多可以放193个元素到哈希表中,之后就需要扩增指针数组了;
5.7 unordered_map 【hashtable的adapter】
- 和
map
的功能基本相同; - 内部没有自动排序功能;
- 操作效率比
map
更高,可达常数时间复杂度; - 在SGI标准中,这种容器被命名为
hash_map
,这里的标题是采用了更为常用的C++11标准容器命名;
5.7.1 数据结构
- 包含了一个
hashtable
的成员变量; - 结构定义如下:
5.7.2 一些常用的函数
- 一些常用的函数实现如下:
- 基本上就是调用
hashtable
的函数; - 初始的桶指针数组大小默认初始传参为
100
,最近的质数为193
,可以放入的元素个数也是193;
5.8 unordered_multiset和unordered_multimap【hashtable的adapter】
- 分别是在
unordered_set
和unordered_map
的基础上增加了允许键值重复; - 插入操作用的是
hashtable
实现的insert_equal()
而不是insert_unique()
,因此可以实现键值的重复; - 在SGI标准中,这两个容器被命名为
hash_multiset
和hash_multimap
,这里的标题是采用了更为常用的C++11标准容器命名;