STL Tips
vector
vector 的功能是长度可变的数组,他里面的数据存储在堆上。vector 是一个模板类,第一个模板参数是数组里元素的类型。
Functions
构造函数和 size
1 |
|
explicit vector(size_t n); 显式构造函数会调用元素的默认构造函数,数字类型会初始化为 0,string 会初始化为空字符串,指针类型会初始化为 nullptr。
std::execution::par
capacity() 函数查询已经分配内存的大小,即最大容量;
1 |
|
size() 返回的其实是已经存储了数据的数组长度。size 范围内的数据都是初始化的。
size 到 capacity 之间的内存是没有初始化的。
另外,使用 reserve() 函数预留一定的容量,这样之后就不会出现容量不足而需要动态扩容影响性能了(前提是保证容量一定足够)。此外,还要注意 reserve 时也会移动元素。
1 |
|
reserve() 只是申请内存,不做初始化。
resize
除了可以在构造函数中指定数组的大小,还可以之后再通过 resize 函数设置大小。
1 |
|
调用 resize(n) 的时候,不足部分,会用 0 填充元素,原有元素会保持不变。
1 |
|
当 resize 的目标长度大于原有的容量(capacity)时,就需要重新分配一段更大的连续内存,并把原数组长度的部分移动过去,多出来的部分则用 0 来填充。导致元素的地址会有所改。
shrink_to_fit
当 resize 到一个更小的大小上时,多余的容量不会释放,而是继续保留。
shrink_to_fit 释放掉多余的容量,只保留刚好为 size() 大小的容量。
shrink_to_fit 会重新分配一段更小内存,他同样是会把元素移动到新内存中的,因此迭代器和指针也会失效。
1 |
|
clear
clear 函数可以清空该数组,也就相当于把长度设为零,变成空数组。
容量(capacity)还是摆在那里,clear 仅仅只是把数组大小(size)标记为 0 而已。
要真正释放掉内存,可以在 clear 之后再调用 shrink_to_fit,这样才会让容量也变成 0(这时 vector 的 data 会返回 nullptr)。
1 |
|
push_back
1 |
|
pop_back 函数则是和 push_back 相反。
要注意的是 pop_back 函数的返回类型是 void,也就是没有返回值,如果需要获取删除的值,可以在 pop_back() 之前先通过 back() 获取末尾元素的值,实现 pop 效果。
data
data() 会返回指向数组中首个元素的指针,也就是等价于 &a[0]。由于 vector 是连续存储的数组,因此只要得到了首地址,下一个元素的地址只需指针 +1 即可。
1 |
|
data() 指针是对 vector 的一种引用,实际对象生命周期仍由 vector 类本身管理。
用他来获取一个 C 语言原始指针 int *,很方便用于调用 C 语言的函数和 API 等,同时还能享受到 vector 容器 RAII 的安全性。
如果用 new/delete 或者 malloc/free 就很容易出现忘记释放内存的情况,造成内存泄露。 而 vector 会在离开作用域时,自动调用解构函数,释放内存,就不必手动释放了,更安全。
如果需要在一个语句块外仍然保持 data() 对数组的弱引用有效,可以把语句块内的 vector 对象移动到外面的一个 vector 对象上。vector 在移动时指针不会失效:
1 |
|
insert
insert 函数,他的第一个参数是要插入的位置(用迭代器表示),第二个参数则是要插入的值。
insert 在容量不足时,同样会造成重新分配以求扩容,会移动其中所有元素,这时所有之前保存的迭代器都会失效。
insert 函数,重复插入多个相同的值。或者直接插入一个初始化列表。或者另一个容器的起止迭代器。
1 |
|
emplace
1 |
|
args... 表示与新插入元素的构造函数相对应的多个参数。
emplace() 每次只能插入一个元素,而不是多个。
emplace() 高效之处在于,直接在容器申请内存之上进行构造。而 insert() 是先构造好对象,在拷贝或者移动到容器的内存中。
assign
和 insert 不同的是,他会把旧有的数组完全覆盖掉,变成一个新的数组。
a.assign(beg, end) 基本和 a = vector<int>(beg, end) 等价,唯一的区别是后者会重新分配内存,而前者会保留原来的容量不会释放掉。
1 |
|
a.assign({x, y, ...}) 和 a = {x, y, ...} 完全等价,都会保留原来的容量。而和 a = vector<int>{x, y, ...} 就不等价,这个会重新分配内存。
erase
erase 的复杂度最坏情况是删除第一个元素 O(n)。如果删的是最后一个元素则复杂度为 O(1)。因为 erase 会移动 pos 之后的那些元素,保持连续数据分配。
1 |
|
Iterator
为什么会有iterator设计的需要?
为不同容器提供一个更一般化的操作接口
假设需要一个print函数,要打印容器中字符元素。简单实现是:
1 |
|
vector 和 string 的底层都是连续的稠密数组,改用首地址指针和数组长度做参数。
这样 print 在无需知道容器具体类型的情况下,只用最简单的接口(首地址指针)就完成了遍历和打印的操作。
1 |
|
通过给指针加减运算,选择其中一部分连续的元素来打印。
再改变一些,改用首地址指针和尾地址指针。用指针作为迭代变量。
尾地址指针实际上是指向末尾元素再往后后一个元素的指针。
数组长度为 0 就是 begptr == endptr 的情况;endptr - begptr 来算出数组的长度;判断是否继续循环的条件为 ptr != endptr 。
1 |
|
通过操作符重载,适配不同内存布局的容器类型
上述首指针和尾指针的组合方法,的确能胜任 vector 这种连续数组,但是对于 list 这种不连续的内存的容器就没辙了。
list<char>::iterator 是一个特殊定义过的类型,其具有 != 和 ++ 以及 * 这些运算符的重载。把 ++ 对应到 链表的 curr = curr->next 上。
用起来就像普通的指针,但内部却通过运算符重载适配不同容器的特殊类,就是迭代器(iterator),迭代器是 STL 中容器和算法之间的桥梁。
1 |
|
++
++p 会返回自增后的值 p + 1,这和 p += 1 完全一样,同样因为返回的是一个左值引用。
p++ 会返回自增前的值 p,返回的是一个右值。后置自增需要先保存旧的迭代器,然后自增自己,再返回旧迭代器,可能会比较低效。
1 |
|
同名函数只能通过参数列表类型来区分,这个 int 类型参数没有任何实际意义,只是为了区分不同的重载。
编译器会在 p++ 的时候自动改成调用 p.operator++(0)。
set
- set会自动给其中的元素从小到大排序,set<T, CompT> CompT定义排序函数
- set会去重
- 红黑树储存,不支持随机访问
- 高效的按值查找
Iterator
提供的运算符重载 | 具有此迭代器的容器 | 对应的 C++20 concept | |
---|---|---|---|
输入迭代器 | *(可读取),!=,==,++(一次性) | istream_iterator | input_iterator |
输出迭代器 | *(可写入),!=,==,++(一次性) | back_insert_iterator | output_iterator |
前向迭代器 | *,!=,==,++ | forward_list | forward_iterator |
双向迭代器 | *,!=,==,++,-- | set,map,list | bidirectional_iterator |
随机访问迭代器 | *,!=,==,++,--,+,-,+=,-=,[] | vector,array,deque | random_access_iterator |
迭代器外包装 | 和他所包装的迭代器保持一致 | reverse_iterator | 和所包装的迭代器一致 |
前向迭代器>双向迭代器>随机访问迭代器。
这意味着如果一个STL模板函数(比如std::find)要求迭代器是前向迭代器即可,那么也可以给他随机访问迭代器,因为前向迭代器是随机访问迭代器的子集。
set 不支持随机访问,没有提供 + 和 += 的重载。
std::next
set 的 + 3 访问,通过std::next实现:
1 |
|
std::advance 会就地自增:
1 |
|
next 和 advance 同样支持负数。
与 next 对应的有 std::prev ,向前访问。
此外 std::distance 可求出两个迭代器之间的距离。
Funtions
insert
1 |
|
insert 函数的返回值是一个 pair 类型,也就是说他同时返回了两个值。其中第一个返回值指向插入/现有元素的迭代器;第二个返回值是 bool 类型,指示了插入是否成功。
pair 作为返回值,可以直接:
1 |
|
C++ 17 可以使用结构化绑定拆解 pair
1 |
|
find
1 |
|
set.find(x) != set.end() 判断集合 set 中是否存在元素 x。相比 set.count(x) != 0 会高效一些。
erase
1 |
|
erase 返回一个整数,表示被他删除元素的个数。使用上比如:set.erase(std::prev(set.end())) 会删除集合中最大的元素。
erase 还支持输入两个迭代器作为参数。删除前开后闭区间 [beg, end) 内的元素。
关于区间,set.lower_bound(x) 找第一个大于等于 x 的元素。set.upper_bound(x) 找第一个大于 x 的元素。
1 |
|
操作 | 实现方法 |
---|---|
增 | a.insert(x) |
删 | a.erase(x) 或者 a.erase(a.find(x)) |
改 | 一旦插入就无法修改,只能先删再增 |
查 | a.find(x) != a.end() 或者 a.count(x) |
容器转换
1 |
|
vector 的构造函数可以接受任何前向迭代器。不一定是 vector 自己的迭代器哦,任何前向迭代器!而 set 是双向迭代器,覆盖了前向迭代器,满足要求。
反过来,可以把 vector 转成 set。set(b.begin(), b.end()) 。
所以可以实现简单的vector排序功能:
1 |
|
clear
1 |
|
emplace
1 |
|
只需要传入构建新元素所需的数据即可,该方法可以自行利用这些数据构建出要添加的元素。
emplace_hint 方法需要额外传入一个迭代器,用来指明新元素添加到 set 容器的具体位置(新元素会添加到该迭代器指向元素的前面)。
相比具有同样功能的 insert() 方法,完成同样的任务,emplace() 和 emplace_hint() 的效率会更高。
multiset
multiset 是 set 不去重版本。使用类似。
支持等值区间查询。equal_range 返回的等值区间。
1 |
|
不同 set 成员函数
函数 | 含义 | set | multiset | unordered_set |
---|---|---|---|---|
insert(x) | 插入一个元素 x | √ | √ | √ |
erase(x) | 删除所有等于 x 的元素 | √ | √ | √ |
count(x) | 有多少个等于 x 的元素 | √,0或1 | √ | √,0或1 |
find(x) | 指向第一个等于 x 的元素 | √ | √ | √ |
lower_bound(x) | 指向第一个大于等于 x 的元素 | √ | √ | × |
upper_bound(x) | 指向第一个大于 x 的元素 | √ | √ | × |
equal_range(x) | 所有等于 x 的元素所组成的区间 | √ | √ | √ |
和 vector 的横向比较:
类型 | 去重 | 有序 | 查找 | 插入 |
---|---|---|---|---|
vector | × | × | O(n) | O(1) ~ O(n) |
set | √ | √ | O(logn) | O(logn) |
multiset | × | √ | O(logn) | O(logn) |
unordered_set | √ | × | O(1) | O(1) |
unordered_multiset | × | × | O(1) | O(1) |
类型 | 头文件 | lower/upper_bound | equal_range | find |
---|---|---|---|---|
vector | √,O(logn) | √,O(logn) | √,O(n) | |
set | √,O(logn) | √,O(logn) | √,O(logn) | |
multiset | √,O(logn) | √,O(logn) | √,O(logn) | |
unordered_set | × | √,O(1) | √,O(1) | |
unordered_multiset | × | √,O(1) | √,O(1) |
unordered_set 的性能在数据量足够大(>1000)时,平均查找时间比 set 短,但不保证稳定。set在数据量小时更高效。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!