文章目录
14. 标准模板库(STL)篇
说实话,STL的一些东西已经在之前出现过很多次了,比如我们常用的vector,还有提到过的map、set等等,它们都是STL的组成部分,有这样一套库对于我们来说真的会方便很多,例如vector的自动增长能够让我们不用过分关注于内存的管理,sort函数对于各种有迭代器类型的容器都可以完成排序,包括原生数组。
(1). STL是什么?
STL是Standard Template Library—标准模板库的缩写,是由Alexander Stepanov、Meng Lee和David R Musser在惠普实验室工作时所开发出来的,STL在1994年2月年才正式成为ANSI/ISO C++的一部分,可以说是挺晚的了。
在C++中,它被主要组织为13个头文件:<algorithm>、<deque>、<functional>、<iterator>、<vector>、<list>、<map>、<memory>、<numeric>、<queue>、<set>、<stack>和<utility>,对于map、set还有对应的 <unordered_map>以及<unordered_set>。
(2). STL包含了哪些东西?
一般来说,STL包括了一系列的容器(容器适配器)、迭代器和算法,不过实际上STL还包括了下一篇的RAII部分会讲到的三种智能指针:unique_ptr、shared_ptr和weak_ptr,以及其它的例如std::functional、std::pair之类的东西。
这一篇中会主要介绍STL中的各种容器(容器适配器)、迭代器和对应的一些算法。
(3). std::array与std::vector
std::array和std::vector的使用方法如下:
std::vector<T> a; // T为存储元素的类型
std::array<T, n> b; // T为存储元素的类型,n为数组的大小
array的参数有T和n两个,也正是因此,std::array的数组才能在栈上完成分配。
#1.std::array的优势和基本使用
首先有个问题,我们在C++中向函数传递一个原生数组的时候会发生什么?看看下面的例子:
#include <iostream>
using namespace std;
void check(int a[])
{
cout << "sizeof(a) = " << sizeof(a) << endl;
}
int main()
{
int a[100]{0};
cout << "sizeof(a) = " << sizeof(a) << endl;
check(a);
return 0;
}
因为函数是传值的,所以传入的a退化成为了指针,sizeof(a)的值只有8了,而真正的数组sizeof(a)是400,原生数组没法传引用,那能不能用类来解决一下这个问题呢?
#include <iostream>
using namespace std;
class Array
{
public:
int a[100];
};
void check(const Array& arr)
{
cout << "sizeof(arr.a) = " << sizeof(arr.a) << endl;
}
int main()
{
Array arr;
cout << "sizeof(arr.a) = " << sizeof(arr.a) << endl;
check(arr);
return 0;
}
好耶!问题解决了,我们可以用传对象引用的方式解决数组退化的问题,所以在C++的STL中,我们引入了std::array,它相当于一个原生数组,其内部的数组也是存储在栈区的,传入std::array可以保证数组不退化。
下面是std::array的一些方法:
函数 | 意义 |
---|---|
at | 访问指定元素,同时进行越界检查 |
operator[] | 访问指定元素 |
front | 返回第一个元素 |
back | 返回最后一个元素 |
data | 返回底层数组 |
empty | 判断是否为空 |
size | 返回数组当前的元素数 |
max_size | 返回可容纳的最大元素数 |
std::array支持迭代器,这意味着我们可以通过迭代器的方式遍历std::array对象,例如:
std::array<int, 30> a;
for (auto it = a.begin(); it != a.end(); it++) {
...
}
// 或者再进一步
for (auto& i : a) {
...
}
迭代器类型重载了解引用运算符,意味着我们可以通过*it的方式访问迭代器对应的元素,看起来很像指针是吧?你可以把它理解为一个高级版本的指针,因为除了begin()还有rbegin(),它可以从最后一个元素向着第一个元素倒着遍历。
#2.std::vector的基本使用
std::vector算是STL中最常用的容器之一了,因为其提供了可以动态变化的容量,我们在向其中插入元素的时候不再需要关注容量问题了,这可是件大好事。
首先来说说vector对象的创建,vector的构造函数形式非常多,这里列几个常用的:
vector(); // 无参构造函数
vector(const vector&); // 拷贝构造函数
vector(vector&&); // 移动构造函数
vector(size_t count); // 构造时预先分配count个元素的空间
vector(std::initializer_list<T> init); // 通过std::initializer_list<T>构造
template<class InputIt>
vector(InputIt first, InputIt last); // 通过传入初始迭代器和末尾迭代器构造
前面三种都好理解,我来说说后面三种。第四个传入count的构造函数会在构造的时候就默认分配好count个元素的位置,这样做可以保证构造完成后,前count个下标能够直接访问,而不会发生越界,因为vector的容量是动态的,当没有通过resize或push_back把元素个数加到对应个数之前,直接进行访问对应下标是可能抛出异常的,而直接在构造的时候分配好位置,能够使得vector的使用和原生数组类似。
第五个构造函数中我们需要传入一个std::initializer_list<T>,也就是说我们可以在初始化的时候就插入一些元素,这个就和原生数组的int a[] = {1, 2, 3};类似,不过这里要注意了,因为有了这个构造函数,下面两行代码对于vector来说行为是不一致的:
vector<int> v1{100}; // vector(std::initializer_list<T> init);
vector<int> v2(100); // vector(size_t count);
当vector类型为可以与int发生隐式类型转换的类型时,使用花括号进行初始化会被认为是使用了对应std::initializer_list<T>的构造函数。
最后一个构造函数中传入了两个迭代器,一个是起始位置,一个是结束位置,在这里你传入某个容器的begin()和end()可以,传入原生数组的头指针和一个尾指针也是可以的,例如:
vector<int> a{1, 2, 3};
vector<int> b(a.begin(), a.end()); // 迭代器
int c[]{3, 2, 1};
vector<int> d(c, c+3); // 指针
vector这个容器有size和capacity两个属性,其中size是当前容器中真正已经存入的元素个数,而capacity是当前容器能够容纳的最大元素个数,一般当size的达到capacity时,vector就会自动完成扩容操作,我们可以通过size()和capacity()两个方法获得当前vector的对应属性的值。
vector<int> a;
cout << "a.size() = " << a.size() << ", a.capacity() = " << a.capacity() << endl;
for (int i = 0; i < 20; i++) {
a.push_back(10);
cout << "a.size() = " << a.size() << ", a.capacity() = " << a.capacity() << endl;
}
你可以凭此探索一下你用的编译器中,vector的扩容规律是什么,MSVC中的是这样:
一般来说,vector会尽可能少的进行扩容操作,因为重新分配内存以及迁移数据是一个比较慢的过程。
接下来是插入元素,首先如果我们要向vector的尾部插入元素,我们需要使用push_back()方法,例如:
vector<int> a;
a.push_back(10);
切记这个a在这里不能使用a[0] = 10;的方法进行元素插入,因为就如上面的图所示,如果使用无参构造函数完成vector的构造,vector默认是不分配内存的,这时候就相当于对一个空指针写入元素。
即便是分配了内存,如果要在尾部插入元素,依旧应该使用push_back,因为push_back会使得size增长,如果没有使用push_back的话,我们通过下标直接访问或at()方法访问时可能会抛出异常。
与插入元素相对应的则是删除最后一个元素,这里需要用到pop_back()方法,pop_back()能够使得size的数目-1,这里就不再演示了。
下面是一些常用的函数:
函数 | 意义 |
---|---|
empty | 判断是否为空 |
reserve | 预留存储空间(改变capacity) |
resize | 改变元素个数(改变size) |
clear | 清空整个容器 |
insert | 插入元素 |
erase | 擦除元素 |
shrink_to_fit | 通过释放未使用的内存减少内存使用 |
insert函数的各种重载
// 在pos迭代器的位置插入一个值
iterator insert(const_iterator pos, const T& value);
// 在pos迭代器的位置插入一个值(移动语义)
iterator insert(const_iterator pos, T&& value);
// 在pos迭代器的位置插入count个值
iterator insert(const_iterator pos, size_t count, const T& value);
// 在pos迭代器的位置插入从first到last的所有元素
template<class InputIt>
iterator insert(const_iterator pos, InputIt first, InputIt last);
// 在pos迭代器的位置插入ilist中的所有值
iterator insert(const_iterator pos, std::initializer_list<T> ilist);
erase函数的各种重载
// 擦除pos迭代器位置上的元素
iterator erase(const_iterator pos);
// 擦除从first迭代器到last迭代器之间的所有元素,左闭右开
iterator erase(const_iterator first, const_iterator last);
vector同样支持迭代器,我们可以使用begin()和rbegin(),以及对应的end()和rend()实现遍历整个vector的操作,下面就不再举例了。
#3.std::vector<bool>和std::bitset
在C++中,最小的内存单元是字节,在基础篇中我们提到过,C++的bool类型不是简单的宏定义,它采用一个字节来存储true和false两个值。
不过说实话,一个字节还是太大了,因为true和false对应1和0,实际上只要一位,即1/8个字节即可,因此STL中的vector对于模板参数为bool的情况做了模板特化,vector<bool>中实现了用一个字节存储八个布尔值的需求,它的确是节省了空间,但因为每次对某个位置的bool值进行读写时都要进行位操作,最终的运行效率会比其他类型的vector慢很多,这是它的一大缺点。
如果要更快地对由1和0组成的序列进行操作,可以考虑使用std::bitset,你可以自行上网查阅相关资料了解一下bitset的基本使用方法。
(4). std::map与std::unordered_map
#1.二者的主要区别和性能对比
map和unordered_map是STL实现的映射表结构,我们可以通过键以下标访问的形式直接访问到值,二者的操作基本完全相同,只是实现不同,map采用红黑树,而unordered_map采用哈希表的形式实现,使用红黑树实现的最大优势是:map中保持键值对有序,而unordered_map则是无序的。
二者在插入、查找和删除操作的的时间复杂度对比如下:
操作 | map时间复杂度 | unordered_map时间复杂度 |
---|---|---|
插入元素 | O ( log n ) O(\log{n}) O(logn) | O ( 1 ) O(1) O(1) |
查找元素 | O ( log n ) O(\log{n}) O(logn) | O ( 1 ) O(1) O(1) |
删除元素 | O ( log n ) O(\log{n}) O(logn) | O ( 1 ) O(1) O(1) |
在速度上unordered_map看似全面超越了map,但是在空间上,unordered_map占用的内存要远超map,上一篇中提到的赛博计算机2077这道题,我有两次提交分别使用了map和unordered_map完成,他们的内存占用对比如下;
上面1.012M是map版本,而13.730M是unordered_map版本,差距真的大的很夸张。
#2.基本使用
首先,使用什么就要导入什么:
#include <map>
#include <unordered_map>
创建map和unordered_map对象的方式如下:
map<KT, VT> m;
unordered_map<KT, VT> m1;
其中KT为键类型,VT为值类型,我们可以在初始化的时候就加入几个键值对:
map<string, int> m{ {"A", 1}, {"B", 2} };
unordered_map<string, int> m{ {"C", 3}, {"D", 4} };
在map和unordered_map中常被我们用于操作键值对的对象是std::pair<K, V> 对象,对于一个pair对象,我们可以这么得到键和值:
// 假设已经有了一个pair<int, int> p
cout << p.first << " : " << p.second << endl;
first代表pair的第一个元素,second则是pair的第二个元素,在map和unordered_map容器中,first就代表键,second就代表值。
因此有了pair,我们也可以用构造pair对象的方式初始化:
map<string, int> m{ pair<string, int>{"A", 1}, pair<string, int>{"B", 2} };
unordered_map<string, int> m{ pair<string, int>{"C", 3}, pair<string, int>{"D", 4} };
当然,除了直接调用pair类的构造函数,我们还可以用一些其他的函数构造pair对象,例如:
map<string, int> m{ make_pair("A", 1), make_pair("B", 2) };
unordered_map<string, int> m{ make_pair("C", 3), make_pair("D", 4) };
如果我们要遍历整个map或unordered_map,我们只能通过迭代器的方式实现:
// 直接使用迭代器
for (auto it = m1.begin(); it != m1.end(); it++) {
... // it->first和it->second分别表示键和值
}
// 或使用基于范围的for循环
for (auto& i : m1) {
... // i.first和i.second分别表示键和值
}
// 或使用结构化绑定(C++17)
for (auto& [k, v] : m1) {
... // k和v分别表示键和值
}
对于map这种有序容器,我们在定义的时候可以在模板参数中传入用于排序的仿函数,这样得到的容器中,就会依照排序规则,将键排序,例如:
#include <iostream>
#include <map>
using namespace std;
class Less
{
public:
bool operator()(const int& a, const int& b) const
{
return a > b;
}
};
int main()
{
map<int, int, Less> m1{ {1, 1}, { 2, 3 }, { 4, 9 }, { 8, 27 }};
map<int, int> m2{ {1, 1}, { 2, 3 }, { 4, 9 }, { 8, 27 }};
cout << "m1:" << endl;
for (auto& [k, v] : m1) {
cout << k << " : " << v << endl;
}
cout << "m2:" << endl;
for (auto& [k, v] : m2) {
cout << k << " : " << v << endl;
}
return 0;
}
这里我们使用了结构化绑定的方式一次性获得键和值,这个在成员函数篇中已经提到过,这里就不再提了,我们来看看它输出的结果:
很好,传入了之后果然是按照我们的要求进行排序的。之后再来谈谈插入,map和unordered_map的键都是不能重复的,因此在插入时,如果键已经存在,则不会对map造成任何影响,下面我们使用insert方法插入键值对:
m1.insert({16, 81});
m1.insert(pair<int, int>{32, 243});
m1.insert(make_pair(64, 729));
同样,这三种形式都是可以的,不过貌似通过中括号访问的方式要更加方便一点:
m1[1] = 3; // 当键已经存在的时候,可以直接进行修改
m1[128] = 2187; // 当键不存在时,可以直接插入一对键值对
用这种看起来要比insert更方便呢,你说是吧?map还具有以下常用的函数:
函数 | 意义 |
---|---|
empty | 判断是否为空 |
size | 当前容纳的元素数 |
clear | 清空整个容器 |
erase | 擦除元素 |
count | 返回匹配特定键的元素数量,返回值只可能为0或1 |
find | 寻找带有特定键的元素,返回迭代器 |
而unordered_map因为内部使用哈希表实现,它的函数和map在除了基本使用方法之外就有一些区别了,在这里我就不进行举例了,你可以自己查询cppreference中有关于unordered_map的内容。
(5). std::set与std::unordered_set
set和unordered_set其实与map和unordered_map类似,set采用红黑树实现,unordered_map采用哈希表实现,它们的定义、声明等和map、unordered_map是基本一致的:
set<T> s1;
unordered_set<T> s2;
不过记得一件事,set和数学的集合概念基本一致,因此set中的每个元素都是唯一的,不过set因为是红黑树实现,所以有序,unordered_map相对于set更加符合集合的概念一点。
这里只有一个模板参数,毕竟只是一个值嘛,set因为有序,所以它同样可以传入一个仿函数,从而改变它的排序规则,两种set的遍历也是一样,不过这次遍历出来可以直接得到值了,例如:
#include <iostream>
#include <set>
#include <unordered_set>
using namespace std;
int main()
{
set<int> s1{1, 3, 4, 2, 0, 2, 2};
for (auto& i : s1) {
cout << i << " ";
}
cout << endl;
return 0;
}
就是这样,多余的元素会被去掉,然后我们通过基于范围的for循环可以直接遍历得到所有的值。set其它的更多方法,和map几乎完全一致,这里我就不再介绍了。
(6). std::deque
deque是STL中的双端队列,即头尾均可入队、出队的队列,要使用双端队列,我们首先需要包含头文件:
#include <deque>
首先是创建deque,这很简单:
deque<int> dq;
deque<int> dq1{1, 2, 3};
我们依旧可以使用无参构造函数和std::initializer_list进行初始化,因为是双端队列,所以我们可以在头尾进行各种操作:
#include <iostream>
#include <deque>
using namespace std;
int main()
{
deque<int> dq1{1, 2, 3, 4, 5, 6, 7};
cout << dq1.back() << endl;
dq1.pop_back();
dq1.push_back(10);
cout << dq1.back() << endl;
cout << dq1.front() << endl;
dq1.pop_front();
dq1.push_front(4);
cout << dq1.front() << endl;
return 0;
}
就是这样,我们用push_back()和push_front()分别在尾和头插入元素,用pop_back()和pop_front()分别从尾和头出队,并且用front()和back()分别访问头和尾的当前元素。
deque支持迭代器,所以我们可以用下面的方式进行遍历:
for (auto it = dq1.begin(); it != dq1.end(); it++) {
... // 通过*it访问元素
}
for (auto& i : dq1) {
... // 基于范围的for循环,i就是元素引用
}
deque还支持insert,erase操作,这个和vector等的操作是基本一致的,这里就不再赘述了。对了,deque甚至还支持下标访问以及at()访问,听起来好像和vector几乎无异啊,是吧?
(7). std::stack、std::queue和std::priority_queue
#1.为啥这仨放一起?
因为这仨虽然也能存东西,但是跟前面和后面说的那些容器不同,在创建这三者的时候,我们可以选择实现它们的容器,也就是说,它们是基于已有容器实现的一种“二级容器”,因为其数据结构的通用性,对于各种有迭代器的容器,它们都可以正常使用,它们三个被称为容器适配器。
我们可以在这三个容器定义时,在模板参数中加入容器类型以完成容器的更替:
stack<int, vector<int>> s;
queue<int, stack<int>> q;
priority_queue<int, vector<int>> pq;
后续的内容中我们不会修改这些容器适配器使用的默认容器。还有一件事,这三种容器适配器均不支持迭代器,我们无法通过迭代器的方式遍历。
#2.std::stack
stack就是栈,我们其实还挺常用栈的,栈是一种后进先出(LIFO)的数据结构,它有以下的一些基本使用方法:
#include <iostream>
#include <stack>
using namespace std;
int main()
{
stack<int> s; // 创建一个stack,当然也可以通过std::initializer_list进行初始化
s.push(1); // 调用push方法向栈顶插入一个元素
for (int i = 0; i < 10; i++) {
s.push(i);
}
cout << s.top() << endl; // 调用top方法返回当前栈顶元素
s.pop(); // 调用pop方法弹出当前栈顶元素
s.clear(); // 调用clear方法清空当前栈
return 0;
}
同时stack还有size()和empty()方法分别返回当前栈中元素的个数以及判断当前栈是否为空。
#3.std::queue
queue则是队列,它只能从队尾入队,从队首出队,和栈正好相反,它是一种先进先出(FIFO)的数据结构,首先还是需要包含头文件:
#include <queue>
它有以下的一些使用方法:
#include <iostream>
#include <queue>
using namespace std;
int main()
{
queue<int> q; // queue不支持用std::initializer_list初始化
for (int i = 0; i < 10; i++) {
q.push(i); // queue使用push()入队
}
cout << q.front() << ", " // 通过front()查看队首元素
<< q.back() << endl; // 通过back()查看队尾元素
q.pop(); // 使用pop()出队
cout << q.front() << endl; // 这时队首已经改变
return 0;
}
其中展示了queue的基本操作,同样,它也有size()和empty()方法。
#4.std::priority_queue
priority_queue是优先队列,有的时候我们也把它叫做堆,根据排序顺序,小的在上的叫做小根堆,大的在上的叫做大根堆,要使用priority_queue,我们要包含头文件:
#include <queue>
这个不错,跟queue在一个头文件中,然后接下来看看它的基本用法:
#include <iostream>
#include <cstdlib>
#include <queue>
using namespace std;
int main()
{
priority_queue<int> pq;
for (int i = 5; i > 0; i--) {
pq.push(i); // 使用push插入元素
}
cout << pq.top() << endl; // 通过top()查看当前根节点的值
for (int i = 0; i < 15; i++) {
pq.push(rand() % 100); // 向pq插入15个0~99之间的随机数
}
while (!pq.empty()) {
cout << pq.top() << ","; // 输出根节点的值
pq.pop(); // 弹出根节点的值
}
cout << endl;
return 0;
}
这里我们完成了一些基本操作,最后还利用优先队列完成了堆排序,看看结果:
看起来优先队列默认是大根堆啊,我们可以在初始化的时候修改一下排序规则:
class Less
{
public:
bool operator()(const int& a, const int& b) const
{
return a > b;
}
};
这样就好了,不过有一个事情要注意一下,priority_queue的less模板参数的位置在容器之后,因此我们指定less的时候要先加上容器的类型,例如这样:
priority_queue<int, vector<int>, Less> pq;
priority_queue默认采用vector,因此这里我就直接写上vector了。
(8). std::list和std::forward_list
list和forward_list是STL中双向链表和单向链表的实现,list的使用和前面用到的各种容器几乎都是一致的,但是链表由于每个节点可能都不相邻,因此不提供下标访问(随机访问),如果要访问元素,只能通过迭代器访问,例如:
#include <iostream>
#include <list>
using namespace std;
int main()
{
list<int> l{1, 2, 3, 4, 5, 6, 7};
for (auto& i : l) {
cout << i << " ";
}
cout << endl;
return 0;
}
这里介绍一些list的特有方法,首先是merge(),merge()可以合并两个已排序链表,例如:
#include <iostream>
#include <list>
using namespace std;
int main()
{
list<int> l1{1, 3, 5, 7}, l2{0, 2, 4, 6, 11};
l1.merge(l2);
for (auto& i : l1) {
cout << i << " ";
}
cout << endl;
return 0;
}
这里就完成了两个有序链表的合并,我知道你可能比较叛逆,你说:“我就是要合并两个无序链表怎么了,它会怎么样? 好,我们来看看会怎么样:
list<int> l1{1, 3, 5, 7}, l2{0, 2, 4, 3, 11};
我们这里就简单地把l2的6改成3,看看运行结果:
这下好了,链表的merge方法里直接用断言检查了链表是否有序,如果无序,直接中断程序的运行,所以不要尝试合并无序的链表哦!
第二个则是splice(),splice()可以把一个链表中的结点转移给另一个,它有以下重载:
void splice(const_iterator pos, list& other);
void splice(const_iterator pos, list&& other);
void splice(const_iterator pos, list& other, const_iterator it);
void splice(const_iterator pos, list&& other, const_iterator it);
void splice(const_iterator pos, list& other,
const_iterator first, const_iterator last);
void splice(const_iterator pos, list&& other,
const_iterator first, const_iterator last);
第一和第二个可以把整个other转移过来,第三和第四个是从other的it起的所有结点转移过来,第五和第六个就是把other中从first到last之间的结点转移过来。
接下来是reverse(),reverse()方法可以实现把整个链表所有的元素倒置:
#include <iostream>
#include <list>
using namespace std;
int main()
{
list<int> l1{1, 3, 5, 7};
l1.reverse();
for (auto& i : l1) {
cout << i << " ";
}
cout << endl;
return 0;
}
之后是sort(),之后我们要讲的algorithm中的标准算法sort不支持对于链表的排序,因此list本身有一个sort()方法可以用于排序,它的两个重载如下:
void sort();
template<class Compare>
void sort(Compare comp);
第一个根据默认规则排序,第二个可以传入一个仿函数作为排序规则进行排序。
最后则是unique()方法,这个方法能够从容器移除所有连续的重复元素,只留下相同元素中的第一个,例如:
#include <iostream>
#include <list>
#include <algorithm>
using namespace std;
int main()
{
list<int> l1{1, 3, 3, 4, 5, 5, 7};
l1.unique();
for (auto& i : l1) {
cout << i << " ";
}
cout << endl;
return 0;
}
这样一来,我们就介绍完了list的各种新奇的方法,关于std::forward_list,我就不再细讲了,因为list的应用场景实际上已经覆盖了forward_list。
(9). 迭代器与基于范围的for循环
有的时候你可能好奇,这个基于范围的for循环到底是怎么实现的,而且前面的一系列容器和容器适配器中,我们发现提供了迭代器的容器可以使用基于范围的for循环,而容器适配器没有提供,它就不可以用,基于范围的for循环和迭代器有什么关系呢?
我自己最近半年内有在尝试自己实现一个STL,在实现Vector的时候,我在类中加入了这样两条语句:
using iterator = T*;
using const_iterator = const T*;
并且在此基础上定义了:
const_iterator begin() const
{
return arr; // arr是内部数组的指针
}
const_iterator end() const
{
return arr + _size; // _size是已容纳元素的个数
}
iterator begin()
{
return arr; // arr是内部数组的指针
}
iterator end()
{
return arr + _size; // _size是已容纳元素的个数
}
于是基于范围的for循环就神奇的可以在我自定义的STL上跑起来了,这也告诉我们一件事情,如果要让自己的类支持基于范围的for循环,就需要自己实现一个可以遍历整个容器的迭代器类型,你当然可以用指针对它进行一个包装,在实现了迭代器之后,你需要再定义好begin()和end()两个函数,这样一来基于范围的for循环就完全可以正常运行了。
我的STL现在在GitHub上开源,项目叫做MySTL,由于本人代码水平一般,所以这个项目中可能有很多值得大幅改进的地方,这只是作为学习STL中的一个实验,如果大家有什么想法可以在仓库中以PR的形式进行提议,感谢大家的支持。
(10). 常用标准算法
最后让我们来说说STL中常用的标准算法,首先我们需要包含头文件:
#include <algorithm>
名字还挺直白,就叫算法。我们这个部分主要介绍sort和stable_sort、lower_bound、upper_bound和binary_search以及find,还有相当相当多的算法在algorithm中,碍于篇幅限制,我不会全部介绍,你可以参考cppreference中的algorithm页面。
#1.sort和stable_sort
sort是算法中最常用过的一个,sort的形式如下:
template< class RandomIt >
void sort( RandomIt first, RandomIt last );
template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp );
主要是这两种,第一种是传入需要排序的首和尾的迭代器,第二种则是在这个基础上在传入一个comp,这里看着好像comp只能传入仿函数,其实不是这样的,我们仍然可以像C语言中qsort的cmp一样,定义一个这样的函数:
bool comp(const T& a, const T& b)
{
return ...;
}
然后调用sort:
sort(a.begin(), a.end(), comp);
sort也并不一定只能接受迭代器,我们往里面传入指针也是可以的,比如:
int a[]{0, 1, 2, 4, -1, 2, 0, 113, 23};
sort(a, a + 9);
这样就行了,对于原生数组也是可以进行排序的,这里要注意两点,首先sort不是稳定的排序,对于两个相等的元素,在排序过后是否还能保持原来的相对位置不变,对于这个问题,algorithm中还提供了stable_sort方法解决这个问题。
第二点则是对于内部存在动态内存分配的对象的数组的排序,我们应该采用sort而不是qsort进行排序,因为qsort进行的移动操作是如同memcpy一样简单的操作,对于这种对象的数组如果用qsort排序很有可能会严重干扰对象的内存管理状况,从而导致对象析构的时候出现重复析构等等问题,所以切勿使用qsort对于这一类的对象进行排序。
#2.lower_bound、upper_bound和binary_search
这三个函数都是用于完成二分搜索的,其中lower_bound找到第一个不小于给定值的元素的迭代器,upper_bound找到第一个大于给定值的元素的迭代器,而binary_search则是确定给定元素是否存在。它们的原型如下:
// lower_bound
template< class ForwardIt, class T >
ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value );
template< class ForwardIt, class T, class Compare >
ForwardIt lower_bound( ForwardIt first, ForwardIt last,
const T& value, Compare comp );
// upper_bound
template< class ForwardIt, class T >
ForwardIt upper_bound( ForwardIt first, ForwardIt last, const T& value );
template< class ForwardIt, class T, class Compare >
ForwardIt upper_bound( ForwardIt first, ForwardIt last,
const T& value, Compare comp );
// binary_search
template< class ForwardIt, class T >
bool binary_search( ForwardIt first, ForwardIt last, const T& value );
template< class ForwardIt, class T, class Compare >
bool binary_search( ForwardIt first, ForwardIt last,
const T& value, Compare comp );
它们三个的参数表完全一致,每个函数都允许我们指定一个比较规则,从而方便我们更好地利用二分查找找到对应的元素。但请特别注意,因为要使用二分查找,你用于查找的序列一定要满足默认(或在指定排序规则下)有序,否则不能完成二分查找的过程!
#3.find
find则是直接找到某个元素的位置,它基于线性搜索实现,因此不要求被查找的序列有序,它的形式如下(包含一种可能的实现):
template<class InputIt, class T>
InputIt find(InputIt first, InputIt last, const T& value)
{
for (; first != last; ++first)
if (*first == value)
return first
return last;
}
当找到元素时,返回对应的迭代器,否则就返回输入的末尾迭代器以表示没有找到。
#4.关于STL
以上介绍的三类算法,对于大部分具备随机访问迭代器的容器都是可以使用的,这便是STL的一个非常强大的优势,因为容器、迭代器和算法在设计时是分开设计的,因此它们之间才能工作的如此合拍。
小结
STL的内容非常非常之多,这一篇只是简要介绍了一些常用的容器以及对应的方法,以及一些常用的标准算法等等,这一篇的内容可以在阅读的时候多去尝试一下,这样能够明显提升熟练度,不过现在大部分IDE的代码提示功能都比较完善了,结合互联网,你应该是可以很快找到你所需要的函数的。
下一篇我们会回到C++相较于C语言的一大重要更新—异常处理机制,敬请期待。