1.4.1 说说STL的基本组成部分
总结
STL 的基本组成部分包括容器、算法、迭代器、函数对象和仿函数和适配器。通过这些组件,STL 提供了高效、灵活和可复用的代码结构,极大地提高了 C++ 的开发效率和程序的可维护性。STL 的设计思想使得算法和数据结构的使用变得更加一致和简单。
C++ 标准模板库(Standard Template Library,STL)是一个功能强大的库,提供了一组常用的数据结构和算法。STL 的基本组成部分主要包括以下几个部分:
1. 容器(Containers)
容器是用于存储对象的集合,它们可以是基本类型的对象,也可以是自定义类型的对象。STL 提供了多种类型的容器,主要分为以下几类:
-
序列容器:按照插入顺序存储元素,提供随机访问。
std::vector
:动态数组,支持快速随机访问和末尾插入。std::deque
:双端队列,可以在两端高效插入和删除元素。std::list
:双向链表,支持在任意位置插入和删除元素,但不支持随机访问。
-
关联容器:基于键值对存储元素,支持高效的查找。
std::set
:不允许重复元素的集合,自动排序。std::map
:键值对集合,根据键进行排序,允许快速查找。std::multiset
:允许重复元素的集合。std::multimap
:允许重复键的键值对集合。
-
无序关联容器:基于哈希表实现,提供常数时间复杂度的查找。
std::unordered_set
:无序集合,允许重复元素。std::unordered_map
:无序键值对集合。
2. 算法(Algorithms)
STL 提供了一系列通用算法,可以对容器中的数据进行操作。这些算法以模板的形式提供,适用于不同类型的容器。常见的算法包括:
- 排序算法:如
std::sort()
、std::stable_sort()
。 - 查找算法:如
std::find()
、std::binary_search()
。 - 修改算法:如
std::copy()
、std::fill()
、std::remove()
。 - 集合算法:如
std::set_union()
、std::set_intersection()
。
3. 迭代器(Iterators)
迭代器是一种通用的访问容器中元素的方式。它们提供了一种统一的接口,可以遍历不同类型的容器。STL 提供了多种类型的迭代器:
- 输入迭代器:只读访问容器的元素。
- 输出迭代器:写入容器的元素。
- 前向迭代器:可读写访问,但只能向前移动。
- 双向迭代器:可读写访问,可以向前和向后移动。
- 随机访问迭代器:支持直接访问任何位置的元素,如指针,允许在容器中任意跳转。
4. 函数对象和仿函数(Function Objects and Functors)
STL 允许使用函数对象作为算法的参数。函数对象是重载了 operator()
的类,使得对象可以像函数一样被调用。STL 的算法可以接受函数指针、函数对象或 lambda 表达式作为参数,以定制算法的行为。
5. 适配器(Adapters)
适配器是对现有容器、迭代器或函数对象的封装,提供了更高层次的功能。常见的适配器包括:
-
容器适配器:
std::stack
:基于底层容器实现的栈。std::queue
:基于底层容器实现的队列。std::priority_queue
:支持优先级的队列。
-
迭代器适配器:
std::reverse_iterator
:反向迭代器。std::back_insert_iterator
:用于向容器插入元素的迭代器。
1.4.2 说说STL中常见的容器,并介绍一下实现原理。
C++ 标准模板库(STL)提供了多种常见的容器,用于存储和管理数据。下面是一些 STL 中常见的容器及其实现原理的概述。
1. 序列容器
a. std::vector
- 定义:动态数组,可以根据需要自动扩展大小。
- 实现原理:
- 内部使用一个连续的内存块来存储元素。
- 当数组容量不足以容纳新元素时,
std::vector
会分配一个更大的内存块(通常是当前大小的 1.5 倍或 2 倍),然后将旧元素复制到新位置。 - 支持随机访问,时间复杂度为 O(1)。
- 插入和删除操作的时间复杂度为 O(n),因为可能需要移动元素。
b. std::deque
- 定义:双端队列,允许在两端高效地插入和删除元素。
- 实现原理:
- 内部使用多个固定大小的数组(块)组成的链表结构。
- 每个块可以动态分配,支持在两端快速插入和删除。
- 随机访问的时间复杂度为 O(1),但由于分散的内存结构,可能会有一定的缓存不命中。
c. std::list
- 定义:双向链表,支持在任意位置高效地插入和删除元素。
- 实现原理:
- 内部实现为双向链表,每个节点包含数据和指向前后节点的指针。
- 插入和删除操作的时间复杂度为 O(1),但不支持随机访问,访问元素的时间复杂度为 O(n)。
2. 关联容器
a. std::set
- 定义:不允许重复元素的集合,自动排序。
- 实现原理:
- 通常使用红黑树(自平衡二叉搜索树)实现。
- 元素按升序排列,插入、删除和查找操作的时间复杂度为 O(log n)。
b. std::map
- 定义:键值对集合,键唯一且按键排序。
- 实现原理:
- 也是基于红黑树实现。
- 支持高效查找、插入和删除操作,时间复杂度为 O(log n)。
c. std::multiset
和 std::multimap
- 定义:允许重复元素的集合和键值对集合。
- 实现原理:
- 同样基于红黑树实现,支持重复元素的插入。
- 查找和删除操作的时间复杂度为 O(log n)。
3. 无序容器
a. std::unordered_set
- 定义:无序集合,允许重复元素。
- 实现原理:
- 基于哈希表实现,使用哈希函数来决定元素的存储位置。
- 查找、插入和删除操作的平均时间复杂度为 O(1)。
b. std::unordered_map
- 定义:无序键值对集合。
- 实现原理:
- 也是基于哈希表实现,键通过哈希函数映射到存储桶。
- 支持高效查找和插入,平均时间复杂度为 O(1)。
4. 适配器
a. std::stack
- 定义:后进先出(LIFO)的数据结构。
- 实现原理:
- 通常基于其他容器(如
std::deque
或std::vector
)实现。 - 提供
push
、pop
和top
操作。
- 通常基于其他容器(如
b. std::queue
- 定义:先进先出(FIFO)的数据结构。
- 实现原理:
- 也基于其他容器(通常是
std::deque
)实现。 - 提供
push
、pop
和front
操作。
- 也基于其他容器(通常是
总结
STL 提供了多种容器,适用于不同的使用场景。每种容器都有其特定的实现原理和性能特点,以满足开发者的需求。选择合适的容器可以提高程序的效率和可维护性。通过 STL,开发者能够轻松地使用和管理各种数据结构,极大地简化了 C++ 编程。
1.4.3 说说STL中 map hashtable deque list 的实现原理
std::map
:使用红黑树实现,支持按键排序,操作复杂度为 O(log n)。std::unordered_map
:基于哈希表实现,支持快速查找和插入,平均复杂度为 O(1)。std::deque
:使用块结构实现的双端队列,支持在两端高效操作,随机访问复杂度为 O(1)。std::list
:实现为双向链表,支持高效的插入和删除操作,但不支持随机访问,访问复杂度为 O(n)。
在 C++ 标准模板库(STL)中,map
、unordered_map
(常称为哈希表)、deque
和 list
是几种常见的容器,每种容器的实现原理都有其特定的结构和特点。以下是对它们实现原理的详细解释。
1. std::map
- 实现原理:
std::map
通常使用 红黑树(自平衡二叉搜索树)来实现。- 每个节点存储一个键值对(key-value pair),并通过键的顺序(通常是升序)进行排序。
- 红黑树的特点是每个节点都有一个颜色属性(红色或黑色),并且遵循特定的平衡规则,确保树的高度是对数级别。
- 操作复杂度:
- 查找、插入和删除操作的时间复杂度为 O(log n)。
2. std::unordered_map
(哈希表)
- 实现原理:
std::unordered_map
基于 哈希表 实现。- 使用一个数组作为基础存储,元素通过哈希函数计算出索引位置,存储在相应的桶中。
- 如果发生哈希冲突(即不同的键映射到同一桶),通常会使用链式法(在同一个桶中使用链表存储冲突的元素)来解决。
- 操作复杂度:
- 查找、插入和删除操作的平均时间复杂度为 O(1),最坏情况下为 O(n)(当所有元素都在同一个桶中)。
3. std::deque
- 实现原理:
std::deque
是一种 双端队列,通常实现为多个固定大小的块(chunk)组成的结构。- 每个块可以动态分配,允许在两端高效插入和删除。
- 它维护一个指向这些块的指针数组,支持随机访问和双端操作。
- 操作复杂度:
- 随机访问的时间复杂度为 O(1),在两端插入和删除操作的时间复杂度也为 O(1),但中间插入和删除的复杂度为 O(n)。
4. std::list
- 实现原理:
std::list
是一个 双向链表,每个节点包含数据、指向前一个节点和后一个节点的指针。- 这种结构允许在任意位置进行高效的插入和删除操作,因为只需更改指针即可,不需要移动其他元素。
- 链表不支持随机访问,访问元素的时间复杂度为 O(n),需要从头遍历到目标节点。
- 操作复杂度:
- 插入和删除操作的时间复杂度为 O(1),但访问操作的时间复杂度为 O(n)。
通过这些实现原理,STL 提供了高效、灵活的数据结构,能够满足不同的编程需求。选择合适的容器对于程序的性能和可维护性至关重要。
1.4.4 介绍一下STL的空间适配器(allocator)
在 C++ 标准模板库(STL)中,空间适配器(Allocator)是一种用于管理内存分配的机制。它为 STL 容器提供了一种可定制的内存管理方式,使得容器可以在需要时动态地分配和释放内存。以下是对 STL 空间适配器的详细介绍。
1. Allocator 的定义
- Allocator 是一个类模板,负责分配和释放内存。它定义了用于内存管理的一组类型和操作。
- 在 STL 中,默认的分配器是
std::allocator<T>
,其中T
是容器中存储的元素类型。
2. Allocator 的基本功能
Allocator 提供了以下基本功能:
-
内存分配:
- 使用
allocate(size_t n)
函数分配大小为n
的元素的内存。 - 返回指向分配内存的指针。
- 使用
-
内存释放:
- 使用
deallocate(T* p, size_t n)
函数释放之前分配的内存。 p
是指向要释放的内存的指针,n
是元素的数量。
- 使用
-
构造和析构:
- 使用
construct(T* p, Args&&... args)
在已分配的内存上构造对象。 - 使用
destroy(T* p)
在已构造的对象上调用析构函数,释放对象的资源。
- 使用
3. Allocator 的使用
STL 容器在内部使用 Allocator 进行内存管理。用户可以通过模板参数自定义 Allocator,例如:
#include <vector>
#include <memory>
int main() {
std::vector<int, std::allocator<int>> vec; // 使用默认的 allocator
std::vector<int, MyAllocator<int>> customVec; // 使用自定义的 allocator
}
4. 自定义 Allocator
用户可以自定义 Allocator,通过继承 std::allocator
或实现自己的分配和释放逻辑。自定义 Allocator 需要遵循 Allocator 的接口要求,提供相应的成员函数。
template <typename T>
class MyAllocator {
public:
using value_type = T;
MyAllocator() = default;
template <typename U>
MyAllocator(const MyAllocator<U>&) {}
T* allocate(std::size_t n) {
// 自定义分配逻辑
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, std::size_t) {
// 自定义释放逻辑
::operator delete(p);
}
template <typename U, typename... Args>
void construct(U* p, Args&&... args) {
new(p) U(std::forward<Args>(args)...); // 在已分配内存上构造对象
}
template <typename U>
void destroy(U* p) {
p->~U(); // 调用析构函数
}
};
5. Allocator 的优势
- 内存管理的灵活性:Allocator 提供了一种方式,使得容器的内存管理可以被替换和定制。用户可以根据需求选择不同的内存分配策略。
- 性能优化:对于特定应用程序,可以使用自定义 Allocator 来优化内存分配和释放,减少内存碎片,提高性能。
- 可扩展性:通过实现自定义 Allocator,用户可以扩展容器的功能,例如实现自定义的内存池或其他特定的分配逻辑。
6. 总结
STL 的空间适配器(Allocator)是内存管理的重要组成部分,它为 STL 容器提供了灵活、可定制的内存分配和释放机制。通过使用 Allocator,开发者可以在需要时调整内存管理策略,以满足特定的性能和资源需求。了解和使用 Allocator 可以帮助提高 C++ 程序的性能和可维护性。
1.4.6 迭代器什么时候会失效?
在 C++ 标准模板库(STL)中,迭代器是一种用于访问和遍历容器中元素的对象。迭代器提供了一种统一的接口,可以对各种类型的容器进行操作,如数组、链表、集合等。
1. 迭代器的定义
- 迭代器是一种指向容器元素的对象,它能够以类似指针的方式访问容器中的元素。
- 迭代器支持多种操作,如递增、解引用等,以便于在容器中遍历。
2. 迭代器的作用
- 访问容器元素:迭代器可以访问容器中的元素,允许读取和修改这些元素。
- 遍历容器:通过迭代器,可以以统一的方式遍历不同类型的容器。
- 算法支持:STL 的算法(如
std::sort
、std::find
等)通常以迭代器作为参数,从而使算法能够独立于容器类型地操作数据。 - 更好的抽象:迭代器提供了一种抽象机制,使得开发者可以使用同一套语法访问不同类型的容器,而不需要关心容器的内部实现细节。
3. 迭代器的类型
迭代器根据其功能和特性可以分为几种类型:
- 输入迭代器:支持只读访问容器的元素。
- 输出迭代器:支持写入容器的元素。
- 前向迭代器:可读写访问,但只能向前移动。
- 双向迭代器:可以向前和向后移动,支持读写。
- 随机访问迭代器:支持直接访问容器的任意元素,如指针,允许进行加法和减法操作。
4. 迭代器失效的情况
迭代器失效是指在某些操作后,迭代器不能再安全地使用,通常会导致未定义行为。以下是一些常见的导致迭代器失效的情况:
-
容器元素的插入和删除:
std::vector
和std::deque
:当在容器中插入或删除元素时,可能会导致其他元素的内存位置改变,导致现有的迭代器失效。一般来说,删除当前迭代器所指向的元素后,指向该元素的迭代器将失效。std::list
:在std::list
中,由于链表的性质,删除一个元素不会导致其他迭代器失效,除非它们直接指向被删除的元素。
-
容器的重新分配:
std::vector
:当std::vector
的容量不足以容纳新元素时,可能会发生内存重新分配,导致所有指向该vector
中元素的迭代器失效。
-
容器的清空:
- 当容器被清空(例如,调用
clear()
方法)时,所有指向该容器的迭代器都将失效。
- 当容器被清空(例如,调用
-
指针或引用:
- 如果迭代器是通过指针或引用访问的,且所指向的对象被删除或作用域结束,迭代器将失效。
5. 总结
- 迭代器是一种用于访问和遍历 STL 容器的对象,具有指向容器元素的能力。
- 迭代器提供了统一的访问接口,支持多种操作,使得容器和算法之间的结合更加灵活。
- 迭代器可能会在插入、删除、清空或容器重新分配时失效,因此在使用迭代器时要格外小心,以避免未定义行为。理解迭代器的生命周期和有效性是高效使用 STL 的关键。
1.4.7 说说迭代器的做用,有指针为何还要迭代器?
迭代器在 C++ 中是用于访问和遍历 STL 容器的一种工具,比指针更通用、灵活。尽管指针可以直接操作数组等连续内存,但迭代器具有更广泛的适用性和功能。
指针与迭代器的区别
特性 | 指针 | 迭代器 |
---|---|---|
通用性 | 仅适用于连续内存 | 适用于各种容器(vector 、list 等) |
功能 | 基础读写遍历 | 支持STL算法和多种访问方式 |
安全性 | 容易越界 | 限制非法操作 |
灵活性 | 支持随机访问 | 支持多种迭代类型 |
迭代器的优势
- 通用性:适配各种容器,包括链表、哈希表等非连续结构。
- 统一接口:标准化接口便于与STL算法配合使用。
- 类型安全:防止非法访问,确保容器的正确操作。
迭代器分类
- 输入/输出迭代器:单向读取/写入。
- 前向/双向迭代器:支持单/双向遍历。
- 随机访问迭代器:支持随机访问(如
vector
)。
总结来说,迭代器提供了比指针更通用的容器访问方式,使得代码可移植性更强,适应性更好。
1.4.8 说说STL迭代器是怎么删除元素的
在 C++ 的 STL 中,迭代器通过特定的容器方法来删除元素,但具体实现和操作方式因容器而异。下面是常见容器的元素删除方法及注意事项:
1. vector
、deque
等序列容器
在 vector
和 deque
中,删除元素通常使用 erase
方法。删除操作后的迭代器指向已删除元素的下一个位置,因此可以直接利用返回的迭代器继续遍历:
std::vector<int> vec = {1, 2, 3, 4};
auto it = vec.begin();
while (it != vec.end()) {
if (*it == 2) {
it = vec.erase(it); // 删除元素,并更新迭代器
} else {
++it;
}
}
- 注意:
erase
会使其他迭代器失效,因为vector
和deque
需要移动后续元素以保持连续内存结构。因此,删除操作后应避免使用旧的迭代器。
2. list
和 forward_list
list
和 forward_list
是链表实现,删除元素时不需要移动其他元素,只需修改指针,因此只会使指向已删除元素的迭代器失效:
std::list<int> lst = {1, 2, 3, 4};
auto it = lst.begin();
while (it != lst.end()) {
if (*it == 2) {
it = lst.erase(it); // 删除后返回下一个有效迭代器
} else {
++it;
}
}
- 优点:由于链表的删除操作不需要移动其他元素,因此性能较高,且不会使其他迭代器失效。
3. map
和 set
在关联容器(如 map
和 set
)中,可以使用 erase
方法删除元素。删除后返回的迭代器指向下一个元素:
std::map<int, int> mp = {{1, 10}, {2, 20}, {3, 30}};
auto it = mp.begin();
while (it != mp.end()) {
if (it->first == 2) {
it = mp.erase(it); // 删除后返回下一个迭代器
} else {
++it;
}
}
- 注意:关联容器的删除操作不会影响其他迭代器,因其内部结构无需移动数据以保持一致性。
4. 使用 remove
和 remove_if
对于序列容器(如 vector
、deque
),可以结合 std::remove
和 erase
实现元素删除:
std::vector<int> vec = {1, 2, 3, 4};
vec.erase(std::remove(vec.begin(), vec.end(), 2), vec.end()); // 删除值为2的元素
- 注意:
remove
只移动元素,并不真正删除元素,需要用erase
来清理移动后的重复元素。
总结
vector
、deque
等:使用erase
删除元素,但会导致迭代器失效。list
、forward_list
:使用erase
,删除操作效率高,只失效当前迭代器。map
、set
:erase
只失效当前迭代器,不影响其他迭代器。remove
/remove_if
与erase
结合使用:适用于批量删除特定值的情况。
1.4.9 说说STL中resize和reserve的区别
在 C++ 的 STL 中,resize
和 reserve
都用于调整容器的大小或容量,但它们的作用和使用场景不同,具体区别如下:
1. resize
resize
用于改变容器的大小,即元素的个数,适用于vector
、deque
等支持动态大小的容器。
- 如果
resize
的新大小大于当前大小,则会自动扩充新元素并填充默认值(对于内置类型为0
,对于类对象则是默认构造的对象)。 - 如果
resize
的新大小小于当前大小,则会删除多余的元素。
示例:
std::vector<int> vec = {1, 2, 3};
vec.resize(5); // vec 现在包含 {1, 2, 3, 0, 0}
vec.resize(2); // vec 现在包含 {1, 2}
- 用途:
resize
用于调整实际元素数量,当我们希望增加或减少容器的元素数量时使用。 - 影响大小和容量:
resize
会改变容器的大小,并在需要时调整容量(扩展存储空间)。
2. reserve
reserve
用于调整容器的容量,即分配内存空间以容纳更多元素,但不改变当前的元素数量。reserve
仅影响 vector
和 string
的容量,不影响大小。
reserve
只增加容器的容量,但不会直接创建或删除元素。reserve
只在容量不足时有效,若reserve
的值小于当前容量,调用无效。
示例:
std::vector<int> vec = {1, 2, 3};
vec.reserve(10); // 预分配存储空间以容纳10个元素,但vec的大小仍为3
- 用途:
reserve
主要用于减少内存分配次数,提高性能。当预计容器要存储大量元素时,可以提前分配空间,避免频繁的内存重新分配。 - 影响容量,不影响大小:
reserve
只影响容器的容量,增加的只是内存空间,不会创建新的元素,也不会影响现有元素。
总结对比
特性 | resize | reserve |
---|---|---|
影响 | 改变容器大小并调整容量 | 仅增加容量,不影响容器大小 |
新增元素 | 会根据需要新增元素并填充默认值 | 不新增元素,只增加内存空间 |
删除元素 | 会移除超出新大小的元素 | 不会删除任何元素 |
应用场景 | 需要改变实际元素数量时 | 预先分配空间以减少内存分配次数 |
resize
用于调整实际元素个数,而 reserve
则是提升性能的优化手段,用于提前分配足够的空间。
1.4.10 说说STL容器动态链接可能产生的问题?
STL 容器动态链接指的是在使用标准模板库(STL)容器时,将这些容器的实现包含在动态链接库(DLL)或共享库(如 .dll
、.so
文件)中,并在程序运行时动态加载这些库。通常情况下,STL 的实现是静态链接的,直接包含在可执行文件内,而动态链接可以让程序在多个模块(如主程序和 DLL)之间共享同一份标准库实现。
在 C++ 中,动态链接 STL 容器主要应用在以下场景:
- 模块化程序设计:将 STL 容器的操作或接口放在动态链接库中,便于代码的模块化管理、版本更新和重用。
- 内存管理和节省空间:动态链接库可以让多个程序共享一份标准库代码,减少可执行文件的大小,并节省系统内存。
- 跨模块数据共享:将 STL 容器封装在动态库中后,主程序和库可以共享数据。
STL 容器动态链接可能出现的问题
动态链接 STL 容器可能会带来一些风险和问题,包括内存分配不一致、二进制接口(ABI)不兼容、迭代器失效等问题,主要是因为:
- 不同模块可能使用不同的内存分配器,导致内存管理冲突。
- 编译器和标准库版本不一致可能导致容器结构不同,导致二进制不兼容。
- 容器内部实现依赖于编译器,动态链接时容器的迭代器、指针可能会失效。
STL 容器动态链接的常见实践
- 接口抽象:避免跨模块传递 STL 容器,使用抽象接口来封装容器的内部操作。
- 确保编译环境一致:确保所有模块使用相同的编译器和标准库版本,以避免 ABI 不兼容。
- 使用 PImpl 设计模式:将 STL 容器的实现封装在私有实现类中,以减少跨模块的 STL 容器依赖。
典型示例
假设在一个动态链接库中封装了一个 std::vector<int>
并提供操作函数,主程序可以调用该函数对向量进行操作,但避免直接传递 std::vector<int>
。这种封装可以减少跨模块传递 STL 容器的风险。
1.4.11 说说map和unordered_map的区别?底层实现
map
和 unordered_map
是 C++ 标准库中的两种关联容器,用于存储键值对(key-value pair)。尽管它们都提供键到值的映射功能,但在实现和特性上存在显著差异。
1. map
与 unordered_map
的区别
特性 | map | unordered_map |
---|---|---|
内部实现 | 基于红黑树(自平衡二叉搜索树) | 基于哈希表 |
有序性 | 按键自动排序 | 键的存储顺序不固定 |
时间复杂度 | 查找、插入、删除:O(log n) | 查找、插入、删除:平均 O(1),最坏 O(n) |
迭代顺序 | 按照键的排序顺序进行遍历 | 无序,按哈希值存储 |
内存消耗 | 相对较少,适合需要顺序访问的场景 | 需要额外的哈希表和负载因子管理,内存占用较高 |
使用场景 | 需要排序或按顺序访问时 | 注重查找效率,不要求顺序时 |
2. 底层实现
map
的底层实现:红黑树
- 数据结构:
map
使用红黑树(Red-Black Tree)实现,这是一种平衡二叉搜索树。红黑树的特性使得插入、删除和查找操作都具有 O(log n) 的时间复杂度。 - 有序性:红黑树确保所有键都按顺序排列,因此
map
是有序的。遍历时按照键的升序或降序输出。 - 自动平衡:在插入或删除元素时,红黑树会进行重新平衡,以确保树的高度维持在 log(n) 级别,使操作效率稳定。
unordered_map
的底层实现:哈希表
- 数据结构:
unordered_map
基于哈希表实现,存储元素时通过哈希函数将键映射到哈希表中的位置(桶),实现快速查找。 - 无序性:哈希表不维护元素顺序,存储顺序仅与哈希值有关,因此
unordered_map
是无序的。 - 哈希冲突:如果两个键的哈希值相同,就会发生哈希冲突。
unordered_map
通过链地址法(开放链地址法)解决冲突,即将相同哈希值的元素存储在一个链表或桶中。 - 负载因子和重新哈希:为了维持查找性能,
unordered_map
有一个负载因子(默认 1.0)。当元素数量超过负载因子阈值时,unordered_map
会扩展容量并重新计算元素的哈希位置。
3. 选择使用场景
map
:当需要键值对按顺序存储,或者频繁执行范围查询(如查找所有大于或小于某键的元素)时使用。unordered_map
:当更关注查找、插入和删除操作的平均性能且不需要顺序时使用。
1.4.12 说说vector和list的区别,分别适用于什么场景?
vector
和 list
是 C++ STL 中两种常见的序列容器,但它们在底层实现、操作效率和使用场景上存在显著差异。下面是详细的比较:
1. vector
与 list
的区别
特性 | vector | list |
---|---|---|
底层实现 | 连续内存块(动态数组) | 双向链表 |
随机访问 | 支持 O(1) 时间复杂度的随机访问 | 不支持随机访问,需从头或尾遍历 |
插入/删除效率 | 尾部插入和删除为 O(1),中间插入/删除为 O(n) | 任意位置插入和删除为 O(1) |
内存占用 | 内存连续分配,节省空间 | 每个元素有额外的指针,内存开销较大 |
迭代器失效 | 插入或删除元素可能导致迭代器失效 | 插入、删除不会导致其他迭代器失效 |
适用场景 | 需要频繁随机访问、按顺序遍历的场景 | 需要频繁在中间插入或删除元素的场景 |
2. vector
的特点和适用场景
-
特点:
- 采用连续内存存储,可以高效地进行随机访问。
- 尾部操作(如
push_back
)效率高,为 O(1) 时间复杂度。 - 进行中间插入或删除时,需要移动大量元素,导致效率较低(O(n))。
-
适用场景:
- 适合需要大量随机访问的情况,如频繁通过索引访问元素。
- 数据量稳定、不需要频繁增减时,如有序数据的缓存、图形点集存储等。
3. list
的特点和适用场景
-
特点:
- 采用双向链表实现,每个节点包含指向前后节点的指针。
- 插入、删除操作效率高,为 O(1) 时间复杂度,特别适合频繁在容器中间进行修改的情况。
- 不支持随机访问,查找效率较低,需要 O(n) 时间遍历链表。
-
适用场景:
- 适合频繁在容器中间插入、删除元素的情况,如事件队列、需要频繁增减的任务列表等。
- 不适合频繁随机访问的场景,除非访问仅限于头尾部的情况(如双端队列实现)。
4. 总结
- 选择
vector
:当需要频繁随机访问,且主要操作是尾部插入时。 - 选择
list
:当数据量不大、需要在中间频繁插入和删除,并且随机访问要求不高时。
1.4.13 简述vector的实现原理
std::vector
是 C++ STL 中的动态数组容器,其底层实现主要基于连续内存块,提供了动态调整大小和高效的随机访问功能。它的实现原理可以分为以下几个方面:
1. 底层存储结构
vector
使用一个连续的内存块来存储元素,这使得它支持 O(1) 时间复杂度的随机访问。- 内存块的大小不固定,当插入元素数量超过当前容量时,
vector
会自动重新分配内存,并将现有元素复制到新的更大的内存块中。 vector
的底层包含三个指针:begin
指向第一个元素,end
指向最后一个元素之后的位置,capacity
指向当前分配内存的尾部位置。
2. 动态扩容机制
- 每当
vector
的元素数量超过当前容量时,就会触发扩容。 - 扩容时,一般会分配一个比当前容量更大的新内存块(通常为当前容量的 2 倍),然后将旧元素复制到新内存块中,释放旧内存。
- 扩容策略为摊还复杂度提供了优化,尽管扩容操作是 O(n),但摊还下来平均插入复杂度为 O(1)。
3. 元素访问
vector
支持使用索引访问元素,提供 O(1) 时间复杂度的随机访问。- 通过重载
[]
操作符和at()
方法,允许使用数组语法访问元素,at()
还会进行边界检查,而[]
不会。
4. 内存管理
vector
使用 RAII(Resource Acquisition Is Initialization)原则来管理内存分配和释放。- 当
vector
被销毁时,析构函数会释放底层内存并调用每个元素的析构函数,确保资源不泄漏。
5. 插入与删除操作
- 尾部插入:通过
push_back
在尾部插入元素。若未超过容量,则直接插入;若超过容量,则触发扩容。 - 尾部删除:通过
pop_back
删除尾部元素,时间复杂度为 O(1)。 - 中间插入或删除:中间位置插入或删除元素会导致大量元素移动,时间复杂度为 O(n)。
6. 迭代器失效
vector
在执行插入、删除和扩容操作时,可能会导致原有的迭代器失效。- 尤其在扩容后,所有迭代器都会失效,因为内存位置已发生变化。
- 尾部插入或删除操作一般只会使尾部迭代器失效,而中间插入删除则可能使所有迭代器失效。
总结
std::vector
是基于动态数组的容器,具有快速的随机访问和动态扩容机制。在不需要频繁的中间插入和删除时,vector
是一个高效的选择。
1.4.14 简述STL中的 map 的实现原理
std::map
是 C++ STL 中的一种关联容器,用于存储键值对(key-value pair),且元素按键的升序排列。它的实现基于一种自平衡二叉搜索树,即红黑树(Red-Black Tree)。以下是 std::map
的实现原理:
1. 红黑树的数据结构
std::map
采用红黑树作为底层数据结构,这是一种平衡二叉搜索树,具有稳定的 O(log n) 时间复杂度的插入、查找和删除操作。- 红黑树是一种特殊的二叉树,其中每个节点都带有一个颜色(红色或黑色),并满足以下性质:
- 根节点是黑色的。
- 每个叶子节点(
nullptr
节点)是黑色的。 - 红色节点的子节点必须是黑色(即不存在连续的红色节点)。
- 从任一节点到其每个叶子节点的路径上包含相同数量的黑色节点。
2. 自动排序
- 红黑树的节点根据键值排序,因此
map
中的元素总是按键升序排列。 - 每次插入新元素时,红黑树会根据键值进行位置调整,确保满足排序要求。
3. 插入与删除操作
- 插入:
std::map
的插入操作会根据键值在红黑树中找到合适的位置,将新节点插入树中。- 插入后可能破坏红黑树的平衡,红黑树会通过旋转和重新着色来恢复平衡。
- 插入的时间复杂度为 O(log n)。
- 删除:
std::map
的删除操作通过定位键值找到目标节点,移除该节点。- 删除同样可能破坏红黑树的平衡,红黑树会进行必要的旋转和重新着色来恢复平衡。
- 删除的时间复杂度为 O(log n)。
4. 查找操作
map
支持 O(log n) 的查找效率。查找过程通过键值定位节点,在红黑树中从根节点开始,根据键值大小沿树路径查找目标元素。
5. 迭代器
std::map
的迭代器是双向迭代器,支持从键值最小到最大或反向遍历所有元素。- 由于红黑树内部结构的稳定性,
std::map
的迭代器在非删除的情况下不会失效。 - 插入元素不会影响其他节点的顺序,但删除节点会影响目标节点和其父节点的迭代器。
6. 节点内存管理
std::map
使用动态分配的节点来构建红黑树结构,每个节点包含键值对和两个子节点指针。- 键值对的内存管理采用 RAII 原则,
std::map
析构时会递归删除节点,调用键和值的析构函数,避免内存泄漏。
7. 适用场景
std::map
适用于需要保持有序性的数据集合,如按键排序、频繁查找、范围查询等场景。- 不适合频繁随机访问的情况,因为其访问效率不如连续内存的容器如
vector
。
总结
std::map
基于红黑树实现,提供高效的有序键值存储,插入、删除和查找操作都具有 O(log n) 的复杂度,是管理有序键值对的良好选择。
1.4.15 C++中的vector和list中,如果删除末尾的元素,其指针和迭代器如何变化?若删除的是中间的元素呢?
在 C++ 中,std::vector
和 std::list
是两种常用的序列容器,它们在删除元素时对指针和迭代器的影响是不同的。以下是对这两种容器在删除末尾和中间元素时的行为的详细说明:
1. std::vector
删除末尾元素
- 操作:使用
pop_back()
或erase()
删除末尾元素。 - 影响:
- 删除末尾元素后,
vector
的容量不变,指向末尾元素的迭代器(如end()
)会失效。 - 其他有效的迭代器(指向未删除元素的迭代器)仍然有效。
- 删除末尾元素后,
删除中间元素
- 操作:使用
erase()
删除中间元素。 - 影响:
- 中间元素被删除后,后面的所有元素会被向前移动以填补空缺。
- 这会导致所有指向被删除元素及其后续元素的迭代器失效,包括
begin()
到end()
范围内的所有迭代器。 - 仅指向删除元素之前的元素的迭代器会保持有效。
2. std::list
删除末尾元素
- 操作:使用
pop_back()
或erase()
删除末尾元素。 - 影响:
- 删除末尾元素后,
list
中的其他元素不需要移动,因为它是基于双向链表的实现。 - 只有指向已删除元素的迭代器会失效,其他指向有效元素的迭代器仍然有效。
- 删除末尾元素后,
删除中间元素
- 操作:使用
erase()
删除中间元素。 - 影响:
- 删除中间元素不会影响链表的其他元素,且不需要移动任何元素。
- 只有指向已删除元素的迭代器会失效,其他指向有效元素的迭代器仍然有效。
总结
-
std::vector
:- 删除末尾元素:末尾的迭代器失效,其他迭代器仍然有效。
- 删除中间元素:所有指向被删除元素及其后续元素的迭代器失效,指向前面的元素的迭代器仍然有效。
-
std::list
:- 删除末尾元素:仅指向已删除元素的迭代器失效,其他迭代器仍然有效。
- 删除中间元素:仅指向已删除元素的迭代器失效,其他迭代器仍然有效。
1.4.16 map 和 set 有什么区别,分别又是怎么实现的?
std::map
和 std::set
是 C++ STL 中的两种关联容器,它们都用于存储有序的数据,但在功能和实现上有一些显著的区别。以下是它们的主要区别以及各自的实现原理。
1. 主要区别
特性 | std::map | std::set |
---|---|---|
存储内容 | 存储键值对(key-value pairs)。 | 仅存储唯一的键(keys)。 |
访问方式 | 通过键访问值(map[key] )。 | 只存储键,不存储值,不能通过键访问。 |
重复性 | 允许相同的键(但值可以不同),值会覆盖同键的旧值。 | 不允许重复的键。 |
迭代器 | 迭代器返回的是键值对(std::pair )。 | 迭代器返回的是唯一键。 |
适用场景 | 需要根据键快速查找、插入和更新值。 | 只需存储唯一值并进行有序访问。 |
2. 实现原理
2.1 std::map
实现
- 底层数据结构:
std::map
通常使用 红黑树(Red-Black Tree)作为底层实现。 - 特性:
- 有序性:红黑树确保插入的元素按键的升序排列。
- 复杂度:插入、查找和删除操作的时间复杂度均为 O(log n)。
- 节点结构:
- 每个节点包含一个键和一个与之关联的值。
- 节点之间通过指针连接,形成二叉搜索树结构。
2.2 std::set
实现
- 底层数据结构:
std::set
也通常使用 红黑树。 - 特性:
- 有序性:与
map
类似,set
的元素根据键的升序排列。 - 复杂度:插入、查找和删除操作的时间复杂度同样为 O(log n)。
- 有序性:与
- 节点结构:
- 每个节点仅包含一个键,没有值部分。
- 节点之间通过指针连接,以形成二叉搜索树结构。
3. 使用示例
-
std::map
示例:#include <iostream> #include <map> int main() { std::map<int, std::string> myMap; myMap[1] = "One"; myMap[2] = "Two"; myMap[1] = "Updated One"; // 更新键为1的值 for (const auto& pair : myMap) { std::cout << pair.first << ": " << pair.second << std::endl; } return 0; }
-
std::set
示例:#include <iostream> #include <set> int main() { std::set<int> mySet; mySet.insert(2); mySet.insert(1); mySet.insert(3); mySet.insert(2); // 插入重复元素,set 不会存储重复值 for (const auto& value : mySet) { std::cout << value << std::endl; } return 0; }
总结
std::map
是键值对的集合,允许根据键快速访问值,并允许重复键(但值会覆盖)。std::set
是唯一键的集合,只存储键,确保没有重复值。- 两者通常都使用红黑树作为底层实现,提供高效的查找、插入和删除操作。
1.4.17 说说push_back 和 emplace_back 的区别
在 C++ 中,push_back
和 emplace_back
是 std::vector
和其他 STL 容器常用的方法,用于在容器的末尾添加元素。虽然这两个方法的功能相似,但它们的工作方式和性能上有一些关键的区别。
1. push_back
- 功能:
push_back
将一个已有的对象复制或移动到容器的末尾。 - 参数:它接受一个对象的引用(通常是左值或右值),并根据情况执行复制构造或移动构造。
- 性能:
- 当传递左值时,会进行复制构造,可能会带来性能开销。
- 当传递右值时,会调用移动构造(如果有),提高效率。
示例:
#include <iostream>
#include <vector>
class MyClass {
public:
MyClass(int x) : value(x) {
std::cout << "Constructed: " << value << std::endl;
}
int value;
};
int main() {
std::vector<MyClass> vec;
MyClass obj(10);
vec.push_back(obj); // 复制构造
vec.push_back(MyClass(20)); // 移动构造(如果有移动构造函数)
return 0;
}
2. emplace_back
- 功能:
emplace_back
在容器的末尾直接构造对象,而不需要先创建一个对象再复制或移动。 - 参数:它接受构造对象所需的参数,并在容器内部直接调用构造函数。
- 性能:
emplace_back
可以避免不必要的复制或移动构造,通常会提供更好的性能。- 适用于那些需要传递多个参数或复杂构造的情况。
示例:
#include <iostream>
#include <vector>
class MyClass {
public:
MyClass(int x) : value(x) {
std::cout << "Constructed: " << value << std::endl;
}
int value;
};
int main() {
std::vector<MyClass> vec;
vec.emplace_back(10); // 直接构造对象
vec.emplace_back(20); // 直接构造对象
return 0;
}
3. 主要区别总结
特性 | push_back | emplace_back |
---|---|---|
对象创建方式 | 复制或移动已有对象 | 直接在容器内部构造对象 |
参数 | 接受对象(左值或右值) | 接受构造对象所需的参数 |
性能 | 可能会涉及额外的复制或移动开销 | 避免不必要的复制或移动,通常更高效 |
适用场景 | 适合简单的对象添加 | 适合复杂对象或多参数构造 |
总结
- 使用
push_back
适合简单的对象添加,但可能会产生额外的复制或移动开销。 emplace_back
更适合需要高性能或复杂对象构造的场景,能够提高程序的效率。
1.4.18 STL中vector 与 list 具体是怎么实现的?常见操作的时间复杂度是多少?
在 C++ 标准模板库(STL)中,std::vector
和 std::list
是两种常用的序列容器,它们的实现和性能特征有很大不同。以下是对这两种容器的具体实现原理以及常见操作的时间复杂度分析。
1. std::vector
的实现
实现原理
- 底层数据结构:
std::vector
使用动态数组实现。它通过一个指向动态分配内存的指针来存储元素。 - 容量管理:
vector
维护一个容量(capacity)和大小(size),当插入新元素导致超出容量时,vector
会:- 分配更大的内存(通常是原容量的 1.5 到 2 倍)。
- 将现有元素复制到新内存中。
- 释放旧内存。
- 连续内存:
vector
的元素在内存中是连续存储的,这使得可以通过指针或迭代器有效地进行随机访问。
常见操作的时间复杂度
操作 | 时间复杂度 |
---|---|
插入(末尾) | O(1) (摊销) |
插入(中间) | O(n) |
删除(末尾) | O(1) |
删除(中间) | O(n) |
查找 | O(n) |
随机访问 | O(1) |
2. std::list
的实现
实现原理
- 底层数据结构:
std::list
使用双向链表实现。每个节点包含指向前一个节点和下一个节点的指针,以及存储的元素。 - 节点分散存储:由于使用链表,
list
中的元素在内存中并不是连续存储的。每个节点在堆上独立分配,具有灵活性。 - 插入和删除效率:在
list
中,插入和删除操作非常高效,因为只需调整相邻节点的指针,不涉及大量数据移动。
常见操作的时间复杂度
操作 | 时间复杂度 |
---|---|
插入(任意位置) | O(1) |
删除(任意位置) | O(1) |
查找 | O(n) |
随机访问 | O(n) |
3. 总结
std::vector
是基于动态数组实现的,适合频繁的随机访问和在末尾插入的场景,但在中间插入和删除时效率较低。std::list
是基于双向链表实现的,适合频繁的插入和删除操作,但不支持随机访问,查找效率较低。
4. 适用场景
- 使用
std::vector
适合需要快速访问元素的情况,特别是频繁在末尾添加元素的场景。 - 使用
std::list
适合需要频繁插入和删除操作的场景,但不需要随机访问元素的情况下。