c++ stl array 传参_浅谈C++下STL库中的容器底层小知识

前排友情提醒,本文章的读者需要具备一些C++基础,不是面向小白的文章,抱歉。

如果您阅读过程中发现有些东西晦涩难懂或者不知作者所云,先道个歉了~另能力有限,如果总结的不到位,欢迎各位大佬批评指正。

前言

对于C++相关从业人员来说,STL( Standard Template Library ,标准模板库)对于C++的重要性不言而喻。STL中现成的容器、算法在刷牛客上的算法题的时候简直不要太好使。

可是对于在大厂面试过程中,STL库中各种容器底层数据结构以及相关实现细节也是常问的知识点,今天就带大家小小梳理一番STL中的常见容器下的一些小知识吧,希望能对大家有所帮助。

比如二分查找方法lower_bound和upper_bound就是一大利器。笔者在某大厂面试时写了一个数组题,里面用了lower_bound和uppder_bound获得了面试官的好评,说懂的利用已有轮子,避免重复性工作等。

本书大部分内容来源于C++大师侯捷老师著作《STL源码剖析》以及《C++Primer 5th》,特别喜欢侯捷老师的一句名言:源码面前,了无秘密。

43470b755ae5ac9d16937b5ebb752739.png

STL下的常见容器主要分为两大类:序列式容器以及关联式容器。顾名思义序列式容器就是在物理上一个挨着一个的,彼此之间相邻,比如数组、栈、队列,其实都是一个挨着一个的。而关联式容器,重点在关联二字,关联关联,至少是两个东西之间存在着一定的联系才可以叫做关联,比如map中的key和value有着一定的关联。

序列式容器

vector

STL中最好用的莫过于vector了,这是一种类似于数组的数据结构,其数据安排以及操作方式与数组array非常类似,两者的唯一差别就是对于空间运用的灵活性。

array占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。

vector则使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中仍然需要经历:重新配置空间,移动数据,释放原空间等操作。

书中侯捷老师说vector扩容倍数为2倍,如下图所示:

5d386752204c7662f76e815d2ea3c657.png

经笔者实践,动态扩容原则跟操作系统相关,在不同环境下扩容系数是不一样的,不可直接说是 2 倍。

  • 在Windows 10 + Visual Studio 2019 下的扩容倍数为 1.5倍;

  • 在Linux + g++ 下扩容倍数是 2 倍,其中Linux为Ubuntu 18.04,g++ 5.4.0。

测试代码如下:

int main() {    vectordata(2, 1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    data.push_back(1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    data.push_back(1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    data.push_back(1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    data.push_back(1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    data.push_back(1);    cout << "size: " << data.size() << " capacity " << data.capacity() << endl;    return 0; }

aadfc967971e998c736abdd837167eaa.png

deque

vector是单向开口(尾部)的连续线性空间,deque则是一种双向开口的连续线性空间,虽然vector也可以在头尾进行元素操作,但是其头部操作的效率十分低下(主要是涉及到vector整体的移动),因此一般不建议在vector的首部进行元素的插入删除等。

38718a3dfd74c2244a3124bab1f0493f.png

deque和vector的最大差异是deque没有容量的概念,它是动态地以分段连续空间组合而成,如下图所示。

2a8408404646348d4eb6453b3da84799.png

一旦需要增加新的空间,只要配置一段定量连续空间拼接在头部或尾部即可,可以随时增加一段新的空间并链接起来的,因此对于deque来说最重要的就是如何维护这个整体的连续性。

deque的数据结构如下:

class deque {    ...protected:    typedef pointer* map_pointer; //指向map指针的指针     map_pointer map; //指向map    size_type map_size; //map的大小public:     ...    iterator begin();     itertator end();     ...}

deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。

deque的迭代器数据结构如下:

struct __deque_iterator{    ...    T* cur;//迭代器所指缓冲区当前的元素    T* first;//迭代器所指缓冲区第一个元素     T* last;//迭代器所指缓冲区最后一个元素     map_pointer node;//指向map中的node     ...}

从deque的迭代器数据结构可以看出,为了保持与容器联结,迭代器主要包含上述4个元素

cb4b8239e225485f2038686fd2d2e9da.png

deque迭代器的“++”、“--”操作是远比vector的迭代器要繁琐的多,其主要工作在于缓冲区边界。当进行自增 或者 自减 操作时候,首先要检查的是当前cur递增或者递减后的结果是否已经越界,如果越界需要对node进行相应的处理,比如指向前一个缓存区或者指向后一个缓冲区等。

stack/queue

严格意义上说stack和queue并不能说是容器,而应该被称作是适配器。原因是因为satck/queue都是在别的容器基础上进行修改,提供某些特定的接口而形成的“容器”。

stack是一种先进后出(First In Last Out)的数据结构,只能通过栈顶来进行元素的获取或者删除,没有其他办法对内部元素进行操作,当然也没有迭代器。其结构图如下:

a62707a3c8a22342fe3d4efc359ebe70.png

stack这种单向开口的数据结构很容易由双向开口的deque和list形成,然后移除某些接口即可实现,stack的部分源码如下:

template <class T, class Sequence = deque >class stack {    ...protected:    Sequence c;public:    bool empty(){return c.empty();}    size_type size() const{return c.size();}     reference top() const {return c.back();}     const_reference top() const{return c.back();}     void push(const value_type& x){c.push_back(x);}     void pop(){c.pop_back();}};

stack的默认Sequence是deque,当然了你也可以自己指定list。

queue(队列)是一种先进先出(First In First Out)的数据结构,可以在队首或者队尾进行某些操作来改变队列。跟stack类似,也没有其他方法可以获取到内部的其他元素,换句话说也是不提供迭代器的。其结构图如下:

f4c6d75ee9363adc27703dfba7608360.png

queue这种“先进先出”的数据结构可以由双向开口的deque和list形成,只需要根据队列先进先出的性质,移除某些特定接口即可实现,queue的源码如下:

template <class T, class Sequence = deque >class queue{    ...protected:    Sequence c;public:    bool empty(){return c.empty();}    size_type size() const{return c.size();}     reference front() const {return c.front();}     const_reference front() const{return c.front();}     void push(const value_type& x){c.push_back(x);}     void pop(){c.pop_front();}...};

跟stack一样,queue的默认Sequence也是deque,当然了你也可以自己指定list。

关联式容器

由于日常使用中,并不会直接使用红黑树这种数据结构,因此这里并不对红黑树做具体介绍,以后单独开一期说红黑树。如果面试过程中,有面试官让你手撸红黑树的,直接转头走人吧,哈哈~

map/set

map的特性是所有元素会根据键值进行自动排序,map中所有的元素都是pair类型的,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的key,可以根据key找到value。

一旦map的key确定了,那么这个key是无法修改的,但是可以修改这个key对应的value。map的架构如下图所示:

66c8abefbdc6804f309bc24958383fa3.png

map的在构造时缺省采用递增排序key,需要注意的是在插入元素时,调用的是红黑树中的insert_unique()方法,而非insert_euqal()函数。

insert_unique()函数:就如同函数名一样,独一无二的插入,当前map中如果有一样的元素时,是无法插入成功的。只有当当前map中没有预插入的元素时,才能够插入成功即一个key值对应且只对应一个value。

insert_euqal()函数:就如同函数名一样,相等的插入,当前map中如果有一样的key时,是可以插入成功的,该函数主要用于multimap中,一个key值可以对应多个value值。

#include #include using namespace std;int main() {    map<int,int> data;    data.insert({ 2,2 });    data.insert({ 1,1 });    data.insert({ 3,3 });    data.insert({ 0,0 });    for (auto it = data.begin(); it != data.end(); ++it) {        cout << it->first << " " << it->second << endl;}    /*    0 0    1 1    2 2    3 3    */     return 0;}

可以看出,插入顺序是{2,2}、{1,1}、{3,3}、{0,0},借助迭代器进行输出时,却按照key值升序输出即{0,0}、{1,1}、{2,2}、{3,3},说明是默认按照key值升序排列的。

在set中,所有元素都会根据元素的值自动被排序(默认升序),set元素的键值就是实值,实值就是键值,set不允许有两个相同的键值。这是因为在底层实现上,set只提供了一个元素类型的接口,如下图所示:

ef05385e8f32b008887c647705a5ee95.png

可以看出identity函数其实就是一个将输入数据原样返回一个函数,换句话说输入是什么输出就什么。这也就从源码角度上说明了为什么set的key和value值是一样的。那是因为在实现上,使用的函数功能就是输入是什么,输出就是什么。

set也不允许迭代器修改元素的值,其迭代器是一种constance_iterators,并不具备修改的功能。

set与map的底层实现都是红黑树rb_tree,只是对rb_tree进行二次封装,修改他的某些接口后形成的具有新特性的容器而已。

小结

set只提供一种数据类型的接口,但是会将这一个元素分配到key和value上,而且它的compare_function用的是 identity()函数,这个函数是输入什么输出什么,这样就实现了set机制,set的key和value其实是一样的了。其实它保存的是两份元素,而不是只保存一份元素。

map则提供两种数据类型的接口,分别放在key和value的位置上,他的比较function采用的是红黑树的compare_function(),保存的确实是两份元素。

代码验证:

#include #include #include using namespace std;int main() {    set<int> st;    st.insert(1);    cout << "只有一个int型元素的set的大小:"<<sizeof(st) << endl;        map<int, int> mp;    mp.insert({1,1});    cout << "只有一个int型元素的map的大小:" << sizeof(mp) << endl;     return 0;}

0d6e1c39fcca78a84e9e784ace03a70a.png

可以看出,set和map都只是保存了一份元素,前者保存了 1,而后者保存了 {1:1},但在默认情况下,所占内存大小是一样的。并不是set保存的数据看起来好像少一个,那么它在同等情况下占的内存就少一些的。

multimap/multiset

multimap和map的唯一区别就是:multimap调用的是红黑树的insert_equal(),可以实现元素的重复插入。而map调用的则是独一无二的插入insert_unique(),只能插入不同的数据。

multiset和set也一样,底层实现都是一样的,只是在插入的时候调用的方法不一样,前者调用的是红黑树的insert_equal(),后者调用的则是独一无二的插入insert_unique()。

hashtable

常见的哈希冲突的解决方法有四种:

1、线性探测法

首先使用hash函数计算出的位置,如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位为止。

2、开链法

每个表格维护一个list,如果hash函数计算出的格子相同,则按顺序存在这个list中。

3、再散列法

发生冲突时使用另一种hash函数再计算一个地址,直到不冲突为止。

4、二次探测法

使用hash函数计算出的位置如果已经有元素占用了,按照$1^2$、$2^2$、$3^2$...的步长依次寻找空位,如果步长是随机数序列,则称之为伪随机探测。

此外还有一种公共溢出区的办法:在计算哈希值时,一旦hash函数计算的结果相同,就放入公共溢出区。

而在STL中,hashtable使用的是开链法解决hash冲突问题,如下图所示。

67697a21136254ee765277c77246addc.png

hashtable中的bucket是其自己定义的由hashtable_node数据结构组成的linked-list,并不是简单的list或者双向list,而bucket用vector进行存储。

在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,...,429496729]。在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(也就是bucket vector的长度)。其中每个bucket所维护的linked-list长度也等于hashtable的容量。

如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。

曾经笔者遇到过一个面试官问我为什么hashtable中要内置28个质数,而第一个质数又为什么要从53开始?针对这个问题,我的回答是:在我们日常生活中有一类问题,作为普通人的我们并没有那个能力或者经验去回答它或者解决它,我想也许是在以往的生产时间生活中,C++相关从业者慢慢发现hashtable的个数需要是质数,并且最小从53开始,最大为429496729,C++相关的委员会也认可这种说法,就把哈希表定义成了现在这个样子。就好像你问我为什么 1+ 1 = 2一样,我也说不清为什么 1+ 1 = 2。

unordered_map/unordered_set

unordered_map/unordered_set的底层使用的是hashtable,而不是像map/set一样使用的红黑树,所以它没有自动排序功能,两者都是对hashtable进行二次封装形成的具体某些特性的新容器。可参考map与set和红黑树的关系来理解unordered_map与unordered_set和hashtable的关系。

其中unordered_map/unordered_set的insert函数()都对hashtable的insert_unique()进行封装得到的,也就是独一无二的插入。

unordered_multimap/unordered_multiset

unordered_multimap/unordered_multiset的底层是使用的也是hashtable,只不过这两者的insert函数是对hashtable的insert_equal()进行封装得到的,也就是可以插入相同元素。

小结

map/set与multimap/multiset都是以红黑树rb_tree为底层数据结构,区别就在于map/set调用的是红黑树的insert_unique()函数,也就是独一无二的插入功能,如果当前map/set中已有,则插入失败;而multimap/multiset调用的是红黑树的insert_equal()函数,也就是可重复性插入,如果当前map/set中已有,则插入成功。

unordered_map/unordered_set与unordered_multimap/unordered_multiset都是以哈希表hashtable为底层数据结构;区别就在于unordered_map/unordered_set调用的是哈希表的insert_unique()函数,也就是独一无二的插入功能,如果当前unordered_map/unordered_set中已有,则插入失败;

unordered_multimap/unordered_multiset调用的是hashtable的insert_equal()函数,也就是可重复性插入,如果当前unordered_map/unordered_set中已有,则插入成功。

总结

一篇短短的文章远远不足以囊括侯捷老师的经典著作,如果大家希望明白的彻底一点,还是需要深入到书籍中去,本文只是做一点小小的总结。

最后,C++是世界上最好的语言,不接受反驳!

b01f6a77f534eb3f4a44d89d04e6ed9b.png

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值