C++常见面试题总结8---STL的数据结构
STL容器底层数据结构
-
vector,底层数据结构是动态数组。由于数组的特点,vector也具有以下特性:
O(1)时间的快速访问;
顺序存储,插入到非尾结点所需时间复杂度为O(n),删除也一样;
扩容规则: 见下一小节。 -
list,底层数据结构为双向链表,特点是支持快速的增删,时间复杂度O(1);不能随机访问,需要进行遍历,时间复杂度为O(n)。
-
map 与 multimap,STL中的关联容器、提供一对一key-value的数据处理能力; map与multimap的区别在于,multimap允许关键字重复,而map不允许重复。map与multimap实现了以key为序排列,即它们有序。底层数据结构均为红黑树。根据红黑树的原理,map与multimap可以实现O(lgn)的查找,插入和删除。
-
unordered_map 与 unordered_multimap,容器中key为无序排列,其底层实现为hash table,因此其查找时间复杂度理论上达到了O(1)。
-
set 与 multiset,有序存储元素,这两种容器的底层实现与map一样都是红黑树,所以能实现O(lgn)的查找,插入,删除操作。set与multiset的区别在于是否允许重复。
-
unordered_set 与 unordered_multiset,与unordered_map & unordered_multimap相同,其底层实现为hash table。
-
priority_queue,优先级队列相当于一个有权值的单向队列queue,在这个队列中,所有元素是按照优先级排列的。根据堆的特性,优先级队列实现了取出最大最小元素时间复杂度为O(1),对于插入和删除,其最坏情况为O(lgn)。
-
stack,栈,为后入先出原则。
-
queue,单向队列,为先入先出原则。
-
deque,双向队列,可以实现在头尾两端高效的插入和删除操作。
vector 与 list
vector
vector(底层是动态顺序表/动态数组)和数组类似,拥有一段连续的内存空间,并且起始地址不变,不容易造成内存碎片,空间利用率高,缓存利用率高。因此能高效的进行随机访问,时间复杂度为O(1)。但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为O(n)。另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。
vector扩容
当我们新建一个vector的时候,会首先分配给他一片连续的内存空间,如std::vector vec,当通过push_back向其中增加元素时,如果初始分配空间已满,就会引起vector扩容,其扩容规则在GCC是二倍扩容(VS13是1.5倍扩容):首先重新申请一个2倍大的内存空间;然后将原空间的内容拷贝过来;最后将原空间内容进行释放,将内存交还给操作系统。并且,由于vector 空间的重新配置,导致旧vector的所有迭代器都失效了。
vector的初始的扩容方式代价太大,初始扩容效率低,需要频繁增长,不仅操作效率比较低,而且频繁的向操作系统申请内存容易造成过多的内存碎片,所以这个时候需要合理使用resize()和reserve()方法提高效率减少内存碎片的,需要resize()和 reserve()。
(1)2倍扩容
假设我们一开始申请了 16Byte 的空间。当需要更多空间的时候,将首先申请 32Byte,然后释放掉之前的 16Byte。这释放掉的16Byte 的空间就闲置在了内存中。当还需要更多空间的时候,你将首先申请 64Byte,然后释放掉之前的 32Byte。这将在内存中留下一个48Byte 的闲置空间(假定之前的 16Byte 和此时释放的32Byte 合并)。当还需要更多空间的时候,你将首先申请128Byte,然后释放掉之前的 64 Byte。这将在内存中留下一个112Byte 的闲置空间(假定所有之前释放的空间都合并成了一个块)。扩容因子为2时,上述例子表明:每次扩容,我们释放掉的内存连接起来的大小,都小于即将要分配的内存大小。
(2)1.5倍扩容
假设我们一开始申请了 16Byte 的空间。当需要更多空间的时候,将申请 24 Byte ,然后释放掉 16 ,在内存中留下 16Byte 的空闲空间。当需要更多空间的时候,将申请 36 Byte,然后释放掉 24,在内存中留下 40Byte (16 + 24)的空闲空间。当需要更多空间的时候,将申请 54 Byte,然后释放 36,在内存中留下 76Byte。当需要更多空间的时候,将申请 81 Byte,然后释放 54, 在内存中留下 130Byte。当需要更多空间的时候,将申请 122 Byte 的空间(可以复用内存中闲置的 130Byte)。
list
list是由双向链表实现的,因此内存空间是不连续的,容易造成内存碎片,空间利用率低,缓存利用率低。只能通过指针访问数据,所以list的访问效率较低,时间复杂度为O(n)。但由于链表的特点,能高效地进行插入和删除。
vector和list的区别
- vector底层是连续结构;list底层是非连续结构。
- vector支持随机访问;list不支持随机访问。
- vector迭代器是原生指针;list迭代器是封装结点的一个类。
- vector在插入和删除时可能会导致迭代器失效;list在删除的时候会导致当前迭代器指向的结点失效。
- vector不容易造成内存碎片,空间利用率高;list容易造成内存碎片,空间利用率低。
- vector在非尾插,删除的时间复杂度为O(n),list在任何地方插入和删除的时间复杂度都为O(1)。
- vector拥有一段连续的内存空间,能很好的支持随机存取,因此vector::iterator支持“+”,“+=”,“<”等操作符。list的内存空间可以是不连续,它不支持随机访问,因此list::iterator则不支持“+”、“+=”、“<”等操作符。
vector::iterator和list::iterator都重载了“++”运算符。
迭代器失效问题
vector在插入元素时的时候,要重新给所有的迭代器赋值。因为插入元素有可能导致扩容,所有迭代器指针和引用都会失效。删除元素位置之后的所有迭代器和指针引用都会失效,需要重新赋值。
list在插入元素的时候不会导致迭代器失效,删除元素的时候只会导致当前迭代器失效,其他的迭代器不会受到影响。
使用场景:如果需要高效的随机访问,而不在乎插入和删除的效率,使用vector。如果需要大量的插入和删除,而不关心随机访问,则应使用list。
红黑树
我这里只简单进行介绍,更详细的可以参考这篇博客: https://www.cnblogs.com/skywang12345/p/3245399.html
红黑树(red-black tree) 是一棵满足下述性质的平衡二叉查找树:
- 每一个结点要么是红色,要么是黑色。
- 根结点是黑色的。
- 所有叶子结点都是黑色的(实际上都是Null指针)。叶子结点不包含任何关键字信息,所有查询关键字都在非叶结点上。
- 每个红色结点的两个子节点必须是黑色的。换句话说:从每个叶子到根的所有路径上不能有两个连续的红色结点。
- 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点。
红黑树的操作
左旋、右旋和颜色变更。 因为每一个红黑树也是一个特化的二叉查找树,因此红黑树上的查找操作与普通二叉查找树上的查找操作相同。然而,在红黑树上进行插入操作和删除操作会导致不再符合红黑树的性质。恢复红黑树的属性需要少量(O(log n))的颜色变更(实际是非常快速的)和不超过三次树旋转(对于插入操作是两次)。虽然插入和删除很复杂,但操作时间仍可以保持为 O(log n) 次。
红黑树的优势
红黑树能够以O(log2(N))的时间复杂度进行搜索、插入、删除操作。此外,红黑树用非严格平衡来换取增删节点时旋转次数的降低,任何不平衡都会在3次旋转之内解决。而AVL是严格平衡树(高度平衡的二叉搜索树),因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。
set、map底层为什么用红黑树(RB-Tree)而不用AVL树?
RB-Tree和AVL树作为平衡二叉搜索树(BBST, Balance Binary Search Tree) 其实现的算法时间复杂度相同,AVL作为最先提出的BBST,貌似RB-tree实现的功能都可以用AVL树是代替,那么为什么还需要引入RB-Tree呢?
红黑树不追求"完全平衡",即不像AVL那样要求节点的 |balFact| <= 1,它只要求部分达到平衡,但是提出了为节点增加颜色,红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。
- 插入节点导致树失衡,AVL和RB-Tree都是最多两次树旋转来实现复衡rebalance,旋转的量级是O(1)。
- 删除节点导致失衡,AVL需要维护从被删除节点到根节点这条路径上所有节点的平衡,旋转的量级为O(logN);而RB-Tree最多只需要旋转3次实现复衡,只需O(1)。所以说RB-Tree删除节点的rebalance的效率更高,开销更小。
- AVL的结构相较于RB-Tree更为平衡,插入和删除引起失衡,如2所述,RB-Tree复衡效率更高;由于AVL高度平衡,其查找效率更高。
故引入RB-Tree是功能、性能、空间开销的折中结果。AVL更平衡,结构上更加直观,时间效能针对读取而言更高;维护稍慢,空间开销较大。红黑树查询略逊于AVL(因其比AVL树不平衡最多一层),维护(插入和删除)强于AVL,空间开销与AVL类似,内容极多时略优于AVL,维护优于AVL。
实际应用中,若搜索的次数远远大于插入和删除,那么选择AVL。如果搜索,插入删除次数几乎差不多,应该选择RB。
为什么map用红黑树而不用哈希表? 为什么unordered_map用哈希表而不用红黑树?
- 有序性和无序性
map的有序性:红黑树(非严格平衡二叉树),该结构具有自动排序的功能,因此map内部的所有元素都是有序的。
unordered_map的无序性:哈希表不会根据key值大小进行排序,存储时是根据key的hash值判断元素是否相同,因此unordered_map内部元素是无序的。 - 运行效率
map的运行效率:红黑树可以在O(log n)时间内做查找,插入和删除,这里的n是树中元素的数目。
unordered_map的运行效率:哈希表的查找的时间复杂度可达到O(1)。 - 占用内存
unordered_map内存占用比map高。hash表占用空间高,插入查找速度都相对快(hash如果及时扩充,平均算法复杂度是O(1),O(n)之前重新hash了);红黑树速度相对慢,空间占用低。