C++STL
STL,全称standard template library,标准模板库,其包含有大量的模板类和模板函数,是 C++ 提供的一个基础模板的集合,STL 基本上达到了各种存储方法和相关算法的高度优化。
三个最为普遍的STL版本:
-
HP STL
其他版本的C++ STL,一般是以HP STL为蓝本实现出来的,HP STL是C++ STL的第一个实现版本,而且开放源代码。
-
P.J.Plauger STL
由P.J.Plauger参照HP STL实现出来的,被Visual C++编译器所采用,不是开源的。
-
SGI STL
由Silicon Graphics Computer Systems公司参照HP STL实现,被Linux的C++编译器GCC所采用,SGI STL是开源软件,源码可读性甚高。
我们一般使用的STL也是SGI STL
源码:https://blog.csdn.net/jmw1407/category_10122107.html
STL的六大部件和联系
- 容器(containers)
- 算法(algorithm)
- 迭代器 (iterator)
- 适配器 (adapter)
- 分配器 (allocator)
- 仿函数 (functor)
容器通过分配器获取数据存储空间,算法通过迭代器存取容器数据,仿函数协助算法完成不同策略,适配器修饰仿函数
容器:各种数据结构,来存放数据,从实现角度来看是一种类模板;
算法:各种算法的封装,从实现角度来看是一种函数模板;
迭代器:是容器与算法之间的胶合剂,即泛型指针;
仿函数:一般函数指针可视为狭义的仿函数;
空间配置器:负责空间的配置与管理;
适配器(配接器):用来修饰容器或仿函数的迭代器接口。通俗的讲,就是像改锥的接口,装上不同的适配器,才能装上不同的改锥头。
容器(containers)
当程序中存在对时耗要求很高的部分时,数据结构的选择就显得尤为重要,有时甚至直接影响程序执行的成败。
为了重复利用已有的实现好的数据结构,引入了STL内部封装好的数据结构-容器,需要包含各自头文件
STL的容器主要利用了C++的模板特性来实现。需要注意:
- 容器缓存了节点,节点类要确保支持拷贝(否则出现浅拷贝问题,导致崩溃)
- 容器中的一般节点类,需要提供拷贝构造函数,并重载等号操作符(用来赋值)
- 容器在插入元素时,会自动进行元素的拷贝。
常用成员函数
- begin:返回指向容器中第一个元素的迭代器
- end:返回指向最后一个元素所在位置后一个位置的迭代器
- size:返回已经保存的元素数目
- capacity:返回当前容器容量,capacity永远不可能小于size,只要小于,就增加一半并向下取整,值得注意的是,至少增加1
- max_size:返回容器最大支持容量,这个数是一个很大的常整数,可以理解为无穷大。这个数目与平台和实现相关
- empty:判断容器是否为空
- reserve:修改的是capacity的大小,无法调整为比size小的数(调整比原来size小视为无效操作)
- resize:修改的是size的大小,未赋值的默认为0,
- 可以调整为比capacity大的数,此时会进行重新配置
- 缩小和扩大容器时,可能导致迭代器,指针,引用失效
容器有些可以使用下标访问,使用方式如数组,但可能发生访问越界,使用at则会检查
容器的大小size一旦超过capacity的大小,vector会重新配置内部的存储器,导致和vector元素相关的所有reference、pointers、iterator都会失效
内存的重新配置耗时较多,遵循:不到万不得已,不要重新分配内存原则。可通过Reserve()保留适当容量或者创建容器时提供足够空间
std::vector<int> v; //1.这是空的vector
std::vector<int> v(5); //2.这是一个capacity为5,size为5,全默认为0的容器
std::vector<int> v{0,0,0,0,0} //3.等价于2
vector<int> v(5,1); //4.这是一个capacity为5,size为5,全为1的容器
序列化容器
共同的特点是不会对存储的元素进行排序,元素排列的顺序取决于存储它们的顺序。
array、vector、deque、list 和 forward_list
容器 | 特性 | 底层实现 |
---|---|---|
array | 固定大小数组 | 数组 |
vector | 可变大小数组1.支持快速随机访问 2.顺序存储 3.迭代器失效(插入,删除,扩容时) | 数组 |
List | 支持快速增删 | 双向链表 |
forward_list | 只支持单向顺序访问 | 单向链表 |
queue | 插入只可以在尾部进行,删除、检索和修改只允许从头部进行。按照先进先出的原则。 | 默认deque |
stack | 操作的项是最近插入序列的项,按照后进先出的原则 | 默认deque |
deque | 支持首尾(中间不能)快速增删,也支持随机访问,底层数据结构为一个中央控制器和多个缓冲区 | 双向队列 |
通常,使用vector是最好的选择
vector
底层结构
vector占用一块连续分配的内存,一种可以存储任意类型的动态数组。使用 3 个迭代器(指针):
Myfirst 指向的是 vector 容器对象的起始字节位置;Mylast 指向当前最后一个元素的末尾字节;_myend 指向整个 vector 容器所占用内存空间的末尾字节。
扩容的本质
另外需要指明的是,当 vector 的大小和容量相等(size==capacity)也就是满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器扩容的过程需要经历以下 3 步:
- 完全弃用现有的内存空间,重新申请更大的内存空间;
- 将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
- 最后将旧的内存空间释放。
遍历
[]
方式,如果越界或出现其他错误,不会抛出异常,可能会崩溃,可能数据随机出现at
方式,如果越界或出现其他错误,会抛出异常,需要捕获异常并处理
常见操作
-
插入:
insert
函数,结合迭代器位置插入指定的元素。如果迭代器位置越界,会抛出异常。- emplace() 是C++11 标准新增加的成员函数,用于在 vector 容器指定位置之前插入一个新的元素。
- 不同点
- insert可以一次插入多个,但emplace只能一次插入一个
- emplace() 在插入元素时,是在容器的指定位置直接构造元素,效率更高
- 通过 insert() 函数向 vector 容器中插入 testDemo 类对象,需要调用类的构造函数和移动构造函数;先单独生成,再将其复制(或移动)到容器中
- 当拷贝构造函数和移动构造函数同时存在时,insert() 会优先调用移动构造函数。
-
删除
-
erase(iterator)
函数,用迭代器指定某个位置或区域删除;删除后会返回当前迭代器的下一个位置。 -
clear():删除 vector 容器中所有的元素,使其变成空的 vector 容器。
- 该函数会改变 vector 的size=0,但其容量capacity仍然保存不变
- 当存储元素是object时,会调用析构函数;当存储元素是指针时,并不会调用这些指针所指对象析构函数(所以指针注意自己delete)
- 如果你想同时做到清空vector的元素和释放vector的容量, 你可以使用swap
- 使vector离开其自身的作用域,从而强制释放vector所占的内存空间,总而言之,释放vector内存最简单的方法是
vector<int>.swap(v1)
-
-
remove() 的实现原理是,在遍历容器中的元素时,一旦遇到目标元素,就做上标记,然后继续遍历,直到找到一个非目标元素,即用此元素将最先做标记的位置覆盖掉,同时将此非目标元素所在的位置也做上标记,等待找到新的非目标元素将其覆盖。
-
vector<int>demo{ 1,3,3,4,3,5 }; //交换要删除元素和最后一个元素的位置 auto iter = std::remove(demo.begin(), demo.end(), 3);
- 在对容器执行完 remove() 函数之后,由于该函数**并没有改变容器原来的大小和容量**,因此无法使用之前的方法遍历容器,而是需要向程序中那样,借助 remove() 返回的迭代器完成正确的遍历。 - 因此,如果将上面程序中 demo 容器的元素全部输出,得到的结果为 `1 4 5 4 3 5`。
-
迭代器失效
迭代器失效是对容器insert ,push_back,erase操作不当引起的。
erase分为两种情况
第一种情况:
使用erase删除某一个结点之后,vector迭代器虽然还是指向当前位置,而且也引起了元素前挪,但是由于删除结点的迭代器就已经失效,指向删除点后面的元素的迭代器也全部失效,
所以不能对当前迭代器进行任何操作;需要对迭代器重新赋值或者接收erase它的返回值;(即当前迭代器失效,需重新赋值)
第二种情况:erase返回擦除的当前点的下一个位置,越界了。
push_back造成扩容后迭代器完全失效,需从头开始
list
底层双向链表实现的,因此内存空间是不连续的。只能通过指针访问数据
distance
函数可以求出当前的迭代器指针it距离头部的位置,也就是容器的指针- 用法:
distance(v1.begin(), it)
- 用法:
- 删除
erase
是通过位置或者区间来删除,主要结合迭代器指针来操作remove
是通过值来删除
deque
非连续存储结构,存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。
提供了两级数组结构, 第一级类似vector的动态数组(map),存储指针,指向那些真正用来存储数据的各个连续空间,;另一级连续存放实际元素。扩容与vector类似
迭代器实现
- 迭代器在遍历 deque 容器时,必须能够确认各个连续空间在 map 数组中的位置;
- 迭代器在遍历某个具体的连续空间时,必须能够判断自己是否已经处于空间的边缘位置。如果是,则一旦前进或者后退,就需要跳跃到上一个或者下一个连续空间中。
template<class T,...>
struct __deque_iterator{
...
T* cur;
T* first;
T* last;
map_pointer node;//map_pointer 等价于 T**
}
deque相当于vector与list的折中
- 如果你需要高效的随即存取,而不在乎插入和删除的效率,使用vector
- 如果你需要大量的插入和删除,而不关心随机存取,则应使用list
- 如果你需要随机存取,而且关心两端数据的插入和删除,则应使用deque
关联式容器
STL为什么选择红黑树而不是AVL-tree?
红黑树的平衡性是比AVL-tree弱的,但是搜索效率几乎相等。两者的插入和删除操作都是O(logn),但是就旋转操作而言,AVL-tree是O(n),而红黑树是O(1),任何不平衡都会在三次旋转之内解决
- 红黑树适合查找
- 哈希表适合增删,增删时间复杂度接近O(1)
根据红黑树的原理,可以实现O(lgn)的查找,插入和删除。
容器 | 特性 | 底层结构 |
---|---|---|
set | 存储的关键字就是value,有序,不重复 | 红黑树 |
multiset | 存储的关键字就是value,有序,可重复 | 红黑树 |
map | 存储的是 |
红黑树 |
multimap | 存储的是 |
红黑树 |
map与set的insert
insert返回的是pair<iterator, bool>
类型,pair的第一个属性表示当前插入的迭代器的位置,第二个属性表示插入是否成功的bool值。
map
只有map有[]操作符
当key在map中存在时,[]完成的是读操作。当key不存在是,[]完成一个写操作。写入一个新的pair<key,T()>。
为啥有unordered_map,还需要map
- log(n)不一定比常数大
- 哈希表是以空间换时间,需要内存消耗;且hashtable创建需要时间
- 如果考虑效率,特别当元素达到一定数量级时,用hash_map。
- 考虑内存,或者元素数量较少时,用map。
hashtable如何避免地址冲突?
1)线性探测:先用hash function计算某个元素的插入位置,如果该位置的空间已被占用,则继续往下寻找,知道找到一个可用空间为止。
其删除采用惰性删除:只标记删除记号,实际删除操作等到表格重新整理时再进行。
2)二次探测:如果计算出的位置为H且被占用,则依次尝试H+12,H+22等(解决线性探测中主集团问题)。
3)链表:每一个表格元素中维护一个list,hash function为我们分配一个list,然后在那个list执行插入、删除等操作。
总结
vector | deque | list | set | mutliset | map | multimap | |
---|---|---|---|---|---|---|---|
内存结构 | 单端数组 | 双端数组 | 双向链表 | 红黑树 | 红黑树 | 红黑树 | 红黑树 |
随机存取 | 是 | 是 | 否 | 否 | 否 | 对key而言是 | 否 |
查找速度 | 慢 | 慢 | 非常慢 | 快 | 快 | 对key而言快 | 对key而言快 |
插入删除 | 尾端 | 头尾两端 | 任何位置 | - | - | - | - |
deque可以看成vector和list的折中,随机存取比vector慢,
无序关联容器
又叫哈希容器,底层结构是hash table
在已提供有 4 种关联式容器的基础上,又新增了各自的“unordered”版本(无序版本、哈希版本),提高了查找指定元素的效率。
即unordered_map、unordered_multimap、unordered_set 以及 unordered_multiset。
STL中的hashtable使用的是开链法解决hash冲突问题
集合 | 底层实现 | 是否有序 | 数值是否可重复 | 能否修改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
set | 红黑树 | 有序 | 否 | 否 | O(logn) | O(logn) |
multiset | 红黑树 | 有序 | 是 | 否 | O(logn) | O(logn) |
unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
映射 | 底层实现 | 是否有序 | 数值是否可重复 | 能否修改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
map | 红黑树 | key有序 | key不可重复 | key不可修改 | O(logn) | O(logn) |
multimap | 红黑树 | key有序 | key可重复 | key不可修改 | O(logn) | O(logn) |
unordered_map | 哈希表 | key有序 | key不可重复 | key不可修改 | O(1) | O(1) |
算法(algorithm)
算法部分主要由头文件
其中常用到的功能范围涉及到比较、交换、查找、遍历操作、复制、修改、移除、反转、排序、合并等等
STL中算法大致分为四类:
1)非可变序列算法:指不直接修改其所操作的容器内容的算法。
2)可变序列算法:指可以修改它们所操作的容器内容的算法。
3)排序算法:对序列进行排序和合并的算法、搜索算法以及有序序列上的集合操作。
4)数值算法:对容器内容进行数值计算。
对集合的查找,最好不要用通用函数find(),它对集合使用的时候,性能非常的差,最好用集合自带的find()函数,它针对了集合进行了优化,性能非常的高。
sort算法要求随机访问迭代器,包括array,deque,string,vector
迭代器 (iterator)
尽管不同容器的内部结构各异,但它们本质上都是用来存储大量数据的,所以对不同容器数据进行遍历的操作方法应该是类似的。
可以利用泛型技术,将它们设计成适用所有容器的通用算法,从而将容器和算法分离开,并且不需暴露该对象的内部表示。
因此引入中介-迭代器,泛型指针,是一种智能指针,重载了*,++,==,!=,=运算符。
标准库为每一种标准容器定义了一种迭代器类型,容器的迭代器的功能强弱,决定了该容器是否支持 STL 中的某种算法。
基础迭代器分为
迭代器名 | 功能 | 应用 |
---|---|---|
输入迭代器 | 只读不写,单遍扫描,只能递增 | istream |
输出迭代器 | 只写不读,单遍扫描,只能递增 | ostream,inserter |
前向迭代器 | 可读写,多遍扫描,只能递增 | forward_list |
双向迭代器 | 可读写,多遍扫描,可递增递减 | list,set,multiset,map,multimap |
随机访问迭代器 | 随机读写 | vector,deque,array,string |
//创建vector
vector<int> v1;
//遍历-迭代器遍历
for (vector<int>::iterator it = v1.begin(); it != v1.end(); it++) {
cout << *it << " ";
}
cout << endl;
//遍历-迭代器逆向遍历
for (vector<int>::reverse_iterator it = v1.rbegin(); it != v1.rend(); it++) {
cout << *it << " ";
}
cout << endl;
适配器(adapter)
一种用来修饰容器(container)或仿函数(functor)或迭代器(iterator)接口的东西。
-
改变functor接口者,称为functor adapter,
-
bind
-
改变container接口者,称为container adapter
-
封装了序列容器的类模板,它在一般序列容器的基础上提供了一些不同的功能。
-
底层可以更换不同容器
//可以自定义底层容器 std::stack<int, std::vector<int> > third; // 使用vector为底层容器的栈
-
分类
- stack 栈适配器,默认deque
- queue 队列适配器,默认deque
- push(x) -- 将一个元素放入队列的尾部。
- pop() -- 从队列首部移除元素。
- peek() -- 返回队列首部的元素。
- empty() -- 返回队列是否为空。
- priority_queue 优先权队列适配器,默认vector容器,堆存储结构
- top 访问队头元素
- empty 队列是否为空
- size 返回队列内元素个数
- push 插入元素到队尾 (并排序)
- emplace 原地构造一个元素并插入队列
- pop 弹出队头元素
- swap 交换内容
#include<queue>//头文件 priority_queue<Type, Container, Functional>//定义 //对于基础类型 默认是大顶堆 priority_queue<int> a; //等同于 priority_queue<int, vector<int>, less<int> > a; priority_queue<int, vector<int>, greater<int> > c; //这样就是小顶堆 //STL中的仿函数less<T>和greater<T>的使用范围仅限于基本类型,当优先队列需要保存我们自定义的数据类型时, //我们需要重载小于号(operator <)或者重写仿函数 //注意:小顶堆>,大顶堆< //方法1:重写仿函数 struct mycomparison { bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) { return lhs.second > rhs.second;//小顶堆 } }; //方法2:类运算符重载<,就无需更换仿函数 struct tmp1 { int x; tmp1(int a) {x = a;} bool operator<(const tmp1& a) const { return x < a.x; //大顶堆 } }; priority_queue<pair<int,int>,vector<pair<int,int>>,mycomparison>pri_que; priority_queue<tmp1>pri_que2;
-
-
改变iterator接口者,称为iterator adapter。
- 迭代器适配器模板类的内部实现,是通过对以上 5 种基础迭代器拥有的成员方法进行整合、修改,甚至为了实现某些功能还会添加一些新的成员方法。
- 分类
- 反向迭代器(reverse_iterator)
- 插入型迭代器(insert_iterator)
- 流迭代器(istream_iterator / ostream_iterator)流缓冲区迭代器(istreambuf_iterator / ostreambuf_iterator)
- 移动迭代器(move_iterator)
分配器(allocator)
负责空间配置与管理。是一个实现了动态空间配置、空间管理、空间释放的class template。
STL的内存配置器在我们的实际应用中几乎不用涉及,它只是在背后默默为各种容器做了大量工作,为容器分配并管理内存,实现统一内存管理
SGI-STL的空间配置器有2种,默认的空间配置器是第二级的配置器。
- 仅仅对c语言的malloc和free进行了简单的封装,
- 设计到小块内存的管理等,运用了内存池技术
alloc
SGI使用时std::alloc作为默认的配置器
- alloc把内存配置和对象构造的操作分开,分别由alloc::allocate()和::construct()负责
- 内存释放和对象析够操作也被分开分别由alloc::deallocate()和::destroy()负责
这样可以保证高效,因为对于内存分配释放和构造析构可以根据具体类型(type traits)进行优化。
内存分配
STL内存分配分为两级
- 当申请的内存大于128byte时,就启动第一级分配器,通过malloc直接向系统的堆空间分配
- 当申请的内存小于128byte时,就启动第二级分配器,从一个预先分配好的内存池中取一块内存交付给用户,这个内存池由16个不同大小(8的倍数,8~128byte)的空闲列表free_list组成,allocator会根据申请内存的大小(将这个大小round up成8的倍数)从对应的空闲块列表取表头块给用户。
8~128byte,刚好到128个字节,这就是为什么大于128bytes要使用第一级配置器了,因为,第二级配置器最多只能分配128bytes,
原因
- 小对象的快速分配。小对象是从内存池分配的,这个内存池是系统调用一次malloc分配一块足够大的区域给程序备用,当内存池耗尽时再向系统申请一块新的区域,从而更快分配小对象内存
- 避免了内存碎片的生成。程序中的小对象的分配极易造成内存碎片,给操作系统的内存管理带来了很大压力,系统中碎片的增多不但会影响内存分配的速度,而且会极大地降低内存的利用率。
仿函数(functor)
又叫做函数对象,可以实现类似于函数一样的对象类型,函数最直接的调用形式就是:返回值 函数名(参数列表),仿函数实现了operator()操作符,使用类似于函数
一般函数指针、回调函数可视为狭义的仿函数。以操作数的个数划分,可分为一元和二元仿函数
为什么要使用仿函数呢?
1).仿函数比一般的函数灵活,且可以实现代码复用,又不必维护公共变量
2).仿函数有类型识别,可以作为模板参数。
3).执行速度上仿函数比函数和指针要更快的。
class less
{
bool operator()(int a,int b){
return a<b?;
}
};
int main()
{
int a=1,b=2;
bool isLess = less (a,b);
cout <<isLess<< '\n';
return 0;
}
如果要自定义仿函数,并且用于STL接配器,那么一定要从binary_function或者,unary_function继承。
其实binary_function只是做一些类型声明而已,但为了方便,安全,可复用性