C++面试连环问-STL

1.讲讲STL的六大组件

  1. 容器(Containers):各种数据结构,包括序列式容器Vector,List,Deque,和关联式容器Set,Map,unordered_map,unordered_set,用来存放数据,STL容器是一种Class Template
  2. 算法(Algorithms):各种常用算法如Sort,Search,Copy,Erase,从实现的角度来看,STL算法是一种Function Templates。

  3. 迭代器(Iterators):扮演容器与算法之间的胶合剂,是所谓的“泛型指针”,共有五种类型,以及其它衍生变化,从实现的角度来看,所有STL容器都附带有自己专属的迭代器。迭代器不是指针,是一种将:Operators*,Operator->,Operator++,Operator--等相关操作予以重载的Class Template,表现的像指针。他只是模拟了指针的一些功能,重载了指针的一些操作符,-->、++、--等。迭代器封装了指针,是一个”可遍历STL( Standard Template Library)容器内全部或部分元素”的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,--等操作。

  4. 仿函数(Functors):行为类似函数,可作为算法的某种策略(Policy),从实现的角度来看,仿函数是一种重载了Operator()的Class 或 Class Template。一般函数指针可视为狭义的仿函数。

  5. 适配器(配接器)(Adapters):一种用来修饰容器(Containers)或仿函数(Functors)或迭代器(Iterators)接口的东西,例如:STL提供的Queue和Stack,虽然看似容器,其实只能算是一种容器配接器,因为 它们的底部完全借助Deque,所有操作有底层的Deque供应。改变Functor接口者,称为Function Adapter;改变Container接口者,称为Container Adapter;改变Iterator接口者,称为Iterator Adapter。

  6. 分配器(Allocators):负责空间配置与管理,从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的Class Template。由于往往只有高级用户才有改变内存分配策略的需求,因此内存分配器对于一般用户来说,并不常用。

2.简单说说vector

动态开辟的数组容器。元素在内存连续存放。随机存取任何元素都能在常数时间完成。在尾端增删元素具有较佳的性能。

  • 动态开辟的空间,随着元素的加入,它的内部机制会自动扩充空间以容纳新的元素。vector的关键技术在于对大小的控制以及重新分配时的数据移动效率。在增加元素时,如果超过自身最大的容量,vector则将自身的容量扩充为原来的两倍。扩充空间需要经过的步骤:重新配置空间,元素移动,释放旧的内存空间。一旦vector空间重新配置,则指向原来vector的所有迭代器都失效了,因为vector的地址改变了。

  • 采用的数据结构是线性的连续空间(泛型的动态类型顺序表),他以两个迭代器start和finish分别指向配置得来的连续空间中目前已将被使用的空间。迭代器end_of_storage指向整个连续的尾部。

vector扩容的过程

  • 重新申请更大的(1.5倍或2倍)内存空间;
  • 将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
  • 最后将旧的内存空间释放。

vector为什么要用加倍扩容而不是每次增加一个固定的扩容容量 

  • vector在push_back以成倍增长可以在均摊后达到O(1)的事件复杂度,相对于增长指定大小的O(n)时间复杂度更好。
  • 为了防止申请内存的浪费,现在使用较多的有2倍与1.5倍的增长方式,而1.5倍的增长方式可以更好的实现对内存的重复利用。
  • 一般情况下,在Windows的VS系列编译器下,是按照1.5倍的方式进行扩容,在Linux的g++中,是按照2倍的方式进行扩容的。

 size、resize、reserve、capacity的区别

  • size表示当前vector中有多少个有效元素(即finish – start);当前容器所存储有效元素的个数,
  • resize可以改变有效空间的大小,也有改变默认值的功能。当resize大小超过capacity的大小时,capacity的大小也会随着改变,当resize大小小于size,size缩小,size后面的空间元素还存在,访问也不会报错,不过不再是有效值,要注意越界问题。可以有多个参数。创建指定数量的元素并指定vector的存储空间。既分配空间又创建对象(v.resize(9, 6);)。
  • reserve是直接扩充到已经确定的大小,可以减少多次开辟、释放空间的问题(优化push_back),从而达到提高效率的目的,其次还可以减少多次要拷贝数据的问题。reserve它只是保证vector中的空间大小(capacity)最少达到参数所指定的大小n。并且它只有一个参数。指定vector的元素总数,不创建对象。
  • capacity函数表示它已经分配的内存中可以容纳多少元素(即end_of_storage – start)。即容器在分配新的存储空间能存储的元素总数。返回vector中能存储元素的最大数。

erase成员函数,它删除了itVect迭代器指向的元素,并且返回要被删除的itVect之后的迭代器,迭代器相当于一个智能指针;clear()函数,只能清空内容,不能改变容量大小;如果要想在删除内容的同时释放内存,那么你可以选择deque容器。

vector迭代器失效问题

迭代器的主要作用就是让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了 封装,比如:vector的迭代器就是原生态指针T*。因此迭代器失效,实际就是迭代器底层对应指针所指向的 空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器, 程序可能会崩溃)。

对于vector可能导致其迭代器失效的操作有哪些

 

  • resize、reserve、insert、assign、push_back等会引起其底层空间改变的操作,都有可能使迭代器失效。

  • 指定位置元素的删除操作--erase


#include 
using namespace std;
#include 
int main()
{
    int a[] = { 1, 2, 3, 4 };
    vector v(a, a + sizeof(a) / sizeof(int));
    // 使用find查找3所在位置的iterator
    vector::iterator pos = find(v.begin(), v.end(), 3);
    // 删除pos位置的数据,导致pos迭代器失效。
    v.erase(pos);
    cout << *pos << endl; // 此处会导致非法访问
    return 0;
}

 erase删除pos位置元素后,pos位置之后的元素会往前移动,没有导致底层空间的改变,理论上讲迭代器应该不会失效,但是如果pos刚好是最后一个元素,删完之后pos刚好是end位置,而end位置是没有元素的,那么pos就失效了。所以删除vector中任意位置上元素时,vs就认为该位置迭代器失效了。

解决方法pos = v.erase(pos);

push_back和emplace_back的区别

emplace_back() 和 push_back() 的主要区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。

频繁对vector调用push_back()对性能的影响和原因

在一个vector的尾部之外的任何位置添加元素,都需要重新移动元素。而且,向一个vector添加元素可能引起整个对象存储空间的重新分配。重新分配一个对象的存储空间需要分配新的内存,并将元素从旧的空间移到新的空间。

3.聊聊STL库中的list

  •  list 是顺序容器的一种。list 是一个双向链表。使用 list 需要包含头文件 list。双向链表的每个元素中都有一个指针指向后一个元素,也有一个指针指向前一个元素。
  • 在 list 容器中,在已经定位到要增删元素的位置的情况下,增删元素能在常数时间内完成。
  • list不支持根据下标随机存取元素。
  • 在任何位置都能高效地插入和删除元素,只要改变元素的指针值,不需要拷贝元素。

list的底层原理 

  • list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。

  • 和vector容器迭代器的实现方式不同,由于 list 容器的元素并不是连续存储的,所以该容器迭代器中,必须包含一个可以指向 list 容器的指针,并且该指针还可以借助重载的 *、++、--、==、!= 等运算符,实现迭代器正确的递增、递减、取值等操作。

vector和list的区别

  • 下标随机访问

vector的底层是一段连续的物理空间,所以支持随机访问。

 因为list是链表,在存放数据的物理地址并不是连续的,所以我们也就不能支持随机访问。

  • 插入和删除效率

vector尾插尾删效率高,跟数组类似,我们能够很轻易的找到最后一个元素,并完成各种操作。前面部分的插入删除数据效率低,如果我们要在前面或中间插入或者删除数据,我们不能直接删,我们需要挪动数据,去覆盖或者增加一段空间,这样我们挪动数据的效率就是O(N)。

list任意位置的插入删除效率高(对比vector)因为list是双向循环链表,我们需要插入新的元素只需要改变原数据的next和prev,所以我们的插入删除效率是O(1)。

  • cpu高速缓存命中率

 因为系统在底层拿空间的时候,是拿一段进cpu,不是只拿单独一个,会提前往后多拿一点,vector的物理地址是连续的,所以我们再拿到数据的时候,cpu访问后面的数据会更快。list的物理地址不连续,CPU提前存的一段数据,可能跟下一个数据完全没有联系,因为它们空间不连续,所以就命中率低。

  •   扩容问题

vector扩容有消耗,可能存在一定的空间浪费,正常情况下我们vector的扩容机制是一旦达到当前空间的capacity(容量)那么我们扩容原空间的1.5倍或者2倍数(vs一般是1.5倍而g++是2倍),这样扩容就有可能导致空间浪费,而且频繁扩容也会影响效率。

list按需申请释放,不需要扩容 list是一个带头双向循环链表,那么链表就是一个个独立的空间链接起的,需要多少,就new多少,不存在空间浪费。

  • 删除元素后迭代器失效问题

对于vector而言,删除某个元素以后,该元素后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。
对于list而言,删除某个元素,只有“指向被删除元素”的那个迭代器失效,其它迭代器不受任何影响。

  • 内存分配问题

vector是动态数组,在堆中分配内存,元素连续存放,有保留内存,如果减少大小后,内存也不会释放;如果新增大小当前大小时才会重新分配内存。

list元素存放在堆中,每个元素都是放在一块内存中,它的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点,使得它的随机存取变得非常没有效率,因此它没有提供[ ]操作符的重载。但是由于链表的特点,它可以很有效的支持任意地方的删除和插入操作。

 4.简单说说deque

  1. deque是一个双向开口的容器,所谓双向开口就是再头尾两端均可以做元素的插入和删除操作。
  2. deque相比于vector最大的差异就在于支持常数时间内对首尾两端进行插入和删除操作,而且deque没有容量的概念,其内部采用分段连续内存空间来存储元素,在插入元素的时候随时都可以重新增加一段新的空间并链接起来。
  3. deque提供了Ramdon Access Iterator,同时也支持随机访问和存取,但是它也为此付出了昂贵的代价,其复杂度不能跟vector的原生指针迭代器相提并论。

详细讲讲deque实现原理

 deque内部实现的是一个双向队列。元素在内存连续存放。随机存取任何元素都在常数时间完成(仅次于vector)。所有适用于vector的操作都适用于deque。在两端增删元素具有较佳的性能(大部分情况下是常数时间)。

你了解deque的中控器吗

deque为了维持整体连续的假象,设计一个中控器,其用来记录deque内部每一段连续空间的地址。大体上可以理解为deque中的每一段连续空间分布在内存的不连续空间上,然后用一个所谓的map作为主控,记录每一段内存空间的入口,从而做到整体连续的假象。

deque的迭代器是怎么回事呢

deque提供的是一个随机访问迭代器,由于是分段连续空间,其必须记录当前元素所在段的信息,从而在该段连续空间的边缘进行前进或者后退的时候能知道跳跃到的上一个或下一个缓冲区。deque必须完完整整的掌握和控制这些信息,以达到正确的跳跃。

说一说deque的数据结构

deque维护着一个map,用来记录每个缓冲区的位置。除了map外,deque的数据结构还维护着start和finish两个迭代器,分别指向deque的首尾。此外,他还必须知道map的大小,一旦map提供的节点不足,就需要配置一块更大的map。

 

5.说说STL迭代器是怎么删除元素的

  1. 对于序列容器vector,deque来说,使用erase后,后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器;

  2. 对于关联容器map,set来说,使用了erase后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素,不会影响下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可;

  3. 对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的迭代器,因此上面两种方法都可以使用。

6. 你了解map和unordered_map嘛?底层实现呢

  • map实现机理

map内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此map内部所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来。

  • unordered_map实现机理

unordered_map内部实现了一个哈希表(也叫散列表),通过把关键码值映射到Hash表中一个位置来访问记录,查找时间复杂度可达O(1),其中在海量数据处理中有着广泛应用。因此,元素的排列顺序是无序的。

map和unordered_map的区别

  •  map是有序的,unordered_map是无序的
  • 基于红黑树实现,查找的时间复杂度是O(logn),unordered_map底层是用哈希表实现的,查找效率非常高,时间复杂度为O(1)。对于查找问题,使用unordered_map更好。
  • map空间占用比较高,虽然说底层是红黑树实现的,提高了运行效率,但是每个节点都要保存父节点和孩子节点和红黑树的性质,使得每一个节点都占用大量的空间。
  • 哈希表的建立比较费时。

为什么map和set和插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效

因为存储的是节点,不需要内存拷贝和内存移动。插入操作只是节点指针换来换去,节点内存没有改变,而iterator就像指向节点的指针,内存没变,指向内存的指针也不会变。

为什么map和set不能像vector一样有个reserve函数来预分配数据

因为在map和set内部存储的已经不是元素本身了,而是包含元素的节点。也就是说map内部使用的Alloc并不是map声明的时候从参数中传入的Alloc。

你知道map和set有什么区别嘛?分别是怎么实现的呢

  • set是一种关联式容器,其特性如下:

    • set以RBTree作为底层容器

    • 所得元素的只有key没有value,value就是key

    • 不允许出现键值重复

    • 所有的元素都会被自动排序

    • 不能通过迭代器来改变set的值,因为set的值就是键,set的迭代器是const的

  • map和set一样是关联式容器,其特性如下:

    • map以RBTree作为底层容器

    • 所有元素都是键+值存在

    • 不允许键重复

    • 所有元素是通过键进行自动排序的

    • map的键是不能修改的,但是其键对应的值是可以修改的

综上所述,map和set底层实现都是红黑树;map和set的区别在于map的值不作为键,键和值是分开的。

map中[ ]与find的区别

  • map的下标运算符[ ]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值的项插入这个map。
  • map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器

hash_map与map的区别?什么时候用hash_map,什么时候用map

构造函数:hash_map需要hash function和等于函数,而map需要比较函数(大于或小于)。
存储结构:hash_map以hashtable为底层,而map以RB-TREE为底层。
总的说来,hash_map查找速度比map快,而且查找速度基本和数据量大小无关,属于常数级别。而map的查找速度是logn级别。但不一定常数就比log小,而且hash_map还有hash function耗时。
如果考虑效率,特别当元素达到一定数量级时,用hash_map。
考虑内存,或者元素数量较少时,用map。

你可能感兴趣的:(面试总结,C++,STL,C++,c++,开发语言)