C++STL容器的选择与使用指南

可能下面的这些容器类你都见过,甚至每天都在不断使用。

vector, list, forward_list#, deque, priority_queue,
set, multiset, unordered_set#, unordered_multiset#,
map, multimap, unordered_map#, unordered_multimap#,
array#, bitset, stack, queue
带#的容器是在C++11版本才正式进入STL

但是,你真的理解它们的特性,各自的适用场合,以及能够在面对一个实际问题时进行准确的抉择吗?

1.事先声明:本文不作深入讨论的几个容器

array, bitset, stack, queue, priority_queue

1.1.array, bitset

因为它们的元素数量固定,跟其他容器比较没有意义,所以不深入分析和比较它们。

  • array:固定长度的数组。定义方式如下:std::array
  • bitset:固定长度的数组(但每个元素只占1bit的空间,取值为01)。定义方式如下:bitset

1.2.stack, queue, priority_queue

这些容器没有独立的底层数据结构实现,只是在其他容器的基础上提取出几个特殊的接口,使程序可读性更强。准确地说,它们应该叫做容器适配器,而不是容器。

  • stack:后进先出,基本只用到这四个接口,toppoppushempty。内部实现实际上是deque
  • queue:先进先出,基本只用到这四个接口,frontpoppushempty。内部实现实际上是deque
  • priority_queue:最大(小)的先出,基本只用到这四个接口,toppoppushempty。内部实现实际上是vector

因此,接下来我们重点讨论的只剩下12个容器了。

vector, list, forward_list#, deque,
set, multiset, unordered_set#, unordered_multiset,
map, multimap, unordered_map, unordered_multimap

2.各自的实现

  • vector
    • 可变长数组。因为元素都存放在一个连续的内存区间,因此内存利用率很高。跟C语言/C++的数组很相似,但能够在必要时自动扩容。
  • list
    • 双向链表。某个节点中保存着指向前一个元素和后一个元素的指针。
  • forward_list
    • 单向链表。某个节点中只保存着指向前一个元素的指针。
  • deque
    • 在一个固定大小的内存块中存放元素,如果满了在申请一个同样大小的内存块继续存放。而指向这些内存块的指针用类似于vector的机制保存起来。与vector相比,头部数据的插入和删除都很快。顺带一提,这个固定大小的内存块在gcc中是512字节
  • setmultisetmapmultimap
    • 内部使用了平衡二叉搜索树,根据元素之间的大小关系,自动调整搜索树的结构,保证树高是lognx倍(n是元素数量)。经常使用的是红黑树,这时x的值为2
  • unordered_setunordered_multisetunordered_map, unordered_multimap
    • 内部使用了哈希表,根据元素的值计算出hash值,再分配到对应的位置上。

3.比较1:访问第x个元素所需的时间

以下面的代码为例

std::vector v[3] = {2, 6, 7};
std::cout << v[1] << std::endl;

像这样子取出第x个元素,如果对于关联数组(***map),则为访问键为x对应的元素所需的时间。

时间从少到多,如下图列出:

容器 时间
vector O(1)
deque O(1)
unordered_map,unordered_multimap O(1)(平均),O(n)(最差)
map,multimap O(log n)
list,forward_list O(n)
set,multiset,unordered_set,unordered_multiset 不可能

对于vector,某个特定元素的地址能够根据该元素的大小和下标值准确地计算出来;而deque也同理,只不过需要计算两次才能最终确定,因此它们都能在O(1)时间之内访问到该元素。

对于listforward_list,只能从头到尾一个个地慢慢前进,才能最终确定目标元素的内存地址,因此STL根本就没有为它们重载[]运算符

对于关联数组(***map)的对比讨论放在比较3再集中说明。

4.比较2:元素的插入/删除所需的时间

比如,对{2,6,7}这样的容器,在6之后插入5删除6这样的操作。

容器 时间
vector O(1)(尾部的删除),O(1)(尾部的插入,平均),O(n)(尾部的插入,最差),O(n)(其它情况)
deque O(1)(头部或尾部的删除),O(1)(头部或尾部的插入,平均),O(n)(头部或尾部的插入,最差),O(n)(其它情况)
list,forward_list O(1)
unordered_set,unordered_multiset,unordered_map, unordered_multimap O(1)(平均),O(n)(最差)
set,multiset,map,multimap O(log n)

listforward_list很高效,因为它们只需要把更改下一两个节点的前后节点指针的值就可以了。但是这是以已经找到要插入的位置为前提(我们一般使用迭代器完成这一任务),这又回到比较1比较3的讨论内容。

对于vector,如果要在某个位置插入或删除元素,它后面的元素必须重新调整位置。(尾部的插入和删除除外)同时,包括尾部在内的所有插入都有可能导致要进行扩容,进而所有元素都要移动到新内存空间的对应位置。

对于deque,它相对于vector在头部的删除和插入十分高效,因为只需调整第一个内存块(固定大小)的元素的位置即可。必要时可以增加(或减少)一个内存块,然后调整存放内存块地址的vector,因此仍需要O(n)的时间耗费,但相对于vector要重新调整全部元素,它的速度仍然要快很多。

对于unordered***来说,由于哈希冲突和哈希表扩容,因此最坏情况会到达O(n),但只要哈希函数设计得合理,再加上估计哈希表的合理大小预先分配,仍能使总体的平均时间保持为O(1),因此实际运用中其性能往往比平均消耗时间为O(log n)setmultisetmapmultimap要好。

5.比较3:查找某一个元素所需的时间

这里所谓的查找某一个元素,仅仅指以下的应用场景:对于一个{2, 6, 7}这样的容器,询问元素6是否存在,若存在则把它找出来给我。因此对于***map来说,并不是以键为查找的基准(这已经在比较1进行了分析),而是以值为查找的基准。

容器 时间
unordered_set, unordered_multiset O(1)(平均),O(n)(最差)
set,multiset O(log n)
vector,deque,list,forward_list,map,multimap,unordered_map,unordered_map O(n)

但是对于已经排好序的vectordeque,可以使用二分查找,从而将查找成本将为O(log n)

6.比较4:内存使用量

以下将讨论在gcc的编译环境下:元素个数为n;对于一般容器ST=sizeof(单个元素),对于关联数组(***mapST = sizeof(单个值) + sizeof(单个键)SP=sizeof(void*),即一个指针所占的内存空间大小。

内存占用从小到大,如下图列出:

容器 时间
vector nST + 3SP(最好/平均情况,刚好塞满),n2ST + 3*SP(最坏情况,空了一半)
deque n*(ST + 2ST/512SP) + 6*SP(GCC4.5.2环境下)
forward_list (n+1)*(ST+SP) + SP
list (n+1)(ST+2SP) + SP
unordered_set,unordered_multiset,unordered_map,unordered_multimap n*(ST+2SP) + 2SP(最好/平均情况,n/容量≈负载因子),n*(ST+3SP) + 2SP(最坏情况,n/容量<<负载因子)
set,multiset,map,multimap (n+1)(ST+3SP) + SP(仅作估算)

解释1:

为了插入和删除节点方便,listforward_list增加了一个空的头结点,即使容器内没有元素,仍需要ST+SP的空间来存放这个头结点,因此才会出现那个诡异的(n+1)

解释2:

对于vector,每增加一个元素,不需要额外的指针,因此为n*(ST)
对于forward_list,每增加一个元素,需要增加一个指针,因此为(n+1)*(ST+SP)
对于list,每增加一个元素,需要增加两个指针,因此为(n+1)*(ST+2*SP)
对于set等,每增加一个元素,需要增加三个指针(分别指向左儿子、右儿子、父节点),因此为(n+1)*(ST+3*SP)

解释3:

如果你看不太懂unordered_set的空间复杂度中,下面这幅图应该能帮到你:

C++STL容器的选择与使用指南_第1张图片

解释4:

为什么vector在最后要加3*SP?这三个指针分别用于①指示第一个元素的内存位置;②指示最后一个元素的内存位置,对应于size()方法;③指示最后一个可容纳元素的内存位置,对应于capacity()方法。

解释5:

为什么deque在最后要加6*SP?首先是两个指针记录首个内存块和最后一个内存块的地址,为了加速头部和尾部的操作,也要记录首个内存块的起始和终止位置、最后一个内存块的起始和终止位置,这样一共6个指针。

7.一览表

对于有利的因素,文字会加粗。

容器 1.访问特定位置元素的时间 2.插入/删除的时间 3.查找某个元素的时间 4.内存占用量
vector O(1) O(1)(尾部),O(n)(其他) O(n) 0~ST
deque O(1) O(1)(头部或尾部),O(n)(其他) O(n) 2ST/512SP
list O(n) O(1) O(n) 2*SP
forward_list O(n) O(1) O(n) SP
unordered_set,unordered_multiset 不可能 O(1) O(1) 2*SP
set,multiset 不可能 O(log n) O(log n) 3*SP
unordered_map,unordered_multimap O(1) O(1) O(n) 2*SP
map,multimap O(log n) O(log n) O(n) 3*SP

你可能感兴趣的:(C++,c语言,stl,c++,数据结构,算法)