C++STL

376 阅读6分钟

参考

  1. 公众号:程序员贺先生——C++八股
  2. (46条消息) map和unordered_map的差别和使用_Real_JumpChen的博客-CSDN博客
  3. C++ map,set底层的红黑树实现 - Conan-Peng - 博客园 (cnblogs.com)
  4. C++ vector(STL vector)底层实现机制(通俗易懂) (biancheng.net)
  5. (46条消息) 当C++容器的迭代器iterator遇到删除函数erase时_Shunhwa的博客-CSDN博客

STL六大组件

微信图片_20230312165851.jpg 微信图片_20230312165310.jpg

容器通过配置器取得数据存储空间,算法通过迭代器存取容器内容,仿函数可以协助算法完成不同的策略变化,配接器可以应⽤于容器、 仿函数和迭代器。

  • 容器:, STL中的常用容器包括:顺序性容器(vector、deque、list)、关联容器(map、set)、容器适配器(queue、stack)。从实现的角度来讲是⼀种类模板。
  • 算法:各种常见的算法,如 sort(插⼊,快排,堆排序),search(⼆分查找), 从实现的角度来讲是⼀种⽅法模板。
  • 迭代器:从实现的⻆度来看,迭代器是⼀种将 operator*,operator->,operator++, operator--等指针相关操作赋予᯿载的类模板,所有的 STL 容器都有⾃⼰的迭代器。
  • 仿函数:从实现的⻆度看,仿函数是⼀种᯿载了 operator()的类或者类模板。 可以帮助算法实现不同的策略。
  • 配接器:⼀种⽤来修饰容器或者仿函数或迭代器接⼝的东⻄。
  • 配置器:负责空间配置与管理,从实现的⻆度讲,配置器是⼀个实现了动态空间配置、空间管理,空间释放的类模板。

STL容器区别和使用场景

1678612344122.png

unodered_map和map底层数据结构的区别、怎么解决hash冲突

unodered_map和map底层数据结构的区别:

  • map: map内部实现了一个红黑树(红黑树是非严格平衡二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉搜索树(又名二叉查找树、二叉排序树,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值)存储的,使用中序遍历可将键值按照从小到大遍历出来。
    优点:有序
    缺点:空间占用率高
  • unordered_map: unordered_map内部实现了一个哈希表(也叫散列表,通过把关键码值映射到Hash表中一个位置来访问记录,查找的时间复杂度可达到O(1),其在海量数据处理中有着广泛应用)。因此,其元素的排列顺序是无序的。
    优点:查找速度快 缺点:建立哈希表建立耗时

红黑树存储结构的存取是O(logn),而哈希表是O(1),当然这是在哈希表没有冲突的情况下的,但实际的hashmap和unordered_map是不允许冲突的。而红黑树的内存占用要比哈希表高。
红黑树存储是有序的,也就是说map容器里面的元素是按关键字排好序的(<),而unordered_map是无序的。

unordered_map怎么解决哈希冲突:
链式哈希解决冲突

在 C++ STL 标准库中,将各个链表称为桶(bucket),每个桶都有自己的编号(从 0 开始)。当有新键值对存储到无序容器中时,整个存储过程分为如下几步:

  1. 将该键值对中键的值带入设计好的哈希函数,会得到一个哈希值(一个整数,用 H 表示);
  2. 将 H 和无序容器拥有桶的数量 n 做整除运算(即 H % n),该结果即表示应将此键值对存储到的桶的编号;
  3. 建立一个新节点存储此键值对,同时将该节点链接到相应编号的桶上。

C++的hash表中有一个负载因子loadFactor,当loadFactor<=1时,hash表查找的期望复杂度为O(1). 因此,每次往hash表中添加元素时,我们必须保证是在loadFactor <1的情况下,才能够添加。
因此,当Hash表中loadFactor==1时,Hash就需要进行rehash。rehash过程中,会模仿C++的vector 扩容方式,Hash表中每次发现loadFactor ==1时,就开辟一个原来桶数组的两倍空间,称为新桶数组 ,然后把原来的桶数组中元素全部重新哈希到新的桶数组中。

无序容器中,负载因子的计算方法为: 负载因子 = 容器存储的总键值对 / 桶数 默认情况下,无序容器的最大负载因子为 1.0。如果操作无序容器过程中,使得最大复杂因子超过了默认值,则容器会自动增加桶数,并重新进行哈希,以此来减小负载因子的值。需要注意的是,此过程会导致容器迭代器失效,但指向单个键值对的引用或者指针仍然有效。

map和set底层原理:

map, set底层都提供了排序功能,红黑树形式存储的键值是有序的。

  • 同时红黑树可以在O(log n)时间内做插入,查找和删除。插入删除操作时仅仅移动指针即可,不涉及内存的移动和拷贝,所以效率比较高。
  • 默认情况下会对元素进行升序排列。所以在set中,不能直接改变元素值,因为那样会打乱原本正确的顺序,要改变元素值必须先删除旧元素,再插入新元素。
  • 不提供直接存取元素的任何操作函数,只能通过迭代器进行间接存取

红黑树是什么、性质:

为什么使用红黑树而不是二叉搜索树?
二叉搜索树并不一定是一棵平衡树,二叉搜索树(BST)只是左子树的值一定小于根节点,而右子树的值一定大于根节点。如果插入的值是有序的,那么构造出来的二叉树将是一个链表,它的时间复杂度将达到O(n)。而使用红黑树,可以通过对每个节点标色的方式,每次更新数据后进行平衡,保证查找效率。

vector怎么扩容、底层、和list区别、扩容机制

vector底层:
内部维护了三个迭代器:分别指向容器对象的起始字节位置、当前最后一个元素的末尾字节,以及 指向整个 vector 容器所占用内存空间的末尾字节。维护了一段连续的线性内存空间。 image.png 在此基础上,将 3 个迭代器两两结合,还可以表达不同的含义,例如:

  • _Myfirst 和 _Mylast 可以用来表示 vector 容器中目前已被使用的内存空间;
  • _Mylast 和 _Myend 可以用来表示 vector 容器目前空闲的内存空间;
  • _Myfirst 和 _Myend 可以用表示 vector 容器的容量。

list底层:
底层是用双向链表实现的,甚至一些 STL 版本中(比如 SGI STL),list 容器的底层实现使用的是双向循环链表。使用链表存储数据,并不会将它们存储到一整块连续的内存空间中。恰恰相反,各元素占用的存储空间(又称为节点)是独立的、分散的,它们之间的线性关系通过指针(图 1 以箭头表示)来维持。 image.png 为了更方便的实现 list 模板类提供的函数,该模板类在构建容器时,会刻意在容器链表中添加一个空白节点,并作为 list 链表的首个节点(又称头节点)。

vector与list区别:

  • 内存空间:vector维护一段连续内存空间,list离散。
  • 随机存取:vector能很好的支持随机存取,因此vector::iterator支持“+”,“+=”,“<”等操作符。而list不支持
  • 元素安插移除:vector只能尾端插入删除,list可以再任意位置插入删除

总之,如果需要高效的随机存取,而不在乎插入和删除的效率,使用vector;
如果需要大量的插入和删除,而不关心随机存取,则应使用list。

vector扩容机制:
当 vector 的大小和容量相等(size==capacity)也就是满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器在进行扩容后,与其相关的指针、引用以及迭代器可能会失效。 vector 容器扩容的过程需要经历以下 3 步:

  1. 完全弃用现有的内存空间,重新申请更大的内存空间;
  2. 将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
  3. 最后将旧的内存空间释放。

vector 扩容是非常耗时的。为了降低再次分配内存空间时的成本,每次扩容时 vector 都会申请比用户需求量更多的内存空间(这也就是 vector 容量的由来,即 capacity>=size),以便后期使用。

list扩容机制:
每放一个节点就在空间中找到一块大小放入元素、空间利用率最高。

使用迭代器, 一般要注意一些什么问题

迭代器的本质是个类,但是重载了像“ * ”,“->”等符号,让我们用起来好像跟指针一样。

  • 删除元素时迭代器失效
    对于关联容器,如map,set(以及multi类型)等,erase当前的迭代器iterator,仅仅会使当前的iterator失效,并不影响以后的内容,所以只要在进行删除前,预先保留下一个当前迭代器的递增值即可。这是因为关联类容器使用了红黑树实现,删除一个节点不会对其他节点造成影响。
    对于序列式容器,如 vector,deque等,删除当前节点会使后面的所有节点的迭代器失效。这是因为这类容器每次进行插入或者删除都会使之后的元素位置进行重新移动定位,致使之前的迭代器失效。
    解决:erase函数会返回指向下一节点的有效迭代器
  • 扩容造成迭代器失效

C++11 STL新特性

vector:emplace_back方法 对比push_back:

emplace_back()函数在原理上比push_back()有了一定的改进,包括在内存优化方面和运行效率方面。内存优化主要体现在使用了就地构造(直接在容器内构造对象,不用拷贝一个复制品再使用)+强制类型转换的方法来实现,在运行效率方面,由于省去了拷贝构造过程,因此也有一定的提升。

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

右值引用 std::move方法

引入右值引用,就是为了移动语义。移动语义就是为了减少拷贝。std::move就是将左值转为右值引用。这样就可以重载到移动构造函数了,移动构造函数将指针赋值一下就好了,不用深拷贝了,提高性能。
在实际场景中,右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。

左右值判断:左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边;有地址的变量就是左值,没有地址的字面值、临时值就是右值。

左值引用:能指向左值,不能指向右值的就是左值引用;引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。int &ref_a = a; // 左值引用指向左值,编译通过

右值引用:右值引用专门为右值而生,可以指向右值,不能指向左值:int &&ref_a_right = 5; // ok

右值引用指向左值:int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向 1686578227440.png