3-1 STL容器剖析(vector & list)
1.前言
C++标准模板库(standard template library, STL)不仅是一个可复用组件库,而且是一个包罗算法与数据结构的模板框架。 STL已完全被内置到支持C++的编译器中,无需额外安装,
STL包括六大组件,可以互相组合套用:
- 容器(containers):各种数据结构的模板类,如vector,list,deque,set,map等用来存放数据。
- 算法(algorithm):sort,search,copy,erase等模板函数,大部分算法都包含在头文件
<algorithm>
中,少部分位于头文件<numeric>
中。
- 算法(algorithm):sort,search,copy,erase等模板函数,大部分算法都包含在头文件
- 迭代器(iterator):容器与算法之间的胶合剂,也就是”泛型指针“。
- 仿函数(functors):如果一个类将 ‘()’ 运算符重载为成员函数,这个类就称为函数对象类,这个类的对象就是函数对象。由于该类对象的行为类似函数,又称为仿函数。
- 配接器(adapters):一种用来修饰容器、仿函数或者迭代器接口的东西。
- 配置器(allocators):负责内存空间配置与管理。
2. vector容器
vector被称为向量容器,该容器擅长在尾部插入或删除元素,时间复杂度为O(1);而对于在vector容器头部或者中部插入或删除元素,则花费时间要长一些(移动元素需要耗费时间),时间复杂度为线性阶O(n)。使用vector容器,需要包含<vector>
,注意这里无扩展名。
2.1 vector内存扩容策略
vector属于序列式容器(sequence_container):其中的元素都可序,但未必有序。
如图所示,vector的内存模型与数组较为相似,但vector的内存模型是动态增长的线性空间,动态增长的实质是:需求空间超过当前可用空间后,不是在原空间之后接续新空间。这是因为线性空间后不一定有足够大小的空间,因此重新申请一块更大的空间来作为载体,然后复制已有数据到新申请的内存空间。
具体操作为:首先配置一块新空间,然后将元素从原空间搬到新的空间上,再把原空间释放。(涉及到了新空间的配置和旧空间的释放)
新空间的大小为一般为原空间大小的二倍。注意:二倍增长并不是必然的,不同的编译环境可以有不同的实现,但若增长倍数小于2则可能会导致扩容频繁;增长倍数较大则可能导致申请了较大的空间而未使用,从而造成浪费。 此外,vector为了降低空间扩容的速度,在配置空间时留有一部分空间以便之后扩容,这就是size()和capacity()的区别。size()返回使用了多少空间,capacity()返回了配置了多少空间。当两者相等时说明vector容器已经满了,再插入元素需要扩容。
2.2 vector迭代器
迭代器是行为类似指针的变量,相当于容器和算法之间的中介。迭代器可以指向容器中的某个元素,通过迭代器就可以读写它指向的元素。
如上图所示,两个迭代器start和finish来指向目前线性空间已使用的头和尾,以迭代器end_of_storage来指向当前配置空间的尾端。该图中扩容和迭代器变化步骤如下:
- vector<int> vec(2,5),即申请两个元素空间并赋值5,此时start指向起始地址,finish指向两元素空间末尾,end_of_storage指向申请空间(两元素)末尾;</int>
- 当push_back(4)时,原2个元素空间已满,申请二倍空间,复制5,5于新空间,并填入4,此时由于空间重新分配,原迭代器失效,新start指向起始地址,finish指向4之后,end_of_storage指向申请空间(四个元素)末尾;
- push_back(3)时,原空间共有4个位置,已填入3个元素,3加入后刚好填满;
- 当push_back(2)时,原4个元素空间已满,便申请一块8元素的新空间,再将2填入;
- 再将1填入,此时仍有两个元素的空位以供备用;
所以finish和end_of_storage不是一个含义,finish指的是使用空间的末尾,end_of_storage是申请的空间的末尾。因此,使用size()和capacity()进行区分,size()的大小时start到finish的大小,capacity()的大小则是start到end_of_storage的大小。
此处需要注意的是,对vector的操作如果引起的空间重新分配,那么原vector的所有迭代器就都失效。class vector { …… protected: iterator start; //当前已用空间的头部 iterator finish; //当前已用空间的尾端 iterator end_of_storage; //当前配置空间的尾端 };
- 再将1填入,此时仍有两个元素的空位以供备用;
2.3 '[]'操作符
vector可以像数组一样,支持使用'[]'操作符根据下标获取元素。简单来讲,vector中元素大小固定,在知道start的基础上,我们只需要在其基础上进行地址偏移就能找到所需元素。(经典面试题:vector的随机访问如何做到?)源码如下:
reference operator[](size_type n){ return *(begin() + n); }
2.4 迭代器的常用方法
vector的所有操作都可以从源码中看出端倪,此处将常用操作源码总结:
STL中规定容器的区间遵循前闭后开原则,即容器中的一对迭代器start和finish标识的前闭后开区间,从start开始,直到finish-1.迭代器finish所指的是“最后一个元素的下一位置”。这样定义的好处主要有两点:1. 为“遍历元素时,循环的结束时机”提供一个简单的判断依据。只要尚未到达end(),循环就可以继续下去; 2. 不必对空区间采取特殊处理手段。空区间的begin()就等于end();
class vector { …… public: /** * begin()函数源码 * 用于返回start迭代器 **/ iterator begin() { return start; } /** * end()函数源码 * 用于返回finish迭代器 **/ iterator end() { // STL容器的前闭后开原则,end迭代器并不指向最后一个元素 而是最后一个元素的后面 return finish; } /** * size()函数源码 * 用于返回start-end的值——已保存的元素个数 **/ size_type size() const { // 返回start-end的值 即已保存的元素个数 return size_type(end() - begin()); } /** * capacity()函数源码 * 返回end_of_storage - start的值——vector能够保存元素上限 **/ size_type capacity() const { // 返回end_of_storage - start的值, 即vector能够保存元素上限 return size_type(end_of_storage - begin()); } /** * empty()函数源码 * 通过判断start迭代器与finish迭代器是否相等,返回vector是否为空 **/ bool empty() const { return begin() == end(); } /** * []操作符重载 * 实现根据下标取元素 **/ reference operator[](size_type n) { return *(begin() + n); } /** * front函数源码 * 根据start迭代器,返回第一个元素的值 **/ reference front() { return *begin(); } /** * back函数源码 * 根据finish迭代器,返回最后一个元素的值 * 由于finish迭代器指向的是最后一个元素的后一个位置(STL容器的前闭后开原则),因此这里需要用finish-1获取最后一个元素的位置 **/ reference back() { // 返回最后一个要素 return *(end() - 1); } };
此处需要注意的是,对vector的操作如果引起的空间重新分配,那么原vector的所有迭代器就都失效。因此,如果我们将vector的迭代器保存到变量中时,可能会因为空间重新分配导致该变量保存的迭代器失效,例如:
#include <vector> #include <iostream> using namespace std; int main() { vector<int> vec; vec.push_back(0); auto iter = vec.begin(); // 使用变量保存迭代器 cout<< *iter << endl; for(int i = 0; i < 10; i++) // 可能会引起vector空间重新分配的代码段 { vec.push_back(i); } cout<< *iter << endl; // 原迭代器失效,此处异常中断 }
2.5 vector的常用元素操作方法
vector元素操作方法很多,此处以常用的函数举例,并搭配空间管理加深理解。
2.5.1 push_back(const T& x)
表头 | 说明 |
---|---|
函数功能 | 将新元素插入vector的尾端,在插入时需要关注两种情况:即vector当前是否还有空间,如果有则直接在备用空间上构造元素,调整迭代器finish;若没有,则需要扩充空间。 |
参数 | 1.需要加入的元素 |
返回值 | void |
时间复杂度 | 若vector有备用空间,则为O(1);否则需要扩充空间时,为O(n) |
push_back方法的源码如下:
void push_back(const T& x) { if(finish != end_of_storage) { constrcut(finish,x); ++finish; } else { insert_aux(end(),x); } }
其中,insert_aux函数涉及到了空间的重新配置问题,这里剖析该函数源码以加深过程理解。
insert_aux方法的源码如下:
template<class T> void insert_aux(iterator position, const T& x) { if(finish != end_of_storage) { // 有备用空间 } else { // 获取当前vector的元素个数 const size_type old_size = size(); // 计算扩展后的vector空间 // 若扩充前vector是空的,则配置申请一个元素的空间 // 若扩充前vector非空,则按照二倍扩充原则进行扩充 const size_type len = old_size != 0 ? 2 * old_size : 1; // 分配器allocator配置新空间 // new_start和new_finish迭代器指向新分配的空间起点 iterator new_start = data_allocator::allocate(len); iterator new_finish = new_start; try { // 拷贝原vector中start到position区间的元素到new_start迭代器所指处 // push_back函数中调用insert_aux(end(),x),因此position就是finish迭代器,这里就将原vector的所有元素拷贝过来 // 执行后 new_finish迭代器就指向了原vector的所有元素拷贝到新位置的末尾 new_finish = uninitialized_copy(start,position,new_start); // 在new_finish处构造新元素x construct(new_finish,x); // new_finish向后移一个位置 ++new_finish; // 再将原vector的position到finish区间的元素拷贝到new_finish迭代器所指处 // push_back函数中调用insert_aux(end(),x),因此position就是finish迭代器,这里元素区间是空 new_finish = uninitialized_copy(position,finish,new_finish); } catch(...) { // 异常时回滚 destroy(new_start,new_finish); data_allocator::dellocate(new_start,len); throw; } // 析构并释放原vector destroy(begin(),end()); deallocate(); // 为start和finish迭代器赋新值 start = new_start; finish = new_finish; end_of_storage = new_start + len; } }
在源码中也可以看出,当空间重新配置时,原vector的所有迭代器也就失效了。
2.5.2 pop_back()
函数项 | 说明 |
---|---|
函数功能 | 删除尾端元素时,调整迭代器并释放即可,不需要空间分配 |
参数 | void |
返回值 | void |
时间复杂度 | O(1) |
pop_back源码如下:
void pop_back() { --finish; //调整末端位置迭代器 destroy(finish); //释放尾端元素的资源 }
2.5.3 iterator erase(iterator first,iterator last)
函数项 | 说明 |
---|---|
函数功能 | 用于删除vector容器中的一段区间的元素 |
参数 | 1.迭代器:iterator first 2.迭代器:iterator last |
返回值 | 指向删除的区间起点迭代器 |
时间复杂度 | O(n) |
erase()源码如下:
// 删除某段元素 参数为需要删除段的迭代器起点和终点 iterator erase(iterator first,iterator last) { iterator i = copy(last, finish, first); //将后面的元素前移 destroy(i,finish); //释放其后元素 finish = finish - (last - first); //调整迭代器 return first; }
2.5.4 iterator erase(iterator position)
函数项 | 说明 |
---|---|
函数功能 | 用于删除vector容器中的一个元素 |
参数 | 1.迭代器:iterator position |
返回值 | 指向被删除元素位置迭代器 |
时间复杂度 | O(n) |
erase()源码如下:
// 删除某个元素 参数为需要删除的元素迭代器 iterator erase(iterator position) { if(position + 1 != end()) { copy(position + 1,finish,position); // 将position后的元素整体向前移动 } --finish; // 迭代器前移 destroy(finish); return position; }
上图为删除一段元素的示例图,可以看出,删除元素的迭代器会发生变化,删除位置之后的迭代器需重新调整。
2.5.5 void insert(iterator position, size_type n, const T& x)
函数项 | 说明 |
---|---|
函数功能 | 在指定位置position前插入n个值为x的元素,返回指向这个元素的迭代器, |
参数 | 1.迭代器:iterator position 2.插入元素的个数 3.插入元素的值 |
返回值 | void |
时间复杂度 | O(n) |
由于vector中的元素顺序存储,所以随机插入元素的操作相对比较麻烦:
void insert(iterator position, size_type n, const T& x) { if(n != 0) { if(size_type(end_of_storage - finish) >= n) { // vector当前备用空间大于n时 T x_copy = x; const size_type elems_after = finish - position; // 计算需要移动元素的个数 iterator old_finish = finish; if(elems_after > n) { // 如果需要移动的元素个数 大于插入的元素数量 // 将finish-n 到 finish区间内的数据拷贝到finish后 // 注意:这里只拷贝了倒数n个元素 而不是elems_after个元素 uninitialized_copy(finish - n,finish,finish); finish += n; // 更新finish迭代器,此时finish的位置为插入n个元素后的为位置 // 再将position 到 old_finish区间的元素移动到old_finish后 copy_backward(postion,old_finish - n,old_finsh); // 最后 在position后填充n个x元素 fill(position,position + n,x_copy); // 可参考下图情况1 } else { // 如果需要移动的元素个数 小于插入的元素数量 // 在finish后 填充n - elems_after个元素, uninitialized_fill_n(finish,n - elems_after, x_copy); finish += n - elems_after; // 调整finish 此时finish的位置不是最终的位置 // 将position 到 old_finish区间内的元素拷贝到finish后 uninitialized_copy(position,old_finish,finish); finish += elems_after; // 继续调整finish fill(position,old_finish,x_copy); // 将position到old_finish区间内填充为 x // 可参考下图情况2 } } else { // vector当前备用空间小于n时 const size_type old_size = size(); const size_type len = old_size + max(old_size,n); // 配置新空间(同上) iterator new_start = data_allocator::allocate(len); iterator new_finish = new_start; // 分三段复制到新vector { //插入点之前的元素复制到新vector new_finish = uninitialized_copy(start,position,new_start); //新增元素填入 new_finish = uninitialized_fill_n(new_finish,n,x); // 插入点之后的元素复制到新vector new_finish = uninitialized_copy(position,finish,new_finish); } // 析构并释放原vector destroy(begin(),end()); deallocate(); // 调整迭代器 start = new_start; finish = new_finish; end_of_storage = new_start + len; // 可参考下图情况3 } } }
情况1:
情况2:
情况3:
3.list容器
3.1 list内存模型
list容器是双向循环链表,同样是序列式容器(sequence_container),list动态增删是每插入和删除一个元素就配置和删除一个元素的空间,因此不像vector一样有备用的空间。根据链表的特点,list应该有三个属性,自身的值、上一个节点的位置、下一个节点的位置。
template<class T> struct __list_node { typedef void* void_pointer; void_pointer prev; // 上一个节点位置 void_pointer next; // 下一个节点位置 T data; // 节点的值 };
list节点的关联是通过其前后节点的指针来描述的,与别的元素无关。所以插入插入/删除操作都只需建立/销毁与前序节点和后序节点的联系即可。因此,对于list来说,只需要一个指针,便可以完整表示整个链表,其源码如下所示:
template<class T> class list { protected: typedef __list_node<T> list_node; public: typedef list_node* link_type; typedef __list_iterator<T,T&,T*> iterator; protected: link_type node; };
为了遵循STL容器的前闭后开原则,list容器用一个头结点(尾结点)来作为链表的开始或终止,该节点是空白节点,不存储元素;我们只需要让指针node指向该空白结点,list便可视为“前闭后开”。
3.2 list的容器迭代器
由于list的节点除了要存储数据和关联节点,还需要适配容器常用的动作,例如递增、递减等;因此普通的节点指针无法满足需求,迭代器需要进行进一步的封装。
迭代器对应到源代码就可以看出端倪:
struct __list_iterator { reference operator*() const { // 重载 "*"操作符,用于通过迭代器取出节点元素 return (*node).data; } pointer operator->() const { // 重载"->"操作符,用于通过迭代器操作元素的成员 return &(operator*()); } self& operator++() { // 迭代器向后移动 返回移动前的迭代器 后缀自增 node = (*node).next; return *this; } self operator++(int) { // 迭代器向后移动 返回移动后的迭代器 前缀自增 self tmp = *this; ++*this; // 依靠后缀自增实现 return tmp; } self& operator--() { // 后缀自减 node = (*node).prev; return *this; } self operator--(int) { // 前缀自减 self tmp = *this; --*this; // 依靠后缀自减实现 return tmp; } };
可以看到,list的迭代器需要进行特定的封装处理后才能执行迭代器的功能。同样,由于头结点/尾节点的存在,迭代器也可以完成很多操作:
iterator begin() { return *node.next; // 头结点的下一节点是开始 } iterator end() { return node; // 尾端的空白空白节点 } bool empty() const { return node->next==node; // 判断头结点后是否是非空节点 } size_type size() const { size_type result=0; distance(begin(), end(), result); // distance为STL算法,可视为遍历函数,每遍历一个节点便将result++ return result; } reference front() { return *begin(); } reference back() { return *(--end()); }
list的元素操作不会引起其他的元素空间重新分配,因此迭代器不会失效。
3.3 list的元素操作源码剖析
list的元素增删操作较为简单:
3.3.1 push_back()
函数项 | 说明 |
---|---|
函数功能 | 用于在list容器尾部插入一个元素,借助insert方法实现 |
参数 | 1.插入的元素实例 |
返回值 | 空 |
时间复杂度 | O(1) |
void push_back(const T& x) { insert(end(),x); }
3.3.2 insert()
函数项 | 说明 |
---|---|
函数功能 | 用于在list容器中插入一个元素 |
参数 | 1.插入元素的位置 2.插入的元素实例 |
返回值 | 空 |
时间复杂度 | O(1) |
iterator insert(iterator position,const T& x) { link_type tmp = create_node(x); // 创建一个新的节点 tmp->next = position.node; // 建立此节点与插入位置节点关系,插入位置节点为其下一节点 tmp->prev = position.node->prev; // 建立此节点与插入位置上一节点关系,此节点上一个节点为原位置节点上一节点 (link_type(position.node->prev))->next = tmp; // 原位置节点上一节点此时的下一节点为此节点 position.node->prev = tmp; // 原位置节点的上一节点变为此节点 return tmp; }
可以看到,插入时,由于前后均为双指针的关系,在调整时需要调整四个指针指向,注意顺序。
3.3.3 erase()
函数项 | 说明 |
---|---|
函数功能 | 用于删除list容器中的元素 |
参数 | 1.删除元素的位置 |
返回值 | 删除元素下一个元素的迭代器 |
时间复杂度 | O(1) |
iterator erase(iterator position){ link_type next_node = link_type(position.node->next); link_type prev_node = link_type(position.node->prev); prev_node->next = next_node; next_node->prev = prev_node; destroy_node(position.node); return iterator(next_node); }
和insert类似的,erase在删除元素时需要获取该节点的前、后结点,并将前节点的next指向后节点,即将被删除的点从链表中断开。
3.3.4 其他操作借助insert或erase方法实现
void push_back(const T& x) { insert(end(),x); } void push_front(const T& x) { insert(begin(),x); } void pop_front() { erase(begin()); } void pop_back() { iterator tmp = end(); erase(--tmp); }
4. 面试热点
4.1 vector和list的区别
【出现频度】★★★★
【难度】☆☆
【参考答案】
- 1.vector和数组类似,拥有一段连续的内存空间。当插入新的元素时,vector当前拥有的内存空间不够插入元素,通常以两倍重新申请一块更大的内存,并将原来的元素拷贝过去。由于vector的元素是连续存储的,因此随机访问非常高效,时间复杂度为O(1);但插入或删除元素时,会引起其他元素整体移动,时间复杂度为O(n);
- 2.list是双向循环链表,各元素的内存空间是不连续的。只能通过指针访问数据,所以list的随机存取非常没有效率,要遍历才能做到,时间复杂度为O(n);但可以高效地进行插入和删除,(不需要拷贝和移动数据,只需要改变指针的指向就可以了),时间复杂度为O(1)。
需要高效的随机存取,而不在乎插入和删除的效率,选vector。 需要大量的插入和删除,而不关心随机存取,则使用list。
4.2 反转链表
【出现频度】★★★★
【难度】☆☆☆
参考此链接反转链表。
4.3 合并链表
【出现频度】★★★★
【难度】☆☆☆
参考此链接:合并两个排序链表。
4.4 vector、list迭代器失效问题
【出现频度】★★★
【难度】☆☆☆☆
vector:
- 当删除一个元素时(erase/pop_back),其后的所有元素的迭代器都会失效,因为vector是连续存储的一段空间,所以当对其进行erase操作时,其后的每一个元素都会向前移一个位置。
- 当插入(insert/push_back)一个元素后,如果引起了vector扩容,进行空间重新分配,则此时first和end操作返回的迭代器都会失效。
- 当插入(insert/push_back)一个元素后,如果未引起扩容,空间未重新分配,指向插入位置之前的元素的迭代器仍然有效,但指向插入位置之后元素的迭代器全部失效。
list:
增加任何元素都不会使迭代器失效。删除元素时,除了指向当前被删除元素的迭代器外,其它迭代器都不会失效。
vector与list容器总结
表头 | vector | list |
---|---|---|
底层结构 | 一段连续的内存 | 双向循环链表(头结点) |
随机访问 | 支持随机访问,效率O(1) | 不支持随机访问,效率O(N) |
插入和删除 | 任意位置插入和删除效率低,需要搬移元素,时间复杂度为O(N),插入时有可能需要扩容,拷贝元素,释放旧空间 | 任意位置插入和删除效率高,不 需要搬移元素,时间复杂度为 O(1) |
空间利用率 | 底层为连续空间,不容易造成内存碎片,空间利用率 高,缓存利用率高 | 底层节点动态开辟,小节点容易 造成内存碎片,空间利用率低, 缓存利用率低 |
迭代器 | 原生态指针 | 对原生态指针(节点指针)进行封装 |
使用场景 | 需要高效存储,支持随机访问,插入及删除删除较少 | 大量插入和删除操作,随机访问较少 |