STL总结
STL包括
-
容器
-
算法
-
迭代器
-
仿函数
即函数对象,是一个能够行使函数功能的类,仿函数的语义和普通的函数调用一样,该类内部重载了函数调用运算符,仿函数一般是为了搭配STL算法使用,单独使用仿函数的情况较少,仿函数的好处在于可扩展性比函数指针强,当函数参数有所变化时,即无法兼容旧的代码,但是仿函数则不存在这种情况
-
容器适配器
-
空间配置器:即实现了动态空间配置、管理、释放的类模板
常见的容器与底层数据结构
容器 | 数据结构 | 是否可重/有序 |
---|---|---|
vector | 数组 | 可重/无序 |
list | 双向链表 | 可重/无序 |
deque | 可重/无序 | |
stack | ||
queue | ||
priority_queue | ||
set | 红黑树 | 不可重/有序 |
multiset | 红黑树 | 可重/有序 |
map | 红黑树 | 不可重/有序 |
multimap | 红黑树 | 可重/有序 |
unordered_set | 哈希表 | 不可重/无序 |
unordered_multiset | 哈希表 | 可重/无序 |
unordered_map | 哈希表 | 不可重/无序 |
unordered_multimap | 哈希表 | 可重/无序 |
顺序容器类比总结
vector
-
原理:
- 类似于一个动态增长的数组,里面有一个指针指向一片连续的内存空间,当空间装不下的时候,会自动申请一片更大的空间(通过空间配置器申请)将原来的数据拷贝到新的空间,然后释放旧的空间,当删除/清空元素时,实际只是删除了元素,但是并不会释放空间。
- vector对空间的运用很灵活,当vector的容器大小不足时,会对其进行扩容,即进行内存重分配(重新分配的内存大小往往是现在容量的一倍),然后将原内存中的元素拷贝/移动到新的内存中来
- vector的实现关键就是其对大小的控制以及重新配置空间时元素的移动效率
- 另外,当vector重新分配了空间,可能导致原迭代器失效
-
操作
-
reserve:
- reserve(n),为容器预留空间,为当前容器至少预留可以容纳n个元素的空间
- 可以直接将空间大小扩充到n,减少多次开辟空间带来的申请,释放,移动数据的开销
- 其扩充空间时并不会在空间中创建元素对象,在没有添加新的元素时,不能引用,通过push_back来添加元素
- 当参数n小于当前容量时,不进行任何操作
- 只能改变capacity大小,无法改变size大小
-
resize
- resize(n,num)/resize(n) 改变当前vector的size大小为n,同时也可能增大capacity
- 如果当容器个数为size,容量为capacity,则若n小于size,则会将size减少到n,删除n和size之间的元素;如果n大于size,则会插入相应的元素(如果有num,则插入num,否则插入该类型的默认初值),使得插入后的size为n;如果n大于capacity还会导致内存的重新分配,增大容量
- 总之,resize后,容器的size大小就成了n
-
capacity:返回当前容器在不分配内存的情况下,可以容纳的元素的个数
-
size:返回当前容器已经存放的元素的个数
-
shrink_to_fit:将vector的容量缩小到size大小
resize和reserve的区别主要是前者会自动创建/删除元素,但是reserve分配内存时,其不会创建元素,此时如果直接访问这块空间会造成越界错误,可以通过push_back添加元素
-
list
- 原理:
- 非连续结构,底层基于双向链表,每个元素维护一对前向和后向指针,从而可以实现前向和后向的遍历
- 这样支持高效的插入和删除操作,但是随机访问效率低
- 每个元素需要额外维护指针,内存开销大
- 使用非连续内存来进行存储
- 优点:
- 不使用连续内存完成动态操作
- 在内部方便进行随机插入和删除操作
- 可以在两端进行push/pop
- 缺点:
- 不能进行内部随机访问,不支持[]操作符和at
- 相对vector占用内存多(因为每个元素要保存一个前向指针和一个后向指针)
deque
https://blog.youkuaiyun.com/qq_32378713/article/details/79415764
https://blog.youkuaiyun.com/ZYZMZM_/article/details/89716913
https://blog.youkuaiyun.com/zrh_优快云/article/details/81050516
-
原理
- deque是一种双开口的连续线性空间,所谓双向开口即可以在头尾两端分别做高效的元素插入和删除
- 其是由一段一段的定量连续空间构成(一个分段数组)容器中的元素存放在这些分段的连续空间中,deque不存在内存重新分配/释放/拷贝等问题。
- 当有必要在deque的前端或者尾端增加新空间时,可以配置一段新的连续空间,串接在整个deque的头端或尾端,deque的最大任务就是在这些分段的连续空间上,维护其整体连续的假象,同时提供随机存取的接口,从而避开了vector内存重分配的问题,但是代价就是其迭代器很复杂
- deque使用中控器map(一个指针数组)来管理这些分段的空间,中控器是一个指针数组,其中存放指向各个分段空间的指针(分段空间又叫缓冲区,默认512字节)
- 中控器是一个固定数组,当中控器已满,单添加了新的缓冲区,则需要为中控器重新分配空间
-
迭代器,deque通过迭代器的实现来使得deque看起来是整体连续的
- deque中包括中控器,头迭代器start和尾迭代器finish,迭代器结构如图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j4kM7QL0-1583848520267)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\1575460772841.png)]
- 从图中可以看到,迭代器中有四个指针,一个指向当前缓冲区的元素起点,一个指向最后一个元素的后一个,一个指向当前元素,另一个指向当前缓冲区在中控器中的位置,当移动迭代器时,如果到了一个缓冲区的末尾,则跳到下一个缓冲区
-
优点:
- 随机访问方便,支持[]和at
- 方便进行头插和尾插/头删和尾删
-
缺点
- 随机删除和插入复杂(不是在两端)
- 占用内存多
三者对比
- list和vector的区别:
- vector底层是一块连续的内存,其可以很好的支持随机访问,但是对于随机插入和删除则需要移动大量的数据,效率较低,(向vector尾部插入和删除是高效的),当vector已满,如果再向其中插入元素,则会导致内存重分配,会带来内存申请,释放,数据拷贝上的开销
- list底层是基于双向链表的,其是离散的,所以list的随机访问效率很低,但是list随机插入和删除效率高
- vector是单向的而list是双向的
- vector中的迭代器使用后就被释放了,但是list再使用后还可以再用,这是独有的
- vector的迭代器支持++/–/随机加减/大小比较,但是list的迭代器仅支持++/–
- vector/list/deque的使用原则
- 若需要高效的随机访问则使用vector
- 若需要高效的随机删除和插入则使用list
- 如果需要高效的在首尾删除,同时还要兼顾随机访问,则用deque其为list和vector的折中
https://blog.youkuaiyun.com/gogokongyin/article/details/51178378
关联容器类比总结
容器 | 实现 | 特点 |
---|---|---|
map | 红黑树 | 以pair作为其节点中保存的元素,pair以键值对的形式存在,不允许有重复元素,同时已经加入的键不允许修改,但是键对应的值可以修改(其迭代器不是const),map中的元素会自动按照键值升序排序,其插入删除和查找的效率高均为O(logn) |
set | 红黑树 | set节点中的元素只有键没有值,不允许键值重复,不能够通过迭代器修改键值(迭代器为const),自动按照键值升序排序 |
multimap | 红黑树 | 键值允许重复,其他和map相同 |
multiset | 红黑树 | 键值允许重复,其他和set相同 |
unordered_map | 哈希表 | 底层实现采用哈希表,内部哈希函数采用除留取余法,采用拉链法解决冲突,其元素不可重复,元素无序 其访问,插入和删除的效率为O©,即常数级的效率 |
unordered_set | 哈希表 | 底层采用哈希表,只有key没有value |
-
map和unordered_map的区别和优缺点
- map基于红黑树实现,有序,其按照键值大小排序,元素的有序性会在很多应用中简化操作
- map的查找/删除/插入的时间复杂度为O(logn)
- unordered_map则是基于哈希表实现,其查找/插入/删除的时间复杂度是常树级的O©,但是其元素是无序的
- 但是哈希表作为底层时,其以(key,value)的形式存储,所以其空间占用率高,同时在数据量大的情况下可能出现大量的哈希冲突,同时其查找/删除/插入的时间复杂度取决于哈希函数,极端情况下可能为log(n)
- 总之,如果需要用排序来简化问题,则用map,或则考虑内存,当元素数量少时,用map
- 对于查找,需要考虑查找的次数,如果查找次数少则考虑用map,如果次数多考虑整体效率则考虑用unordered_map(因为哈希表可能会出现O(n),如果查询次数少且出现一次,则对性能影响大)
-
为什么map和set的插入删除效率比其他顺序容器高?
因为其插入和删除就是节点指针指向的改变,不存在内存拷贝/移动,自然效率高
-
为什么map/set每次insert/删除后,以前保存的iterator(不包括删除的节点)不会失效?
因为插入/删除元素时,只是导致元素节点指针指向的改变,而迭代器就相当于指向结点的指针,除了被删除的结点外,其他结点内存不变(内存中保存的元素也不变),指向这些结点的迭代器当然也不会变,即对于map(关联容器)而言,当删除某个元素时,除了指向该元素的迭代器失效外,其余迭代器都不会失效
迭代器
C++ traits技术,特性萃取技术,是一种用于提取对象的类型信息的技术,常用于C++STL中用于提取迭代器所指向的元素的类型(主要是提取类型以做返回类型),STL中算法和容器之间是分离的,而一个算法可以通过传入不同的迭代器来对不同的容器进行操作,所以迭代器就是一个STL中算法和容器之间的粘合剂,但是传入一个迭代器,算法本身并不知道要传进来什么类型,算法中可能会使用迭代器所绑定的元素的类型来做返回类型或者声明变量
萃取技术主要用到了模板的推导机制以及类模板的偏特化机制
//下面这个函数可以接受任意迭代器类型,但是其以迭代器所指向对象的类型作为返回类型,此时就需要用到萃取技术
template <typename I>
typename traits<I>::value_type function(I iter)
{}
template<typename I>
struct iterator
{
typedef I value_type;
}
template<typename I>
struct traits
{
typedef typename I::value_type value_type;
}
iterator<int> iter;
function(iter);//此时I被推导出来为iterator<int>
/*
进一步在traits中I被推导为iterator<int> 所以返回类型为iterator<int>中的value即int类型
*/
//但是上面的写法有一个问题,如果传入的是原生指针怎么办?原生指针内部并没有value_type,此时需要用到模板偏特化
template<typename I>
struct traits<I*>
{
typedef I value_type;
}
int* iter;
function(iter);
//此时traits将推导出的版本为偏特化的版本
/*
所以对于一个迭代器而言,必须在其内部重命名多种类型,这是五个类型
*/
template<class I>
struct iterator_traits
{
value_type;
pointer;
reference;
difference_type;
iterator_category;
}
//同样对于原生指针也需要偏特化出相应的类型
什么是迭代器:
迭代器是一种泛型指针,其是将* -> ++ --等指针操作进行重载的类模板,所有的STL容器都有自己专属的迭代器,原生指针也是一种迭代器,迭代器是容器和算法之间的粘合器。
迭代器的类型:
- 输入迭代器:只读迭代器,在每个被遍历的位置上只能读取一次(例如find函数返回的迭代器只能被读一次就失效了)
- 输出迭代器:只写迭代器,在每个被遍历的位置上只能被写一次
- 前向迭代器:兼具输入和输出迭代器的能力,可以对同一个位置反复读写,但它不支持operator–,只能前进
- 双向迭代器:在前向迭代器基础上,可以前进也可以后退
- 随机迭代器:在双向迭代器基础上,具有迭代器算术能力,即可以一次进行/后退任意位置,包含指针的各种算术操作
迭代器失效问题
-
对于数组式容器vector/deque,当通过erase(iter)删除某个迭代器指向的元素时,其后所有的迭代器都会失效,因为删除一个元素会导致后面的元素前移,从而导致失效,此时的iter是一个野指针,不能再执行iter++,所以erase(iter++)是错误的,但是erase(iter)会返回删除后下一个有效的迭代器
//该删除操作会报错,删除后iter失效,不能再iter++ for (auto iter = vec.begin(); iter != vec.end();) { if (*iter % 5 == 0) vec.erase(iter); else iter++; } //可以用此方法来删除 for (auto iter = vec.begin(); iter != vec.end();) { if (*iter % 5 == 0) iter = vec.erase(iter);//返回删除后有效迭代器 else iter++; }
-
对于关联容器,当erase(iter)时,除了该结点外,其他元素上的迭代器不会失效,所以可以用erase(iter++)
//这样会报错,因为删除iter后,iter就失效了,成为了野指针 for(auto iter = m.begin();iter!=m.end();iter++) { if(iter->second%5==0) m.erase(iter); } //这样写是可行的,我们要首先搞清楚i++的原理 for(auto iter = m.begin();iter!=m.end();) { if(iter->second%5==0) m.erase(iter++); else iter++; } //以下为i++的原理 { int i=10; i++; //i++等价于 int tmp = i; i=i+1; return tmp; //即i++后其返回的是i自增前的一个临时变量 } /*所以m.erase(iter++)等价于*/ for(auto iter = m.begin();iter!=m.end();) { if(iter->second%5==0) { auto tmp =iter; iter++; m.erase(tmp);//这样当然可行了 } else iter++; }
迭代器失效问题总结:
所谓迭代器失效是指当对容器进行插入和删除时可能造成容器中元素的改变,从而导致迭代器所指向的内存发生变化,进而导致迭代器失效(迭代器失效后为一个野指针),erase和Insert会导致迭代器失效,删除元素时,正确的做法是返回删除操作时的那个迭代器
容器 | 插入 | 删除 |
---|---|---|
vector | 当不发生内存重分配时,只有插入点之后的迭代器会失效,否则全部失效 | 删除点后的迭代器全部失效 |
deque | 在中间插入元素导致所有迭代器失效,在首尾插入元素不会失效 | 在中间删除元素导致所有迭代器失效,在首尾删除元素只会到删除元素的迭代器失效 |
list | 所有迭代器都不会失效 | 只有被删除的那个元素的迭代器会失效 |
map/set | 所有迭代器都不会失效,关联容器每个元素是结点,插入只是结点指针指向发生改变,内存不会变 | 只有被删除的那个元素的迭代器会失效 |
空间配置器
allocator,空间配置器,其将内存的分配和元素的构造分离开,其主要用于对内存的分配和管理,空间配置器常见的作用是在编写容器时,为容器进行内存分配。allocator的行为类似于operator new/operator delete,但是实际上其复杂的多
为什么要引入空间配置器?
- 频繁使用malloc free会导致开辟释放小块内存带来性能效率的低下
- 频繁开辟和释放小内存会导致内存碎片问题,造成不连续小内存不可用的浪费
空间配置器具有两级配置器
-
一级配置器
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TYvdj6ua-1583848520284)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\1575703010372.png)]
在STL中,如果分配的内存大于128字节,则调用一级空间配置器向系统申请内存,一级空间配置器就是简单的malloc和free,但是一级配置器引入了C++ new-handler机制,如果申请内存失败,可以通过用户注册的oom_malloc()等函数来尝试释放其他内存,从而获取内存,但是这个函数STL默认是空,即如果用户不自己定义则会抛出异常,但是如果用户自定义的函数不够合理,则可能导致程序陷入死循环。
- 二级空间配置器
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJmbozyw-1583848520284)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\1575703313293.png)]
二级配置是由内存池+自由链表的形式组成,这样可以避免小块内存带来的碎片化
自由链表:用一个自由链表数组(类似于哈希桶)维护16个空闲自由链表,每个链表的结点内存大小都是8的倍数,16个链表分别从8-128.当申请内存小于128个字节时,则从自由链表中取走一个结点,释放内存时将内存还给自由链表
所谓内存池,不过是一块连续的内存罢了,通过指针来管理即可
那么如何通过二级配置器来获取内存?
- 当申请内存小于128个字节时,首先将内存需求上调至8的倍数,如果free_list中有可用的内存块,则直接拿来使用,否则调用refill()来填充free_list
- refill()调用ChunkAlloc()函数从内存池中取出相应的内存,填充自由链表,默认情况下会取出20个结点的内存来,但是一般会对应三种情况
- 足够开辟20块内存,则返回一块给用户,剩下的挂到自由链表上
- 如果不足二十个,但是还有至少一个,则返回一块给用户,剩下的挂到自由链表上
- 如果一个都不够了,则向系统申请空间,但是此时又可能出现问题
- 如果申请成功,则再次调用chunkAlloc()即可
- 否则查看free_list上有没有比需要的n大的空闲空间
- 如果有,则将这个内存块交给内存池,然后调用chunkAlloc()
- 如果不存在,则调用一级配置器
- 如果调用一级配置器成功,则再次调用ChunkAlloc()
- 否则抛出异常
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uBPeljrC-1583848520286)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\1575704546774.png)]
二级配置器也是有缺点的
- 二级配置器中,当内存释放时,如果释放的内存小于128字节,则并不是直接释放,而是挂在了自由链表上,等到程序结束时才是真正的释放,这样会导致该进程只要不结束,则一直占用这些内存,别的内存无法使用
- 二级配置器会造成新的内存碎片问题,即如果申请的内存不足8的倍数,则会被提高到8的倍数,将多的空间进行分配,从而造成内存浪费
- 如果用户频繁申请某个小的内存(例如8),则可能最后会导致堆中所有可用空间都挂到了8的自由链表上,此时如果申请一个16的内存,则会失败,所以设置一个内存释放函数也是有必要的(当一个自由链表上挂了太多的相同结点,可用考虑释放部分)
容器适配器
C++提供了三种容器适配器
stack/queue/priority_queue
所谓容器适配器,就是使用基础容器,但是使得该容器的行为像另一种类型,举个例子就是,我们需要模拟一个栈,但是普通的容器功能太多,所以我们通过容器适配器来将普通容器进行转换,精简其部分操作,使其表现得像一个栈,容器适配器中没有迭代器得概念
stack默认基于vector,因为stack主要要有push_back/pop_back/back操作,主要基于list/deque,很少用vector是因为扩容耗时
queue默认基于deque,因为queue要求pop_front/front等功能,但是也可以使用list,不过不能用vector,因为vector不支持pop_from
priority_queue基于deque/vector,不能使用list(因为要求底层是数组类型的容器,即必须支持随机访问,因为底部为堆排序),其元素有一个优先级,默认按照优先级从大到小排序,但是也可以修改其默认优先级
priority_queue使用方法如下:
priority_queue<int> q;
priority_queue<int,vector<int>,greater<int>> q;//升序
priority_queue<int,vector<int>,less<int>> q;//降序
//如果需要重新定义优先级的比较方法则
struct tmp2
{
bool operator()(pair<int,int>& a, pair<int,int>& b)
return a.second<b.second;
};
//type为队列的类型,tmp2为重写的比较函数,即可调用对象
priority_queue<int,vector<pair<int,int>>,tmp2> q;//升序
//只能用这种可调用对象,不能用仿函数等
STL的线程安全问题
首先声明STL中所有容器在使用时,对一个容器并发读,对多个不同容器并发读写都是线程安全的,但是对同一个容器并发读写则不是线程安全的。
以shared_ptr为例,shared_ptr底层实现包括一个原生指针T*和一个引用计数,C++11中对引用计数的操作是原子操作,即引用计数本身的操作是线程安全的,但是指针和引用计数这两个操作则不是线程安全的了。