c++面试题

1. vector 的元素类型为什么不能是引用

在 C++ 中,std::vector 的元素类型不能是引用(如 int&)的根本原因是
因为引用在 C++ 标准中被设计为没有独立的存储单元。

1. 引用没有独立的存储
引用的性质:引用在 C++ 中实际上是另一对象的别名,而不是一个可以存在容器中的实体。
它没有自己的存储空间,只是所引用对象的一个符号指代。
2. 内存管理问题
无法处理引用的存储:容器(如 std::vector)需要在内存中为所存储的每一个元素分配空间。
然而,为引用分配空间没有意义,因为它们没有自己的存储。
3. 复制和重新分配的复杂性
复制行为:在 std::vector 等容器需要扩容时,元素会被移动或复制到新的内存位置。
引用的复制会导致复杂且错误的内存管理,因为它们其实指向其他对象。
拥有引用类型的容器无法正确地处理复制,因为它无法复制引用所关联的对象。
4. 元素生命周期的不确定性
生命周期管理:如果 vector 中存储引用,则引用所指对象的生命周期无法由 vector 控制,
这可能导致悬挂引用(dangling reference)的问题。


替代方案
如果需要在 std::vector 中存储引用的效果,可以考虑以下几个替代方案:

指针:使用指针(如 int*),虽然指针也指向对象,不解决生命周期问题,但在内存管理上比引用更灵活。

智能指针:使用 std::shared_ptr 或 std::unique_ptr 能够更好地管理指针及其引用对象的生命周期。

包装引用:使用 std::reference_wrapper 可以间接存储引用,但这仍然要求对所引用对象的生命周期进行外部管理。

2. 什么时候使用vector list deque

std::vector:适用于需要快速随机访问和尾部操作的场景。
std::list:适用于需要频繁任意位置插入和删除操作的场景。
std::deque:适用于需要高效双端操作和随机访问的场景。

std::vector
特性
连续存储:std::vector 的元素存储在连续的内存块中,支持快速的随机访问。
动态数组:在尾部插入和删除元素效率高,但在中间或头部插入和删除元素效率较低。
扩容机制:当容量不足时,std::vector 会重新分配更大的内存块,并复制或移动现有元素。
适用场景
需要快速随机访问:std::vector 支持 O(1) 时间复杂度的随机访问,适合需要频繁访问元素的场景。
尾部插入和删除:如果主要操作是在尾部插入和删除元素,std::vector 是一个高效的选择。
内存连续性:需要内存连续性以提高缓存命中率的场景。

std::list
特性
双向链表:std::list 的元素存储在非连续的内存块中,每个元素包含前驱和后继指针。
任意位置插入和删除:在任意位置插入和删除元素的时间复杂度为 O(1)。
不支持随机访问:访问特定位置的元素需要遍历链表,时间复杂度为 O(n)。
适用场景
频繁插入和删除:如果需要在任意位置频繁插入和删除元素,std::list 是一个高效的选择。
不需要随机访问:不需要快速随机访问,而是需要高效的插入和删除操作的场景。
内存不连续性:对内存连续性要求不高,但需要频繁动态调整元素位置的场景。

std::deque
特性
分段连续存储:std::deque 的元素存储在多个固定大小的连续内存块中,通过中央指针数组管理。
双端队列:支持在头部和尾部高效插入和删除元素,时间复杂度为 O(1)。
随机访问:支持 O(1) 时间复杂度的随机访问。
适用场景
双端操作:如果需要在头部和尾部频繁插入和删除元素,std::deque 是一个高效的选择。
需要随机访问:需要快速随机访问,同时需要高效的双端操作的场景。
内存分段管理:对内存连续性要求不高,但需要高效双端操作的场景。

3. 迭代器底层实现原理?及其有哪些种类?

4. priority_queue 的底层实现原理

5. vector中reserve和resize的区别

reserve
作用:reserve 用于预分配 vector 的容量(capacity)而不改变其当前大小(size)。它只影响内存分配,以便将来插入元素时减少内存重新分配的次数。

用法:reserve(n) 预分配至少能够容纳 n 个元素的空间,但 vector 的大小(即实际包含的元素数量)不会改变。

场景:使用 reserve 可以提高性能,尤其是在你知道即将填充大量元素的情况下。通过预分配足够的内存,可以减少动态增长时频繁的内存分配和数据拷贝。

影响:调用 reserve 可能导致内存重新分配,但不会初始化这些新预留的空间。调用 reserve 后 size() 不变,capacity() 可能增加。

resize
作用:resize 调整 vector 的当前大小。因此,它不仅会改变容器的 size,还会影响实际元素的数量。

用法:resize(n) 将 vector 调整为包含 n 个元素。如果 n 大于当前大小,并且提供了默认值,则使用该默认值填充新增元素,否则使用元素类型的默认构造函数。
    如果 n 小于当前大小,则删除多余的元素。

场景:resize 常用于需要增加或减少 vector 中有效元素的数量时。
例如,初始化 vector 到一个固定大小,并填充默认值。

影响:如果 resize 增加了 size,新元素被默认构造;如果减少了 size,多余元素被删除并调用其析构函数。 size不会减少


6. unordered_map 的底层实现原理

7. vector 底层实现原理

std::vector 是 C++ 标准库中的一个动态数组容器,它提供了高效的随机访问和动态内存管理。
1. 内存分配和管理
使用动态内存分配来管理其元素。它通常使用一个指向连续内存块的指针来存储元素,并维护三个关键指针:

start:指向数组的起始位置。
finish:指向数组中最后一个元素的下一个位置(即当前元素的末尾)。
end_of_storage:指向当前分配的内存块的末尾。
这些指针用于管理 std::vector 的容量(capacity)和大小(size)。

2. 动态扩容
当向 std::vector 中添加元素时,如果当前容量不足以容纳新元素,std::vector 会自动进行动态扩容。扩容的过程通常包括以下步骤:

分配新内存:分配一块更大的内存块,通常是当前容量的两倍(或其他策略)。
复制元素:将原内存块中的元素复制到新内存块中。
释放旧内存:释放原内存块。
扩容操作的时间复杂度为 O(n),其中 n 是当前元素的数量。为了避免频繁扩容,std::vector 通常会预留一些额外的容量。

3. 元素访问
由于 std::vector 的元素存储在连续的内存块中,因此可以通过下标运算符 [] 或 at() 方法进行高效的随机访问。访问单个元素的时间复杂度为 O(1)。

4. 插入和删除
在 std::vector 的末尾插入或删除元素的时间复杂度为 O(1)(均摊时间复杂度)。然而,在中间或开头插入或删除元素的时间复杂度为 O(n),因为需要移动后续元素。

5. 内存对齐
std::vector 会确保其元素的内存对齐,以提高访问效率。内存对齐通常由编译器和硬件平台决定。

6. 迭代器
std::vector 提供了随机访问迭代器(Random Access Iterator),支持高效的迭代操作。迭代器可以用于遍历 std::vector 中的元素,并支持算术运算(如 +、-、++、-- 等)。

7. 异常安全
std::vector 提供了异常安全的操作,确保在发生异常时不会破坏容器的状态。例如,在扩容过程中,如果内存分配失败,std::vector 会保持原状态不变。


8. STL 容器线程安全性

9. 迭代器失效?连续和非连续存储容器的失效?

10. vector 内存增长机制

std::vector 在需要增加容量(即容量不足以容纳新元素)时,会进行动态扩容。这种内存增长机制有助于提高内存分配的效率,减少频繁重新分配内存和复制元素的开销。

1. 扩容触发
std::vector 会在以下情况下触发扩容:

调用 push_back() 或 emplace_back() 并且当前容量不足以容纳新元素时。
调用 insert() 并且需要插入多个元素导致容量不足时。
调用 resize() 并且新大小大于当前容量时。

2. 扩容策略
std::vector 扩容时通常采用倍增策略,具体实现细节取决于标准库的实现,但常见的做法是将容量增加到当前容量的两倍(有时可能略多于两倍)。这种策略在时间复杂度和空间使用之间提供了一个良好的平衡:

时间复杂度:倍增策略使得平均时间复杂度为摊销常数时间 O(1)。虽然单次扩容的时间复杂度是 O(n),但因为扩容较少发生,所以在长时间序列中的摊销复杂度很低。
空间使用:倍增策略减少了扩容次数,降低了多次小型内存分配带来的碎片化问题。

3. 扩容过程
扩容过程包括以下几个步骤:

分配新内存:根据扩容策略分配一块新的更大的内存块。
移动/复制元素:将所有元素从旧的内存块复制或移动到新的内存块中。对于具有复杂构造或析构的元素类型,通常会使用移动构造来提高效率。
释放旧内存:如果所有元素都成功复制或移动,释放旧内存块。

4. 处理异常和异常安全
在扩容过程中,如果分配新内存或在复制/移动操作中发生异常(如在移动构造中抛出异常),
std::vector 会保持其异常安全以保证不会破坏现有数据。
通常,这意味着如果扩容操作失败,std::vector 将恢复到操作前的状态。

1. 清除所有元素,但保留容量
你可以使用 clear() 方法清除所有元素,但这不会改变 std::vector 的容量。clear() 只是将 size 设为 0,而不释放内存。

2. 释放多余的容量
要释放 std::vector 中未使用的容量,可以使用 shrink_to_fit() 方法。shrink_to_fit() 是一个非强制性的请求,提示标准库释放未使用的内存,使容量与当前大小匹配:

3. 交换技巧
通过与一个空的 std::vector 交换,可以强制缩减内存容量到0:在这种方法中,swap 方法用于与一个空的 std::vector 交换内容,从而达到释放内存的效果。

4. 指针类型元素
当 std::vector 中的元素是指针类型时,调用 clear()、erase() 或者 vector 本身被销毁时,仅仅会销毁存储这些指针的内存空间,而不会自动管理这些指针所指向的对象的生命周期。

11. multiset 的底层实现原理


std::multiset 是一种关联容器,类似于 std::set,但不同的是 multiset 允许存储相同键的多个副本(即重复元素)。
multiset 的底层实现通常基于一种平衡二叉搜索树,例如红黑树。
这使得 multiset 能够高效地进行元素的插入、删除和查找操作。

1. 数据结构
平衡二叉搜索树:multiset 通常使用一种平衡二叉搜索树(如红黑树)来存储元素。
平衡树结构确保所有基本操作(插入、删除、查找)的时间复杂度为 O(log n),其中 n 是容器中元素的数量。
2. 键的存储
重复元素:与 std::set 不同,multiset 允许存储相同键的多个副本。
底层树结构会在需要的时候增加树节点来存储相同值的元素,无需额外的去重机制。
3. 元素排序
自动排序:multiset 会自动对其元素进行排序,与 std::set 一样,multiset 中的元素以严格弱序(通常是按从小到大的顺序)存储。
4. 插入操作
复杂度:插入操作会在内部平衡树中添加一个新节点,时间复杂度为 O(log n)。
位置插入:由于树的性质,不提供插入位置的显式控制,元素插入后会自动放置在适当的位置使树仍保持排序。
5. 删除操作
复杂度:删除某个值的所有实例或特定实例的时间复杂度也为 O(log n)。
平衡调整:每当删除节点时,树需要重新平衡以维护性能。
6. 查找和遍历
查找:可以快速查找某个值在 multiset 中是否存在,查找时间复杂度为 O(log n)。
遍历:可以通过迭代器遍历 multiset 的元素,按顺序访问(从小到大或从大到小,取决于比较函数)每个元素。
7. 内存管理
由于使用了动态数据结构(如红黑树),multiset 会对内存进行动态分配和管理,以便在添加或删除元素时进行调整。

12. list 底层实现原理

std::list 是 C++ 标准库中的一个双向链表容器。它提供对元素的快速插入和删除操作,但访问特定位置的元素需要遍历列表。

1. 数据结构
std::list 通常实现为一个双向链表。这意味着它由一系列节点组成,每个节点包含以下元素:

数据成员:存储实际的数据元素。
前驱指针:指向前一个节点。
后继指针:指向下一个节点。
这种数据结构允许在常数时间内完成插入和删除操作,因为只需调整指针即可。

2. 节点与头尾指针
头指针(head):指向链表的第一个节点。
尾指针(tail):指向链表的最后一个节点。
哨兵节点(sentinel nodes):很多实现使用哨兵节点来简化边界条件处理。
也就是说,链表的头尾可能不是直接指向真实数据节点,而是指向一个特殊的哨兵节点。
哨兵节点可以简化空链表和边界节点的处理,也可以缓存列表的大小。

3. 内存管理
每次插入一个新元素时,std::list 会为新元素动态分配一个节点。删除元素时,相应的节点会被释放。

4. 插入和删除操作
插入:为了将元素插入到链表中,只需调整相关节点的前驱和后继指针。例如,插入操作可以在指定位置、头部或尾部发生。

删除:删除操作涉及跳过要删除的节点并释放其内存,同样通过调整前驱和后继指针来完成。

5. 迭代器
std::list 提供双向迭代器(Bidirectional Iterator),支持通过增量(++)和减量(--)遍历列表。
这与其他容器的随机访问迭代器不同,因为访问和定位特定索引没有常数时间保证。

6. 操作特性
快的插入与删除:在链表中插入和删除元素的时间复杂度为 O(1)(只需调整指针)。

慢的随机访问:由于没有下标操作符和直接访问地址,访问元素的时间复杂度为 O(n)。

稳定性:链表操作可以在不影响迭代器的有效性的情况下进行,这便于迭代器安全的算法实现。

13. deque 底层实现原理

14. 哨兵节点

哨兵节点是链表中的一个特殊节点,用作标尺,它并不代表实际数据,而是用于简化链表的操作。这些节点通常用于指示链表的起始(头)或结束(尾)。哨兵节点可以出现在链表的两端(双哨兵),或仅在一端。

哨兵节点的优点
简化代码:使用哨兵节点可以去除对空指针的检查。当进行插入、删除和遍历操作时,不需要对边界进行特别处理,简化了代码实现。

统一操作:在没有哨兵节点的链表中,处理头节点(如插入到头部和删除头部)和其他节点的操作可能不同。引入哨兵节点后,这些操作可以被统一处理,因为列表头部的操作不再是特殊情况。

减少特殊判断:在各种链表操作中,哨兵节点的存在意味着不需要特别判断链表为空的情况,这对代码的鲁棒性和可维护性都有所提升。

应用示例
假设我们创建一个双向链表,并在此链表中使用了头尾哨兵节点:

头哨兵:在链表的开头,用于表示链表第一个有效元素前的位置。
尾哨兵:在链表的结尾,用于表示链表最后一个有效元素后的位置。
动态表现
在双向链表中,头哨兵的 next 指针指向第一个实际节点,尾哨兵的 prev 指针指向最后一个实际节点。以下是链表的伪结构:

[Head Sentinel] <-> [Node1] <-> [Node2] <-> ... <-> [NodeN] <-> [Tail Sentinel]

这种设计使得在链表的头部或尾部添加或删除节点的逻辑变得简单,因为对头和尾的操作变成与中间节点相同,统一的代码处理,不需特判。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

可能只会写BUG

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值