一.概述
本文主要聚焦于STL容器,STL完整的容器分类体系如下所示,下文将逐一对各个容器底层的数据结构以及常见用法进行介绍。
测试环境:Ubuntu 22.04 g++ 11.3.0
二.顺序容器
顺序容器都对应着线性数据结构。
2.1 array
array的使用需要引入头文件<array>
,它表示固定大小的数组,与C风格的数组一致。在定义array时,需要指定类型T
和数组大小N
,即array<T,N>
,array创建及常见用法如下:
#include <iostream>
#include <array>
using namespace std;
int main()
{
array<int, 3> arr1;
// 对array填充特定的值
arr1.fill(5); // arr1: {5, 5, 5}
// 遍历数组方式一
for (int a : arr1) {
cout << a << " ";
}
cout << endl; // 5 5 5
//遍历数组方式二
for (auto it = arr1.begin(); it != arr1.end(); it++) {
cout << *it << " ";
}
cout << endl; // 5 5 5
// 定义时指定初值
array<int, 3> arr2 = {2, 5, 4};
// 交换两个数组的内容,前提是类型和数组大小都要相同
arr1.swap(arr2); // arr1: {2, 5, 4}, arr2: {5, 5, 5}
// 对数组进行排序
array<double, 2> arr3 = { 3.14, 2.56 };
// 获取数组的大小
cout << arr3.size() << endl; // 2
// 获取数组的第一个元素元素
cout << arr3.front() << endl; // 3.14
// 获取数组的最后一个元素
cout << arr3.back() << endl; // 2.56
// 获取数组的首地址
cout << arr3.data() << endl;
return 0;
}
2.2 vector
vector表示动态数组,同array一样,vector中的元素也是连续存储在内存中的,但vector的大小是可变的。在使用vector时需要引入头文件<vector>
,一般而言使用vector时仅需要指定数据类型T
即可。
需要注意的是,vector并不是每添加一个元素就分配一次新的存储空间,实际上,当vector分配的容量全部填满元素后再添加新元素时,vector的分配器会重新分配两倍容量的新空间,然后将元素挪过去。vector示意图如下:
图源自侯捷老师的《STL源码剖析》,侵权删。
注意:各家C++的实现可能略有区别,上述的重新分配规则不一定适用,但可以确定的是vector分配容量时一般都要未雨绸缪,即容量要比实际大小更大,否则需要重新分配。
vector的创建及常见用法示例如下:
#include <iostream>
#include <vector>
using namespace std;
int main()
{
// 创建空vector
vector<int> arr1;
// vector末端插入元素
arr1.push_back(1);
arr1.push_back(3);
arr1.push_back(5);
arr1.push_back(7);
arr1.push_back(9);
// 创建时指定初值
vector<int> arr2 = { 1,2,3 };
// 创建时指定大小和默认值
vector<int> arr3(3, 5); // arr3: {5, 5, 5}
// 遍历方式一
for (int a : arr1) {
cout << a << " ";
}
cout << endl; // 1 3 5 7 9
// 遍历方式二
for (auto it = arr2.begin(); it != arr2.end(); it++) {
cout << *it << " ";
}
cout << endl; // 1 2 3
// 索引vector元素
cout << arr1[1] << endl; // 3
// 获取vector的第一个和最后一个元素
cout << arr2.front() << " " << arr2.back() << endl; // 1 3
// 判断数组是否为空
cout << arr1.empty() << endl; // 0
// 获取数组的大小和实际容量
cout << arr1.size() << " " << arr1.capacity() << endl; // 5 8
// 测试是否2倍扩张容量
int n = 4;
for (int i = 0; i < n; i++)
arr1.push_back(i);
cout << arr1.size() << " " << arr1.capacity() << endl; // 9 16
// 清空vector的元素
arr1.clear();
// 插入元素,需要通过迭代器指定位置
arr3.insert(arr3.begin(), 0); // arr3: {0, 5, 5, 5}
// 删除vector末端的元素
arr3.pop_back(); // arr3: {0, 5, 5}
// 删除vector指定位置的元素,需要通过迭代器指定位置
arr3.erase(arr3.begin());
return 0;
}
注:对于vector,索引任意位置的元素的时间复杂度为 O ( 1 ) O(1) O(1),往末端插入或删除末端元素的时间复杂度为 O ( 1 ) O(1) O(1),往其它位置插入或删除其它位置元素的时间复杂度为 O ( n ) O(n) O(n)。
2.3 deque
deque表示双向队列,是一种双向开口的"连续"空间。要使用deque需要引入头文件<deque>
,并指定数据类型T
。
连续只是用户看起来,实际deque的存储并非连续的,而是由一系列单独分配的固定大小的数组组成,另外,deque中还需要记录模块(中控器)来维护记录各个段的信息。
与vector比起来,deque的扩展更廉价,因为它不涉及将现有元素复制到新的内存位置。deque的示意图如下所示,其中map便是中控器,其中每个元素都指向一段连续的空间,称之为缓冲区,缓冲器才是用来存储数据的。从下图可以看出,deque还需要维护start和finish两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素。此外,这两个迭代器还需要指向map,否则无法在不同的缓冲区间转移。
图源自stackoverflow: What really is a deque in STL?
deque的创建以及常见用法示例如下:
#include <iostream>
#include <deque>
using namespace std;
int main()
{
deque<int> d = {1, 3, 5, 8, 10};
//在deque开头插入元素
d.push_front(0); // d: {0,1,3,5,8,10}
// 删除deque开头的元素
d.pop_front(); // d: {1,3,5,8,10}
// 在deque结尾插入元素
d.push_back(7); // d: {1,3,5,8,10,7}
//删除deque结尾处的元素
d.pop_back(); // d: {1,3,5,8,10}
// 在指定位置插入元素,会返回一个指向插入元素的迭代器
auto it = d.begin();
it++;
it = d.insert(it, 11); // d: {1,11,3,5,8,10}
for (; it != d.end();it++)
{
cout << *it << " ";
}
cout << endl; // {11,3,5,8,10}
// 删除指定范围的元素
d.erase(d.begin() + 2, d.begin() + 5); // {1,11,10}
//获取deque开头和结尾的元素
cout << d.front() << " " << d.back() << endl; // 1 10
// 获取deque的大小
cout << d.size() << endl; // 3
return 0;
}
注:deque在开头和结尾可以进行元素的快速插入和删除。
2.4 list
list表示环形双向链表,链表最大的特点的大小可变且存储非连续。要使用list需要引入头文件<list>
,创建对象时需要传入数据类型T
。
STL中list节点结构示意图如下图所示,其中包含两个指针prev
和next
分别指向当前节点的前一个和后一个节点,data
存储当前节点的数据。
图源自侯捷老师的《STL源码剖析》,侵权删。
基于节点的结构,list的可视化示意图如下所示,可以看到,由于环状要求,还需要一个空白节点(红框标记)。
list的创建与常见用法如下所示:
#include <iostream>
#include <list>
#include <algorithm>
using namespace std;
int main()
{
// 创建list并初始化
list<int> l = {3, 5, 7, 9};
// 在list前面添加一个数值为1的节点
l.push_front(1);
// 在list后面添加一个数值为11的节点
l.push_back(11);
// 遍历链表
for(int a:l){
cout << a << " ";
}
cout << endl; // 1 3 5 7 9 11
// 删除链表头的元素
l.pop_front(); // l: {3,5,7,9,11}
// 删除链表尾的元素
l.pop_back(); // l:{3,5,7,9}
// 寻找链表中大于6的第一个元素
auto it = upper_bound(l.begin(), l.end(), 6);
// 链表中插入元素
if(it != l.end())
l.insert(it, 6); // l: {3,5,6,7,9}
// 删除满足某些特殊条件的元素
l.remove_if([](const int x)
{ return x < 5;
}); // l: {5,6,7,9}
// 删除值等于某个特定值的元素
l.remove(7); // l:{5,6,9}
list<int> l1 = {3, 1, 7, 10};
// 对链表进行排序
l1.sort(); // l1: {1,3,7,10}
// 归并两个有序链表
l.merge(l1); // l:{1,3,5,6,7,9,10}
//对链表进行逆序
l.reverse(); // l:{10,9,7,6,5,3,1}
// 获取链表的大小
cout << l.size() << endl; // 7
// 获取链表头的元素
cout << l.front() << endl; // 10
// 获取链表尾的元素
cout << l.back() << endl; // 1
return 0;
}
注:list插入和和删除元素的时间复杂度为 O ( 1 ) O(1) O(1)。
2.5 forward_list
foward_list为单向链表,其行为与list类似,只不过只能单向访问。要使用forward_list需要引入头文件<forward_list>
,创建对象时需要指定数据类型参数T
。
forward_list的示意图如下所示:
forward_list的创建与常见用法如下所示:
#include <iostream>
#include <forward_list>
using namespace std;
int main()
{
// 创建对象并初始化
forward_list<int> l = {2, 4, 6, 8};
// 在链表开头插入一个元素
l.push_front(0); // l {0,2,4,6,8}
// 删除链表头的元素
l.pop_front(); // {2,4,6,8}
// 在链表中指定位置后插入元素,位置通过迭代器来指定
auto it = l.begin();
it++;
l.insert_after(it, 10); // {2,4,10,6,8}
return 0;
}
forward_list的好多操作与list类似,这里不做重复赘述。
三.关联容器
关联容器底层的数据结构是红黑树。
3.1 红黑树
红黑树是一种平衡二叉搜索树,其示意图如下所示:
红黑树需要满足如下规则:
- 每个节点不是红色就是黑色。
- 根节点需为黑色。
- 红节点的子节点不能为红色。
- 任一节点至NULL(树尾端)的任何路径所包含的黑色节点数须相同。
当新节插入时,若未符合上述规则,则需要调整颜色并旋转树形,使得其仍然是保持上述性质的平衡二叉搜索树。
红黑树中节点是有序的,即每个节点的左子树中各个节点的值都小于根节点,右子树中所有节点的值都大于根节点。
红黑树的插入、删除和查找操作的时间复杂度都是 O ( log N ) O(\text{log}N) O(logN)。
限于篇幅,本文不对红黑树进行展开讲解,后续将会写一篇专门的博客来介绍红黑树。
3.2 容器说明
对于set、multiset、map、multimap四种容器,其底层的数据结构都是红黑树,其中:
- set表示集合,其中包含一系列无重复的键(key)。set的使用需要引入头文件
<set>
,另外还需要指定键的数据类型T
和排序准则Compare
,注意后者不是必须的,只有当需要自定义键的排序准则时才需要显式指定。 - multiset与set类似,但其可以包含重复的键。
- map表示字典,即map中每个元素是一个键唯一的键值对。map的使用需要引入头文件
<map>
,另外需要指定键(key)和值(value)的数据类型Key
和T
,此外其同样可以指定排序准则Compare
。 - multimap与map类似,但其允许键值对的键重复。
上述四种容器中,set和map用的比较多,下面仅介绍两者的用法。
set的创建和常见用法示例如下:
#include <iostream>
#include <set>
using namespace std;
int main()
{
// 创建并初始化,注意set会自动去重
set<int> s = {1, 3, 3, 5, 5};
cout << s.size() << endl; // 3
// 插入元素
s.insert(7); // s: {1,3,5,7}
// 删除指定位置的元素
s.erase(s.begin()); // s: {3,5,7}
// 删除特定的键
s.erase(7); // s:{3,5}
// 寻找集合中特定的key的数量(非1即0)
cout << s.count(3) << " " << s.count(7) << endl; // 1 0
s.insert(8);
s.insert(0);
s.insert(4); // s: {0,3,4,5,8}
// 返回第一个不小于给定键的元素的迭代器
auto it = s.lower_bound(6);
if(it != s.end())
cout << *it << endl; // 8
// 返回第一个大于给定键的元素的迭代器
it = s.upper_bound(2);
if(it !=s.end())
cout << *it << endl; // 3
return 0;
}
map的创建和常见用法示例如下:
#include <iostream>
#include <map>
using namespace std;
int main()
{
// 创建并初始化
map<string, int> wc = {{"tom", 18}, {"andy", 12}};
// 使用[]可以获取特定key对应的元素
cout << wc["tom"] << endl; // 18
// 使用[]时当不存在指定的key时,则会插入给定键的节点,若未指定值则值会设置为Value的默认值
wc["jake"]; // {{"andy", 12}, {"jake":0}, {"tom", 18}}
// 获取map中元素的个数
cout << wc.size() << endl; // 3
// 插入的另一种方式
wc.insert({"smith", 22});
auto it = wc.find("sala");
if(it == wc.end()) // not find
cout << "not find" << endl;
else
cout << "find" << endl;
return 0;
}
注:map中存在很多查找操作与set类似,故没有重复介绍。
四.无序关联容器
无序关联容器底层的数据结构是哈希表。
4.1 哈希表
哈希表(散列表)在插入、删除、查找等操作上具有平均复杂度为 O ( 1 ) O(1) O(1),其数据结构示意图如下:
图源:hast-table
假定数组有 m m m个桶(bucket),哈希表通过哈希函数(hash function)计算key映射到索引(哈希代码),然后将元素放置到索引对应的桶。
但使用哈希函数可以回带来一个问题,哈希碰撞,即不同的元素被映射到相同的位置。目前解决哈希碰撞最常见的方法为开链(separate chaining),即数组每个桶都维护一个list,通过哈希函数可以为key分配一个桶,然后可以在桶对应list上进行相应的插入、删除和查找操作,若list足够短,速度仍然是非常快的。
限于篇幅,后面写博客详细介绍。
4.2 容器说明
unordered_set、unordered_multiset、unordered_map和unordered_multimap的底层数据结构都是哈希表,其中
- unordered_set:包含一组唯一的键(key),使用需要引用
<unordered_set>
,并指定键的数据类型。 - unordered_multiset:与unordered_set类似,但键可以重复。使用需要引用
<unordered_multiset>
。 - unordered_map:包含一组键(key)唯一的键值对,引用需要导入
<unordered_map>
,并指定键和值的数据类型。 - unordered_multimap:与unordered_map类似,但键可以重复。使用需要引用
<unordered_multimap>
。
注意:上述容器还可以指定哈希函数Hash
和判断键是否相等的函数KeyEqual
。
上述四种容器中,unordered_set和unordered_map用的比较多,下面仅介绍两者的用法。
unordered_set的创建和常见用法如下所示:
#include <iostream>
#include <unordered_set>
using namespace std;
int main()
{
// 创建并初始化
unordered_set<int> us = {3, 3, 4, 4, 7, 9, 1};
// 获取元素个数
cout << us.size() << endl; // 5
// 获取哈希表的桶个数
cout << us.bucket_count() << endl; // 13
// 判断容器是否为空
cout << us.empty() << endl; // 0
// 返回特定桶的元素数
cout << us.bucket_size(0) << endl; // 0
// 返回每个桶的平均元素数
cout << us.load_factor() << endl; // 0.384615
// 往哈希表中插入元素
us.insert(10); // us: {}
auto it = us.find(9);
if(it != us.end()) // find key!!!
cout << "find key!!!" << endl;
else
cout << "not find" << endl;
unordered_set<int> us1 = {11, 8, 9};
us.merge(us1); // us:{11,8,10,1,9,7,4,3}
return 0;
}
unordered_map的创建和常见用法如下所示:
#include <iostream>
#include <unordered_map>
using namespace std;
int main()
{
unordered_map<string, double> grades = {{"math", 85}, {"chinese", 77}};
// 插入特定的键与值
grades["english"] = 90;
// 插入特定的元素
grades.insert({"biology", 85.5});
// 获取特定键对应的值
cout << grades["math"] << endl; // 85
// 返回特定键的元素个数,非1即0
cout << grades.count("phyics") << " " << grades.count("math") << endl; // 0 1
return 0;
}
unordered_map的好多用法与unordered_set类似,故不重复赘述。
五.容器适配器
容器适配器是依靠其底层容器来完成特定数据结构的功能。
5.1 堆栈
stack是一种先进后出的数据结构,其示意图如下所示:
图源自侯捷老师的《STL源码剖析》,侵权删。
可以看出stack只允许在栈顶进行元素的插入(push)、删除(pop)和获取。(stack不允许遍历行为)
STL中要使用stack,需要引入头文件<stack>
,并指定栈内数据类型T
,stack的默认底层容器为deque
。由于deque是双向队列,因此只要将deque的底部结构封闭,便能形成一个栈。
stack的创建以及常见用法为:
#include <iostream>
#include <stack>
using namespace std;
int main()
{
stack<int> s;
// 往栈中插入元素
s.push(1);
s.push(3);
s.push(4);
s.push(9);
s.push(10);
// 获取栈中元素的个数
cout << s.size() << endl; // 5
// 通过empty()来判断栈是否为空
while(!s.empty()) //
{
// 获取栈顶的元素
cout << s.top() << " ";
// 弹出栈顶的元素
s.pop();
}
cout << endl; // 10 9 4 3 1
return 0;
}
5.2 队列
queue是一种先进先出的数据结构,其示意图为:
图源自侯捷老师的《STL源码剖析》,侵权删。
可以看出,queue允许从队首获取元素,从对尾插入元素。(queue不允许遍历行为)
要使用queue,需要引入头文件<queue>
,并指定队列元素的数据类型T
,queue的默认底层容器同样为deque
。对于deque,只要封闭其底端的出口和首端的入口,便能轻易形成一个queue。
queue的创建及常见用法如下:
#include <iostream>
#include <queue>
using namespace std;
int main()
{
queue<int> q;
// 往队尾插入元素
q.push(3);
q.push(5);
q.push(4);
q.push(8);
q.push(1);
// 获取队列的大小
cout << q.size() << endl; // 5
// 通过empty判断队列是否为空
while(!q.empty()){
// 获取队首的元素
cout << q.front() << " ";
// 弹出队首的元素
q.pop();
}
cout << endl; // 3 5 4 8 1
return 0;
}
5.3 优先级队列
priority_queue表示具有元素优先级的队列,其示意图如下:
图源自侯捷老师的《STL源码剖析》,侵权删。
priority_queue的行为与queue类似,都是只能从队尾加入元素,从队首获取元素。另外,由于priority_queue中元素带有优先级,因此其内的元素并非依次按照入队的次序排列,而是按照元素的优先级权值来进行排序,权值高的排在前面。
要使用priority_queue需要引用头文件<queue>
,并指定元素类型<T>
。priority_queue的底层是利用一个最大堆(max-heap)完成的,而max-heap是一个以vector表现的完全二叉树。使用最大堆,可以满足优先级队列中权值高的排在前面的特性。
注意,priority_queue还可以指定权值大小的比较方法Compare
。
最大堆中根节点的值都不小于其孩子节点的值。
priority_queue的创建及常见用法如下:
#include <iostream>
#include <queue>
using namespace std;
int main()
{
priority_queue<int> pq;
int n = 10;
for (int i = 0;i<n;i++){
// 往优先级队列中插入元素
pq.push(rand() % 20);
}
// 获取队列大小
cout << pq.size() << endl; // 10
// 通过empty判断队列是否为空
while(!pq.empty())
{
// 获取队首元素
cout << pq.top() << " ";
// 弹出队首元素
pq.pop();
}
cout << endl; // 17 15 15 13 12 9 6 6 3 1
return 0;
}
六.参考资料
本文的完成参考了如下资料:
- cppreference.com
- cplusplus.com
- 《STL源码剖析》侯捷著
以上便是本文的全部内容,要是觉得不错可以关注或点赞支持一下,有任何问题也敬请批评指正!!!。