【C++ 笔记五】STL 标准模板库 —— 容器基础进阶
文接上文 【C++ 笔记四】STL 标准模板库 —— 容器基础
文章目录
I - 简单回顾
STL 六大组件之间的关系:
容器存储数据,存储需要使用内存,容器使用分配器去分配和释放内存,算法通过迭代器访问容器中的数据,仿函数用于算法的特别处理,适配器帮助仿函数/迭代器完成一些更细节的设置等。
1.1 - 序列式容器(顺序容器)
Sequence containers
顺序容器根据其内存结构可以大致分为三种
- 数组,连续内存空间
- 链表,非连续内存空间
- 映射表管理的分段连续空间
array / vector 连续内存空间,支持随机访问。
list / forward-list 支持双/单向顺序访问。
forward-list 相较 list 少一倍的指针,只支持单向的顺序访问,其的迭代器也不支持 - - 递减运算符和reverse_iterator, 与手写最好单向链表相当的性能,所以也不支持 size()
操作。
stack 和 queue 底层是 deque 映射表管理多端连续内存,为容器适配器不提供迭代器访问。
选用容器时,通常最佳选用 vector,除非中间插入删除操作非常多时使用 list, forward-list,通常相同元素数量情况下,后两者内存额外开销较大。
1.2 - 关联式容器 (关联容器)
Associative containers
在大部分书籍中,不定序容器(unordered container)也归类于关联式容器。关联式容器底层实现主要包含两种数据结构:
- 红黑树
- 哈希表
查找速度:哈希表 > 红黑树
哈希表 bucket_size 与元素数量相同时,会两倍扩容,使用新的哈希计算方式,元素会被重新打散,分布到新的 bucket 后。哈希查找 O(1),产生哈希冲突,即哈希值相同时只能循序查找。
1.3 - 访问方法/对外接口
- 插入
push_back(elem);
emplace_back(elem);
push_front(elem);
emplace_front(elem);
- 访问
// 若不存在,行为未定义
container[n];
// 若不存在,抛出异常 out_of_range
container.at(n);
行为未定义,表示可能获取到一个意外的数据,或者程序异常退出等。
- 删除
pop_back();
pop_front();
// 注意删除时刷新迭代器
erase(iter);
erase(iterA, iterB);
// 清空
clear();
删除示例:
std::vector<int> vec = { 1, 2, 4, 5, 6, 9 };
// lambda 表达式 [捕获](入参) -> 返回值类型 { 函数体 };
auto delete_element = [&vec](int a) -> bool
{
bool deleted(false);
for (auto iter = vec.begin();
iter != vec.end(); ++iter)
{
if (a == (*iter))
{
// 删除时刷新迭代器
iter = vec.erase(iter);
deleted = true;
}
}
return deleted;
};
delete_element(5);
1.4 - 迭代器
迭代器为一种泛化的指针,容器内的可访问范围为一种左闭合区间 (left-inclusive interval) ,左闭右开 [begin, end) 。
反向迭代器的递增/递减 (++/--
) 操作与正向迭代器方向相反。
II - 容器概述与注意事项
2.1 - 时间复杂度
顺序容器
顺序容器的查找时间复杂度为 O(n),即在存在 n 个元素时,大 O 表示时间渐进复杂度上界,也就是最差的情况下需要查找 n 次。
关联容器
红黑树为一种特殊的平衡二叉搜索树 (balanced binary search tree),查找复杂度为 O( l o g n log_{}n logn)。
红黑树如下图所示:
红黑树的最左节点为其最小值,最右节点为其最大值。
O( l o g n log_{}n logn) 推导过程:
因为每次查找从根节点开始,每次查找下降一个高度, 元素即节点为 n 的情况下,高度约为 l o g 2 n + 1 log_{2}n+1 log2n+1 ,也就是约需要查找 l o g 2 n + 1 log_{2}n+1 log2n+1 次。算法复杂度只保留最高阶即 l o g 2 n log_{2}n log2n,根据定义,
l o g 2 n log_{2}n log2n, 假定 a 为 0 到正无穷,不为 0 且不为 1 的常数, 根据换底公式
l o g 2 n = l o g a n l o g a 2 = 1 l o g a 2 ∗ l o g a n log_{2}n = \frac {log_{a}n} {log_{a}2} = \frac {1} {log_{a}2} * log_{a}n log2n=loga2logan=loga21∗logan
1 l o g a 2 \frac {1} {log_{a}2} loga21 可视为常数,可见底数为 2 或任何对于 log 有意义的底数都作用不大,所以 2 省掉即为
l o g n log_{}n logn
2.2 - 注意事项
2.2.1 - 前递增/递减操作更高效
前递增/递减的运算结果为左值,后递增/递减的运算结果为右值。因此可进行连续的前递增/减操作,连续的后递增/减操作会产生编译错误。
int i = 0;
++++i; // ok
i++; // ok
i++++; // compilation error, need l-value
----i; // ok
i--; // ok
i----; // compilation error, need l-value
编译错误为 需要一个左值。左值与右值,简而言之,对于一个赋值运算,等号左边的值为左值,等号右边的值为右值。
左值与右值的主要不同为,右值不可修改不可取地址。使用左值一般意义是使用其内存即地址值,右值一般使用其字面值即数据值,举例
int a(2), b(3);
a = 4; // 1
a = b; // 2
- 1 处 4 为右值,使用其数据值,a 在此处为左值,使用其存储空间即地址值
- 2 处 b 虽为与 a 相同的变量,但这里为右值,使用其数据值
容器迭代器实现前后递增操作符重载的方式是,后递增递减增加一个 int
参数以区分,但此参数无意义,仅用于区分。
list 的前递增与后递增代码示例:
// 前 ++,node 指针指向其下一个节点,且此处返回引用,即自身。如 cout << 可实现级联式输出操作,原理一致。
self& operator++()
{ node = (link_type) ((*node).next); return *this; }
// 后 ++ ,使用 int 参数区分,但本身此 int 无实际意义。
self operator++(int)
{ self tmp = *this; ++*this; return tmp; }
// 调用前 ++ ,并返回一个临时变量。
list 的后递增操作调用了前递增,由此也可得到前递增操作更高效。
2.2.2 - 基于范围的 for 循环 (range-based for loop)
C++11 为容器增加了基于范围的 for 循环语法,只能够访问和修改容器,但不能够在循环体内对容器进行增/删元素,否则会出现未定义行为 (undefined behavior)
for (auto decl : coll)
{
// loop body
}
// 例,访问
std::vector<double> vec;
for (auto elem : vec) { cout << elem << endl; }
// 修改
for (auto & elem : vec) { elem *= 3; }
// 添加元素 undefined behavior
std::vector<int> vec = { 1, 2, 4, 6 };
for (auto temp : vec) {
if (4 == temp)
vec.push_back(5);
std::cout << temp << " "; // 1 2 4 -572662307
}
由于,上述语法在进入循环体之前,容器尾迭代器已经为确定的值,等效代码如下:
auto &&__coll = coll;
for (auto __begin = std::begin(__coll), __end = std::end(__coll);
__begin != __end; ++__begin)
{
auto decl = *__begin;
// loop body
}
附加 cppreference 链接 :https://en.cppreference.com/w/cpp/language/range-for
2.2.3 - list 不能使用 std::sort
std::list<int> lst = { 5, 6, 10, 1, 4, 8 };
std::sort(lst.begin(), lst.end(), greater<int>()); // (a)
std::sort(lst.begin(), lst.end(),
[](int a, int b)-> bool { return a > b; }); // (b)
首先,此处代码无法编译通过,因为 std::sort()
入参需要随机访问迭代器,而 list 的迭代器为双向顺序访问迭代器。list 排序只能使用自身类的 sort() 方法。
lambda 表达式可以做到与仿函数一样的效果。(a) 与 (b) 等价。
有些初学者分不清楚, std::less<T>()
和 std::greater<T>()
哪个是从大到小,哪个是从小到大排列,这里有个记忆方法,greater 意为大于,大于号,也就是说此排序方式,使得容器中任意两个相邻元素之间大于号都成立,即 elem1 > elem2 > elem3 …,即为递减排列。less 同理。
III - Traits 方法
算法为了能够更好的适配容器,提高性能,需要了解迭代器的类型。
函数模板,编译器实参推导 (argument deduction),可以结合函数重载和静态多态来理解。
Traits 方法使用了模板函数偏特化的方式来萃取迭代器类型,理解 Traits 方法,是能看懂 STL 源码的重要一环。
// 范化
template <class type>
struct __type_traits {
// ...
}
// 特化 Specialization 特化即为特殊处理
template<> struct __type_traits<int>
{
//...
}
typedef template<> __STL_TEMPLATE_NULL
// 偏特化 Partial Specialization 即为部分特化,部分特殊处理。
template<class Alloc>
class vector<bool, Alloc>
{
//...
}
算法为了更好的适配迭代器,需要知道迭代器类型,每个STL迭代器都定义了五种类型,迭代器分类,值类型,首尾迭代器差值类型,指针类型,引用类型。
STL 加入中间层 Traits 使用模板偏特化为 原生指针封装此五种类型。
template <class I>
struct iterator_traits {
typedef typename I::iterator_category iterator_category;
typedef typename I::value_type value_type;
typedef typename I::difference_type difference_type;
typedef typename I::pointer pointer;
typedef typename I::reference reference;
};
// pointer to T
template <class T>
struct iterator_traits<T*>
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef ptrdiff_t difference_type;
typedef T* pointer;
typedef T& reference;
};
// pointer to const T
template <class T>
struct iterator_traits<const T*>
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef ptrdiff_t difference_type;
typedef T* pointer;
typedef T& reference;
};
// 由于依然保留模板 所以为偏特化。
Traits 方法,也是为何 Qt 的容器也被 std 算法所支持的原因。
IV - 其他注意事项
push_back 与 emplace_back 的区别。
emplace_back 是直接将构造函数所需的参数传递过去,然后构建一个新的对象出来,填充到容器尾部。也就是 就地构造(直接在容器内构造对象,不用拷贝一个复制品再使用)+ 强制类型转换 的方法来实现,在运行效率方面,由于省去了拷贝构造过程,因此效率更高。
V - 参考书目
- 《STL源码剖析》— 侯捷
- 《C++ Primer (中文版)》- 第 5 版