C++ STL 容器与容器适配器
前言
C++ STL 容器与容器适配器简记,这是一篇用法与底层实现思想的简记,不含源码解析。
顺序容器
主要有这些:
顺序容器
arrary
:定长数组vector
:动态数组deque
:双端队列list
:双向循环链表forward_list
:单向链表
STL 库中的容器(以及 String)有一些相似的方法和特点,例如使用operator=
赋值或assign()
赋值,用operator==
可以判相等等等,下面是一些个人觉得常见的方法。不过这些方法也并不一定是对所有容器有效,应该根据实际容器的特点来。(例如对于一个定长的array
,显然我们也没办法要求 resize()
或者push
)
迭代器:
begin()
:指向开始的迭代器end()
:指向末尾的迭代器
容量:
size()
:当前大小empty()
:是否为空max_size
:理论上的最大大小。例如对于vector
是可用 RAM 大小,对array
则是声明的大小。
元素访问:
operator[]
:C风格的下标访问at()
:operator[]
的不越界安全替代品front
:第一个元素back()
:最后一个元素
以及修改操作:
push(_back)
:压入一个元素(到尾部)pop(_back)
:弹出一个元素(从尾部)emplace()
:原地构造一个元素放入(对于自定义类型,避免来回构造析构不必要的临时变量)swap()
:交换insert()
:在指定位置前插入元素resize()
:重整大小,小于原值就删去那些元素clear()
:清空所有erase()
:清空指定位置元素
Array
这是对 C 中的简单的 int a[]
这样的数组的替代品,提高了安全性。因此它也如原始数组一样不能变长等。
fill()
:用指定元素赋值。
Vector
动态长度的数组。
- 在中间和前方插入值会消耗较多的时间,因为要挨个挪后面的元素。
- 内存结构:vector所采用的数据结构非常简单,线性连续空间(即数组),两个迭代器
Myfirst
和Mylast
分别指向配置得来的连续空间中目前已被使用的范围,并以迭代器_Myend
指向整块连续内存空间的尾端。 - 扩容:扩充空间是“配置新空间-数据移动-释放旧空间”的大工程,时间成本很高。因此当容器满的时候,
vector
会另辟一段空间(大小为 2^N ),将旧空间的内容复制到新空间然后释放旧空间。如果容器内存放的是自定义类型,那么C++会使用 move 将所有权转移到新空间内,而不是挨个调用构造函数。 - 迭代失效:对
vector
的任何操作,一旦引起空间的重新分配,指向原vector
的所有迭代器就都失效了。
Deque:双端队列
deque
容器可以对其两段的数据进行操作,支持随机访问。它不需要像 vector
那样重新配置空间来扩容,因此,deque 没有 capacity 属性,没有 reserve()
功能。
- 在前端插入删除和后端插入删除一样方便,但是中间会需要挪元素。
deque
容器存储数据的空间是由一段一段等长的连续空间(数组)构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。为了管理这些连续空间,deque
容器用数组存储着各个连续空间的首地址。也就是说,容器中实际存储的都是指针,指向那些真正用来存储数据的各个连续空间。
这提高了在序列两端添加或删除元素的效率,但也使容器迭代器的底层实现变得更复杂。
为了实现遍历 deque
容器的功能,deque
迭代器定义了如下的结构(迭代的是那个指针数组的元素):
1 | template<class T,...> |
迭代器内部包含 4 个指针,它们各自的作用为:
- cur:指向当前正在遍历的元素;
- first:指向当前连续空间的首地址;
- last:指向当前连续空间的末尾地址;
- node:指向指针数组中“指向当前连续空间的指针”。
其中:
- start 迭代器记录着 map 数组中首个连续空间的信息。start 迭代器中的 cur 指针指向的是连续空间中首个元素;
- finish 迭代器记录着 map 数组中最后一个连续空间的信息。 finish 迭代器中的 cur 指针指向的是连续空间最后一个元素的下一个位置。
1 | //当迭代器处于当前连续空间边缘的位置时,如果继续遍历,就需要跳跃到其它的连续空间中,该函数可用来实现此功能 |
内存调度策略
如果 deque
数组的总空间大于目前使用空间的两倍,那么不会重新分配内存。
否则会使用 reallocate_map()
函数, 空间不足时:
deque
空间实际足够:这种情况由总是在一个方向插值造成-
deque
内部进行调整start
, 和finish
当
deque
后端缓存耗尽时,deque
会将start复制到中间;
当deque
前端缓存耗尽时,deque
会从后往前复制,将start
复制到中间。(如果不从后向前复制,那么start
移动后可能会落在之前的区间内,这时进行复制会覆盖尚未被复制的后部数据)。
-
deque
空间真的不足- 申请更大的空间
- 拷贝元素过去
- 修改
map
和start
,finish
指向
如果 deque
数组总空间小于目前使用空间的两倍。那么其会根据下列公式计算扩增倍数。
1 | __new_size = _M_size+max(_M_size,__nodes_to_add)+2 |
可以看出是至少 2 倍扩容,通过比较当前 deque
数组大小与插入节点数,同时还预留一个为 2 的常数,防止频繁map的重新分配。
List:双向循环链表
list
就是一个双向循环链表,list
节点有 prev 和 next 两个指针。对于任何位置的元素插入或元素移除, list
永远是常数时间。
既然是链表,因此它有一些链表的特点
- 插入、接合操作,不会造成迭代器失效。即使删除操作,只有指向删除元素的那个迭代器失效。
- 无法随机存取,但是方便随机插入删除。
- 不预留空间,每分配一个就是内存中取一块地。
node 指向尾端的一个空白节点,就能符合 ”前闭后开“ 区间的要求。
Forward_list:单向链表
具有和 list 容器相同的特性,但是单链表只能从前向后遍历,而不支持反向遍历。
关联式容器
关联式容器大体可以分为 set
和 map
,有序的均是以RB-Tree(红黑树)为底层架构,无序的( Unordered_
前缀)以哈希表为底层架构。
- set/multiset
- map/multimap
- unordered_set/map/multiset/multimap
关联容器插入删除效率比用其他序列容器高,因为对于关联容器来说,不需要做内存拷贝和内存移动。而是键-值映射。
除了顺序容器中提到的各种方法,关联式容器中还常有
count()
:匹配指定键的元素个数。find()
:找指定键的元素。merge()
:从另一个容器合并结点。equal_range()
:等于给定键的元素范围(?)。lower/upper_bound()
:首个不小于/大于指定键的元素的迭代器。(再次提醒是不小于,不是小于)。contains()
(c++20):是否包含指定键。extract()
(c++17):从另一个容器提取结点。
Set/ Multiset:集合
- 元素既是键(value)又是值(value)。
- 键(即元素)是不可被修改的。
insert()
函数插入之后会自行被排序,默认是升序set
不可插入重复值,multiset
则可以;
Map/Multimap:映射(键值对)
- 类比 python 中的字典,拥有键值(key)和实值(value)
- 所有元素都会根据键值来自动排序,当对它的容器元素进行新增操作或者删除操作时,操作之前的所有迭代器,在操作完成之后依然有效。
map
中不允许出现重复键值,multimap
中允许。map
重载了operator[]
,但是multimap
没有,因为键是不唯一的。
由于 map
是键值对,因此定义了 pair
,这是一个有两个值的简单模板结构体。其中第一个值(键)是 const,即键无法修改,值可以修改。它还重载了 operator== 、构造等。而 map
就是一个 pair
结构的红黑树。
Unordered_xxxx
上述四种容器在 C++11 后均增加了 unordered_ 类。其与上述四种容器的区别是底层实现原理为哈希表。
这造成了两点重要影响:
- 不支持排序,迭代器做范围访问时效率更低(迭代器自加自减的访问效率更低);
- 直接访问元素的速度更快(尤其在规模很大时),因为直接计算 key 的哈希值是O(1)复杂度。
容器适配器
容器适配器是一个封装了序列容器的一个类模板,它在一般的序列容器的基础上提供了一些不同的功能。之所以称为容器适配器,是因为它是适配容器来提供其它不一样的功能。通过对应的容器和成员函数来实现我们需要的功能
stack:栈
栈是什么就不必多说了。默认情况下 stack
底层容器是 deque
。也可以指定底层容器,标准容器 vector
、deque
、list
均符合需求。
stack
不提供遍历和随机访问功能,也不提供迭代器。只有 push 和 pop 操作。
queue:队列
默认情况下 queue 底层容器是 deque
。
队列是一种 FIFO 的数据结构,允许从一端新增元素,从另一端移除元素。
queue不提供遍历功能,也不提供迭代器。
pripority_queue:优先队列/堆
优先队列的默认容器是 vector
。
优先队列的核心特点在于其严格弱序特性(strict weak ordering):也即 priority_queue
保证容器中的第一个元素始终是所有元素中最大的。为此,用户在实例化一个 priority_queue
时,必须为元素类型重载<运算符,以用于元素排序。
更进一步的认识
更进一步当然就是需要阅读源码,除了源代码,也可以参考
- 一个发表于Github的源码解析文章:STL/README.md at master · FunctionDou/STL
- 你永远值得信任的 cppreference 老大哥: C++ 标准库 - cppreference.com