C++:STL容器

一:vector

std::vector是C++标准模板库(STL)中的一种动态数组容器,它可以在尾部进行快速插入和删除操作,并支持随机访问。下面是std::vector的底层实现原理:

  1. 内存连续std::vector在内存中的布局是连续的,这意味着你可以像使用普通数组一样来使用它。这也使得它能提供高效的随机访问。

  2. 动态扩容:当向std::vector添加元素而其已分配的内存不足以容纳新元素时,它会重新分配更大的内存空间。通常,新空间大小为当前容量的两倍(这可能因具体实现而异)。所有旧元素都被复制或移动到新空间,然后释放旧空间。由于需要复制或移动所有元素,所以这个操作相对耗时。

  3. 插入和删除:向尾部插入和删除元素非常快速(时间复杂度为O(1)),因为只需修改尾部指针即可。但是在其他位置插入或删除元素则需要移动该位置之后的所有元素(时间复杂度为O(n))。

  4. 预留空间:如果你知道将要添加多少个元素,那么可以使用 reserve() 函数预先分配足够大小的内存空间。这样可以避免频繁地重新分配内存从而提高性能。

  5. 无法保证引用有效性: 由于 std::vector 可能会因扩容导致底层数组地址变化, 所以保存了 std::vector 元素引用或者迭代器可能会在某些操作后失效.

  6. 异常安全: 在构造、拷贝、赋值等操作过程中, 如果发生异常(std::bad_alloc) , std::vector 保证不会泄露资源.

vector补充问题:

        (1)vector的扩容细节

                vector扩容时元素是逐个复制/移动(如果元素有移动构造函数,那么就使用移动),扩容后原有的所有迭代器失效。(扩容时直接分配新的内存,)

        (2)如何让vector的size等于capacity(释放没有使用的空间)?

                方法一:使用vector中的成员函数shrink_to_fit 。它会使存储在vector中的元素被复制/移动到一个新的更小的内存区域,然后释放原来的内存。

                方法二:使用swap。假设有vector<int> nums,利用如下代码解决:

                vector<int>(nums).swap(nums);   注意:顺序不能反,原因是swap不接受右值。

                创建一个临时的vector逐元素进行赋值,它的sizecapacity都等于原vectorsize。然后,使用swap()方法交换这两个vector。由于临时vector在交换后拥有了原vector的内存,当临时vector在语句结束后被销毁时,这部分内存也就被释放了。

                这两种方法的本质都是新创建一个容器(size==capacity),然后执行交换。需要注意的是,方法二(使用swap)是逐元素的复制,而方法一(shrink_to_fit)是复制/移动。

        (3)resize 和 reserve

                resize会改变 std::vector 的大小(size),也就是它所包含的元素的数量:

                如果新的大小大于当前的大小,resize 会在 vector 的末尾添加新的元素。这些新元素会被默认初始化,或者如果你提供了一个参数,它们会被初始化为这个参数的值; 如果新的大小小于当前的大小,resize 会删除 vector 末尾的元素。

      reserve 函数则会改变 std::vector 的容量(capacity),也就是它在重新分配内存前可以包含的元素的最大数量:

                如果新的容量大于当前的容量,reserve 会分配新的内存块,并将现有的元素移动或复制到新的内存块中;如果新的容量小于或等于当前的容量,reserve 不会做任何事情。

        (4)函数返回vector对象效率问题

                考虑返回值优化RVO 以及 移动语义。

        (5)导致vector迭代器失效的操作

                "迭代器失效"是指迭代器无法再安全地用于访问容器中的元素。失效的迭代器可能指向不存在的内存位置,或者指向的内存位置已经不再包含原来的元素。

                扩容:重新分配了内存,原先的所有迭代器都失效。

                插入或删除:插入/删除位置之后的所有迭代器失效。

                swap操作,以及一些调整大小也有可能会导致迭代器失效。

        注意:迭代器失效不一定代表迭代器中指针指向不存在的内存位置。

二:list

std::list是C++标准模板库(STL)中的一种容器,它实现了双向循环链表。下面是std::list的底层实现原理:

  1. 节点结构:每个元素在内存中都作为一个单独的节点存储,每个节点包含元素值以及两个指针,分别指向前一个和后一个节点。

  2. 双向链表:所有的节点通过前后指针连接在一起形成双向链表。这使得无论从哪个方向遍历都非常高效。

  3. 插入和删除:由于链表结构,插入和删除操作只需要改变相邻节点的指针就可以完成,因此在任何位置进行插入或删除操作都能达到常数时间复杂度(O(1))。

  4. 不支持随机访问:由于元素不连续存储,并且没有索引结构,所以不能像数组或vector那样通过索引直接访问某个元素。如果需要访问特定位置的元素,则必须从头(或尾)开始遍历到该位置。

  5. 额外内存开销:每个节点除了数据外还需要额外空间存储两个指针。这意味着相比于其他线性容器(如vector),list有更大的内存开销。

  6. 稳定性: 任何插入、移动、删除操作, 都不会导致已有迭代器失效, 也不会改变其他元素在容器中相对顺序.

  7. 排序: std::list 提供了成员函数 sort() 进行排序, 使用归并排序算法.

list补充问题:

        (1)为什么list要使用成员函数的sort()?

                全局的sort函数采用的是快排+堆排+插入排序的混合排序方法。它开始时采用标准的快速排序,当数据量小于一定规模时(16个元素?)转为插入排序,当快速排序的递归调用深度超过一定程度(2LogN?)时,为避免快排的最差情况(O N^2),转为堆排序。

                而快速排序和堆排序都需要容器拥有随机访问迭代器,因此list不能使用全局的sort函数。

        (2)为什么遍历list比遍历vector要慢?

                list结点在空间上不是连续的,而vector是在内存中的布局是连续的。

                局部性原理:考虑 缓存行 + 预取策略。

三:deque

std::deque(双端队列)是C++标准模板库(STL)中的一种容器,它支持在两端进行快速插入和删除操作,并支持随机访问。下面是std::deque的底层实现原理:

  1. 分段连续内存:不同于 std::vector 的连续内存布局,也不同于 std::list 的非连续内存布局,std::deque 使用了一种"分段连续"的内存布局。具体来说,它使用了一个中控器来管理多个块(block)或者段(segment),每个块都是一段连续的内存空间,而这些块本身可能并不连续。

  2. 动态扩展:当在两端插入元素时,如果当前的块无法容纳更多元素,则会动态地添加新块。由于只需要添加新块而无需移动已有元素,所以在两端插入元素的时间复杂度为O(1)。

  3. 随机访问:虽然 std::deque 的内存非完全连续,但由于其特殊设计(每个块大小固定),结合中控器记录各个块位置信息, 使得它仍然可以提供有效地随机访问能力。

  4. 迭代器复杂性:由于其分段连续的特性, std::deque 的迭代器实现相对复杂, 需要能正确跨越各个分段.

  5. 额外开销:相比 vector 来说, deque 在管理上需要更多额外开销, 如中控区域、各个分段等.

  6. 异常安全: 在构造、拷贝、赋值等操作过程中, 如果发生异常(std::bad_alloc) , std::deque 保证不会泄露资源.

四:stack queue

std::stackstd::queue 是 C++ 标准模板库(STL)中的容器适配器,它们并不直接管理元素,而是通过封装其他容器(如 std::deque, std::list, std::vector等)来提供特定的数据结构 —— 栈和队列。

  1. stack:栈是一种后进先出(LIFO, Last In First Out)的数据结构。在C++ STL中,std::stack默认使用 std::deque 作为底层容器进行实现。栈只允许在顶部添加元素或者删除元素,因此它只提供了对顶部元素操作的接口,例如push、pop和top。

  2. queue:队列是一种先进先出(FIFO, First In First Out)的数据结构。在C++ STL中,std::queue默认也使用 std::deque 作为底层容器进行实现。队列允许在尾部添加元素,在头部删除元素,因此它提供了push、pop、front和back等接口。

虽然这两个容器适配器都默认使用 std::deque 作为底层容器实现,但你也可以指定其他类型作为底层容器(如用list或vector)。需要注意的是不论采用何种底层容器实现,其必须支持所需操作,并且满足性能要求。例如,在 stack 中 push/pop/top 操作分别对应于底层容器的 push_back/pop_back/back 操作,在 queue 中 push/pop/front/back 操作分别对应于底层容器的 push_back/pop_front/front/back 操作。

总之, stack 和 queue 的具体行为与其所选取的基础数据结构有关, 它们本身仅定义了一个行为协议(接口), 不涉及具体实现细节.

五:map set

std::mapstd::set 是 C++ 标准模板库(STL)中的关联容器,它们都是基于红黑树实现的。

  1. mapstd::map 是一种关联数组,它存储的元素由一个键和一个值组成,并且元素按键自动排序。在 std::map 中,键是唯一的。由于基于红黑树实现,因此查找、插入、删除操作的平均时间复杂度为 O(log n)。

  2. setstd::set 存储唯一元素,并且元素自动排序。与 std::map 类似,它也是基于红黑树实现,所以查找、插入、删除操作的平均时间复杂度同样为 O(log n)。

红黑树扩充

下面来说说这两个容器底层使用的数据结构——红黑树:

  • 红黑树:这是一种自平衡二叉搜索树,在每次插入或删除后都会通过特定操作进行平衡调整使得满足以下性质:
    1. 每个节点要么是红色要么是黑色。
    2. 根节点总是黑色。
    3. 所有叶子节点(通常为NULL)都是黑色。
    4. 如果一个节点是红色,则其两个子节点都是黑色。
    5. 对每个节点而言,从该点至叶子结点路径上所有包含相同数量的黑节点。

这些性质保证了从根到叶子最长可能路径不超过最短可能路径长度的两倍长。结果就能够在最坏情况下提供O(log n) 的查找效率。

总之, std::mapstd::set, 这两种数据结构在处理大量数据并需要快速查找时具有很好性能, 并且保证了元素间有序性和唯一性(对于 set 而言), 或者键值对间有序性和键唯一性(对于 map 而言).

红黑树和AVL树都是自平衡的二叉搜索树,它们在许多操作上都有相似的性能,例如查找、插入和删除操作都可以在O(log n)时间内完成。但是它们在维持平衡上的策略不同,导致了它们各自更适用于不同的场景。

AVL树是一种高度平衡的二叉搜索树,即任何节点左子树和右子树的高度最大差为1。这使得AVL树具有非常好的查找性能,比红黑树更快。然而,为了维持这种严格的平衡性质,在插入或删除节点后可能需要频繁地进行旋转操作以重新达到平衡状态。

相比之下,红黑树则允许更大程度上的不平衡。虽然这会导致某些情况下查找效率稍低于AVL树,但却减少了因插入或删除节点后需要进行重新调整(旋转)次数。因此,在频繁插入和删除操作时红黑数通常表现出较好性能。

总结起来:

  • 如果你主要关心查找速度,并且数据集合变动不频繁(例如只进行一次初始化),那么可能会倾向于使用AVL。
  • 如果你需要处理动态变化的数据,并且对插入与删除性能很关心,则可能会倾向于使用红黑数。

由于C++ STL中std::mapstd::set 需要处理动态变化数据并且保证良好插入与删除性能, 所以选择了使用红黑数作为底层实现。

        熟悉概念就行,可以进一步了解插入/删除元素是如何维持平衡性质的(左旋/右旋,颜色翻转)。

六:unordered_map unordered_set

  std::unordered_setstd::unordered_map 是 C++ STL 中的两种无序关联容器,它们分别提供了集合和映射表的功能。与 std::setstd::map 不同,这两种容器不保证元素的有序性,但在一些操作上可以提供更好的性能。这是因为它们是基于哈希表实现的。

  1. 哈希表:哈希表是一种使用哈希函数将键(key)映射到存储桶(bucket)中的数据结构。当我们插入一个元素时,首先使用哈希函数计算出该元素键对应的桶号,然后将该元素存放在对应的桶中。如果多个键映射到同一个桶中(即发生了冲突),那么这些元素会以某种方式(例如链地址法或开放寻址法)共享这个桶。

  2. 查找:当我们查找一个元素时,首先使用哈希函数计算出该元素键对应的桶号,然后在对应的桶中搜索该元素。由于每个桶通常只包含少数几个甚至一个元素,所以查找操作可以在常数时间内完成。

  3. 插入和删除:插入和删除操作也类似于查找操作,在理想情况下也可以在常数时间内完成。

  4. rehashing:随着更多元素被插入到哈希表中, 为了保持良好性能, 哈希表可能需要进行rehashing 操作, 即增加存储空间并重新分配所有已存在对象到新bucket.

  5. 负载因子与扩展策略:负载因子定义为当前已经存在对象数量除以bucket数量, 当负载因子超过预设阈值时(例如1), 就会触发rehashing.

总之, std::unordered_setstd::unordered_map, 这两种数据结构提供了快速查找、插入、删除等操作, 平均时间复杂度为 O(1). 但需要注意其空间消耗较高,并且由于可能触发 rehashing 操作导致部分情况下性能波动较大.

负载因子 = 存储在哈希表中的元素数量 / 哈希桶的数量

在C++ STL的 std::unordered_setstd::unordered_map 中,采用链地址法(Separate Chaining)来处理哈希冲突。也就是说,每个桶都存储一个链表,所有哈希值相同的元素都存储在这个链表中。

至于桶的数量和扩容:

  • 桶的初始数量是可以在创建 std::unordered_setstd::unordered_map 对象时指定的。如果没有指定,则使用库实现提供的默认值。具体数值取决于具体实现,但通常会选择一个较小且为质数的值。

  • 在插入新元素时,如果导致负载因子(即元素数量除以桶数量)超过预设阈值(称为最大负载因子),则会触发扩容操作。最大负载因子默认值一般为1.0,但也可以通过成员函数 max_load_factor() 进行查询或修改。

  • 扩容操作包括分配更多的桶并重新计算所有已有元素对应新哈希桶位置, 以保持良好性能.

在数据结构中,哈希函数主要用于快速查找和存储数据。例如,在一个哈希表中,我们可以使用哈希函数将键转换为数组索引,并在该索引位置存储相应的值。由于数组访问时间是常数级别的,所以理论上通过这种方式我们可以实现常数时间复杂度的查找和插入操作。

理想的哈希函数有以下特性:

  1. 确定性:对于同一输入值,无论执行多少次都会产生相同的输出结果。
  2. 高效计算:计算哈希值需要高效快速。
  3. 均匀分布:对于不同输入,其输出(即生成的索引)应该在可能空间上均匀分布,以减少冲突。
  4. 敏感度高:即使是微小改动的输入也能产生显著不同的输出。

桶的数量为什么是质数

选择质数作为哈希表的桶数是一种常见的做法,其主要目的是为了减少冲突并使得元素在桶中更均匀地分布。

哈希函数通常会将输入键值映射到一个固定范围内,例如0到某个数字N。如果N是一个质数,那么不同键值经过哈希函数计算后产生的余数(即映射到的桶号)可能性就会更多,从而使得元素在各个桶中分布得更均匀。(可以用解决冲突方法中的双重哈希方法举个例子说明)

额外内容:

        一:priority_queue

  priority_queue是C++标准库中的一个容器适配器,它提供了优先队列的功能。优先队列是一种特殊的队列,其

以下是priority_queue的一些主要特性:

  1. 元素访问:你可以使用top()成员函数访问队列中的最优先元素。priority_queue不提供对队列中其他元素的直接访问。

  2. 插入和删除:你可以使用push()成员函数在队列中插入一个元素。push()函数会确保新元素插入到正确的位置,以保持队列的排序。你可以使用pop()函数删除最优先的元素,但请注意,pop()函数不会返回被删除的元素。

  3. 大小:你可以使用size()成员函数获取队列中元素的数量。你也可以使用empty()函数检查队列是否为空。

  4. 底层容器std::priority_queue是一个容器适配器,这意味着它使用另一个容器作为其底层的数据存储。默认情况下,底层容器是std::vector,但你也可以在创建std::priority_queue时指定其他容器类型。

  5. 比较函数:你可以在创建std::priority_queue时提供一个自定义的比较函数。这个函数定义了队列中元素的排序方式。默认情况下,std::priority_queue使用元素类型的<运算符作为比较函数,这意味着最大的元素总是最优先的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值