什么是C++ STL?
C++ STL从广义来讲包括了三类:算法,容器和迭代器。
算法包括排序,复制等常用算法,以及不同容器特定的算法。
容器就是数据的存放形式,包括序列式容器和关联式容器,序列式容器就是list,vector等,关联式容器就是set,map等。
迭代器就是在不暴露容器内部结构的情况下对容器的遍历。
什么时候需要用hash_map,什么时候需要用map?
总体来说,hash_map 查找速度会比 map 快,而且查找速度基本和数据数据量大小无关,属于常数级别;而 map 的查找速度是 log(n) 级别。
并不一定常数就比 log(n) 小,hash 还有 hash 函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑 hash_map。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,hash_map 可能会让你陷入尴尬,特别是当你的 hash_map 对象特别多时,你就更无法控制了。而且 hash_map 的构造速度较慢。
现在知道如何选择了吗?权衡三个因素: 查找速度, 数据量, 内存使用 。
STL中hashtable的底层实现?
STL中的hashtable使用的是开链法解决hash冲突问题,如下图所示。
hashtable中的bucket所维护的list既不是list也不是slist,而是其自己定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作
在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,…,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。
vector 底层原理及其相关面试题
(1)vector的底层原理
vector底层是一个动态数组,包含三个迭代器,start和finish之间是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部。
当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间【vector内存增长机制】。
当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。
因此,对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器会都失效了。
(2)vector中的reserve和resize的区别
reserve是直接扩充到已经确定的大小,可以减少多次开辟、释放空间的问题(优化push_back),就可以提高效率,其次还可以减少多次要拷贝数据的问题。reserve只是保证vector中的空间大小(capacity)最少达到参数所指定的大小n。reserve()只有一个参数。
resize()可以改变有效空间的大小,也有改变默认值的功能。capacity的大小也会随着改变。resize()可以有多个参数。
(3)vector中的size和capacity的区别
size表示当前vector中有多少个元素(finish – start),而capacity函数则表示它已经分配的内存中可以容纳多少元素(end_of_storage – start)。
(4)vector的元素类型可以是引用吗?
vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用。
(5)vector迭代器失效的情况
当插入一个元素到vector中,由于引起了内存重新分配,所以指向原内存的迭代器全部失效。
当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase方法会返回下一个有效的迭代器,所以当我们要删除某个元素时,需要it=vec.erase(it);。
(6)正确释放vector的内存(clear(), swap(), shrink_to_fit())
vec.clear():清空内容,但是不释放内存。
vector().swap(vec):清空内容,且释放内存,想得到一个全新的vector。
vec.shrink_to_fit():请求容器降低其capacity和size匹配。
vec.clear();vec.shrink_to_fit();:清空内容,且释放内存。
(7)vector 扩容为什么要以1.5倍或者2倍扩容?
根据查阅的资料显示,考虑可能产生的堆空间浪费,成倍增长倍数不能太大,使用较为广泛的扩容方式有两种,以2倍的方式扩容,或者以1.5倍的方式扩容。
以2倍的方式扩容,导致下一次申请的内存必然大于之前分配内存的总和,导致之前分配的内存不能再被使用,所以最好倍增长因子设置为(1,2)之间:
vector<int> vec(10,100); 创建10个元素,每个元素值为100
vec.resize(r,vector<int>(c,0)); 二维数组初始化
reverse(vec.begin(),vec.end()) 将元素翻转
sort(vec.begin(),vec.end()); 排序,默认升序排列
vec.push_back(val); 尾部插入数字
vec.size(); 向量大小
find(vec.begin(),vec.end(),1); 查找元素
iterator = vec.erase(iterator) 删除元素
list 底层原理及其相关面试题
list 底层原理及其相关面试题
(1)list的底层原理
list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。
list不支持随机存取,适合需要大量的插入和删除,而不关心随即存取的应用场景。
list.push_back(elem) 在尾部加入一个数据
list.pop_back() 删除尾部数据
list.push_front(elem) 在头部插入一个数据
list.pop_front() 删除头部数据
list.size() 返回容器中实际数据的个数
list.sort() 排序,默认由小到大
list.unique() 移除数值相同的连续元素
list.back() 取尾部迭代器
list.erase(iterator) 删除一个元素,参数是迭代器,返回的是删除迭代器的下一个位置
deque底层原理及其相关面试题
(1)deque的底层原理
deque是一个双向开口的连续线性空间(双端队列),在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度。
(2)什么情况下用vector,什么情况下用list,什么情况下用deque
vector可以随机存储元素(即可以通过公式直接计算出元素地址,而不需要挨个查找),但在非尾部插入删除数据时,效率很低,适合对象简单,对象数量变化不大,随机访问频繁。除非必要,我们尽可能选择使用vector而非deque,因为deque的迭代器比vector迭代器复杂很多。
list不支持随机存储,适用于对象大,对象数量变化频繁,插入和删除频繁,比如写多读少的场景。
需要从首尾两端进行插入或删除操作的时候需要选择deque。
(3)deque的常用函数
deque.push_back(elem) 在尾部加入一个数据。
deque.pop_back() 删除尾部数据。
deque.push_front(elem) 在头部插入一个数据。
deque.pop_front() 删除头部数据。
deque.size() 返回容器中实际数据的个数。
deque.at(idx) 传回索引idx所指的数据,如果idx越界,抛出out_of_range。
Vector如何释放空间?
由于vector的内存占用空间只增不减,比如你首先分配了10,000个字节,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收。
如果需要空间动态缩小,可以考虑使用deque。如果vector,可以用swap()来帮助你释放内存。
vector(Vec).swap(Vec); //将Vec的内存清除;
vector().swap(Vec); //清空Vec的内存;
如何在共享内存上使用STL标准库?
- 想像一下把STL容器,例如map, vector, list等等,放入共享内存中,IPC一旦有了这些强大的通用数据结构做辅助,无疑进程间通信的能力一下子强大了很多。
我们没必要再为共享内存设计其他额外的数据结构,另外,STL的高度可扩展性将为IPC所驱使。STL容器被良好的封装,默认情况下有它们自己的内存管理方案。
当一个元素被插入到一个STL列表(list)中时,列表容器自动为其分配内存,保存数据。考虑到要将STL容器放到共享内存中,而容器却自己在堆上分配内存。
一个最笨拙的办法是在堆上构造STL容器,然后把容器复制到共享内存,并且确保所有容器的内部分配的内存指向共享内存中的相应区域,这基本是个不可能完成的任务。
- 假设进程A在共享内存中放入了数个容器,进程B如何找到这些容器呢?
一个方法就是进程A把容器放在共享内存中的确定地址上(fixed offsets),则进程B可以从该已知地址上获取容器。另外一个改进点的办法是,进程A先在共享内存某块确定地址上放置一个map容器,然后进程A再创建其他容器,然后给其取个名字和地址一并保存到这个map容器里。
进程B知道如何获取该保存了地址映射的map容器,然后同样再根据名字取得其他容器的地址。
map插入方式有哪几种?
- 用insert函数插入pair数据,
mapStudent.insert(pair<int, string>(1, "student_one"));
- 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type (1, "student_one"));
- 在insert函数中使用make_pair()函数
mapStudent.insert(make_pair(1, "student_one"));
- 用数组方式插入数据
mapStudent[1] = "student_one";
map、set、multiset、multimap 底层原理及其相关面试题
(1)map 、set、multiset、multimap的底层原理
map 、set、multiset、multimap的底层实现都是红黑树,epoll模型的底层数据结构也是红黑树,linux系统中CFS进程调度算法,也用到红黑树
红黑树的特性:
每个结点或是红色或是黑色;
根结点是黑色;
每个叶结点是黑的;
如果一个结点是红的,则它的两个儿子均是黑色;
每个结点到其子孙结点的所有路径上包含相同数目的黑色结点。
红黑树详解具体看这篇:别再问我什么是红黑树了
对于STL里的map容器,count方法与find方法,都可以用来判断一个key是否出现,mp.count(key) > 0统计的是key出现的次数,因此只能为0/1,而mp.find(key) != mp.end()则表示key存在。
(2)map 、set、multiset、multimap的特点
set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复。
map和multimap将key和value组成的pair作为元素,根据key的排序准则自动将元素排序(因为红黑树也是二叉搜索树,所以map默认是按key排序的),map中元素的key不允许重复,multimap可以重复。
map和set的增删改查速度为都是logn,是比较高效的。
(3)为何map和set的插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效?
因为存储的是结点,不需要内存拷贝和内存移动。
因为插入操作只是结点指针换来换去,结点内存没有改变。而iterator就像指向结点的指针,内存没变,指向内存的指针也不会变。
(4)为何map和set不能像vector一样有个reserve函数来预分配数据?
因为在map和set内部存储的已经不是元素本身了,而是包含元素的结点。也就是说map内部使用的Alloc并不是map<Key, Data, Compare, Alloc>声明的时候从参数中传入的Alloc。
(5)map 、set、multiset、multimap的常用函数
it map.begin() 返回指向容器起始位置的迭代器(iterator)
it map.end() 返回指向容器末尾位置的迭代器
bool map.empty() 若容器为空,则返回true,否则false
it map.find(k) 寻找键值为k的元素,并用返回其地址
int map.size() 返回map中已存在元素的数量
map.insert({int,string}) 插入元素
for (itor = map.begin(); itor != map.end();)
{
if (itor->second == "target")
map.erase(itor++) ; // erase之后,令当前迭代器指向其后继。
else
++itor;
}
unordered_map、unordered_set 底层原理及其相关面试题
(1)unordered_map、unordered_set的底层原理
unordered_map的底层是一个防冗余的哈希表(采用除留余数法)。哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,时间复杂度为O(1);而代价仅仅是消耗比较多的内存。
使用一个下标范围比较大的数组来存储元素。可以设计一个函数(哈希函数(一般使用除留取余法),也叫做散列函数),使得每个元素的key都与一个函数值(即数组下标,hash值)相对应,于是用这个数组单元来存储这个元素;也可以简单的理解为,按照key为每一个元素“分类”,然后将这个元素存储在相应“类”所对应的地方,称为桶。
但是,不能够保证每个元素的key与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不同的元素分在了相同的“类”之中。 一般可采用拉链法解决冲突:
(2)哈希表的实现
#include <iostream>
#include <vector>
#include <list>
#include <random>
#include <ctime>
using namespace std;
(3)unordered_map 与map的区别?使用场景?
构造函数:unordered_map 需要hash函数,等于函数;map只需要比较函数(小于函数).
存储结构:unordered_map 采用hash表存储,map一般采用红黑树(RB Tree) 实现。因此其memory数据结构是不一样的。
总体来说,unordered_map 查找速度会比map快,而且查找速度基本和数据数据量大小,属于常数级别;而map的查找速度是log(n)级别。并不一定常数就比log(n)小,hash还有hash函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑unordered_map 。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,unordered_map 可能会让你陷入尴尬,特别是当你的unordered_map 对象特别多时,你就更无法控制了,而且unordered_map 的构造速度较慢。
(4)unordered_map、unordered_set的常用函数
unordered_map.begin() 返回指向容器起始位置的迭代器(iterator)
unordered_map.end() 返回指向容器末尾位置的迭代器
unordered_map.cbegin() 返回指向容器起始位置的常迭代器(const_iterator)
unordered_map.cend() 返回指向容器末尾位置的常迭代器
unordered_map.size() 返回有效元素个数
unordered_map.insert(key) 插入元素
unordered_map.find(key) 查找元素,返回迭代器
unordered_map.count(key) 返回匹配给定主键的元素的个数
迭代器的底层机制和失效的问题
1、迭代器的底层原理
迭代器是连接容器和算法的一种重要桥梁,通过迭代器可以在不了解容器内部原理的情况下遍历容器。它的底层实现包含两个重要的部分:萃取技术和模板偏特化。
萃取技术(traits)可以进行类型推导,根据不同类型可以执行不同的处理流程,比如容器是vector,那么traits必须推导出其迭代器类型为随机访问迭代器,而list则为双向迭代器。
例如STL算法库中的distance函数,distance函数接受两个迭代器参数,然后计算他们两者之间的距离。显然对于不同的迭代器计算效率差别很大。比如对于vector容器来说,由于内存是连续分配的,因此指针直接相减即可获得两者的距离;而list容器是链式表,内存一般都不是连续分配,因此只能通过一级一级调用next()或其他函数,每调用一次再判断迭代器是否相等来计算距离。vector迭代器计算distance的效率为O(1),而list则为O(n),n为距离的大小。
使用萃取技术(traits)进行类型推导的过程中会使用到模板偏特化。模板偏特化可以用来推导参数,如果我们自定义了多个类型,除非我们把这些自定义类型的特化版本写出来,否则我们只能判断他们是内置类型,并不能判断他们具体属于是个类型。
template <typename T>
struct TraitsHelper {
static const bool isPointer = false;
};
template <typename T>
struct TraitsHelper<T*> {
static const bool isPointer = true;
};
2、一个理解traits的例子
// 需要在T为int类型时,Compute方法的参数为int,返回类型也为int,
// 当T为float时,Compute方法的参数为float,返回类型为int
template <typename T>
class Test {
public:
TraitsHelper<T>::ret_type Compute(TraitsHelper<T>::par_type d);
private:
T mData;
};
当函数,类或者一些封装的通用算法中的某些部分会因为数据类型不同而导致处理或逻辑不同时,traits会是一种很好的解决方案。
2、迭代器的种类
输入迭代器:是只读迭代器,在每个被遍历的位置上只能读取一次。例如上面find函数参数就是输入迭代器。
输出迭代器:是只写迭代器,在每个被遍历的位置上只能被写一次。
前向迭代器:兼具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它不支持operator–,所以只能向前移动。
双向迭代器:很像前向迭代器,只是它向后移动和向前移动同样容易。
随机访问迭代器:有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。支持前面四种Iterator的所有操作,并另外支持it + n、it – n、it += n、 it -= n、it1 – it2和it[n]等操作。
3、迭代器失效的问题
(1)插入操作
对于vector和string,如果容器内存被重新分配,iterators,pointers,references失效;如果没有重新分配,那么插入点之前的iterator有效,插入点之后的iterator失效;
对于deque,如果插入点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,deque的迭代器失效,但reference和pointers有效;
对于list和forward_list,所有的iterator,pointer和refercnce有效。
(2)删除操作
对于vector和string,删除点之前的iterators,pointers,references有效;off-the-end迭代器总是失效的;
对于deque,如果删除点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,off-the-end失效,其他的iterators,pointers,references有效;
对于list和forward_list,所有的iterator,pointer和refercnce有效。
对于关联容器map来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用,否则会导致程序无定义的行为。
为什么vector的插入操作可能会导致迭代器失效?
vector动态增加大小时,并不是在原空间后增加新的空间,而是以原大小的两倍在另外配置一片较大的新空间,然后将内容拷贝过来,并释放原来的空间。由于操作改变了空间,所以迭代器失效。
vector的reserve()和resize()方法之间有什么区别?
首先,vector的容量capacity()是指在不分配更多内存的情况下可以保存的最多元素个数,而vector的大小size()是指实际包含的元素个数;
其次,vector的reserve(n)方法只改变vector的容量,如果当前容量小于n,则重新分配内存空间,调整容量为n;如果当前容量大于等于n,则无操作;
最后,vector的resize(n)方法改变vector的大小,如果当前容量小于n,则调整容量为n,同时将其全部元素填充为初始值;如果当前容量大于等于n,则不调整容量,只将其前n个元素填充为初始值。
标准库中有哪些容器?分别有什么特点?
标准库中的容器主要分为三类:顺序容器、关联容器、容器适配器。
-
顺序容器包括五种类型:
array<T, N>数组:固定大小数组,支持快速随机访问,但不能插入或删除元素;
vector动态数组:支持快速随机访问,尾位插入和删除的速度很快;
deque双向队列:支持快速随机访问,首尾位置插入和删除的速度很快;(可以看作是vector的增强版,与vector相比,可以快速地在首位插入和删除元素)
list双向链表:只支持双向顺序访问,任何位置插入和删除的速度都很快;
forward_list单向链表:只支持单向顺序访问,任何位置插入和删除的速度都很快。 -
关联容器包含两种类型:
map容器:
map<K, T>关联数组:用于保存关键字-值对;
multimap<K, T>:关键字可重复出现的map;
unordered_map<K, T>:用哈希函数组织的map;
unordered_multimap<K, T>:关键词可重复出现的unordered_map;
set容器:
set:只保存关键字;
multiset:关键字可重复出现的set;
unordered_set:用哈希函数组织的set;
unordered_multiset:关键词可重复出现的unordered_set; -
容器适配器包含三种类型:
stack栈、queue队列、priority_queue优先队列。
容器内部删除一个元素
- 顺序容器(序列式容器,比如vector、deque)
erase迭代器不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;
It = c.erase(it);
- 关联容器(关联式容器,比如map、set、multimap、multiset等)
erase迭代器只是被删除元素的迭代器失效,但是返回值是void,所以要采用erase(it++)的方式删除迭代器;
c.erase(it++)
vector越界访问下标,map越界访问下标?vector删除元素时会不会释放空间?
-
通过下标访问vector中的元素时会做边界检查,但该处的实现方式要看具体IDE,不同IDE的实现方式不一样,确保不可访问越界地址。
-
map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有该key和value的某人值插入这个map。
-
erase()函数,只能删除内容,不能改变容量大小;
erase成员函数,它删除了itVect迭代器指向的元素,并且返回要被删除的itVect之后的迭代器,迭代器相当于一个智能指针;clear()函数,只能清空内容,不能改变容量大小;如果要想在删除内容的同时释放内存,那么你可以选择deque容器。
map中[]与find的区别?
-
map的下标运算符[ ]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值的项插入这个map。
-
map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。
STL内存优化?
STL内存管理使用二级内存配置器。
(1) 第一级配置器:
第一级配置器以malloc(),free(),realloc()等C函数执行实际的内存配置、释放、重新配置等操作,并且能在内存需求不被满足的时候,调用一个指定的函数。一级空间配置器分配的是大于128字节的空间,如果分配不成功,调用句柄释放一部分内存,如果还不能分配成功,抛出异常。
第一级配置器只是对malloc函数和free函数的简单封装,在allocate内调用malloc,在deallocate内调用free。同时第一级配置器的oom_malloc函数,用来处理malloc失败的情况。
(2) 第二级配置器:
第一级配置器直接调用malloc和free带来了几个问题:
-
内存分配/释放的效率低
-
当配置大量的小内存块时,会导致内存碎片比较严重
-
配置内存时,需要额外的部分空间存储内存块信息,所以配置大量的小内存块时,还会导致额外内存负担
如果分配的区块小于128bytes,则以内存池管理,第二级配置器维护了一个自由链表数组,每次需要分配内存时,直接从相应的链表上取出一个内存节点就完成工作,效率很高
自由链表数组:自由链表数组其实就是个指针数组,数组中的每个指针元素指向一个链表的起始节点。数组大小为16,即维护了16个链表,链表的每个节点就是实际的内存块,相同链表上的内存块大小都相同,不同链表的内存块大小不同,从8一直到128。如下所示,obj为链表上的节点,free_list就是链表数组。
内存分配:allocate函数内先判断要分配的内存大小,若大于128字节,直接调用第一级配置器,否则根据要分配的内存大小从16个链表中选出一个链表,取出该链表的第一个节点。若相应的链表为空,则调用refill函数填充该链表。默认是取出20个数据块。
填充链表 refill:若allocate函数内要取出节点的链表为空,则会调用refill函数填充该链表。refill函数内会先调用chunk_alloc函数从内存池分配一大块内存,该内存大小默认为20个链表节点大小,当内存池的内存也不足时,返回的内存块节点数目会不足20个。接着refill的工作就是将这一大块内存分成20份相同大小的内存块,并将各内存块连接起来形成一个链表。
内存池:chunk_alloc函数内管理了一块内存池,当refill函数要填充链表时,就会调用chunk_alloc函数,从内存池取出相应的内存。
- 在chunk_alloc函数内首先判断内存池大小是否足够填充一个有20个节点的链表,若内存池足够大,则直接返回20个内存节点大小的内存块给refill;
- 若内存池大小无法满足20个内存节点的大小,但至少满足1个内存节点,则直接返回相应的内存节点大小的内存块给refill;
- 若内存池连1个内存节点大小的内存块都无法提供,则chunk_alloc函数会将内存池中那一点点的内存大小分配给其他合适的链表,然后去调用malloc函数分配的内存大小为所需的两倍。若malloc成功,则返回相应的内存大小给refill;若malloc失败,会先搜寻其他链表的可用的内存块,添加到内存池,然后递归调用chunk_alloc函数来分配内存,若其他链表也无内存块可用,则只能调用第一级空间配置器。
频繁对vector调用push_back()对性能的影响和原因?
在一个vector的尾部之外的任何位置添加元素,都需要重新移动元素。而且,向一个vector添加元素可能引起整个对象存储空间的重新分配。重新分配一个对象的存储空间需要分配新的内存,并将元素从旧的空间移到新的空间。