STL详解
- STL介绍
- 空间配置器
- 一级空间配置器
- 二级空间配置器
- 序列式容器
- vector
- list
- deque
- 适配器
- stack
- queue
- heap
- priority_queue
- 关联式容器
- set
- multiset
- map
- multimap
- 非标准容器
- hash_set(unordered_set)
- hash_multiset(unordered_multiset)
- hash_map (unordered_map)
- hash_multimap(unordered_multimap)
- 常见的面试问答
- 1、说说 vector 和 list 的区别
- 2、map 和 set 有什么区别
- 3、unordered_map和map 说说区别
STL介绍
空间配置器
一级空间配置器
二级空间配置器
序列式容器
vector
vector是动态空间,随着元素的加入,它的内部机制会自动扩充空间以容纳新元素。因此vector的运用对于内存的合理利用与运用的灵活性有很大的帮助。
通过分析 vector 容器的源代码不难发现,它就是使用 3 个迭代器(可以理解成指针)来表示的:
注意:iterator 是普通的指针
iterator statrt指向vector 容器对象的起始字节位置,即是第一个元素;
iterator finish指向当前最后一个元素的下一个位置
iterator end_of_storage指向整个 vector 容器所占用内存空间的末尾字节。
如图 演示了以上这 3 个迭代器分别指向的位置
如图 演示了以上这 2个迭代器分别指向的位置
在此基础上,将 3 个迭代器两两结合,还可以表达不同的含义,例如:
size() :finish - start 可以用来表示 vector 容器中目前已被使用的内存空间;
end_of_storage - finish 可以用来表示 vector 容器目前空闲的内存空间;
capacity() :end_of_storage - start 可以用表示 vector 容器的容量。
vector的迭代器模拟实现
typedef T* Iteratot;
typedef T* const_Iteratot;
Iteratot cend()const {
return final_end;
}
Iteratot cbegin()const {
return start;
}
//begin()返回的是vector 容器对象的起始字节位置;
//end()返回的是当前最后一个元素的末尾字节;
Iteratot end() {
return final_end;
}
Iteratot begin() {
return start;
}
vector的扩容reserve()
void reserve(size_t n) {
if (n > capacity()) {
T* temp = new T [n];
//把start中的数据拷贝到temp中
size_t size1 = size();
memcpy(temp, start, sizeof(T*) * size());
start = temp;
final_end = start + size1;
finally = start + n;
}
}
注意:
vector的容量capacity == 大小size时,如果再向vector添加新元素,那么vector需要扩容,扩容过程分为三步:
1、完全丢弃原来的内存空间,开辟分配新的内存空间,大小一般是原来的2倍(1.5倍,编译器不同分配大小可能不同)。
2、将原来的内存空间数据按顺序全部拷贝到新的内存空间。
3、将原来的内存空间释放掉。
这也就解释了,为什么 vector 容器在进行扩容后,与其相关的指针、引用以及迭代器可能会失效的原因,后续会讲失效的原因。
此外,如果对象中涉及到资源管理时,千万不能使用memcpy进行对象之间的拷贝,因为memcpy是浅拷贝,否则可能会引起内存泄漏甚至程序崩溃。
vector的push_back()、pop_back()
void push_back(const T&var) {
if (final_end ==finally) {
size_t newcode = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcode);
}
*final_end = var;
++final_end;
void pop_back() {
final_end--;
}
在插入新的元素前,先判断容器是否有空闲空间(根据capacity == size),当容器没有空闲空间时,若容器的容量capacity = 0,则开辟分配4字节的大小,若容量不为0,则分配原来容量的2倍。
对insert()插入时迭代器失效刨析
Iteratot insert(Iteratot iterator,const T&var) {
assert(iterator <= final_end && iterator >= start);
//先保存插入值的迭代器的位置
size_t pos = iterator - start;
//扩容,造成迭代器失效,但可以使用pos解决失效问题
if (final_end == finally) {
size_t newcode = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcode);
}
//插入操作
auto it = final_end;
//将插入位置的后面元素均向后移动
while (it >= start+pos) {
*(it+1)=*it;
it--;
}
//将值插入到该位置
*iterator = var;
final_end++;
//返回该位置的值
return iterator;
}
对erase()数据删除时迭代器失效刨析
Iteratot erase(Iteratot iterator) {
assert(iterator <= final_end && iterator >= start);
auto it = iterator;
//删除,将后面的元素均往前移动
while (it <final_end) {
*it = *(it+1);
it++;
}
final_end--;
//返回的是删除元素的下一个迭代器
//比如:1,2,3,4,5,6 需要删除3 ,则返回的是4的迭代器
return iterator;
}
由于erase()返回的是删除元素的下一个迭代器,故使得当前的迭代器失效。
问题呈现:
#include<iostream>
#include<vector>
using namespace std;
int main(){
int a[] = {1, 4, 3, 7, 9, 3, 6, 8, 3, 3, 5, 2, 3, 7};
vector<int> arr(a, a + sizeof(a)/sizeof(int));
auto it = arr.begin();
while(it != arr.end())
{
if(*it == 3)
{
arr.erase(it);
cout << *it << endl;
}
it++;
}
for(vector<int>::iterator itor = arr.begin(); itor != arr.end(); itor++)
{
cout << * itor << " ";
}
return 0;
}
解决方案:其实就是将获取返回的迭代器,或将迭代器–。
#include<iostream>
#include<vector>
using namespace std;
int main(){
int a[] = {1, 4, 3, 7, 9, 3, 6, 8, 3, 3, 5, 2, 3, 7};
vector<int> arr(a, a + sizeof(a)/sizeof(int));
auto it = arr.begin();
//方法一:
while(it != arr.end())
{
if(*it == 3)
{
arr.erase(it--);
}
it++;
}
//方法二:
/*
while(it != arr.end())
{
if(*it == 3)
{
it = arr.erase(it);
}
else
it++;
}
*/
for(vector<int>::iterator itor = arr.begin(); itor != arr.end(); itor++)
{
cout << * itor << " ";
}
return 0;
}
list
list:双向环形链表。它是一种物理存储单元上非连续、非顺序的存储结构,但也是一种序列式容器,因为每个节点都包含两个指针,前指针和后指针,将整个链表中的节点连接起来,在逻辑上是连续的。
前面讲到的vector是连续线性存储容器,虽然在随机访问上效率很高,但插入和删除的效率一般较低,因为每一次对非末尾进行插入和删除,都会导致数据的移动或扩容。
而list正是和vector相反,由于list在物理存储上非连续线性,所以它随机访问效率很低,但插入和删除效率却很高,因为每一次插入和删除只需要改变某节点前后指针的指向即可。
根据源码刨析去分析:
链表节点的定义:
template<typename T>
struct __list_node{
typedef void* list_node_pointer;//设计的不理想,在4.9版本改为了__list_node*
list_node_pointer prev;
list_node_pointer next;
T data;
};
链表的迭代器定义:
template<typename T,class Ref,class Ptr>
struct __list_iterator{
typedef __list_iterator<T,T&,T*> iterator;
typedef __list_iterator<T,Ref,Ptr> self;
typedef __list_node<T>* link_type;
//迭代器的类别:双向迭代器
typedef bidirectional_iterator_tag iterator_category;
typedef T value_type;
typedef T& reference;
typedef value_type* pointer;
//俩个迭代器之间的距离,ptrdiff_t : unsigned long
typedef ptrdiff_t difference_type;
typedef size_t size_type;
link_type node; //成员
//构造函数
__list_iterator(link_type x = nullptr):node(x){}
.....//先省略成员函数
}
链表的定义:
template<typename T,class Alloc = alloc>//按照 list_node为单位分配内存,只分配一个元素大小的内存
class list{
protected:
typedef __list_node<T> list_node;
public:
typedef list_node* link_type;
public:
//迭代器
typedef __list_iterator<value_type> iterator;
private:
link_type node; // 只要一个指针,便可表示整个环状双向链表
.............//为了更清晰的看list的定义,先省略其他的函数
}
示图:
注意:图中的end()指向位置,它是一个空白节点,使得整个链表遵循前闭后开。
list的成员函数:
iterator begin()
{
//返回一个迭代器
//调用__list_iterator<value_type>(link_type x)构造函数临时创建一个
return (link_type)((*node).next);
}
iterator end()
{
return node;
}
bool empty()
{
return node->next == node;
}
size_type size()
{
size_type result = 0;
distance(begin(),end(),result);//全局函数,计算两个迭代器之间的距离
return result;
}
reference front()
{
return *begin();
}
reference back()
{
return *(--end());
}
list内部提供的transfer迁移操作:
//将[first,last)内的所有元素移动到position之前
void transfer(iterator position ,iterator first, iterator last)
{
if(position != last)
{
//改变某些节点的指针指向
//...
}
}
在它的基础上为splice、sort、merge、reverse等奠定了良好的基础。
splice
//将x接合于position 所指位置之前的,x必须不同于*this
void splice(iterator position,list& x){}
//将同一个list的元素i接合到position 所指位置之前的
void splice(iterator position,list& ,iterator i){}
//将[first,last)的元素i接合到position 所指位置之前的,但position不能在[first,last)内
void splice(iterator position,list& ,iterator first,iterator last){}
merge
//将x合并到*this身上。两个lists的内容都必须经过递增排序
void merge(list<T,Alloc>& x){}
最后的注意:list的插入操作insert和splice接合操作都不会导致原有的list迭代器失效,不像vector的插入和删除操作都可能造成原有迭代器失效,因为vector插入操作可能会进行扩容,开辟新的存储空间,释放原有的旧空间,而list插入只会分配一个元素大小的空间,删除操作只会使得被删除的元素的迭代器失效。
deque
deque:双端队列。vector是单向开口的连续线性空间, deque 则是一种双向开口的连续线性空间,准确的是:deque是分段连续空间,维持其“整体连续”的假象。所谓双向开口,意思是可以在头尾两端分别做元素的插人和删除操作,如下图所示。vector当然也可以在头尾两端进行操作(从技术观点),但是其头部操作效率奇差,无法被接受。
deque 和vector的最大差异,一在于deque允许于常数时间内对起头端进行元素的插入或移除操作,二在于deque没有所谓容量(capacity)观念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来。换句话说,像vector那样“因旧空间不足而重新配置一块更大空间,然后复制元素,再释放旧空间”这样的事情在 deque是不会发生的。也因此,deque没有必要提供所谓的空间保留(reserve)功能。
deque的主控:
deque 采用一块所谓的map (注意,不是 STL的map容器)作为主控。这里所谓map是一小块连续空问,其中每个元素(此处称为一个节点,node)都是指针,指向另一段(较大的)连续线性空间,称为缓冲区。缓冲区才是deque的储存空间主体。SGI STL允许我们指定缓冲区大小,默认值0表示在元素大小不超过512字节时,将使用(512 / 一个元素的大小)bytes的缓冲区,超过512字节则一个缓冲区存储一个元素。
queue的定义:
template <class T,class Alloc = alloc,size_t BufSiz = 0>
class deque{
public:
typedef T value_type;
typedef value_type* pointer;
...
public:
typedef __deque_iterator<T,T&,T*,BufSiz> iterator;
protected:
typedef pointer* map_pointer;
//它是个二级指针T**,指向的一块连续空间map,map存储着指向另一块空间的指针(也称缓冲区)
map_pointer map;
//表现的第一个节点
iterator start;
//表现最后一个节点
iterator finish;
size_type map_size;
protected:
//两个专属的空间配置器
//每次配置一个元素大小空间
typedef simple_alloc<value_type,Alloc> data_allocator;
//每次配置一个指针大小,为push_back、push_front、构造等操作提供的配置器,需要配置一个map大小的空间
typedef simple_alloc<pointer,Alloc> map_allocator;
};
注意:map的设计很巧妙,当map满载时候,需要寻找一块更大的空间作为map,新的map会将原空间的数据拷贝过来,这些数据对称存储在新的map,这也是有利于后面增加新的缓冲区。
queue的迭代器:
前面提到了deque本身其实是分段连续的空间,它的每个元素的物理地址不一定连续的,可能存储在不同的缓冲区中,那它对外声称连续性空间的任务都落在了迭代器的设计上面,即迭代器的operator++和operator–。
它的设计难点主要在于跨缓冲区操作,每次操作前都必须能够指出操作对象在哪个分段连续空间内(缓冲区),还必须判断是否达到了缓冲区的边界,若达到了边界需要跳跃到上一个/下一个缓冲区。为了能够正确的跳跃缓冲区,迭代器必须也有个主控来控制map,因为它定位哪一块缓冲区。
template <class T,class Ref,class Ptr,size_t BufSiz>
struct __deque_iterator{
typedef __deque_iterator<T,T&,T*,BufSiz> iterator;
...
//迭代器都必须自行撰写五个必要的迭代器相应型别。
typedef random_access_iterator_tag iterator_category;
...
typedef T** map_pointer;
T* cur;//指向缓冲区的当前元素
T* first;//缓冲区的左边界
T* last;//缓冲区的右边界
map_pointer node;//控制缓冲区的跳跃(主控)
//缓冲区大小,能够存放多少个T类型的元素
static size_t buffer_size() {return __deque_buf_suze(BufSize,sizeof(T));}
};
注意:__deque_buf_suze()也是一个全局函数,它的定义如下:
//当 n != 0时,缓冲区的大小由n来决定
//当 n == 0时,且元素类型的大小 sz < 512 bytes时,缓冲区大小 = 512 / sz
//当 n == 0时,且元素类型的大小 sz >= 512 bytes时,缓冲区大小 = 1(每个缓冲区只放入一个元素)
inline size_t __deque_buf_suze(size_t n,size_t sz)
{
return n != 0 ? n : (sz < 512 ? size_t(512 / sz) : size_t(1));
}
迭代器的主要操作:operator+=(最主要),operator[]
operatot+= 它实现了随机存取,迭代器可以直接跳跃n个距离
self& operator+=(difference_type n)
{
//偏移量
difference_type offset = n + cur - first;
//判断是否在同一个缓冲区移动
if(offset >= 0 && offset < difference_type(buffer_size());
{
//同一块缓冲区
cur += n;
}
else
{
difference_type node_offset = offset > 0 ? offset / difference_type(buffer_size()) : -difference_type((-offset-1) / buffer_size())-1;
//切换到正确的节点位置
set_node(node + node_offset);
//切换到正确的元素位置
cur = first + (offset - difference_type(buffer_size()));
}
return *this;
}
//设置下一个缓冲区位置
void set_node(map_pointer new_node)
{
node = new_node;
first = *new_node;
last = first + difference_type(buffer_size());
}
这里有必要讲一下insert和erase操作:
//erase(iterator pos)删除某个位置的元素
首先判断该位置的左右元素个数,left > right时
将right个元素向左移动一个位置,将pos移动到最右端
然后进行pop_back(),反之同理
//erase(iteratot first,iterator last)删除某区间的元素
首先判断该区间的元素左右元素个数
移动它们覆盖清除的区间的元素数据
移动完毕之后调用析构。
//insert(iterator pos,const value_type& x)
如果插入的时最前端,调用push_front
如果插入的是最后端,调用push_back
如果都不是,则判断该位置的前后元素个数
将在最前端/最后端push_front()/push_back()一个front()/back()的值
将它们向前/后移动一个位置
最后pos的位置腾空出来,直接插入pos即可。
适配器
stack
stack 是﹒种先进后出〔First In Last Out,FILO)的数据结构。它只有一个出口,形式如图4-18所示。stack 允许新增元素、移除元素、取得最顶端元素。但除了最顶端外,没有任何其它方法可以存取stack 的其它元素,换言之,stack不允许有遍历行为。
将元素推人 stack的操作称为push,将元素推出stack 的操作称为pop.
以某种既有容器作为底部结构,将其接口改变,使之符合“先进后出”的特性,形成一个 stack,是很容易做到的.deque是双向开口的数据结构,若以deque为底部结构并封闭其头端开口,便轻而易举地形成了一个stack。因此,SGI STL便以 deque 作为缺省情况下的 stack底部结构,stack 的实现因而非常简单,源代码十分简短,本处完整列出。
由于 stack 系以底部容器完成其所有工作,而具有这种“修改某物接口,形成另一种风貌”之性质者,称为adapter(配接器),因此,STL stack 往往不被归类为container (容器),而被归类为container adapter.
注意:stack没有迭代器,stack不提供遍历、随机访问等操作,只能在最顶端插入删除、访问元素。
stack默认底层结构使用deque,但list也是可以的。
queue
queue 是一种先进先出(First In First Out,FIFO)的数据结构。它有两个出口,形式如图4-19所示。queue 允许新增元素、移除元素、从最底端加入元素、取得最顶端元素。但除了最底端可以加入、最顶端可以取出外,没有任何其它方法可以存取queue的其它元素。换言之,queue不允许有遍历行为。
将元素推人queue的操作称为push,将元素推出queue的操作称为pop.
以某种既有容器为底部结构,将其接口改变,使其符合“先进先出”的特性,形成一个queue,是很容易做到的.deque是双向开口的数据结构,若以 deque 为底部结构并封闭其底端的出口和前端的人П,便轻而易举地形成了一个queue。因此,SGI STL便以deque 作为缺省情况下的queue 底部结构,queue 的实现因而非常简单,源代码十分简短,本处完整列出.
由于queue系以底部容器完成其所有工作,而具有这种“修改某物接П,形成另一种风貌”之性质者,称为adapter(配接器),因此,STL queue往往不被归类为container(容器),而被归类为container adapter。
注意:queue没有迭代器,queue所有元素的进出都必须符合“先进先出”的条件,只有queue顶端的元素,才有机会被外界取用。queue 不提供遍历功能,也不提供迭代器。也可以使用list作为底层结构,但效率比不上deque。
heap
heap并不归属于STL容器组件,它是个幕后英雄,扮演priority queue的助手。顾名思义,priority queue 允许用户以任何次序将任何元素推人容器内,但取出时一定是从优先权最高(也就是数值最高)的元素开始取。binarymax heap正是具有这样的特性,适合作为 priority queue 的底层机制。
堆的分类:最大堆和最小堆
最大堆:它的每一个节点的键值比子节点大或等于,故最大键值的节点是根节点。
最小堆:它的每一个节点的键值比子节点小或等于,故最大键值的节点是根节点。
主要的heap操作:
注意:heap操作都是在vector或array操作,array不能动态变化,所以它的push_heap()执行后和原的heap一样,因为push_heap默认数组尾部是新增的元素。
//将vector或array建立堆,默认为最大堆
1、make_heap()
//向堆加入新元素总是在完全二叉树中最下层的叶子节点从左到右填补,这样的顺序也即是插入到vector尾部
//默认新元素插入到vector的尾端,则向上调整堆
2、push_heap()
//将根节点和尾端节点交换,向下调整堆,注意没有删除最大值的节点
//只是将它放在了vector尾端。故可以利用这个特性完成sort_heap()操作
3、pop_heap()
//利用了pop_heap()特性,循环遍历执行pop_heap操作,完成了降序排序
4、sort_heap()
priority_queue
顾名思义,priority_queue是一个拥有权值观念的queue,它允许加入新元索、移除旧元索、审视元素值等功能。由于这是一个queue,所以只允许在底端加入元素,并从顶端取出元素,除此之外别无其它存取元素的途径。
priority_queue带有权值观念,其内的元素并非依照被推入的次序排列,而是自动依照元素的权值排列(通常权值以实值表示)。权值最高者,排在最前面。
缺省情况下priority-queue系利用一个max-heap + vector 完成的。
priority_queue的主要操作:
//数据成员:
vector<T> c; //底层容器
Compare comp; //比较大小的标准
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
const_reference top() const { return c.front(); }
//前面讲push_heap()是默认vector末尾元素是新增的,现在push()需要先将元素push_back()
void push(const value_type& x){ c.push_back(x);push_heap(c.begin(),c.end(),comp);}
//pop_heap()是将根节点和尾端元素交换位置,并没有删除,故现在需要在pop_heap()之后进行pop_back()
void pop() { pop_heap(c.begin(),c.end(),comp); c.pop_back();}
priority_queue的测试
#include <iostream>
#include <algorithm>
#include <queue> //头文件
#include <vector>
using namespace std;
int main()
{
vector<int> arr;
arr.push_back(5);
arr.push_back(8);
arr.push_back(2);
arr.push_back(6);
arr.push_back(1);
for(auto& x : arr) cout << x << " "; // 5 8 2 6 1
cout << endl;
//priority_queue<int,vector<int>,greater<int>> 最小堆
//priority_queue<int,vector<int>,less<int>> 最大堆
priority_queue<int> que(arr.begin(),arr.end());
cout << "que.size() = " << que.size() << endl; // 5
cout << "que.top() = " << que.top() << endl; // 8
que.pop(); //删除根节点,并进行堆调整,之后的que.top()是下一个最大值
cout << que.top() << endl; // 6
cout << que.size() << endl; // 4
return 0;
}
注意:priority_queue也是一种适配器,以某种特点的容器作为底层容器,更应该称为容器的适配器,它没有迭代器,不能随机访问,只能访问顶端元素(优先级最高)。
关联式容器
set
multiset
map
multimap
非标准容器
hash_set(unordered_set)
hash_multiset(unordered_multiset)
hash_map (unordered_map)
hash_multimap(unordered_multimap)
常见的面试问答
1、说说 vector 和 list 的区别
1) vector, 连续存储的容器,动态数组,在堆上分配空间 ;
底层实现:数组。
如果没有剩余空间了,则会重新配置原有元素个数的两倍空间,然后将原空间元素通过复制的方式初始化新空间,再向新空间增加元素。
扩容的三部曲:
1、分配更大的新空间
2、移动数据
3、释放旧空间
适用场景:经常随机访问,且不经常对非尾节点进行插入删除。
2)list,双向环形链表,在堆上分配空间,每插入一个元素都会分配空间(一个元素大小的空间),每删除一个元素都会释放空间。
底层:双向环形链表,内部含有一个空白节点,满足符合前闭后开原则。
访问:随机访问性能很差,只能快速访问头尾节点。
适用场景:经常插入删除大量数据
3) vector在中间节点进行插入删除会导致内存拷贝,list不会。
4) vector一次性分配好内存,不够时才进行2倍扩容;list每次插入新节点都会进行内存申请。
5) vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。
6)list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。
7)vector的迭代器是一个普通指针,而list是一个双向型别的迭代器。
2、map 和 set 有什么区别
1) map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree)。
2) map中的元素是key-value(关键字—值)对:关键字起到索引的作用,值则表示与索引相关联的数据;set与之相对就是关键字的简单集合,set中每个元素只包含一个关键字。
3) set的迭代器是const的,不允许修改元素的值data;map允许修改data,但不允许修改key。
4) map支持下标操作,set不支持下标操作。map可以用key做下标。
3、unordered_map和map 说说区别
内部实现机理
map: map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率。
unordered_map: unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的
优缺点以及适用场景
map
优点:
有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作
红黑树,内部实现一个红黑书使得map的很多操作在的时间复杂度下就可以实现,因此效率非常的高
缺点:
适用处,对于那些有顺序要求的问题,用map会更高效一些
空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点,孩子节点以及红/黑性质,使得每一个节点都占用大量的空间。
unordered_map
优点:
因为内部实现了哈希表,因此其查找速度非常的快
缺点:
适用处,对于查找问题,unordered_map会更加高效一些,因此遇到查找问题,常会考虑一下用unordered_map,哈希表的建立比较耗费时间。