15 Standard Library Containers
概述
C++ 标准库提供很多容器。顺序容器有 std::vector
std::deque
std::list
std::forward_list
std::array
;顺序视图 std::span
std::mdspan
;容器适配器有 std::queue
std::priority_queue
stack
;关联容器有三类,有序的、无序的和平铺的,分别是 std::map
std::multimap
std::set
std::multiset
std::unordered_map
std::unordered_multimap
std::unordered_set
std::unordered_multiset
std::flat_map
std::flat_multimap
std::flat_set
std::flat_multiset
。另外某种程度上 std::string
和流也能作为容器使用,std::bitset
存储固定个数的 bits。
容器是值语义,因此放进去的类型要支持拷贝,请求访问容器内的元素,返回的是拷贝的引用。如果想保存引用,可以这样声明 std::vector<std::reference_wrapper<T>>
,可以使用 std::ref
std::cref
得到对象的引用。只能移动的元素也可以放到容器中,此时有些操作会导致无法编译。模板支持自定义分配器,但是有默认值,因此大部分场景无需关心。对于关联容器,可以自定义比较,也有默认值。标准库容器内部会经常使用移动或者拷贝,因此要放入容器的类型最好实现移动语义。容器析构的时候,会依次析构存储的元素。
标准库容器只做了相当有限的错误检查,使用的时候需要操作确保有效。
顺序容器
std::vector
<vector>
头文件提供的 std::vector
是最常用的容器,本质是动态数组。最常用的函数有 operator[]
返回元素的引用,如果 std::vector
是 const
的,那么 operator[]
返回的是 const
引用,无法赋值;at()
类似,不过会检查传入的索引是否有效,无效会抛出异常;size()
返回有多少个元素;front()
back()
返回第一个和最后一个元素的引用,如果 std::vector
为空,行为未定义。
C++23 开始 std::format()
std::print()
支持了容器。
std::vector
有几个常用的构造函数。默认构造函数创建一个零个元素的 std::vector
;std::vector(count, default_value)
构造包含 count
个值为 default_value
的 std::vector
,如果不传递第二个参数,使用对应类型的零值初始化。可以使用初始化列表方便的初始化,比如 std::vector<int> numbers = { 1, 2, 3, 4 }
。这里列表初始化的构造函数高于其他构造函数,因此 std::vector<int> numbers{10, 100}
构造的是两个元素 10, 100 的 std::vector
而不是 10 个元素值为 100 的 std::vector
,如果想要后者,必须写成 std::vector<int> numbers(10, 100)
。
std::vector
的拷贝会拷贝每一个元素,因此最好避免 std::vector
的拷贝,比如函数参数是 std::vector
的引用或 const
引用。assign()
会清除所有数据然后存放传入的数据。swap()
会交换两个 std::vector
的数据。
std::vector
支持六种比较运算符,当两个 std::vector
的长度一样,对应元素也相等,那么两个 std::vector
相等,否则不相等。从 0 开始逐个元素比较,第一个不相同的元素决定了哪个 std::vector
更小。
有两种常见方式来遍历 std::vector
,如果无需修改,可以添加 const
。
for (auto &element : vec)
{
}
for (auto iter { std::begin(vec) }; iter != std::end(doubleVector); ++iter)
{
}
使用迭代器要注意两个问题,第一是 end()
返回的迭代器不指向任何有效数据,不要解引用,第二个是不要比较两个不同容器的迭代器。
std::vector
的迭代器是随机迭代器,可以向前也可以向后迭代,还可以使用 +=
-=
跳跃。
std::vector
最常用的插入函数是 push_back()
,相应的移除元素的函数是 pop_back()
,不会后者并不返回元素,因此需要那个元素,需要先调用 back()
取得最后一个元素。
insert()
函数有五种重载,可以插入一个或多个元素到指定位置:插入一个元素;插入 n
个相同元素;插入迭代器指定的范围;移动一个元素到容器内;插入一个初始化列表。C++23 开始提供了额外一组函数 assign_range()
替换所有现有元素,insert_range()
插入给定范围到指定为止,append_range()
插入范围元素到尾部。
erase()
删除元素,传入一个迭代器的话删除指定元素,传入一组迭代器删除迭代器范围的元素。clear()
删除所有元素。
如果写一个 for
循环删除满足条件的元素的话,是平方复杂度,使用 C++20 提供的非成员函数 std::erase()
和 std::erase_if()
,删除所有等于参数的元素或者所有满足传入的谓词的元素,线性复杂度。
push_back()
接受右值引用,将对象移动进容器。emplace_back()
就地构造对象,既不拷贝也不移动。emplace()
会在指定位置构造对象。
插入或者删除元素,std::vector
会移动插入或删除点之后的元素,因此时间复杂度是线性的,同时改点即之后的迭代器可能会失效。std::vector
还可能重新分配一块内存,导致所有的迭代器都失效。
empty()
返回容器是否有元素,size()
返回有多少个元素,capacity()
返回容量,再放 size() - capacity()
个元素,无需分配空间。reserve()
会实现分配足够的空间,但是不创建元素,resize()
类似,但是会创建元素。shrink_to_fit()
会缩小到合适的大小释放内存,但这仅仅是建议,取决于标准库的实现。另一种回收内存的方式是与一个空容器 swap()
。
通过 data()
成员函数和 std::data()
能够直接访问数据,另一种方式是 &vec[0]
,但是可读性和维护性不足够好。
当容器内的元素的移动构造和移动赋值是 noexcept
的,那么整个容器作为返回值的时候可以移动而不是拷贝返回。
std::deque
deque
是 double-ended queue
,和 vector
类似,但是使用频率低非常多。和 vector
的主要区别有:1)非连续存放;2)两端插入都是常量时间;3)提供在头部操作的函数 push_front()
pop_front()
emplace_front()
prepend_range()
;4)不会移动内存因此不会是迭代器失效;5)没有内存管理函数,比如 reserve()
capacity()
。
std::list
标准库 std::list
是双向链表,在任意位置插入删除都是常量时间,但是访问是线性时间复杂度,不提供 operator[]
随机访问运算符。
list
迭代器是双向迭代器不是随机访问迭代器。
提供 std::vector
std::deque
的插入和移出元素的函数。提供 size()
resize()
empty()
但是没有 reserve()
capacity()
函数。
splice()
可以将一个链表的一个元素或者一个范围转移到另一个链表的指定位置,参数可以是左值引用也可以是右值引用。
list
提供一些特化的算法函数,更高效,remove()
remove_if()
unique()
merge()
sort()
reverse()
。
std::forward_list
std::forward_list
是单链表,每个节点占用空间比 std::list
小一个指针大小,只能向后迭代,提供了大部分 std::list
提供的功能。
std::array
固定大小,不会初始化元素,基本就是类型安全的、带长度的 C 风格数组。随机访问。固定大小,因此不支持 push_back()
pop_back()
insert()
erase()
clear()
resize()
reserve()
capacity()
。提供 fill()
填充整个数组。vector
的 swap()
是常量时间,但是 array
的 swap()
是线性时间复杂度,也不能被移动。
std::get<n>()
返回第 n
个元素,会做编译期的索引检查。
std::to_array()
可以将 C 风格数组转成 std::array
。
顺序视图
std::span
包含指向第一个元素的指针和长度,表示一段连续的数据。如果一个函数,想对 std::vector
std::array
C 风格数组都能适用,可以考虑参数是 std::span
。subspan()
可以方便的做切片。first()
last()
方便放回前或后 n
个元素的 std::span
。std::span
还提供了 begin()
end()
rbegin()
rend()
front()
back()
operator[]
data()
size()
empty()
。与 std::string_view
不同的是,std::span
是可写的,如果不想修改,可以用 const
修饰类型 T
。
std::span
是 C++23 提供的多维顺序视图,有四个模板参数,分别是元素类型,各个维度长度,布局(行优先、列优先、自定义)和访问策略(控制 operator[]
)。这样,可以像访问多维数据(比如矩阵)这样访问线性数据。
容器适配器
C++ 提供三种容器:std::queue
std::priority_queue
std::stack
。这是一个绝佳的接口和实现的例子,更换底层容器,无需改动其余代码。
队列是先进先出的数据结构。std::queue
定义如下
deque
,还可以是 list
,因为要求支持 push_back()
pop_front()
,因此不能使用 vector
。
队列支持 push()
emplace()
在尾部插入数据,C++23 提供了函数 push_range()
。pop()
从头尾移出数据。访问头部和尾部使用 front()
back()
函数,返回引用类型,如果队列是 const
,那么返回的是 const
引用。还支持 size()
empty()
swap()
。
优先级队列保持最高优先级的元素的队列头部。std::priority_queue
定义如下
template<typename T, typename Container = vector<T>, typename Compare = less<T>>
class priority_queue;
默认容器是 vector
,使用 deque
也是可行的,不过不能使用 list
,因为需要随机访问。Compare
默认是 T
类型的 operator<
,也可以自定义。
std::priority_queue
提供的接口更少,插入函数有 push()
emplace()
push_range()
,pop()
删除头部元素,top()
返回 const
引用,因为需要保持有序所以这个不会返回非 const
的引用,否则调用者修改了内容导致无法保序。还支持 size()
empty()
swap()
。
栈和队列恰好相反,先进后出的数据结构。std::stack
定义如下
deque
,也可以使用 vector
list
。
std::stack
提供插入函数有 push()
emplace()
push_range()
,pop()
删除元素,top()
返回栈顶的引用,是否是 const
依赖于栈对象。还支持 size()
empty()
swap()
。
关联容器
有序关联容器
标准库提供四种有序关联容器(ordered associative container
) map
multimap
set
multiset
。
首先,我们分析 map
。它和 vector
类似,只不过存储的是 pair
,同时底层数据结构按照 pair.first
,即 key
排序了。
map
支持统一初始化,比如下面的例子。不过不支持类型推导,因此不能省略 map
后面的类型。
map
插入数据。第一种方式是调用 insert()
函数插入一个 pair
,返回类型是一个 pair
,first
是一个迭代器,second
表名是否插入成功,如果 key
已经存在,那么插入失败。C++23 提供了类似插入范围的 API insert_range()
。第二种方式是 operator[]
,总是会插入成功,如果存在,替换之前的旧值。这种方式总会创建一个对象,如果一个 map
是 const
的,因为 const
无法创建对象,编译会报错。emplace()
emplace_hint()
会就地构造对象,try_emplace()
仅在 key
不存在的时候插入对象。
find()
返回查找的 key
对应的迭代器,如果没有找到,返回的是 end()
。at()
类似与 vector
,如果 key
不存在,会抛异常。contains()
函数返回是否包含次 key
。
关联容器都是基于节点(node
)的数据结构,node_type
表示存储的节点类型。节点是存储数据的所有者,通过 extract()
可以将指定节点抽出来,也就将存储数据从容器中删除了,通过 insert()
可以将节点直接插入另一个容器,这样无需拷贝或者移动数据就能完成数据在不同容器的转移。merge()
就是通过节点操作合并两个 map
,合并后,参数 map
仅仅剩下与目标 map
key
重复的数据。
std::multimap
允许 key
重复,因此与 map
不同,不提供 operator[]
at()
insert_or_assign()
try_emplace()
函数,insert()
始终会成功。find()
会返回指向其中一个满足条件的数据,不一定是第一个。lower_bound()
和 upper_bound()
会返回一组迭代器,两者之间是满足条件的所有数据。equal_range()
是找一个 key
对应的迭代器对效率更高的选择。
std::set
和 map
近乎一样,只是只存储 key
不存储 value
,因此没有 operator[]
insert_or_assign()
try_emplace()
函数。另外,不能修改保存在 set
中的数据,否则顺序可能就无法保证了。
std::multiset
之于 std::set
,就是 multimap
之于 map
。
无序关联容器
标准库提供四种无序关联容器,也称为哈希表,分别是 std::unordered_map
std::unordered_multimap
std::unordered_set
std::unordered_multiset
,与之前分析的 map
multimap
set
multiset
对应。因此下面仅分析 std::unordered_map
,包括与 map
的差异,其余三种容器类似,不再赘述。
std::unordered_map
定义如下。有五个模板参数,分别是 key
的类型,value
的类型,哈希函数,等于比较函数和分配器,后三个有默认参数。
template<typename Key,
typename T,
typename Hash = hash<Key>,
typename Pred = std::equal_to<Key>,
typename Alloc = std::allocator<std::pair<const Key, T>>>
class unordered_map;
与 map
的差异如下表。unordered_map
支持一些和哈希相关的操作。比如 load_factor
表示每个桶有多少个元素,由此推测冲突次数。bucket_count()
返回桶的个数。bucket(key)
返回第几个桶,begin(index)
end(index)
返回 local_iterator
,这样就可以遍历桶里面的数据了。与 local_iterator
对应,也有 const_local_iterator
。
OPERATION | map |
unordered_map |
---|---|---|
at() |
Y | Y |
begin() |
Y | Y |
begin(n) |
N | Y |
bucket() |
N | Y |
bucket_count() |
N | Y |
bucket_size() |
N | Y |
cbegin() |
Y | Y |
cbegin(n) |
N | Y |
cend() |
Y | Y |
cend(n) |
N | Y |
clear() |
Y | Y |
contains() |
Y | Y |
count() |
Y | Y |
crbegin() |
Y | N |
crend() |
Y | N |
emplace() |
Y | Y |
emplace_hint() |
Y | Y |
empty() |
Y | Y |
end() |
Y | Y |
end(n) |
N | Y |
equal_range() |
Y | Y |
erase() |
Y | Y |
extract() |
Y | Y |
find() |
Y | Y |
insert() |
Y | Y |
insert_or_assign() |
Y | Y |
insert_range() (C++23) |
Y | Y |
iterator / const_iterator |
Y | Y |
load_factor() |
N | Y |
local_iterator / const_local_iterator |
N | Y |
lower_bound() |
Y | N |
max_bucket_count() |
N | Y |
max_load_factor() |
N | Y |
max_size() |
Y | Y |
merge() |
Y | Y |
operator[] |
Y | Y |
rbegin() |
Y | N |
rehash() |
N | Y |
rend() |
Y | N |
reserve() |
N | Y |
reverse_iterator / const_reverse_iterator |
Y | N |
size() |
Y | Y |
swap() |
Y | Y |
try_emplace() |
Y | Y |
upper_bound() |
Y | N |
平铺关联容器适配器
这里平铺是 flat
,也有人翻译成平坦。
C++23 提供了四种适配器,分别是 std::flat_set
std::flat_multiset
std::flat_map
std::flat_multimap
。和之前讨论的数据结构类似。flat_set
flat_multiset
需要一个顺序容器,flat_map
flat_multimap
需要两个顺序容器,分别保存 key
value
。要求容器支持随机访问,因此只能是 deque
和 vector
,默认是后者。
适配器存储并不是基于节点存储,没有节点的概念和相关函数。同时,提供随机访问迭代器。
平铺适配器添加和删除元素较慢,线性复杂度,查询是对数时间,和非平铺复杂度一致。不过平铺容器迭代更高效,cache 友好,同时内存使用量更低。
其他容器
字符串 std::string
可以看作是容器,和 vector
更类似,提供了 insert()
push_back()
erase()
size()
reserve()
capacity()
等函数。
std::bitset
和容器有相似之处。
bitset
是固定长度的一系列比特。C++23 开始,这个类是 constexpr
,因此可以用于编译期。
bitset
模板参数需要指定长度,默认构造函数会将所有比特初始化成零,也可以从一个包含 0
1
的 string
构造。
set()
reset()
flip()
分别是设置、清除和反转比特。operator[]
也可以用于读写比特。flip()
与 operator~
等价。to_string()
返回由 0
1
组成的 string
,注意,第 0 个比特在字符串最后。
bitset
表示一系列比特,因此支持 &
|
^
~
<<
>>
&=
|=
^=
<<=
>>=
这些比特操作。