2024年Go最新【C++ STL学习笔记】C+,秀出天际

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

C++ STL vector插入元素(insert()和emplace())详解

vector容器提供了 insert() 和 emplace() 这 2 个成员函数,用来实现在容器指定位置处插入元素,本节将对它们的用法做详细的讲解。

另外,如果想实现在 vector 容器尾部添加元素,可阅读《vector添加元素》一节。

insert()

insert() 函数的功能是在 vector 容器的指定位置插入一个或多个元素。该函数的语法格式有多种,如表 1 所示。

语法格式用法说明
iterator insert(pos,elem)在迭代器 pos 指定的位置之前插入一个新元素elem,并返回表示新插入元素位置的迭代器。
iterator insert(pos,n,elem)在迭代器 pos 指定的位置之前插入 n 个元素 elem,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,first,last)在迭代器 pos 指定的位置之前,插入其他容器(不仅限于vector)中位于 [first,last) 区域的所有元素,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,initlist)在迭代器 pos 指定的位置之前,插入初始化列表(用大括号{}括起来的多个元素,中间有逗号隔开)中所有的元素,并返回表示第一个新插入元素位置的迭代器。

下面的例子,演示了如何使用 insert() 函数向 vector 容器中插入元素。

#include <iostream> 
#include <vector> 
#include <array> 
using namespace std;
int main()
{
    std::vector<int> demo{1,2};
    //第一种格式用法
    demo.insert(demo.begin() + 1, 3);//{1,3,2}
    //第二种格式用法
    demo.insert(demo.end(), 2, 5);//{1,3,2,5,5}
    //第三种格式用法
    std::array<int,3>test{ 7,8,9 };
    demo.insert(demo.end(), test.begin(), test.end());//{1,3,2,5,5,7,8,9}
    //第四种格式用法
    demo.insert(demo.end(), { 10,11 });//{1,3,2,5,5,7,8,9,10,11}
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    return 0;
}

运行结果为:

1 3 2 5 5 7 8 9 10 11

emplace()

emplace() 是 C++ 11 标准新增加的成员函数,用于在 vector 容器指定位置之前插入一个新的元素。

再次强调,emplace() 每次只能插入一个元素,而不是多个。

该函数的语法格式如下:

iterator emplace (const_iterator pos, args…);

其中,pos 为指定插入位置的迭代器;args… 表示与新插入元素的构造函数相对应的多个参数;该函数会返回表示新插入元素位置的迭代器。

简单的理解 args…,即被插入元素的构造函数需要多少个参数,那么在 emplace() 的第一个参数的后面,就需要传入相应数量的参数。

举个例子:

#include <vector>
#include <iostream>
using namespace std;
int main()
{
    std::vector<int> demo1{1,2};
    //emplace() 每次只能插入一个 int 类型元素
    demo1.emplace(demo1.begin(), 3);
    for (int i = 0; i < demo1.size(); i++) {
        cout << demo1[i] << " ";
    }
    return 0;
}

运行结果为:

3 1 2

既然 emplace() 和 insert() 都能完成向 vector 容器中插入新元素,那么谁的运行效率更高呢?答案是 emplace()。在说明原因之前,通过下面这段程序,就可以直观看出两者运行效率的差异:

#include <vector>
#include <iostream>
using namespace std;
class testDemo
{
public:
    testDemo(int num) :num(num) {
        std::cout << "调用构造函数" << endl;
    }
    testDemo(const testDemo& other) :num(other.num) {
        std::cout << "调用拷贝构造函数" << endl;
    }
    testDemo(testDemo&& other) :num(other.num) {
        std::cout << "调用移动构造函数" << endl;
    }
    testDemo& operator=(const testDemo& other);
private:
    int num;
};
testDemo& testDemo::operator=(const testDemo& other) {
    this->num = other.num;
    return *this;
}
int main()
{
    cout << "insert:" << endl;
    std::vector<testDemo> demo2{};
    demo2.insert(demo2.begin(), testDemo(1));
    cout << "emplace:" << endl;
    std::vector<testDemo> demo1{};
    demo1.emplace(demo1.begin(), 1);
    return 0;
}

运行结果为:

insert:
调用构造函数
调用移动构造函数
emplace:
调用构造函数

注意,当拷贝构造函数和移动构造函数同时存在时,insert() 会优先调用移动构造函数。

可以看到,通过 insert() 函数向 vector 容器中插入 testDemo 类对象,需要调用类的构造函数和移动构造函数(或拷贝构造函数);而通过 emplace() 函数实现同样的功能,只需要调用构造函数即可。

简单的理解,就是 emplace() 在插入元素时,是在容器的指定位置直接构造元素,而不是先单独生成,再将其复制(或移动)到容器中。因此,在实际使用中,推荐大家优先使用 emplace()。

C++ STL vector删除元素的几种方式(超级详细)

前面提到,无论是向现有 vector 容器中访问元素、添加元素还是插入元素,都只能借助 vector 模板类提供的成员函数,但删除 vector 容器的元素例外,完成此操作除了可以借助本身提供的成员函数,还可以借助一些全局函数。

基于不同场景的需要,删除 vecotr 容器的元素,可以使用表 1 中所示的函数(或者函数组合)。

函数功能
pop_back()删除 vector 容器中最后一个元素,该容器的大小(size)会减 1,但容量(capacity)不会发生改变。
erase(pos)删除 vector 容器中 pos 迭代器指定位置处的元素,并返回指向被删除元素下一个位置元素的迭代器。该容器的大小(size)会减 1,但容量(capacity)不会发生改变。
swap(beg)、pop_back()先调用 swap() 函数交换要删除的目标元素和容器最后一个元素的位置,然后使用 pop_back() 删除该目标元素。
erase(beg,end)删除 vector 容器中位于迭代器 [beg,end)指定区域内的所有元素,并返回指向被删除区域下一个位置元素的迭代器。该容器的大小(size)会减小,但容量(capacity)不会发生改变。
remove()删除容器中所有和指定元素值相等的元素,并返回指向最后一个元素下一个位置的迭代器。值得一提的是,调用该函数不会改变容器的大小和容量。
clear()删除 vector 容器中所有的元素,使其变成空的 vector 容器。该函数会改变 vector 的大小(变为 0),但不是改变其容量。

下面就表 1 中罗列的这些函数,一一讲解它们的具体用法。

pop_back() 成员函数的用法非常简单,它不需要传入任何的参数,也没有返回值。举个例子:

#include <vector>
#include <iostream>
using namespace std;
int main()
{
    vector<int>demo{ 1,2,3,4,5 };
    demo.pop_back();
    //输出 dmeo 容器新的size
    cout << "size is :" << demo.size() << endl;
    //输出 demo 容器新的容量
    cout << "capacity is :" << demo.capacity() << endl;
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    return 0;
}

运行结果为:

size is :4
capacity is :5
1 2 3 4

可以发现,相比原 demo 容器,新的 demo 容器删除了最后一个元素 5,容器的大小减了 1,但容量没变。

如果想删除 vector 容器中指定位置处的元素,可以使用 erase() 成员函数,该函数的语法格式为:

iterator erase (pos);

其中,pos 为指定被删除元素位置的迭代器,同时该函数会返回一个指向删除元素所在位置下一个位置的迭代器。

下面的例子演示了 erase() 函数的具体用法:

#include <vector>
#include <iostream>
using namespace std;
int main()
{
    vector<int>demo{ 1,2,3,4,5 };
    auto iter = demo.erase(demo.begin() + 1);//删除元素 2
    //输出 dmeo 容器新的size
    cout << "size is :" << demo.size() << endl;
    //输出 demo 容器新的容量
    cout << "capacity is :" << demo.capacity() << endl;
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    //iter迭代器指向元素 3
    cout << endl << *iter << endl;
    return 0;
}

运行结果为:

size is :4
capacity is :5
1 3 4 5
3

通过结果不能看出,erase() 函数在删除元素时,会将删除位置后续的元素陆续前移,并将容器的大小减 1。

另外,如果不在意容器中元素的排列顺序,可以结合 swap() 和 pop_back() 函数,同样可以实现删除容器中指定位置元素的目的。

注意,swap() 函数在头文件 <algorithm><utility> 中都有定义,使用时引入其中一个即可。

例如:

#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
    vector<int>demo{ 1,2,3,4,5 };
    //交换要删除元素和最后一个元素的位置
    swap(*(std::begin(demo)+1),*(std::end(demo)-1));//等同于 swap(demo[1],demo[4])
   
    //交换位置后的demo容器
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    demo.pop_back();
    cout << endl << "size is :" << demo.size() << endl;
    cout << "capacity is :" << demo.capacity() << endl;
    //输出demo 容器中剩余的元素
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    return 0;
}

运行结果为:

1 5 3 4 2
size is :4
capacity is :5
1 5 3 4

当然,除了删除容器中单个元素,还可以删除容器中某个指定区域内的所有元素,同样可以使用 erase() 成员函数实现。该函数有 2 种基本格式,前面介绍了一种,这里使用另一种:

iterator erase (iterator first, iterator last);

其中 first 和 last 是指定被删除元素区域的迭代器,同时该函数会返回指向此区域之后一个位置的迭代器。

举个例子:

#include <vector>
#include <iostream>
using namespace std;
int main()
{
    std::vector<int> demo{ 1,2,3,4,5 };
    //删除 2、3
    auto iter = demo.erase(demo.begin()+1, demo.end() - 2);
    cout << "size is :" << demo.size() << endl;
    cout << "capacity is :" << demo.capacity() << endl;
    for (int i = 0; i < demo.size(); i++) {
        cout << demo[i] << " ";
    }
    return 0;
}

运行结果为:

size is :3
capacity is :5
1 4 5

可以看到,和删除单个元素一样,删除指定区域内的元素时,也会将该区域后续的元素前移,并缩小容器的大小。

如果要删除容器中和指定元素值相同的所有元素,可以使用 remove() 函数,该函数定义在 <algorithm> 头文件中。例如:

#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
    vector<int>demo{ 1,3,3,4,3,5 };
    //交换要删除元素和最后一个元素的位置
    auto iter = std::remove(demo.begin(), demo.end(), 3);
    cout << "size is :" << demo.size() << endl;
    cout << "capacity is :" << demo.capacity() << endl;
    //输出剩余的元素
    for (auto first = demo.begin(); first < iter;++first) {
        cout << *first << " ";
    }
    return 0;
}

运行结果为:

size is :6
capacity is :6
1 4 5

注意,在对容器执行完 remove() 函数之后,由于该函数并没有改变容器原来的大小和容量,因此无法使用之前的方法遍历容器,而是需要向程序中那样,借助 remove() 返回的迭代器完成正确的遍历。

remove() 的实现原理是,在遍历容器中的元素时,一旦遇到目标元素,就做上标记,然后继续遍历,直到找到一个非目标元素,即用此元素将最先做标记的位置覆盖掉,同时将此非目标元素所在的位置也做上标记,等待找到新的非目标元素将其覆盖。因此,如果将上面程序中 demo 容器的元素全部输出,得到的结果为 1 4 5 4 3 5

另外还可以看到,既然通过 remove() 函数删除掉 demo 容器中的多个指定元素,该容器的大小和容量都没有改变,其剩余位置还保留了之前存储的元素。我们可以使用 erase() 成员函数删掉这些 “无用” 的元素。

比如,修改上面的程序:

#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
    vector<int>demo{ 1,3,3,4,3,5 };
    //交换要删除元素和最后一个元素的位置
    auto iter = std::remove(demo.begin(), demo.end(), 3);
    demo.erase(iter, demo.end());
    cout << "size is :" << demo.size() << endl;
    cout << "capacity is :" << demo.capacity() << endl;
    //输出剩余的元素
    for (int i = 0; i < demo.size();i++) {
        cout << demo[i] << " ";
    }
    return 0;
}

运行结果为:

size is :3
capacity is :6
1 4 5

remove()用于删除容器中指定元素时,常和 erase() 成员函数搭配使用。

如果想删除容器中所有的元素,则可以使用 clear() 成员函数,例如:

#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
    vector<int>demo{ 1,3,3,4,3,5 };
    //交换要删除元素和最后一个元素的位置
    demo.clear();
    cout << "size is :" << demo.size() << endl;
    cout << "capacity is :" << demo.capacity() << endl;
    return 0;
}

运行结果为:

size is :0
capacity is :6

如何避免vector容器进行不必要的扩容?

前面提到,我们可以将 vector 容器看做是一个动态数组。换句话说,在不超出 vector 最大容量限制(max_size() 成员方法的返回值)的前提下,该类型容器可以自行扩充容量来满足用户存储更多元素的需求。

值得一提的是,vector 容器扩容的整个过程,和 realloc() 函数的实现方法类似,大致分为以下 4 个步骤:

  1. 分配一块大小是当前 vector 容量几倍的新存储空间。注意,多数 STL 版本中的 vector 容器,其容器都会以 2 的倍数增长,也就是说,每次 vector 容器扩容,它们的容量都会提高到之前的 2 倍;
  2. 将 vector 容器存储的所有元素,依照原有次序从旧的存储空间复制到新的存储空间中;
  3. 析构掉旧存储空间中存储的所有元素;
  4. 释放旧的存储空间。

通过以上分析不难看出,vector 容器的扩容过程是非常耗时的,并且当容器进行扩容后,之前和该容器相关的所有指针、迭代器以及引用都会失效。因此在使用 vector 容器过程中,我们应尽量避免执行不必要的扩容操作。

要实现这个目标,可以借助 vector 模板类中提供的 reserve() 成员方法。不过在讲解如何用 reserve() 方法避免 vector 容器进行不必要的扩容操作之前,vector 模板类中还提供有几个和 reserve() 功能类似的成员方法,很容易混淆,这里有必要为读者梳理一下,如表 1 所示。

成员方法功能
size()告诉我们当前 vector 容器中已经存有多少个元素,但仅通过此方法,无法得知 vector 容器有多少存储空间。
capacity()告诉我们当前 vector 容器总共可以容纳多少个元素。如果想知道当前 vector 容器有多少未被使用的存储空间,可以通过 capacity()-size() 得知。注意,如果 size() 和 capacity() 返回的值相同,则表明当前 vector 容器中没有可用存储空间了,这意味着,下一次向 vector 容器中添加新元素,将导致 vector 容器扩容。
resize(n)强制 vector 容器必须存储 n 个元素,注意,如果 n 比 size() 的返回值小,则容器尾部多出的元素将会被析构(删除);如果 n 比 size() 大,则 vector 会借助默认构造函数创建出更多的默认值元素,并将它们存储到容器末尾;如果 n 比 capacity() 的返回值还要大,则 vector 会先扩增,在添加一些默认值元素。
reserve(n)强制 vector 容器的容量至少为 n。注意,如果 n 比当前 vector 容器的容量小,则该方法什么也不会做;反之如果 n 比当前 vector 容器的容量大,则 vector 容器就会扩容。

通过对以上几个成员方法功能的分析,我们可以总结出一点,即只要有新元素要添加到 vector 容器中而恰好此时 vector 容器的容量不足时,该容器就会自动扩容。

因此,避免 vector 容器执行不必要的扩容操作的关键在于,在使用 vector 容器初期,就要将其容量设为足够大的值。换句话说,在 vector 容器刚刚构造出来的那一刻,就应该借助 reserve() 成员方法为其扩充足够大的容量。

举个例子,假设我们想创建一个包含 1~1000 的 vector,通常会这样实现:

vector<int>myvector;
for (int i = 1; i <= 1000; i++) {
    myvector.push_back(i);
}

值得一提的是,上面代码的整个循环过程中,vector 容器会进行 2~10 次自动扩容(多数的 STL 标准库版本中,vector 容器通常会扩容至当前容量的 2 倍,而这里 1000≈2 10),程序的执行效率可想而知。

在上面程序的基础上,下面代码演示了如何使用 reserve() 成员方法尽量避免 vector 容器执行不必要的扩容操作:

vector<int>myvector;
myvector.reserve(1000);
cout << myvector.capacity();
for (int i = 1; i <= 1000; i++) {
    myvector.push_back(i);
}

相比前面的代码实现,整段程序在运行过程中,vector 容器的容量仅扩充了 1 次,执行效率大大提高。

当然在实际场景中,我们可能并不知道 vector 容器到底要存储多少个元素。这种情况下,可以先预留出足够大的空间,当所有元素都存储到 vector 容器中之后,再去除多余的容量。

关于怎样去除 vector 容器多余的容量,可以借助该容器模板类提供的 shrink_to_fit() 成员方法,另外后续还会讲解如何使用 swap() 成员方法去除 vector 容器多余的容量,两种方法都可以。

vector swap()成员方法还可以这样用!

如何避免vector容器进行不必要的扩容》一节中,遗留了一个问题,即如何借助 swap() 成员方法去除 vector 容器中多余的容量?本节将就此问题给读者做详细的讲解。

我们知道,在使用 vector 容器的过程中,其容器会根据需要自行扩增。比如,使用 push_back()、insert()、emplace() 等成员方法向 vector 容器中添加新元素时,如果当前容器已满(即 size() == capacity()),则它会自行扩容以满足添加新元素的需求。当然,还可以调用 reserve() 成员方法来手动提升当前 vector 容器的容量。

举个例子(程序一):

#include <iostream>
#include <vector>
using namespace std;
int main()
{
    vector<int>myvector;
    cout << "1、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    //利用 myvector 容器存储 10 个元素
    for (int i = 1; i <= 10; i++) {
        myvector.push_back(i);
    }
    cout << "2、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    //手动为 myvector 扩容
    myvector.reserve(1000);
    cout << "3、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    return 0;
}

程序执行结果为:

1、当前 myvector 拥有 0 个元素,容量为 0
2、当前 myvector 拥有 10 个元素,容量为 13
3、当前 myvector 拥有 10 个元素,容量为 1000

除了可以添加元素外,vector 模板类中还提供了 pop_back()、erase()、clear() 等成员方法,可以轻松实现删除容器中已存储的元素。但需要注意得是,借助这些成员方法只能删除指定的元素,容器的容量并不会因此而改变。

例如在程序一的基础上,末尾(return 0 之前)添加如下语句:

myvector.erase(myvector.begin());
cout << "4、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
myvector.pop_back();
cout << "5、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
myvector.clear();
cout << "6、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;

此段代码的执行结果为:

4、当前 myvector 拥有 9 个元素,容量为 1000
5、当前 myvector 拥有 8 个元素,容量为 1000
6、当前 myvector 拥有 0 个元素,容量为 1000

显然,myvector 容器存储的元素个数在减少,但容量并不会减小。

幸运的是,myvector 模板类中提供有一个 shrink_to_fit() 成员方法,该方法的功能是将当前 vector 容器的容量缩减至和实际存储元素的个数相等。例如,在程序一的基础上,添加如下语句:

myvector.shrink_to_fit();
cout << "7、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;

该语句的执行结果为:

7、当前 myvector 拥有 10 个元素,容量为 10

显然,myvector 容器的容量由 1000 缩减到了 10。

利用swap()方法去除vector多余容量

除此之外,vector 模板类中还提供有 swap() 成员方法,该方法的基础功能是交换 2 个相同类型的 vector 容器(交换容量和存储的所有元素),但其也能用于去除 vector 容器多余的容量。

如果想用 swap() 成员方法去除当前 vector 容器多余的容量时,可以套用如下的语法格式:

vector(x).swap(x);

其中,x 指当前要操作的容器,T 为该容器存储元素的类型。

下面程序演示了此语法格式的 swap() 方法的用法和功能:

#include <iostream>
#include <vector>
using namespace std;
int main()
{
    vector<int>myvector;
    //手动为 myvector 扩容
    myvector.reserve(1000);
    cout << "1、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    //利用 myvector 容器存储 10 个元素
    for (int i = 1; i <= 10; i++) {
        myvector.push_back(i);
    }
    //将 myvector 容量缩减至 10
    vector<int>(myvector).swap(myvector);
    cout << "2、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    return 0;
}

程序执行结果为:

1、当前 myvector 拥有 0 个元素,容量为 1000
2、当前 myvector 拥有 10 个元素,容量为 10

显然,第 16 行代码成功将 myvector 容器的容量 1000 修改为 10,此行代码的执行流程可细分为以下 3 步:

  1. 先执行 vector(myvector),此表达式会调用 vector 模板类中的拷贝构造函数,从而创建出一个临时的 vector 容器(后续称其为 tempvector)。

值得一提的是,tempvector 临时容器并不为空,因为我们将 myvector 作为参数传递给了复制构造函数,该函数会将 myvector 容器中的所有元素拷贝一份,并存储到 tempvector 临时容器中。

注意,vector 模板类中的拷贝构造函数只会为拷贝的元素分配存储空间。换句话说,tempvector 临时容器中没有空闲的存储空间,其容量等于存储元素的个数。

  1. 然后借助 swap() 成员方法对 tempvector 临时容器和 myvector 容器进行调换,此过程不仅会交换 2 个容器存储的元素,还会交换它们的容量。换句话说经过 swap() 操作,myvetor 容器具有了 tempvector 临时容器存储的所有元素和容量,同时 tempvector 也具有了原 myvector 容器存储的所有元素和容量。
  2. 当整条语句执行结束时,临时的 tempvector 容器会被销毁,其占据的存储空间都会被释放。注意,这里释放的其实是原 myvector 容器占用的存储空间。

经过以上 3 个步骤,就成功的将 myvector 容器的容量由 100 缩减至 10。

利用swap()方法清空vector容器

在以上内容的学习过程中,如果读者善于举一反三,应该不难想到,swap() 方法还可以用来清空 vector 容器。

当 swap() 成员方法用于清空 vector 容器时,可以套用如下的语法格式:

vector().swap(x);

其中,x 指当前要操作的容器,T 为该容器存储元素的类型。

注意,和上面语法格式唯一的不同之处在于,这里没有为 vector() 表达式传递任何参数。这意味着,此表达式将调用 vector 模板类的默认构造函数,而不再是复制构造函数。也就是说,此格式会先生成一个空的 vector 容器,再借助 swap() 方法将空容器交换给 x,从而达到清空 x 的目的。

下面程序演示了此语法格式的 swap() 方法的用法和功能:

#include <iostream>
#include <vector>
using namespace std;
int main()
{
    vector<int>myvector;
    //手动为 myvector 扩容
    myvector.reserve(1000);
    cout << "1、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    //利用 myvector 容器存储 10 个元素
    for (int i = 1; i <= 10; i++) {
        myvector.push_back(i);
    }
    //清空 myvector 容器
    vector<int>().swap(myvector);
    cout << "2、当前 myvector 拥有 " << myvector.size() << " 个元素,容量为 " << myvector.capacity() << endl;
    return 0;
}

程序执行结果为:

1、当前 myvector 拥有 0 个元素,容量为 1000
2、当前 myvector 拥有 0 个元素,容量为 0

切忌,vector不是存储bool类型元素的vector容器!

前面章节中,已经详细介绍了 vector 容器的功能和用法。特别需要提醒的是,在使用 vector 容器时,要尽量避免使用该容器存储 bool 类型的元素,即避免使用 vector。

具体来讲,不推荐使用 vector 的原因有以下 2 个:

  1. 严格意义上讲,vector 并不是一个 STL 容器;
  2. vector 底层存储的并不是 bool 类型值。

读者可能会感到有些困惑,别着急,继续往下读。

vector不是容器

值得一提的是,对于是否为 STL 容器,C++ 标准库中有明确的判断条件,其中一个条件是:如果 cont 是包含对象 T 的 STL 容器,且该容器中重载了 [ ] 运算符(即支持 operator[]),则以下代码必须能够被编译:

T *p = &cont[0];

此行代码的含义是,借助 operator[ ] 获取一个 cont 容器中存储的 T 对象,同时将这个对象的地址赋予给一个 T 类型的指针。

这就意味着,如果 vector 是一个 STL 容器,则下面这段代码是可以通过编译的:

//创建一个 vector<bool> 容器
vector<bool>cont{0,1};
//试图将指针 p 指向 cont 容器中第一个元素
bool *p = &cont[0];

但不幸的是,此段代码不能通过编译。原因在于 vector 底层采用了独特的存储机制。

实际上,为了节省空间,vector 底层在存储各个 bool 类型值时,每个 bool 值都只使用一个比特位(二进制位)来存储。也就是说在 vector 底层,一个字节可以存储 8 个 bool 类型值。在这种存储机制的影响下,operator[ ] 势必就需要返回一个指向单个比特位的引用,但显然这样的引用是不存在的。

C++ 标准中解决这个问题的方案是,令 operator[] 返回一个代理对象(proxy object)。有关代理对象,由于不是本节重点,这里不再做描述,有兴趣的读者可自行查阅相关资料。

同样对于指针来说,其指向的最小单位是字节,无法另其指向单个比特位。综上所述可以得出一个结论,即上面第 2 行代码中,用 = 赋值号连接 bool *p 和 &cont[0] 是矛盾的。

由于 vector 并不完全满足 C++ 标准中对容器的要求,所以严格意义上来说它并不是一个 STL 容器。可能有读者会问,既然 vector 不完全是一个容器,为什么还会出现在 C++ 标准中呢?

这和一个雄心勃勃的试验有关,还要从前面提到的代理对象开始说起。由于代理对象在 C++ 软件开发中很受欢迎,引起了 C++ 标准委员会的注意,他们决定以开发 vector 作为一个样例,来演示 STL 中的容器如何通过代理对象来存取元素,这样当用户想自己实现一个基于代理对象的容器时,就会有一个现成的参考模板。

然而开发人员在实现 vector 的过程中发现,既要创建一个基于代理对象的容器,同时还要求该容器满足 C++ 标准中对容器的所有要求,是不可能的。由于种种原因,这个试验最终失败了,但是他们所做过的尝试(即开发失败的 vector)遗留在了 C++ 标准中。

至于将 vector 遗留到 C++ 标准中,是无心之作,还是有意为之,这都无关紧要,重要的是让读者明白,vector 不完全满足 C++ 标准中对容器的要求,尽量避免在实际场景中使用它!

如何避免使用vector

那么,如果在实际场景中需要使用 vector 这样的存储结构,该怎么办呢?很简单,可以选择使用 deque 或者 bitset 来替代 vector。

要知道,deque 容器几乎具有 vecotr 容器全部的功能(拥有的成员方法也仅差 reserve() 和 capacity()),而且更重要的是,deque 容器可以正常存储 bool 类型元素。

有关 deque 容器的具体用法,后续章节会做详细讲解。

还可以考虑用 bitset 代替 vector,其本质是一个模板类,可以看做是一种类似数组的存储结构。和后者一样,bitset 只能用来存储 bool 类型值,且底层存储机制也采用的是用一个比特位来存储一个 bool 值。

和 vector 容器不同的是,bitset 的大小在一开始就确定了,因此不支持插入和删除元素;另外 bitset 不是容器,所以不支持使用迭代器。

有关 bitset 的用法,感兴趣的读者可查阅 C++ 官方提供的 bitset使用手册

C++ STL deque容器(详解版)

deque 是 double-ended queue 的缩写,又称双端队列容器。

前面章节中,我们已经系统学习了 vector 容器,值得一提的是,deque 容器和 vecotr 容器有很多相似之处,比如:

  • deque 容器也擅长在序列尾部添加或删除元素(时间复杂度为O(1)),而不擅长在序列中间添加或删除元素。
  • deque 容器也可以根据需要修改自身的容量和大小。

和 vector 不同的是,deque 还擅长在序列头部添加或删除元素,所耗费的时间复杂度也为常数阶O(1)。并且更重要的一点是,deque 容器中存储元素并不能保证所有元素都存储到连续的内存空间中。

当需要向序列两端频繁的添加或删除元素时,应首选 deque 容器。

deque 容器以模板类 deque(T 为存储元素的类型)的形式在 头文件中,并位于 std 命名空间中。因此,在使用该容器之前,代码中需要包含下面两行代码:

#include <deque>
using namespace std;

注意,std 命名空间也可以在使用 deque 容器时额外注明,两种方式都可以。

创建deque容器的几种方式

创建 deque 容器,根据不同的实际场景,可选择使用如下几种方式。

  1. 创建一个没有任何元素的空 deque 容器:
std::deque<int> d;

和空 array 容器不同,空的 deque 容器在创建之后可以做添加或删除元素的操作,因此这种简单创建 deque 容器的方式比较常见。

  1. 创建一个具有 n 个元素的 deque 容器,其中每个元素都采用对应类型的默认值:
std::deque<int> d(10);

此行代码创建一个具有 10 个元素(默认都为 0)的 deque 容器。

  1. 创建一个具有 n 个元素的 deque 容器,并为每个元素都指定初始值,例如:
std::deque<int> d(10, 5)

如此就创建了一个包含 10 个元素(值都为 5)的 deque 容器。

  1. 在已有 deque 容器的情况下,可以通过拷贝该容器创建一个新的 deque 容器,例如:
std::deque<int> d1(5);
std::deque<int> d2(d1);

注意,采用此方式,必须保证新旧容器存储的元素类型一致。

  1. 通过拷贝其他类型容器中指定区域内的元素(也可以是普通数组),可以创建一个新容器,例如:
//拷贝普通数组,创建deque容器
int a[] = { 1,2,3,4,5 };
std::deque<int>d(a, a + 5);
//适用于所有类型的容器
std::array<int, 5>arr{ 11,12,13,14,15 };
std::deque<int>d(arr.begin()+2, arr.end());//拷贝arr容器中的{13,14,15}

deque容器可利用的成员函数

基于 deque 双端队列的特点,该容器包含一些 array、vector 容器都没有的成员函数。

表 1 中罗列了 deque 容器提供的所有成员函数。

函数成员函数功能
begin()返回指向容器中第一个元素的迭代器。
end()返回指向容器最后一个元素所在位置后一个位置的迭代器,通常和 begin() 结合使用。
rbegin()返回指向最后一个元素的迭代器。
rend()返回指向第一个元素所在位置前一个位置的迭代器。
cbegin()和 begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crend()和 rend() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
size()返回实际元素个数。
max_size()返回容器所能容纳元素个数的最大值。这通常是一个很大的值,一般是 232-1,我们很少会用到这个函数。
resize()改变实际元素的个数。
empty()判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。
shrink _to_fit()将内存减少到等于当前元素实际所使用的大小。
at()使用经过边界检查的索引访问元素。
front()返回第一个元素的引用。
back()返回最后一个元素的引用。
assign()用新元素替换原有内容。
push_back()在序列的尾部添加一个元素。
push_front()在序列的头部添加一个元素。
pop_back()移除容器尾部的元素。
pop_front()移除容器头部的元素。
insert()在指定的位置插入一个或多个元素。
erase()移除一个元素或一段元素。
clear()移出所有的元素,容器大小变为 0。
swap()交换两个容器的所有元素。
emplace()在指定的位置直接生成一个元素。
emplace_front()在容器头部生成一个元素。和 push_front() 的区别是,该函数直接在容器头部构造元素,省去了复制移动元素的过程。
emplace_back()在容器尾部生成一个元素。和 push_back() 的区别是,该函数直接在容器尾部构造元素,省去了复制移动元素的过程。

和 vector 相比,额外增加了实现在容器头部添加和删除元素的成员函数,同时删除了 capacity()、reserve() 和 data() 成员函数。

和 array、vector 相同,C++ 11 标准库新增的 begin() 和 end() 这 2 个全局函数也适用于 deque 容器。这 2 个函数的操作对象既可以是容器,也可以是普通数组。当操作对象是容器时,它和容器包含的 begin() 和 end() 成员函数的功能完全相同;如果操作对象是普通数组,则 begin() 函数返回的是指向数组第一个元素的指针,同样 end() 返回指向数组中最后一个元素之后一个位置的指针(注意不是最后一个元素)。

deque 容器还有一个std::swap(x , y) 非成员函数(其中 x 和 y 是存储相同类型元素的 deque 容器),它和 swap() 成员函数的功能完全相同,仅使用语法上有差异。

如下代码演示了表 1 中部分成员函数的用法:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    //初始化一个空deque容量
    deque<int>d;
    //向d容器中的尾部依次添加 1,2,3
    d.push_back(1); //{1}
    d.push_back(2); //{1,2}
    d.push_back(3); //{1,2,3}
    //向d容器的头部添加 0 
    d.push_front(0); //{0,1,2,3}
    //调用 size() 成员函数输出该容器存储的字符个数。
    printf("元素个数为:%d\n", d.size());
   
    //使用迭代器遍历容器
    for (auto i = d.begin(); i < d.end(); i++) {
        cout << *i << " ";
    }
    cout << endl;
    return 0;
}

运行结果为:

元素个数为:4
0 1 2 3

表 1 中这些成员函数的具体用法,后续学习用到时会具体讲解,感兴趣的读者,也可以通过查阅 STL手册做详细了解。

C++ STL deque容器迭代器用法详解

deque 容器迭代器的类型为随机访问迭代器,deque 模板类提供了表 1 所示这些成员函数,通过调用这些函数,可以获得表示不同含义的随机访问迭代器。

有关迭代器及其类型的介绍,可以阅读《C++ STL迭代器(iterator)》一节,本节不再做具体介绍。

成员函数功能
begin()返回指向容器中第一个元素的正向迭代器;如果是 const 类型容器,在该函数返回的是常量正向迭代器。
end()返回指向容器最后一个元素之后一个位置的正向迭代器;如果是 const 类型容器,在该函数返回的是常量正向迭代器。此函数通常和 begin() 搭配使用。
rbegin()返回指向最后一个元素的反向迭代器;如果是 const 类型容器,在该函数返回的是常量反向迭代器。
rend()返回指向第一个元素之前一个位置的反向迭代器。如果是 const 类型容器,在该函数返回的是常量反向迭代器。此函数通常和 rbegin() 搭配使用。
cbegin()和 begin() 功能类似,只不过其返回的迭代器类型为常量正向迭代器,不能用于修改元素。
cend()和 end() 功能相同,只不过其返回的迭代器类型为常量正向迭代器,不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过其返回的迭代器类型为常量反向迭代器,不能用于修改元素。
crend()和 rend() 功能相同,只不过其返回的迭代器类型为常量反向迭代器,不能用于修改元素。

C++ 11 新添加的 begin() 和 end() 全局函数也同样适用于 deque 容器。即当操作对象为 deque 容器时,其功能分别和表 1 中的 begin()、end() 成员函数相同,具体用法本节后续会做详细介绍。

表 1 中这些成员函数的具体功能如图 2 所示。

img
图 2 迭代器的具体功能示意图

从图 2 可以看出,这些成员函数通常是成对使用的,即 begin()/end()、rbegin()/rend()、cbegin()/cend()、crbegin()/crend() 各自成对搭配使用。其中,begin()/end() 和 cbegin/cend() 的功能是类似的,同样 rbegin()/rend() 和 crbegin()/crend() 的功能是类似的。

值得一提的是,以上函数在实际使用时,其返回值类型都可以使用 auto 关键字代替,编译器可以自行判断出该迭代器的类型。

deque容器迭代器的基本用法

deque 容器迭代器常用来遍历容器中存储的各个元素。

begin() 和 end() 分别用于指向「首元素」和「尾元素+1」 的位置,下面程序演示了如何使用 begin() 和 end() 遍历 deque 容器并输出其中的元素:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d{1,2,3,4,5};
    //从容器首元素,遍历至最后一个元素
    for (auto i = d.begin(); i < d.end(); i++) {
        cout << *i << " ";
    }
    return 0;
}

运行结果为:

1 2 3 4 5

前面提到,STL 还提供有全局的 begin() 和 end() 函数,当操作对象为容器时,它们的功能是上面的 begin()/end() 成员函数一样。例如,将上面程序中的第 8~10 行代码可以用如下代码替换:

for (auto i = begin(d); i < end(d); i++) {
    cout << *i << " ";
}

重新编译运行程序,会发现输出结果和上面一致。

cbegin()/cend() 成员函数和 begin()/end() 唯一不同的是,前者返回的是 const 类型的正向迭代器,这就意味着,由 cbegin() 和 cend() 成员函数返回的迭代器,可以用来遍历容器内的元素,也可以访问元素,但是不能对所存储的元素进行修改。

举个例子:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d{1,2,3,4,5};
    auto first = d.cbegin();
    auto end = d.cend();
    //常量迭代器不能用来修改容器中的元素值
    //*(first + 1) = 6;//尝试修改容器中元素 2 的值
    //*(end - 1) = 10;//尝试修改容器中元素 5 的值
    //常量迭代器可以用来遍历容器、访问容器中的元素
    while(first<end){
        cout << *first << " ";
        ++first;
    }
    return 0;
}

运行结果:

1 2 3 4 5

程序中,由于 first 和 end 都是常量迭代器,因此第 10、11 行修改容器内元素值的操作都是非法的。

deque 模板类中还提供了 rbegin() 和 rend() 成员函数,它们分别表示指向最后一个元素和第一个元素前一个位置的随机访问迭代器,又常称为反向迭代器(如图 2 所示)。

需要注意的是,在使用反向迭代器进行 ++ 或 – 运算时,++ 指的是迭代器向左移动一位,-- 指的是迭代器向右移动一位,即这两个运算符的功能也“互换”了。

反向迭代器用于以逆序的方式遍历容器中的元素。例如:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d{1,2,3,4,5};
    for (auto i = d.rbegin(); i < d.rend(); i++) {
        cout << *i << " ";
    }
    return 0;
}

运行结果为:

5 4 3 2 1

crbegin()/crend() 组合和 rbegin()/crend() 组合唯一的区别在于,前者返回的迭代器为 const 类型迭代器,不能用来修改容器中的元素,除此之外在使用上和后者完全相同。

deque容器迭代器的使用注意事项

首先需要注意的一点是,迭代器的功能是遍历容器,在遍历的同时可以访问(甚至修改)容器中的元素,但迭代器不能用来初始化空的 deque 容器。

例如,如下代码中注释部分是错误的用法:

#include <iostream>
#include <vector>
using namespace std;
int main()
{
    vector<int>values;
    auto first = values.begin();
    //*first = 1;
    return 0;
}

对于空的 deque 容器来说,可以通过 push_back()、push_front() 或者 resize() 成员函数实现向(空)deque 容器中添加元素。

除此之外,当向 deque 容器添加元素时,deque 容器会申请更多的内存空间,同时其包含的所有元素可能会被复制或移动到新的内存地址(原来占用的内存会释放),这会导致之前创建的迭代器失效。

举个例子:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d;
    d.push_back(1);
    auto first = d.begin();
    cout << *first << endl;
    //添加元素,会导致 first 失效
    d.push_back(1);
    cout << *first << endl;
    return 0;
}

程序中第 12 行代码,会导致程序运行崩溃,其原因就在于在创建 first 迭代器之后,deque 容器做了添加元素的操作,导致 first 失效。

在对容器做添加元素的操作之后,如果仍需要使用之前以创建好的迭代器,为了保险起见,一定要重新生成。

深度剖析deque容器底层实现原理

事实上,STL 中每个容器的特性,和它底层的实现机制密切相关,deque 自然也不例外。《C++ STL deque容器》一节中提到,deque 容器擅长在序列的头部和尾部添加或删除元素。本节将介绍 deque 容器的底层实现机制,探究其拥有此特点的原因。

想搞清楚 deque 容器的实现机制,需要先了解 deque 容器的存储结构以及 deque 容器迭代器的实现原理。

deque容器的存储结构

和 vector 容器采用连续的线性空间不同,deque 容器存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。

为了管理这些连续空间,deque 容器用数组(数组名假设为 map)存储着各个连续空间的首地址。也就是说,map 数组中存储的都是指针,指向那些真正用来存储数据的各个连续空间(如图 1 所示)。

deque容器的底层存储机制
图 1 deque容器的底层存储机制

通过建立 map 数组,deque 容器申请的这些分段的连续空间就能实现“整体连续”的效果。换句话说,当 deque 容器需要在头部或尾部增加存储空间时,它会申请一段新的连续空间,同时在 map 数组的开头或结尾添加指向该空间的指针,由此该空间就串接到了 deque 容器的头部或尾部。

有读者可能会问,如果 map 数组满了怎么办?很简单,再申请一块更大的连续空间供 map 数组使用,将原有数据(很多指针)拷贝到新的 map 数组中,然后释放旧的空间。

deque 容器的分段存储结构,提高了在序列两端添加或删除元素的效率,但也使该容器迭代器的底层实现变得更复杂。

deque容器迭代器的底层实现

由于 deque 容器底层将序列中的元素分别存储到了不同段的连续空间中,因此要想实现迭代器的功能,必须先解决如下 2 个问题:

  1. 迭代器在遍历 deque 容器时,必须能够确认各个连续空间在 map 数组中的位置;
  2. 迭代器在遍历某个具体的连续空间时,必须能够判断自己是否已经处于空间的边缘位置。如果是,则一旦前进或者后退,就需要跳跃到上一个或者下一个连续空间中。

为了实现遍历 deque 容器的功能,deque 迭代器定义了如下的结构:

template<class T,...>
struct __deque_iterator{
    ...
    T* cur;
    T* first;
    T* last;
    map_pointer node;//map_pointer 等价于 T**
}

可以看到,迭代器内部包含 4 个指针,它们各自的作用为:

  • cur:指向当前正在遍历的元素;
  • first:指向当前连续空间的首地址;
  • last:指向当前连续空间的末尾地址;
  • node:它是一个二级指针,用于指向 map 数组中存储的指向当前连续空间的指针。

借助这 4 个指针,deque 迭代器对随机访问迭代器支持的各种运算符进行了重载,能够对 deque 分段连续空间中存储的元素进行遍历。例如:

//当迭代器处于当前连续空间边缘的位置时,如果继续遍历,就需要跳跃到其它的连续空间中,该函数可用来实现此功能
void set_node(map_pointer new_node){
    node = new_node;//记录新的连续空间在 map 数组中的位置
    first = *new_node; //更新 first 指针
    //更新 last 指针,difference_type(buffer_size())表示每段连续空间的长度
    last = first + difference_type(buffer_size());
}
//重载 * 运算符
reference operator*() const{return *cur;}
pointer operator->() const{return &(operator *());}
//重载前置 ++ 运算符
self & operator++(){
    ++cur;
    //处理 cur 处于连续空间边缘的特殊情况
    if(cur == last){
        //调用该函数,将迭代器跳跃到下一个连续空间中
        set_node(node+1);
        //对 cur 重新赋值
        cur = first;
    }
    return *this;
}
//重置前置 -- 运算符
self& operator--(){
    //如果 cur 位于连续空间边缘,则先将迭代器跳跃到前一个连续空间中
    if(cur == first){
        set_node(node-1);
        cur == last;
    }
    --cur;
    return *this;
}

deque容器的底层实现

了解了 deque 容器底层存储序列的结构,以及 deque 容器迭代器的内部结构之后,接下来看看 deque 容器究竟是如何实现的。

deque 容器除了维护先前讲过的 map 数组,还需要维护 start、finish 这 2 个 deque 迭代器。以下为 deque 容器的定义:

//_Alloc为内存分配器
template<class _Ty,
    class _Alloc = allocator<_Ty>>
class deque{
    ...
protected:
    iterator start;
    iterator finish;
    map_pointer map;
...
}

其中,start 迭代器记录着 map 数组中首个连续空间的信息,finish 迭代器记录着 map 数组中最后一个连续空间的信息。另外需要注意的是,和普通 deque 迭代器不同,start 迭代器中的 cur 指针指向的是连续空间中首个元素;而 finish 迭代器中的 cur 指针指向的是连续空间最后一个元素的下一个位置。

因此,deque 容器的底层实现如图 2 所示。

deque容器的底层实现
图 3 deque容器的底层实现

借助 start 和 finish,以及 deque 迭代器中重载的诸多运算符,就可以实现 deque 容器提供的大部分成员函数,比如:

//begin() 成员函数
iterator begin() {return start;}
//end() 成员函数
iterator end() { return finish;}
//front() 成员函数
reference front(){return *start;}
//back() 成员函数
reference back(){
    iterator tmp = finish;
    --tmp;
    return *tmp;
}
//size() 成员函数
size_type size() const{return finish - start;}//deque迭代器重载了 - 运算符
//enpty() 成员函数
bool empty() const{return finish == start;}

C++ STL deque容器访问元素(4种方法)

通过《STL deque容器》一节,详细介绍了如何创建一个 deque 容器,本节继续讲解如何访问(甚至修改)deque 容器存储的元素。

和 array、vector 容器一样,可以采用普通数组访问存储元素的方式,访问 deque 容器中的元素,比如:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d{ 1,2,3,4 };
    cout << d[1] << endl;
    //修改指定下标位置处的元素
    d[1] = 5;
    cout << d[1] << endl;
    return 0;
}

运行结果为:

2
5

可以看到,容器名[n]的这种方式,不仅可以访问容器中的元素,还可以对其进行修改。但需要注意的是,使用此方法需确保下标 n 的值不会超过容器中存储元素的个数,否则会发生越界访问的错误。

如果想有效地避免越界访问,可以使用 deque 模板类提供的 at() 成员函数,由于该函数会返回容器中指定位置处元素的引用形式,因此利用该函数的返回值,既可以访问指定位置处的元素,如果需要还可以对其进行修改。

不仅如此,at() 成员函数会自行判定访问位置是否越界,如果越界则抛出std::out_of_range异常。例如:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int>d{ 1,2,3,4 };
    cout << d.at(1) << endl;
    d.at(1) = 5;
    cout << d.at(1) << endl;
    //下面这条语句会抛出 out_of_range 异常
    //cout << d.at(10) << endl;
    return 0;
}

运行结果为:

2
5

读者可能有这样一个疑问,即为什么 deque 容器在重载 [] 运算符时,没有实现边界检查的功能呢?答案很简单,因为性能。如果每次访问元素,都去检查索引值,无疑会产生很多开销。当不存在越界访问的可能时,就能避免这种开销。

除此之外,deque 容器还提供了 2 个成员函数,即 front() 和 back(),它们分别返回 vector 容器中第一个和最后一个元素的引用,通过利用它们的返回值,可以访问(甚至修改)容器中的首尾元素。

举个例子:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int> d{ 1,2,3,4,5 };
    cout << "deque 首元素为:" << d.front() << endl;
    cout << "deque 尾元素为:" << d.back() << endl;
    //修改首元素
    d.front() = 10;
    cout << "deque 新的首元素为:" << d.front() << endl;
    //修改尾元素
    d.back() = 20;
    cout << "deque 新的尾元素为:" << d.back() << endl;
    return 0;
}

运行结果为:

deque 首元素为:1
deque 尾元素为:5
deque 新的首元素为:10
deque 新的尾元素为:20

注意,和 vector 容器不同,deque 容器没有提供 data() 成员函数,同时 deque 容器在存储元素时,也无法保证其会将元素存储在连续的内存空间中,因此尝试使用指针去访问 deque 容器中指定位置处的元素,是非常危险的。

另外,结合 deque 模板类中和迭代器相关的成员函数,可以实现遍历 deque 容器中指定区域元素的方法。例如:

#include <iostream>
#include <deque>
using namespace std;
int main()
{
    deque<int> d{ 1,2,3,4,5 };
    //从元素 2 开始遍历
    auto first = d.begin() + 1;
    //遍历至 5 结束(不包括 5)
    auto end = d.end() - 1;
    while (first < end) {
        cout << *first << " ";
        ++first;
    }
    return 0;
}

运行结果为:

2 3 4

当然,deque 模板类中和迭代器相关的成员函数,还有很多,大家可以阅读《STL deque容器迭代器》做详细了解。

C++ STL deque容器添加和删除元素方法完全攻略

deque 容器中,无论是添加元素还是删除元素,都只能借助 deque 模板类提供的成员函数。表 1 中罗列的是所有和添加或删除容器内元素相关的 deque 模板类中的成员函数。

成员函数功能
push_back()在容器现有元素的尾部添加一个元素,和 emplace_back() 不同,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的尾部。
pop_back()移除容器尾部的一个元素。
push_front()在容器现有元素的头部添加一个元素,和 emplace_back() 不同,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的头部。
pop_front()移除容器尾部的一个元素。
emplace_back()C++ 11 新添加的成员函数,其功能是在容器尾部生成一个元素。和 push_back() 不同,该函数直接在容器头部构造元素,省去了复制或移动元素的过程。
emplace_front()C++ 11 新添加的成员函数,其功能是在容器头部生成一个元素。和 push_front() 不同,该函数直接在容器头部构造元素,省去了复制或移动元素的过程。
insert()在指定的位置直接生成一个元素。和 emplace() 不同的是,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的指定位置。
emplace()C++ 11 新添加的成员函数,其功能是 insert() 相同,即在指定的位置直接生成一个元素。和 insert() 不同的是,emplace() 直接在容器指定位置构造元素,省去了复制或移动元素的过程。
erase()移除一个元素或某一区域内的多个元素。
clear()删除容器中所有的元素。

在实际应用中,常用 emplace()、emplace_front() 和 emplace_back() 分别代替 insert()、push_front() 和 push_back(),具体原因本节后续会讲。

以上这些成员函数中,除了 insert() 函数的语法格式比较多,其他函数都只有一种用法(erase() 有 2 种语法格式),下面这段程序演示了它们的具体用法:

#include <deque>
#include <iostream>
using namespace std;
int main()
{
    deque<int>d;
    //调用push_back()向容器尾部添加数据。
    d.push_back(2); //{2}
    //调用pop_back()移除容器尾部的一个数据。
    d.pop_back(); //{}
    //调用push_front()向容器头部添加数据。
    d.push_front(2);//{2}
    //调用pop_front()移除容器头部的一个数据。
    d.pop_front();//{}
    //调用 emplace 系列函数,向容器中直接生成数据。
    d.emplace_back(2); //{2}
    d.emplace_front(3); //{3,2}
    //emplace() 需要 2 个参数,第一个为指定插入位置的迭代器,第二个是插入的值。
    d.emplace(d.begin() + 1, 4);//{3,4,2}
    for (auto i : d) {
        cout << i << " ";
    }
    //erase()可以接受一个迭代器表示要删除元素所在位置
    //也可以接受 2 个迭代器,表示要删除元素所在的区域。
    d.erase(d.begin());//{4,2}
    d.erase(d.begin(), d.end());//{},等同于 d.clear()
    return 0;
}

运行结果为:

3 4 2

这里重点讲一下 insert() 函数的用法。insert() 函数的功能是在 deque 容器的指定位置插入一个或多个元素。该函数的语法格式有多种,如表 2 所示。

语法格式功能
iterator insert(pos,elem)在迭代器 pos 指定的位置之前插入一个新元素elem,并返回表示新插入元素位置的迭代器。
iterator insert(pos,n,elem)在迭代器 pos 指定的位置之前插入 n 个元素 elem,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,first,last)在迭代器 pos 指定的位置之前,插入其他容器(不仅限于vector)中位于 [first,last) 区域的所有元素,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,initlist)在迭代器 pos 指定的位置之前,插入初始化列表(用大括号{}括起来的多个元素,中间有逗号隔开)中所有的元素,并返回表示第一个新插入元素位置的迭代器。

下面的程序演示了 insert() 函数的这几种用法:

#include <iostream>
#include <deque>
#include <array>
using namespace std;
int main()
{
    std::deque<int> d{ 1,2 };
    //第一种格式用法
    d.insert(d.begin() + 1, 3);//{1,3,2}
    //第二种格式用法
    d.insert(d.end(), 2, 5);//{1,3,2,5,5}
    //第三种格式用法
    std::array<int, 3>test{ 7,8,9 };
    d.insert(d.end(), test.begin(), test.end());//{1,3,2,5,5,7,8,9}
    //第四种格式用法
    d.insert(d.end(), { 10,11 });//{1,3,2,5,5,7,8,9,10,11}
    for (int i = 0; i < d.size(); i++) {
        cout << d[i] << " ";
    }
    return 0;
}

运行结果为:

1,3,2,5,5,7,8,9,10,11

emplace系列函数的优势

有关 emplace()、emplace_front() 和 emplace_back() 分别和 insert()、push_front() 和 push_back() 在运行效率上的对比,可以通过下面的程序体现出来:

#include <deque>
#include <iostream>
using namespace std;
class testDemo
{
public:
    testDemo(int num) :num(num) {
        std::cout << "调用构造函数" << endl;
    }
    testDemo(const testDemo& other) :num(other.num) {
        std::cout << "调用拷贝构造函数" << endl;
    }
    testDemo(testDemo&& other) :num(other.num) {
        std::cout << "调用移动构造函数" << endl;
    }
    testDemo& operator=(const testDemo& other);
private:
    int num;
};
testDemo& testDemo::operator=(const testDemo& other) {
    this->num = other.num;
    return *this;
}
int main()
{
    //emplace和insert
    cout << "emplace:" << endl;
    std::deque<testDemo> demo1;
    demo1.emplace(demo1.begin(), 2);
    cout << "insert:" << endl;
    std::deque<testDemo> demo2;
    demo2.insert(demo2.begin(), 2);
   
    //emplace_front和push_front
    cout << "emplace_front:" << endl;
    std::deque<testDemo> demo3;
    demo3.emplace_front(2);
    cout << "push_front:" << endl;
    std::deque<testDemo> demo4;
    demo4.push_front(2);
    //emplace_back()和push_back()
    cout << "emplace_back:" << endl;
    std::deque<testDemo> demo5;
    demo5.emplace_back(2);
    cout << "push_back:" << endl;
    std::deque<testDemo> demo6;
    demo6.push_back(2);
    return 0;
}

运行结果为:

emplace:
调用构造函数
insert:
调用构造函数
调用移动构造函数
emplace_front:
调用构造函数
push_front:
调用构造函数
调用移动构造函数
emplace_back:
调用构造函数
push_back:
调用构造函数
调用移动构造函数

可以看到,相比和它同功能的函数,emplace 系列函数都只调用了构造函数,而没有调用移动构造函数,这无疑提高了代码的运行效率。

C++ list(STL list)容器完全攻略(超级详细)

STL list 容器,又称双向链表容器,即该容器的底层是以双向链表的形式实现的。这意味着,list 容器中的元素可以分散存储在内存空间里,而不是必须存储在一整块连续的内存空间中。

图 1 展示了 list 双向链表容器是如何存储元素的。

img
图 1 list 双向链表容器的存储结构示意图

可以看到,list 容器中各个元素的前后顺序是靠指针来维系的,每个元素都配备了 2 个指针,分别指向它的前一个元素和后一个元素。其中第一个元素的前向指针总为 null,因为它前面没有元素;同样,尾部元素的后向指针也总为 null。

基于这样的存储结构,list 容器具有一些其它容器(array、vector 和 deque)所不具备的优势,即它可以在序列已知的任何位置快速插入或删除元素(时间复杂度为O(1))。并且在 list 容器中移动元素,也比其它容器的效率高。

使用 list 容器的缺点是,它不能像 array 和 vector 那样,通过位置直接访问元素。举个例子,如果要访问 list 容器中的第 6 个元素,它不支持容器对象名[6]这种语法格式,正确的做法是从容器中第一个元素或最后一个元素开始遍历容器,直到找到该位置。

实际场景中,如何需要对序列进行大量添加或删除元素的操作,而直接访问元素的需求却很少,这种情况建议使用 list 容器存储序列。

list 容器以模板类 list(T 为存储元素的类型)的形式在<list>头文件中,并位于 std 命名空间中。因此,在使用该容器之前,代码中需要包含下面两行代码:

#include <list>
using namespace std;

注意,std 命名空间也可以在使用 list 容器时额外注明,两种方式都可以。

list容器的创建

根据不同的使用场景,有以下 5 种创建 list 容器的方式供选择。

  1. 创建一个没有任何元素的空 list 容器:
std::list<int> values;

和空 array 容器不同,空的 list 容器在创建之后仍可以添加元素,因此创建 list 容器的方式很常用。

  1. 创建一个包含 n 个元素的 list 容器:
std::list<int> values(10);

通过此方式创建 values 容器,其中包含 10 个元素,每个元素的值都为相应类型的默认值(int类型的默认值为 0)。

  1. 创建一个包含 n 个元素的 list 容器,并为每个元素指定初始值。例如:
std::list<int> values(10, 5);

如此就创建了一个包含 10 个元素并且值都为 5 个 values 容器。

  1. 在已有 list 容器的情况下,通过拷贝该容器可以创建新的 list 容器。例如:
std::list<int> value1(10);
std::list<int> value2(value1);

注意,采用此方式,必须保证新旧容器存储的元素类型一致。

  1. 通过拷贝其他类型容器(或者普通数组)中指定区域内的元素,可以创建新的 list 容器。例如:
//拷贝普通数组,创建list容器
int a[] = { 1,2,3,4,5 };
std::list<int> values(a, a+5);
//拷贝其它类型的容器,创建 list 容器
std::array<int, 5>arr{ 11,12,13,14,15 };
std::list<int>values(arr.begin()+2, arr.end());//拷贝arr容器中的{13,14,15}

list容器可用的成员函数

表 2 中罗列出了 list 模板类提供的所有成员函数以及各自的功能。

成员函数功能
begin()返回指向容器中第一个元素的双向迭代器。
end()返回指向容器中最后一个元素所在位置的下一个位置的双向迭代器。
rbegin()返回指向最后一个元素的反向双向迭代器。
rend()返回指向第一个元素所在位置前一个位置的反向双向迭代器。
cbegin()和 begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crend()和 rend() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
empty()判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。
size()返回当前容器实际包含的元素个数。
max_size()返回容器所能包含元素个数的最大值。这通常是一个很大的值,一般是 232-1,所以我们很少会用到这个函数。
front()返回第一个元素的引用。
back()返回最后一个元素的引用。
assign()用新元素替换容器中原有内容。
emplace_front()在容器头部生成一个元素。该函数和 push_front() 的功能相同,但效率更高。
push_front()在容器头部插入一个元素。
pop_front()删除容器头部的一个元素。
emplace_back()在容器尾部直接生成一个元素。该函数和 push_back() 的功能相同,但效率更高。
push_back()在容器尾部插入一个元素。
pop_back()删除容器尾部的一个元素。
emplace()在容器中的指定位置插入元素。该函数和 insert() 功能相同,但效率更高。
insert()在容器中的指定位置插入元素。
erase()删除容器中一个或某区域内的元素。
swap()交换两个容器中的元素,必须保证这两个容器中存储的元素类型是相同的。
resize()调整容器的大小。
clear()删除容器存储的所有元素。
splice()将一个 list 容器中的元素插入到另一个容器的指定位置。
remove(val)删除容器中所有等于 val 的元素。
remove_if()删除容器中满足条件的元素。
unique()删除容器中相邻的重复元素,只保留一个。
merge()合并两个事先已排好序的 list 容器,并且合并之后的 list 容器依然是有序的。
sort()通过更改容器中元素的位置,将它们进行排序。
reverse()反转容器中元素的顺序。

除此之外,C++ 11 标准库还新增加了 begin() 和 end() 这 2 个函数,和 list 容器包含的 begin() 和 end() 成员函数不同,标准库提供的这 2 个函数的操作对象,既可以是容器,还可以是普通数组。当操作对象是容器时,它和容器包含的 begin() 和 end() 成员函数的功能完全相同;如果操作对象是普通数组,则 begin() 函数返回的是指向数组第一个元素的指针,同样 end() 返回指向数组中最后一个元素之后一个位置的指针(注意不是最后一个元素)。

list 容器还有一个std::swap(x , y)非成员函数(其中 x 和 y 是存储相同类型元素的 list 容器),它和 swap() 成员函数的功能完全相同,仅使用语法上有差异。

如下代码演示了表 2 中部分成员函数的用法:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    //创建空的 list 容器
    std::list<double> values;
    //向容器中添加元素
    values.push_back(3.1);
    values.push_back(2.2);
    values.push_back(2.9);
    cout << "values size:" << values.size() << endl;
    //对容器中的元素进行排序
    values.sort();
    //使用迭代器输出list容器中的元素
    for (std::list<double>::iterator it = values.begin(); it != values.end(); ++it) {
        std::cout << *it << " ";
    }
    return 0;
}

运行结果为:

values size:3
2.2 2.9 3.1

表 2 中这些成员函数的具体用法,后续学习用到时会具体讲解,感兴趣的读者,也可以通过查阅 STL手册做详细了解。

C++ STL list迭代器及用法(详解版)

只有运用迭代器,才能访问 list 容器中存储的各个元素。list 模板类提供了如表 1 所示的这些迭代器函数。

迭代器函数功能
begin()返回指向容器中第一个元素的双向迭代器(正向迭代器)。
end()返回指向容器中最后一个元素所在位置的下一个位置的双向迭代器。(正向迭代器)。
rbegin()返回指向最后一个元素的反向双向迭代器。
rend()返回指向第一个元素所在位置前一个位置的反向双向迭代器。
cbegin()和 begin() 功能相同,只不过在其基础上,正向迭代器增加了 const 属性,即不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,正向迭代器增加了 const 属性,即不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过在其基础上,反向迭代器增加了 const 属性,即不能用于修改元素。
crend()和 rend() 功能相同,只不过在其基础上,反向迭代器增加了 const 属性,即不能用于修改元素。

除此之外,C++ 11 新添加的 begin() 和 end() 全局函数也同样适用于 list 容器。即当操作对象为 list 容器时,其功能分别和表 1 中的 begin()、end() 成员函数相同。

表 1 中各个成员函数的功能如图 2 所示。

img
图 2 list 容器迭代器的功能示意图

注意,list 容器的底层实现结构为双向链表,图 2 这种表示仅是为了方便理解各个迭代器函数的功能。

从图 2 可以看出,这些成员函数通常是成对使用的,即 begin()/end()、rbegin()/rend()、cbegin()/cend()、crbegin()/crend() 各自成对搭配使用。其中,begin()/end() 和 cbegin/cend() 的功能是类似的,同样 rbegin()/rend() 和 crbegin()/crend() 的功能是类似的。

前面章节已经详细介绍了 array、vector、deque 容器的迭代器,和它们相比,list 容器迭代器最大的不同在于,其配备的迭代器类型为双向迭代器,而不再是随机访问迭代器。

这意味着,假设 p1 和 p2 都是双向迭代器,则它们支持使用 ++p1、 p1++、 p1–、 p1++、 *p1、 p1==p2 以及 p1!=p2 运算符,但不支持以下操作(其中 i 为整数):

  • p1[i]:不能通过下标访问 list 容器中指定位置处的元素。
  • p1-=i、 p1+=i、 p1+i 、p1-i:双向迭代器 p1 不支持使用 -=、+=、+、- 运算符。
  • p1<p2、 p1>p2、 p1<=p2、 p1>=p2:双向迭代器 p1、p2 不支持使用 <、 >、 <=、 >= 比较运算符。

有关迭代器类别和功能的具体介绍,可阅读 《C++ STL迭代器》一节。

下面这个程序演示了如何使用表 1 中的迭代器遍历 list 容器中的各个元素。

#include <iostream>
#include <list>
using namespace std;
int main()
{
    //创建 list 容器
    std::list<char> values{'h','t','t','p',':','/','/','c','.','b','i','a','n','c','h','e','n','g','.','n','e','t'};
    //使用begin()/end()迭代器函数对输出list容器中的元素
    for (std::list<char>::iterator it = values.begin(); it != values.end(); ++it) {
        std::cout << *it;
    }
    cout << endl;
    //使用 rbegin()/rend()迭代器函数输出 lsit 容器中的元素
    for (std::list<char>::reverse_iterator it = values.rbegin(); it != values.rend();++it) {
        std::cout << *it;
    }
    return 0;
}

输出结果为:

http://c.biancheng.net
ten.gnehcnaib.c//:ptth

注意,程序中比较迭代器之间的关系,用的是 != 运算符,因为它不支持 < 等运算符。另外在实际场景中,所有迭代器函数的返回值都可以传给使用 auto 关键字定义的变量,因为编译器可以自行判断出该迭代器的类型。

值得一提的是,list 容器在进行插入(insert())、接合(splice())等操作时,都不会造成原有的 list 迭代器失效,甚至进行删除操作,而只有指向被删除元素的迭代器失效,其他迭代器不受任何影响。

举个例子:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    //创建 list 容器
    std::list<char> values{'h','t','t','p',':','/','/','c','.','b','i','a','n','c','h','e','n','g','.','n','e','t'};
    //创建 begin 和 end 迭代器
    std::list<char>::iterator begin = values.begin();
    std::list<char>::iterator end = values.end();
    //头部和尾部插入字符 '1'
    values.insert(begin, '1');
    values.insert(end, '1');
    while (begin != end)
    {
        std::cout << *begin;
        ++begin;
    }
    return 0;
}

运行结果为:

http://c.biancheng.net1

可以看到,在进行插入操作之后,仍使用先前创建的迭代器遍历容器,虽然程序不会出错,但由于插入位置的不同,可能会遗漏新插入的元素。

C++ list容器底层存储结构(详解版)

前面在讲 STL list 容器时提到,该容器的底层是用双向链表实现的,甚至一些 STL 版本中(比如 SGI STL),list 容器的底层实现使用的是双向循环链表。

img
图 1 双向链表( a) )和双向循环链表( b) )

图 1 中,node 表示链表的头指针。

如图 1 所示,使用链表存储数据,并不会将它们存储到一整块连续的内存空间中。恰恰相反,各元素占用的存储空间(又称为节点)是独立的、分散的,它们之间的线性关系通过指针(图 1 以箭头表示)来维持。

list 容器节点结构

通过图 1 可以看到,双向链表的各个节点中存储的不仅仅是元素的值,还应包含 2 个指针,分别指向前一个元素和后一个元素。

通过查看 list 容器的源码实现,其对节点的定义如下:

template<typename T,...>
struct __List_node{
    //...
    __list_node<T>* prev;
    __list_node<T>* next;
    T myval;
    //...
}

注意,为了方便读者理解,此代码以及本节后续代码,都省略了和本节核心内容不相关的内容,如果读者对此部分感兴趣,可查看 list 容器实现源码。

可以看到,list 容器定义的每个节点中,都包含 *prev、*next 和 myval。其中,prev 指针用于指向前一个节点;next 指针用于指向后一个节点;myval 用于存储当前元素的值。

list容器迭代器的底层实现

和 array、vector 这些容器迭代器的实现方式不同,由于 list 容器的元素并不是连续存储的,所以该容器迭代器中,必须包含一个可以指向 list 容器的指针,并且该指针还可以借助重载的 *、++、–、==、!= 等运算符,实现迭代器正确的递增、递减、取值等操作。

因此,list 容器迭代器的实现代码如下:

template<tyepname T,...>
struct __list_iterator{
    __list_node<T>* node;
    //...
    //重载 == 运算符
    bool operator==(const __list_iterator& x){return node == x.node;}
    //重载 != 运算符
    bool operator!=(const __list_iterator& x){return node != x.node;}
    //重载 * 运算符,返回引用类型
    T* operator *() const {return *(node).myval;}
    //重载前置 ++ 运算符
    __list_iterator<T>& operator ++(){
        node = (*node).next;
        return *this;
    }
    //重载后置 ++ 运算符
    __list_iterator<T>& operator ++(int){
        __list_iterator<T> tmp = *this;
        ++(*this);
        return tmp;
    }
    //重载前置 -- 运算符
    __list_iterator<T>& operator--(){
        node = (*node).prev;
        return *this;
    }
    //重载后置 -- 运算符
    __list_iterator<T> operator--(int){
        __list_iterator<T> tmp = *this;
        --(*this);
        return tmp;
    }
    //...
}

可以看到,迭代器的移动就是通过操作节点的指针实现的。

list容器的底层实现

本节开头提到,不同版本的 STL 标准库中,list 容器的底层实现并不完全一致,但原理基本相同。这里以 SGI STL 中的 list 容器为例,讲解该容器的具体实现过程。

SGI STL 标准库中,list 容器的底层实现为双向循环链表,相比双向链表结构的好处是在构建 list 容器时,只需借助一个指针即可轻松表示 list 容器的首尾元素。

如下是 SGI STL 标准库中对 list 容器的定义:

template <class T,...>
class list
{
    //...
    //指向链表的头节点,并不存放数据
    __list_node<T>* node;
    //...以下还有list 容器的构造函数以及很多操作函数
}

另外,为了更方便的实现 list 模板类提供的函数,该模板类在构建容器时,会刻意在容器链表中添加一个空白节点,并作为 list 链表的首个节点(又称头节点)。

使用双向链表实现的 list 容器,其内部通常包含 2 个指针,并分别指向链表中头部的空白节点和尾部的空白节点(也就是说,其包含 2 个空白节点)。

比如,我们经常构造空的 list 容器,其用到的构造函数如下所示:

list() { empty_initialize(); }
// 用于空链表的建立
void empty_initialize()
{
    node = get_node();//初始化节点
    node->next = node; // 前置节点指向自己
    node->prev = node; // 后置节点指向自己
}

显然,即便是创建空的 list 容器,它也包含有 1 个节点。

除此之外,list 模板类中还提供有带参的构造函数,它们的实现过程大致分为以下 2 步:

  • 调用 empty_initialize() 函数,构造带有头节点的空 list 容器链表;
  • 将各个参数按照次序插入到空的 list 容器链表中。

由此可以总结出,list 容器实际上就是一个带有头节点的双向循环链表。如图 2 所示,此为存有 2 个元素的 list 容器:

img
图 1 list 容器底层存储示意图

在此基础上,通过借助 node 头节点,就可以实现 list 容器中的所有成员函数,比如:

//begin()成员函数
__list_iterator<T> begin(){return (*node).next;}
//end()成员函数
__list_iterator<T> end(){return node;}
//empty()成员函数
bool empty() const{return (*node).next == node;}
//front()成员函数
T& front() {return *begin();}
//back()成员函数
T& back() {return *(--end();)}
//...

以上也只是罗列了 list 容器中一部分成员函数的实现方法,其它成员函数的具体实现,这里不再具体描述,感兴趣的读者,可下载 list 容器的实现源码。

C++ list(STL list)访问元素的几种方法

不同于之前学过的 STL 容器,访问 list 容器中存储元素的方式很有限,即要么使用 front() 和 back() 成员函数,要么使用 list 容器迭代器。

list 容器不支持随机访问,未提供下标操作符 [] 和 at() 成员函数,也没有提供 data() 成员函数。

通过 front() 和 back() 成员函数,可以分别获得 list 容器中第一个元素和最后一个元素的引用形式。举个例子:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    std::list<int> mylist{ 1,2,3,4 };
    int &first = mylist.front();
    int &last = mylist.back();
    cout << first << " " << last << endl;
    first = 10;
    last = 20;
    cout << mylist.front() << " " << mylist.back() << endl;
    return 0;
}

输出结果为:

1 4
10 20

可以看到,通过 front() 和 back() 的返回值,我们不仅能分别获取当前 list 容器中的首尾元素,必要时还能修改它们的值。

除此之外,如果想访问 list 容存储的其他元素,就只能使用 list 容器的迭代器。例如:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    const std::list<int> mylist{1,2,3,4,5};
    auto it = mylist.begin();
    cout << *it << " ";
    ++it;
    while (it!=mylist.end())
    {
        cout << *it << " ";
        ++it;  
    }
    return 0;
}

运行结果为:

1 2 3 4 5

值得一提的是,对于非 const 类型的 list 容器,迭代器不仅可以访问容器中的元素,也可以对指定元素的值进行修改。

当然,对于修改容器指定元素的值,list 模板类提供有专门的成员函数 assign(),感兴趣的读者可自行查找该成员函数的用法。

C++ STL list添加(插入)元素方法详解

前面章节介绍了如何创建 list 容器,在此基础上,本节继续讲解如何向现有 list 容器中添加或插入新的元素。

list 模板类中,与“添加或插入新元素”相关的成员方法有如下几个:

  • push_front():向 list 容器首个元素前添加新元素;
  • push_back():向 list 容器最后一个元素后添加新元素;
  • emplace_front():在容器首个元素前直接生成新的元素;
  • emplace_back():在容器最后一个元素后直接生成新的元素;
  • emplace():在容器的指定位置直接生成新的元素;
  • insert():在指定位置插入新元素;
  • splice():将其他 list 容器存储的多个元素添加到当前 list 容器的指定位置处。

以上这些成员方法中,除了 insert() 和 splice() 方法有多种语法格式外,其它成员方法都仅有 1 种语法格式,下面程序演示了它们的具体用法。

#include <iostream>
#include <list>
using namespace std;
int main()
{
    std::list<int> values{1,2,3};
    values.push_front(0);//{0,1,2,3}
    values.push_back(4); //{0,1,2,3,4}
    values.emplace_front(-1);//{-1,0,1,2,3,4}
    values.emplace_back(5);  //{-1,0,1,2,3,4,5}
   
    //emplace(pos,value),其中 pos 表示指明位置的迭代器,value为要插入的元素值
    values.emplace(values.end(), 6);//{-1,0,1,2,3,4,5,6}
    for (auto p = values.begin(); p != values.end(); ++p) {
        cout << *p << " ";
    }
    return 0;
}

输出结果为:

-1,0,1,2,3,4,5,6

list insert()成员方法

insert() 成员方法的语法格式有 4 种,如表 1 所示。

语法格式用法说明
iterator insert(pos,elem)在迭代器 pos 指定的位置之前插入一个新元素 elem,并返回表示新插入元素位置的迭代器。
iterator insert(pos,n,elem)在迭代器 pos 指定的位置之前插入 n 个元素 elem,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,first,last)在迭代器 pos 指定的位置之前,插入其他容器(例如 array、vector、deque 等)中位于 [first,last) 区域的所有元素,并返回表示第一个新插入元素位置的迭代器。
iterator insert(pos,initlist)在迭代器 pos 指定的位置之前,插入初始化列表(用大括号 { } 括起来的多个元素,中间有逗号隔开)中所有的元素,并返回表示第一个新插入元素位置的迭代器。

下面的程序演示了如何使用 insert() 方法向 list 容器中插入元素。

#include <iostream>
#include <list>
#include <array>
using namespace std;
int main()
{
    std::list<int> values{ 1,2 };
    //第一种格式用法
    values.insert(values.begin() , 3);//{3,1,2}
    //第二种格式用法
    values.insert(values.end(), 2, 5);//{3,1,2,5,5}
    //第三种格式用法
    std::array<int, 3>test{ 7,8,9 };
    values.insert(values.end(), test.begin(), test.end());//{3,1,2,5,5,7,8,9}
    //第四种格式用法
    values.insert(values.end(), { 10,11 });//{3,1,2,5,5,7,8,9,10,11}
    for (auto p = values.begin(); p != values.end(); ++p)
    {
        cout << *p << " ";
    }
    return 0;
}

输出结果为:

3 1 2 5 5 7 8 9 10 11

学到这里,读者有没有发现,同样是实现插入元素的功能,无论是 push_front()、push_back() 还是 insert(),都有以 emplace 为名且功能和前者相同的成员函数。这是因为,后者是 C++ 11 标准新添加的,在大多数场景中,都可以完全替代前者实现同样的功能。更重要的是,实现同样的功能,emplace 系列方法的执行效率更高。

有关 list 模板类中 emplace 系列函数执行效率更高的原因,前面在讲解 deque 容器模板类中的 emplace 系列函数时已经讲过,读者可阅读《C++ STL deque容器添加和删除元素》一节做详细了解。

list splice()成员方法

和 insert() 成员方法相比,splice() 成员方法的作用对象是其它 list 容器,其功能是将其它 list 容器中的元素添加到当前 list 容器中指定位置处。

splice() 成员方法的语法格式有 3 种,如表 2 所示。

语法格式功能
void splice (iterator position, list& x);position 为迭代器,用于指明插入位置;x 为另一个 list 容器。 此格式的 splice() 方法的功能是,将 x 容器中存储的所有元素全部移动当前 list 容器中 position 指明的位置处。
void splice (iterator position, list& x, iterator i);position 为迭代器,用于指明插入位置;x 为另一个 list 容器;i 也是一个迭代器,用于指向 x 容器中某个元素。 此格式的 splice() 方法的功能是将 x 容器中 i 指向的元素移动到当前容器中 position 指明的位置处。
void splice (iterator position, list& x, iterator first, iterator last);position 为迭代器,用于指明插入位置;x 为另一个 list 容器;first 和 last 都是迭代器,[fist,last) 用于指定 x 容器中的某个区域。 此格式的 splice() 方法的功能是将 x 容器 [first, last) 范围内所有的元素移动到当前容器 position 指明的位置处。

我们知道,list 容器底层使用的是链表存储结构,splice() 成员方法移动元素的方式是,将存储该元素的节点从 list 容器底层的链表中摘除,然后再链接到当前 list 容器底层的链表中。这意味着,当使用 splice() 成员方法将 x 容器中的元素添加到当前容器的同时,该元素会从 x 容器中删除。

下面程序演示了 splice() 成员方法的用法:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    //创建并初始化 2 个 list 容器
    list<int> mylist1{ 1,2,3,4 }, mylist2{10,20,30};
    list<int>::iterator it = ++mylist1.begin(); //指向 mylist1 容器中的元素 2
   
    //调用第一种语法格式
    mylist1.splice(it, mylist2); // mylist1: 1 10 20 30 2 3 4
                                 // mylist2:
                                 // it 迭代器仍然指向元素 2,只不过容器变为了 mylist1
    //调用第二种语法格式,将 it 指向的元素 2 移动到 mylist2.begin() 位置处
    mylist2.splice(mylist2.begin(), mylist1, it);   // mylist1: 1 10 20 30 3 4
                                                    // mylist2: 2
                                                    // it 仍然指向元素 2
   
    //调用第三种语法格式,将 [mylist1.begin(),mylist1.end())范围内的元素移动到 mylist.begin() 位置处                  
    mylist2.splice(mylist2.begin(), mylist1, mylist1.begin(), mylist1.end());//mylist1:
                                                                             //mylist2:1 10 20 30 3 4 2
   
    cout << "mylist1 包含 " << mylist1.size() << "个元素" << endl;
    cout << "mylist2 包含 " << mylist2.size() << "个元素" << endl;
    //输出 mylist2 容器中存储的数据
    cout << "mylist2:";
    for (auto iter = mylist2.begin(); iter != mylist2.end(); ++iter) {
        cout << *iter << " ";
    }
    return 0;
}

程序执行结果为:

mylist1 包含 0个元素
mylist2 包含 7个元素
mylist2:1 10 20 30 3 4 2

empty()和size()都可以判断容器是否为空,谁更好?

到目前为止,我们已经了解了 C++ STL 标准库中 vector、deque 和 list 这 3 个容器的功能和具体用法。学习过程中,读者是否想过一个问题,即这些容器的模板类中都提供了 empty() 成员方法和 size() 成员方法,它们都可以用来判断容器是否为空。

换句话说,假设有一个容器 cont,则此代码:

if(cont.size() == 0)

本质上和如下代码是等价的:

if(cont.empty())

那么,在实际场景中,到底应该使用哪一种呢?

建议使用 empty() 成员方法。理由很简单,无论是哪种容器,只要其模板类中提供了 empty() 成员方法,使用此方法都可以保证在 O(1) 时间复杂度内完成对“容器是否为空”的判断;但对于 list 容器来说,使用 size() 成员方法判断“容器是否为空”,可能要消耗 O(n) 的时间复杂度。

注意,这个结论不仅适用于 vector、deque 和 list 容器,后续还会讲解更多容器的用法,该结论也依然适用。

那么,为什么 list 容器这么特殊呢?这和 list 模板类提供了独有的 splice() 成员方法有关。

深度剖析选用empty()的原因

做一个大胆的设想,假设我们自己就是 list 容器的设计者。从始至终,我们的目标都是让 list 成为标准容器,并被广泛使用,因此打造“高效率的 list”成为我们奋斗的目标。

在实现 list 容器的过程中我们发现,用户经常需要知道当前 list 容器中存有多少个元素,于是我们想让 size() 成员方法的执行效率达到 O(1)。为了实现这个目的,我们必须重新设计 list,使它总能以最快的效率知道自己含有多少个元素。

要想令 size() 的执行效率达到 O(1),最直接的实现方式是:在 list 模板类中设置一个专门用于统计存储元素数量的 size 变量,其位于所有成员方法的外部。与此同时,在每一个可用来为容器添加或删除元素的成员方法中,添加对 size 变量值的更新操作。由此,size() 成员方法只需返回 size 这个变量即可(时间复杂度为 O(1))。

不仅如此,由于 list 容器底层采用的是链式存储结构(也就是链表),该结构最大的特点就是,某一链表中存有元素的节点,无需经过拷贝就可以直接链接到其它链表中,且整个过程只需要消耗 O(1) 的时间复杂度。考虑到很多用户之所以选用 list 容器,就是看中了其底层存储结构的这一特性。因此,作为 list 容器设计者的我们,自然也想将 splice() 方法的时间复杂度设计为 O(1)。

这里就产生了一个矛盾,即如果将 size() 设计为 O(1) 时间复杂度,则由于 splice() 成员方法会修改 list 容器存储元素的个数,因此该方法中就需要添加更新 size 变量的代码(更新方式无疑是通过遍历链表来实现),这也就意味着 splice() 成员方法的执行效率将无法达到 O(1);反之,如果将 splice() 成员方法的执行效率提高到 O(1),则 size() 成员方法将无法实现 O(1) 的时间复杂度。

也就是说,list 容器中的 size() 和 splice() 总有一个要做出让步,即只能实现其中一个方法的执行效率达到 O(1)。

值得一提的是,不同版本的 STL 标准库,其底层解决此冲突的抉择是不同的。以本教程所用的 C++ STL 标准模板库为例,其选择将 splice() 成员方法的执行效率达到 O(1),而 size() 成员方法的执行效率为 O(n)。当然,有些版本的 STL 标准库中,选择将 size() 方法的执行效率设计为 O(1)。

但不论怎样,选用 empty() 判断容器是否为空,效率总是最高的。所以,如果程序中需要判断当前容器是否为空,应优先考虑使用 empty()。

C++ STL list删除元素详解

对 list 容器存储的元素执行删除操作,需要借助该容器模板类提供的成员函数。幸运的是,相比其它 STL 容器模板类,list 模板类提供了更多用来实现此操作的成员函数(如表 1 所示)。

成员函数功能
pop_front()删除位于 list 容器头部的一个元素。
pop_back()删除位于 list 容器尾部的一个元素。
erase()该成员函数既可以删除 list 容器中指定位置处的元素,也可以删除容器中某个区域内的多个元素。
clear()删除 list 容器存储的所有元素。
remove(val)删除容器中所有等于 val 的元素。
unique()删除容器中相邻的重复元素,只保留一份。
remove_if()删除容器中满足条件的元素。

其中,pop_front()、pop_back() 和 clear() 的用法非常简单,这里仅给出一个样例,不再过多解释:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    list<int>values{ 1,2,3,4 };
   
    //删除当前容器中首个元素
    values.pop_front();//{2,3,4}
   
    //删除当前容器最后一个元素
    values.pop_back();//{2,3}
   
    //清空容器,删除容器中所有的元素
    values.clear(); //{}
   
    for (auto begin = values.begin(); begin != values.end(); ++begin)
    {
        cout << *begin << " ";
    }
    return 0;
}

运行程序,可以看到输出结果为“空”。

erase() 成员函数有以下 2 种语法格式:

iterator erase (iterator position);
iterator erase (iterator first, iterator last);

利用第一种语法格式,可实现删除 list 容器中 position 迭代器所指位置处的元素,例如:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    list<int>values{ 1,2,3,4,5 };
    //指向元素 1 的迭代器
    auto del = values.begin();
    //迭代器右移,改为指向元素 2
    ++del;
    values.erase(del); //{1,3,4,5}
    for (auto begin = values.begin(); begin != values.end(); ++begin)
    {
        cout << *begin << " ";
    }
    return 0;
}

运行结果为:

1 3 4 5

利用第二种语法格式,可实现删除 list 容器中 first 迭代器和 last 迭代器限定区域内的所有元素(包括 first 指向的元素,但不包括 last 指向的元素)。例如:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    list<int>values{ 1,2,3,4,5 };
    //指定删除区域的左边界
    auto first = values.begin();
    ++first;//指向元素 2
    //指向删除区域的右边界
    auto last = values.end();
    --last;//指向元素 5
    //删除 2、3 和 4
    values.erase(first, last);
    for (auto begin = values.begin(); begin != values.end(); ++begin)
    {
        cout << *begin << " ";
    }
    return 0;
}

运行结果为:

1 5

erase() 成员函数是按照被删除元素所在的位置来执行删除操作,如果想根据元素的值来执行删除操作,可以使用 remove() 成员函数。例如:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    list<char>values{'a','b','c','d'};
    values.remove('c');
    for (auto begin = values.begin(); begin != values.end(); ++begin)
    {
        cout << *begin << " ";
    }
    return 0;
}

运行结果为:

a b d

unique() 函数也有以下 2 种语法格式:

void unique()
void unique(BinaryPredicate)//传入一个二元谓词函数

以上 2 种格式都能实现去除 list 容器中相邻重复的元素,仅保留一份。但第 2 种格式的优势在于,我们能自定义去重的规则,例如:

#include <iostream>
#include <list>
using namespace std;
//二元谓词函数
bool demo(double first, double second)
{
    return (int(first) == int(second));
}
int main()
{
    list<double> mylist{ 1,1.2,1.2,3,4,4.5,4.6 };
    //删除相邻重复的元素,仅保留一份
    mylist.unique();//{1, 1.2, 3, 4, 4.5, 4.6}
    for (auto it = mylist.begin(); it != mylist.end(); ++it)
        cout << *it << ' ';
    cout << endl;
    //demo 为二元谓词函数,是我们自定义的去重规则
    mylist.unique(demo);
    for (auto it = mylist.begin(); it != mylist.end(); ++it)
        std::cout << *it << ' ';
    return 0;
}

运行结果为:

1 1.2 3 4 4.5 4.6
1 3 4

注意,除了以上一定谓词函数的方式,还可以使用 lamba表达式以及函数对象的方式定义。

可以看到,通过调用无参的 unique(),仅能删除相邻重复(也就是相等)的元素,而通过我们自定义去重的规则,可以更好的满足在不同场景下去重的需求。

除此之外,通过将自定义的谓词函数(不限定参数个数)传给 remove_if() 成员函数,list 容器中能使谓词函数成立的元素都会被删除。举个例子:

#include <iostream>
#include <list>
using namespace std;
int main()
{
    std::list<int> mylist{ 15, 36, 7, 17, 20, 39, 4, 1 };
    //删除 mylist 容器中能够使 lamba 表达式成立的所有元素。
    mylist.remove_if([](int value) {return (value < 10); }); //{15 36 17 20 39}
    for (auto it = mylist.begin(); it != mylist.end(); ++it)
        std::cout << ' ' << *it;
    return 0;
}

运行结果为:

15 36 17 20 39

C++ STL forward_list容器完全攻略

forward_list 是 C++ 11 新添加的一类容器,其底层实现和 list 容器一样,采用的也是链表结构,只不过 forward_list 使用的是单链表,而 list 使用的是双向链表(如图 1 所示)。

单链表和双向链表
图 1 单链表( a) )和双向链表( b) )

图 1 中,H 表示链表的表头。

通过图 1 不难看出,使用链表存储数据最大的特点在于,其并不会将数据进行集中存储(向数组那样),换句话说,链表中数据的存储位置是分散的、随机的,整个链表中数据的线性关系通过指针来维持。

因此,forward_list 容器具有和 list 容器相同的特性,即擅长在序列的任何位置进行插入元素或删除元素的操作,但对于访问存储的元素,没有其它容器(如 array、vector)的效率高。

另外,由于单链表没有双向链表那样灵活,因此相比 list 容器,forward_list 容器的功能受到了很多限制。比如,由于单链表只能从前向后遍历,而不支持反向遍历,因此 forward_list 容器只提供前向迭代器,而不是双向迭代器。这意味着,forward_list 容器不具有 rbegin()、rend() 之类的成员函数。

有关迭代器的具体分类以及各种迭代器的具体功能,可以阅读《C++ STL迭代器》一节。

那么,既然 forward_list 容器具有和 list 容器相同的特性,list 容器还可以提供更多的功能函数,forward_list 容器有什么存在的必要呢?

当然有,forward_list 容器底层使用单链表,也不是一无是处。比如,存储相同个数的同类型元素,单链表耗用的内存空间更少,空间利用率更高,并且对于实现某些操作单链表的执行效率也更高。

效率高是选用 forward_list 而弃用 list 容器最主要的原因,换句话说,只要是 list 容器和 forward_list 容器都能实现的操作,应优先选择 forward_list 容器。

forward_list容器的创建

由于 forward_list 容器以模板类 forward_list(T 为存储元素的类型)的形式被包含在<forward_list>头文件中,并定义在 std 命名空间中。因此,在使用该容器之前,代码中需包含下面两行代码:

#include <forward_list>
using namespace std;

std 命名空间也可以在使用 forward_list 容器时额外注明,两种方式都可以。

创建 forward_list 容器的方式,大致分为以下 5 种。

  1. 创建一个没有任何元素的空 forward_list 容器:
std::forward_list<int> values;

由于 forward_list 容器在创建后也可以添加元素,因此这种创建方式很常见。

  1. 创建一个包含 n 个元素的 forward_list 容器:
std::forward_list<int> values(10);

通过此方式创建 values 容器,其中包含 10 个元素,每个元素的值都为相应类型的默认值(int类型的默认值为 0)。

  1. 创建一个包含 n 个元素的 forward_list 容器,并为每个元素指定初始值。例如:
std::forward_list<int> values(10, 5);

如此就创建了一个包含 10 个元素并且值都为 5 个 values 容器。

  1. 在已有 forward_list 容器的情况下,通过拷贝该容器可以创建新的 forward_list 容器。例如:
std::forward_list<int> value1(10);
std::forward_list<int> value2(value1);

注意,采用此方式,必须保证新旧容器存储的元素类型一致。

  1. 通过拷贝其他类型容器(或者普通数组)中指定区域内的元素,可以创建新的 forward_list 容器。例如:
//拷贝普通数组,创建forward_list容器
int a[] = { 1,2,3,4,5 };
std::forward_list<int> values(a, a+5);
//拷贝其它类型的容器,创建forward_list容器
std::array<int, 5>arr{ 11,12,13,14,15 };
std::forward_list<int>values(arr.begin()+2, arr.end());//拷贝arr容器中的{13,14,15}

forward_list容器支持的成员函数

表 2 中罗列出了 forward_list 模板类提供的所有成员函数以及各自的功能。

成员函数功能
before_begin()返回一个前向迭代器,其指向容器中第一个元素之前的位置。
begin()返回一个前向迭代器,其指向容器中第一个元素的位置。
end()返回一个前向迭代器,其指向容器中最后一个元素之后的位置。
cbefore_begin()和 before_begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cbegin()和 begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
empty()判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。
max_size()返回容器所能包含元素个数的最大值。这通常是一个很大的值,一般是 232-1,所以我们很少会用到这个函数。
front()返回第一个元素的引用。
assign()用新元素替换容器中原有内容。
push_front()在容器头部插入一个元素。
emplace_front()在容器头部生成一个元素。该函数和 push_front() 的功能相同,但效率更高。
pop_front()删除容器头部的一个元素。
emplace_after()在指定位置之后插入一个新元素,并返回一个指向新元素的迭代器。和 insert_after() 的功能相同,但效率更高。
insert_after()在指定位置之后插入一个新元素,并返回一个指向新元素的迭代器。
erase_after()删除容器中某个指定位置或区域内的所有元素。
swap()交换两个容器中的元素,必须保证这两个容器中存储的元素类型是相同的。
resize()调整容器的大小。
clear()删除容器存储的所有元素。
splice_after()将某个 forward_list 容器中指定位置或区域内的元素插入到另一个容器的指定位置之后。
remove(val)删除容器中所有等于 val 的元素。
remove_if()删除容器中满足条件的元素。
unique()删除容器中相邻的重复元素,只保留一个。
merge()合并两个事先已排好序的 forward_list 容器,并且合并之后的 forward_list 容器依然是有序的。
sort()通过更改容器中元素的位置,将它们进行排序。
reverse()反转容器中元素的顺序。

除此之外,C++ 11 标准库还新增加了 begin() 和 end() 这 2 个函数,和 forward_list 容器包含的 begin() 和 end() 成员函数不同,标准库提供的这 2 个函数的操作对象,既可以是容器,还可以是普通数组。当操作对象是容器时,它和容器包含的 begin() 和 end() 成员函数的功能完全相同;如果操作对象是普通数组,则 begin() 函数返回的是指向数组第一个元素的指针,同样 end() 返回指向数组中最后一个元素之后一个位置的指针(注意不是最后一个元素)。

forward_list 容器还有一个std::swap(x , y)非成员函数(其中 x 和 y 是存储相同类型元素的 forward_list 容器),它和 swap() 成员函数的功能完全相同,仅使用语法上有差异。

下面的样例演示了表 2 中部分成员函数的用法:

#include <iostream>
#include <forward_list>
using namespace std;
int main()
{
    std::forward_list<int> values{1,2,3};
    values.emplace_front(4);//{4,1,2,3}
    values.emplace_after(values.before_begin(), 5); //{5,4,1,2,3}
    values.reverse();//{3,2,1,4,5}
    for (auto it = values.begin(); it != values.end(); ++it) {
        cout << *it << " ";
    }
    return 0;
}

运行结果为:

3 2 1 4 5

表 2 中这些成员函数的具体用法,后续学习用到时会具体讲解,感兴趣的读者,也可以通过查阅 STL手册做详细了解。

和使用forward_list容器相关的函数

通过表 2 我们知道,forward_list 容器中是不提供 size() 函数的,但如果想要获取 forward_list 容器中存储元素的个数,可以使用头文件 中的 distance() 函数。举个例子:

#include <iostream>
#include <forward_list>
#include <iterator>
using namespace std;
int main()
{
    std::forward_list<int> my_words{1,2,3,4};
    int count = std::distance(std::begin(my_words), std::end(my_words));
    cout << count;
    return 0;
}

运行结果为:

4

并且,forward_list 容器迭代器的移动除了使用 ++ 运算符单步移动,还能使用 advance() 函数,比如:

#include <iostream>
#include <forward_list>
using namespace std;
int main()
{
    std::forward_list<int> values{1,2,3,4};
    auto it = values.begin();
    advance(it, 2);
    while (it!=values.end())
    {
        cout << *it << " ";
        ++it;
    }
    return 0;
}

运行结果为:

3 4

C++(STL)容器适配器

容器适配器是一个封装了序列容器的类模板,它在一般序列容器的基础上提供了一些不同的功能。之所以称作适配器类,是因为它可以通过适配容器现有的接口来提供不同的功能。

本章将介绍 3 种容器适配器,分别是 stack、queue、priority_queue:

  1. stack:是一个封装了 deque 容器的适配器类模板,默认实现的是一个后入先出(Last-In-First-Out,LIFO)的压入栈。stack 模板定义在头文件 stack 中。
  2. queue:是一个封装了 deque 容器的适配器类模板,默认实现的是一个先入先出(First-In-First-Out,LIFO)的队列。可以为它指定一个符合确定条件的基础容器。queue 模板定义在头文件 queue 中。
  3. priority_queue:是一个封装了 vector 容器的适配器类模板,默认实现的是一个会对元素排序,从而保证最大元素总在队列最前面的队列。priority_queue 模板定义在头文件 queue 中。

适配器类在基础序列容器的基础上实现了一些自己的操作,显然也可以添加一些自己的操作。它们提供的优势是简化了公共接口,而且提高了代码的可读性。本章我们会详细地探讨这些适配器的应用。

什么是适配器,C++ STL容器适配器详解

在详解什么是容器适配器之前,初学者首先要理解适配器的含义。

其实,容器适配器中的“适配器”,和生活中常见的电源适配器中“适配器”的含义非常接近。我们知道,无论是电脑、手机还是其它电器,充电时都无法直接使用 220V 的交流电,为了方便用户使用,各个电器厂商都会提供一个适用于自己产品的电源线,它可以将 220V 的交流电转换成适合电器使用的低压直流电。

从用户的角度看,电源线扮演的角色就是将原本不适用的交流电变得适用,因此其又被称为电源适配器。

再举一个例子,假设一个代码模块 A,它的构成如下所示:

class A{
public:
    void f1(){}
    void f2(){}
    void f3(){}
    void f4(){}
};

现在我们需要设计一个模板 B,但发现,其实只需要组合一下模块 A 中的 f1()、f2()、f3(),就可以实现模板 B 需要的功能。其中 f1() 单独使用即可,而 f2() 和 f3() 需要组合起来使用,如下所示:

class B{
private:
    A * a;
public:
    void g1(){
        a->f1();
    }
    void g2(){
        a->f2();
        a->f3();
    }
};

可以看到,就如同是电源适配器将不适用的交流电变得适用一样,模板 B 将不适合直接拿来用的模板 A 变得适用了,因此我们可以将模板 B 称为 B 适配器。

容器适配器也是同样的道理,简单的理解容器适配器,其就是将不适用的序列式容器(包括 vector、deque 和 list)变得适用。容器适配器的底层实现和模板 A、B 的关系是完全相同的,即通过封装某个序列式容器,并重新组合该容器中包含的成员函数,使其满足某些特定场景的需要。

容器适配器本质上还是容器,只不过此容器模板类的实现,利用了大量其它基础容器模板类中已经写好的成员函数。当然,如果必要的话,容器适配器中也可以自创新的成员函数。

需要注意的是,STL 中的容器适配器,其内部使用的基础容器并不是固定的,用户可以在满足特定条件的多个基础容器中自由选择。

STL容器适配器的种类

STL 提供了 3 种容器适配器,分别为 stack 栈适配器、queue 队列适配器以及 priority_queue 优先权队列适配器。其中,各适配器所使用的默认基础容器以及可供用户选择的基础容器,如表 1 所示。

容器适配器基础容器筛选条件默认使用的基础容器
stack基础容器需包含以下成员函数:empty()size()back()push_back()pop_back()满足条件的基础容器有 vector、deque、list。deque
queue基础容器需包含以下成员函数:empty()size()front()back()push_back()pop_front()满足条件的基础容器有 deque、list。deque
priority_queue基础容器需包含以下成员函数:empty()size()front()push_back()pop_back()满足条件的基础容器有vector、deque。vector

不同场景下,由于不同的序列式容器其底层采用的数据结构不同,因此容器适配器的执行效率也不尽相同。但通常情况下,使用默认的基础容器即可。当然,我们也可以手动修改,具体的修改容器适配器基础容器的方法,后续讲解具体的容器适配器会详细介绍。

C++ stack(STL stack)容器适配器用法详解

stack 栈适配器是一种单端开口的容器(如图 1 所示),实际上该容器模拟的就是栈存储结构,即无论是向里存数据还是从中取数据,都只能从这一个开口实现操作。

stack适配器示意图
图 1 stack 适配器示意图

如图 1 所示,stack 适配器的开头端通常称为栈顶。由于数据的存和取只能从栈顶处进行操作,因此对于存取数据,stack 适配器有这样的特性,即每次只能访问适配器中位于最顶端的元素,也只有移除 stack 顶部的元素之后,才能访问位于栈中的元素。

栈中存储的元素满足“后进先出(简称LIFO)”的准则,stack 适配器也同样遵循这一准则。

stack容器适配器的创建

由于 stack 适配器以模板类 stack<T,Container=deque>(其中 T 为存储元素的类型,Container 表示底层容器的类型)的形式位于头文件中,并定义在 std 命名空间里。因此,在创建该容器之前,程序中应包含以下 2 行代码:

#include <stack>using namespace std;

std 命名空间也可以在使用 stack 适配器时额外注明。

创建 stack 适配器,大致分为如下几种方式。

  1. 创建一个不包含任何元素的 stack 适配器,并采用默认的 deque 基础容器:
std::stack<int> values;

上面这行代码,就成功创建了一个可存储 int 类型元素,底层采用 deque 基础容器的 stack 适配器。

  1. 上面提到,stack<T,Container=deque> 模板类提供了 2 个参数,通过指定第二个模板类型参数,我们可以使用出 deque 容器外的其它序列式容器,只要该容器支持 empty()、size()、back()、push_back()、pop_back() 这 5 个成员函数即可。

在介绍适配器时提到,序列式容器中同时包含这 5 个成员函数的,有 vector、deque 和 list 这 3 个容器。因此,stack 适配器的基础容器可以是它们 3 个中任何一个。例如,下面展示了如何定义一个使用 list 基础容器的 stack 适配器:

#include <stack>
using namespace std;

  1. 可以用一个基础容器来初始化 stack 适配器,只要该容器的类型和 stack 底层使用的基础容器类型相同即可。例如:
std::list<int> values {1, 2, 3};
std::stack<int,std::list<int>> my_stack (values);

注意,初始化后的 my_stack 适配器中,栈顶元素为 3,而不是 1。另外在第 2 行代码中,stack 第 2 个模板参数必须显式指定为 list(必须为 int 类型,和存储类型保持一致),否则 stack 底层将默认使用 deque 容器,也就无法用 lsit 容器的内容来初始化 stack 适配器。

  1. 还可以用一个 stack 适配器来初始化另一个 stack 适配器,只要它们存储的元素类型以及底层采用的基础容器类型相同即可。例如:
std::list<int> values{ 1, 2, 3 };
std::stack<int, std::list<int>> my_stack1(values);
std::stack<int, std::list<int>> my_stack=my_stack1;
//std::stack<int, std::list<int>> my_stack(my_stack1);

可以看到,和使用基础容器不同,使用 stack 适配器给另一个 stack 进行初始化时,有 2 种方式,使用哪一种都可以。

注意,第 3、4 种初始化方法中,my_stack 适配器的数据是经过拷贝得来的,也就是说,操作 my_stack 适配器,并不会对 values 容器以及 my_stack1 适配器有任何影响;反过来也是如此。

stack容器适配器支持的成员函数

和其他序列容器相比,stack 是一类存储机制简单、提供成员函数较少的容器。表 1 列出了 stack 容器支持的全部成员函数。

成员函数功能
empty()当 stack 栈中没有元素时,该成员函数返回 true;反之,返回 false。
size()返回 stack 栈中存储元素的个数。
top()返回一个栈顶元素的引用,类型为 T&。如果栈为空,程序会报错。
push(const T& val)先复制 val,再将 val 副本压入栈顶。这是通过调用底层容器的 push_back() 函数完成的。
push(T&& obj)以移动元素的方式将其压入栈顶。这是通过调用底层容器的有右值引用参数的 push_back() 函数完成的。
pop()弹出栈顶元素。
emplace(arg…)arg… 可以是一个参数,也可以是多个参数,但它们都只用于构造一个对象,并在栈顶直接生成该对象,作为新的栈顶元素。
swap(stack & other_stack)将两个 stack 适配器中的元素进行互换,需要注意的是,进行互换的 2 个 stack 适配器中存储的元素类型以及底层采用的基础容器类型,都必须相同。

下面这个例子中演示了表 1 中部分成员函数的用法:

#include <iostream>
#include <stack>
#include <list>
using namespace std;
int main()
{
    //构建 stack 容器适配器
    list<int> values{ 1, 2, 3 };
    stack<int, list<int>> my_stack(values);
    //查看 my_stack 存储元素的个数
    cout << "size of my_stack: " << my_stack.size() << endl;
    //将 my_stack 中存储的元素依次弹栈,直到其为空
    while (!my_stack.empty())
    {  
        cout << my_stack.top() << endl;
        //将栈顶元素弹栈
        my_stack.pop();
    }
    return 0;
}

运行结果为:

size of my_stack: 3
3
2
1

表 1 中其它成员函数的用法也非常简单,这里不再给出具体示例,后续章节用法会做具体介绍。

stack容器适配器实现计算器(含实现代码)

前面章节中,已经对 stack 容器适配器及其用法做了详细的讲解。本节将利用 stack 适配器实现一个简单的计算机程序,此计算机支持基本的加(+)、 减(-)、乘(*)、除(/)、幂(^)运算。

这里,先给大家展示出完整的实现代码,读者可先自行思考该程序的实现流程。当然,后续也会详细的讲解:

#include <iostream>
#include <cmath>       // pow()
#include <stack>       // stack<T>
#include <algorithm>   // remove()
#include <stdexcept>   // runtime_error
#include <string>      // string
using std::string;
// 返回运算符的优先级,值越大,优先级越高
inline size_t precedence(const char op)
{
    if (op == '+' || op == '-')
        return 1;
    if (op == '*' || op == '/')
        return 2;
    if (op == '^')
        return 3;
    throw std::runtime_error{ string {"表达中包含无效的运算符"} +op };
}
// 计算
double execute(std::stack<char>& ops, std::stack<double>& operands)
{
    double result{};
    double rhs{ operands.top() }; // 得到右操作数
    operands.pop();                                   
    double lhs{ operands.top() }; // 得到做操作数
    operands.pop();                                    
    switch (ops.top()) // 根据两个操作数之间的运算符,执行相应计算
    {
    case '+':
        result = lhs + rhs;
        break;
    case '-':
        result = lhs - rhs;
        break;
    case '*':
        result = lhs * rhs;
        break;
    case '/':
        result = lhs / rhs;
        break;
    case '^':
        result = std::pow(lhs, rhs);
        break;
    default:
        throw std::runtime_error{ string{"invalid operator: "} +ops.top() };
    }
    ops.pop(); //计算完成后,该运算符要弹栈
    operands.push(result);//将新计算出来的结果入栈
    return result;
}
int main()
{
    std::stack<double> operands; //存储表达式中的运算符
    std::stack<char> operators; //存储表达式中的数值
    string exp;  //接受用户输入的表达式文本
    try
    {
        while (true)
        {
            std::cout << "输入表达式(按Enter结束):" << std::endl;
            std::getline(std::cin, exp, '\n');
            if (exp.empty()) break;
            //移除用户输入表达式中包含的无用的空格
            exp.erase(std::remove(std::begin(exp), std::end(exp), ' '), std::end(exp));
            size_t index{};
            //每个表达式必须以数字开头,index表示该数字的位数
            operands.push(std::stod(exp, &index)); // 将表达式中第一个数字进栈
            std::cout << index << std::endl;
            while (true)
            {
                operators.push(exp[index++]); // 将运算符进栈
                size_t i{};
                operands.push(std::stod(exp.substr(index), &i));  //将运算符后的数字也进栈,并将数字的位数赋值给 i。
                index += i;  //更新 index
                if (index == exp.length())                  
                {
                    while (!operators.empty())  //如果 operators不为空,表示还没有计算完
                        execute(operators, operands);
                    break;
                }
                //如果表达式还未遍历完,但子表达式中的运算符优先级比其后面的运算符优先级大,就先计算当前的子表达式的值
                while (!operators.empty() && precedence(exp[index]) <= precedence(operators.top()))
                    execute(operators, operands);
            }
            std::cout << "result = " << operands.top() << std::endl;
        }
    }
    catch (const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
    }
    std::cout << "计算结束" << std::endl;
    return 0;
}

下面是一些示例输出:

输入表达式(按Enter结束):
52-3
result = 7
输入表达式(按Enter结束):
4+4
2
result = 12
输入表达式(按Enter结束):↙ <–键入Enter

计算结束

计算器程序的实现流程

了解一个程序的功能,通常是从 main() 函数开始。因此,下面从 main() 函数开始,给大家讲解程序的整个实现过程。

首先,我们创建 2 个 stack 适配器,operands 负责将表达式中的运算符逐个压栈,operators 负责将表达式的数值逐个压栈,同时还需要一个 string 类型的 exp,用于接收用户输入的表达式。

正如上面代码中所有看到的,所有的实现代码都包含在一个由 try 代码块包裹着的 while 循环中,这样既可以实现用户可以多次输入表达式的功能(当输入的表达式为一个空字符串时,循环结束),还可以捕获程序运行过程中抛出的任何异常(在 catch 代码块中,调用异常对象的成员函数 what() 会将错误信息输出到标准错误流中)。

当用户输入完要计算的表达式之后,由于整个表达式是以字符串的形式接收的,考虑到字符串中可能掺杂空格,影响后续对字符串的处理,因此又必须借助 remove() 函数来移除输入表达式中的多余空格(第 70 行代码处)。

得到统一格式的表达式之后,接下来才是实现计算功能的核心,其实现思路为:

  1. 因为所有的运算符都需要两个操作数,所以有效的输入表达式格式为“操作数 运算符 操作数 运算符 操作数…”,即序列的第一个和最后一个元素肯定都是操作数,每对操作数之间有一个运算符。由于有效表达式总是以操作数开头,所以第一个操作数在分析表达式的嵌套循环之前被提取出来。
  2. 在循环中,输入字符串的运算符会被压入 operators 栈。在确认没有到达字符串末尾后,再从 exp 提取第二个操作数。这时 stod() 的第一个参数是从 index 开始的 exp 字符串,它是被压入 operators 栈的运算符后的所有字符。此时字符串中第一个运算符的索引为 i,因为 i 是相对于 index 的,所以我们会将 index 加上 i 的值,使它指向操作数后的一个运算符(如果是 exp 中的最后一个操作数,它会指向字符串末尾的下一个位置)。
  3. 当 index 的值超过 exp 的最后一个字符时,会执行 operators 容器中剩下的运算符。如果没有到达字符串末尾,operators 容器也不为空,我们会比较 operators 栈顶运算符和 exp 中下一个运算符的优先级。如果栈顶运算符的优先级高于下一个运算符,就先执行栈顶的运算符。否则,就不执行栈顶运算符,在下一次循环开始时,将下一个运算符压入 operators 栈。通过这种方式,就可以正确计算出带优先级的表达式的值。

以“5-2*3+1”为例,以上程序的计算过程如下:

  1. 取 5 和 2 进 operands 栈容器,同时它们之间的 - 运算符进 operators 栈容器,判断后续是否还有表达式,显然还有“*3+1”,这种情况下,取 operators 栈顶运算符 - 和后续的 * 运算符做优先级比较,由于 * 的优先级更高,此时继续将后续的 * 和 3 分别进栈;

此时,operands 中从栈顶依次存储的是 3、2、5,operators 容器中从栈顶依次存储的是 *、-。

  1. 继续判断后续是否还有表达式,由于还有“+1”,则取 operators 栈顶运算符 * 和 + 运算符做优先级比较,显然前者的优先级更高,此时将 operands 栈顶的 2 个元素(2 和 3)取出并弹栈,同时将 operators 栈顶元素()取出并弹栈,计算它们组成的表达式 23,并将计算结果再入 operands 栈。

计算到这里,operands 中从栈顶依次存储的是 6、5,operators 中从栈顶依次存储的是 -。

  1. 由于 operator 容器不空,因此继续取新的栈顶运算符“-”和“+”做优先级比较,由于它们的优先级是相同的,因为继续将 operands 栈顶的 2 个元素(5 和 6)取出并弹栈,同时将 operators 栈顶元素(-) 取出并弹栈,计算它们组成的表达式“5-6”,并将计算结果 -1 再入 operands 栈。

此时,operands 中从栈顶依次存储的是 -1,operator 为空。

4)由于此时 operator 栈为空,因此将后续“+1”表达式中的 1 和 + 分别进栈。由于后续再无其他表达式,此时就可以直接取 operands 位于栈顶的 2 个元素(-1 和 1),和 operator 的栈顶运算符(+),执行 -1+1 运算,并将计算结果再入 operands 栈。

通过以上几步,最终“5-2*3+1”的计算结果 0 位于 operands 的栈顶。

C++ STL queue容器适配器详解

和 stack 栈容器适配器不同,queue 容器适配器有 2 个开口,其中一个开口专门用来输入数据,另一个专门用来输出数据,如图 1 所示。

queue容器适配器
图 1 queue容器适配器

这种存储结构最大的特点是,最先进入 queue 的元素,也可以最先从 queue 中出来,即用此容器适配器存储数据具有“先进先出(简称 “FIFO” )”的特点,因此 queue 又称为队列适配器。

其实,STL queue 容器适配器模拟的就是队列这种存储结构,因此对于任何需要用队列进行处理的序列来说,使用 queue 容器适配器都是好的选择。

queue容器适配器的创建

queue 容器适配器以模板类 queue<T,Container=deque>(其中 T 为存储元素的类型,Container 表示底层容器的类型)的形式位于头文件中,并定义在 std 命名空间里。因此,在创建该容器之前,程序中应包含以下 2 行代码:

#include <queue>
using namespace std;

创建 queue 容器适配器的方式大致可分为以下几种。

  1. 创建一个空的 queue 容器适配器,其底层使用的基础容器选择默认的 deque 容器:
std::queue<int> values;

通过此行代码,就可以成功创建一个可存储 int 类型元素,底层采用 deque 容器的 queue 容器适配器。

  1. 当然,也可以手动指定 queue 容器适配器底层采用的基础容器类型。通过学习 《STL容器适配器详解》一节我们知道,queue 容器适配器底层容器可以选择 deque 和 list。

作为 queue 容器适配器的基础容器,其必须提供 front()、back()、push_back()、pop_front()、empty() 和 size() 这几个成员函数,符合条件的序列式容器仅有 deque 和 list。

例如,下面创建了一个使用 list 容器作为基础容器的空 queue 容器适配器:

std::queue<int, std::list<int>> values;

注意,在手动指定基础容器的类型时,其存储的数据类型必须和 queue 容器适配器存储的元素类型保持一致。

  1. 可以用基础容器来初始化 queue 容器适配器,只要该容器类型和 queue 底层使用的基础容器类型相同即可。例如:
std::deque<int> values{1,2,3};
std::queue<int> my_queue(values);

由于 my_queue 底层采用的是 deque 容器,和 values 类型一致,且存储的也都是 int 类型元素,因此可以用 values 对 my_queue 进行初始化。

  1. 还可以直接通过 queue 容器适配器来初始化另一个 queue 容器适配器,只要它们存储的元素类型以及底层采用的基础容器类型相同即可。例如:
std::deque<int> values{1,2,3};
std::queue<int> my_queue1(values);
std::queue<int> my_queue(my_queue1);
//或者使用
//std::queue<int> my_queue = my_queue1;

注意,和使用基础容器不同,使用 queue 适配器给另一个 queue 进行初始化时,有 2 种方式,使用哪一种都可以。

值得一提的是,第 3、4 种初始化方法中 my_queue 容器适配器的数据是经过拷贝得来的,也就是说,操作 my_queue 容器适配器中的数据,并不会对 values 容器以及 my_queue1 容器适配器有任何影响;反过来也是如此。

queue容器适配器支持的成员函数

queue 容器适配器和 stack 有一些成员函数相似,但在一些情况下,工作方式有些不同。表 2 罗列了 queue 容器支持的全部成员函数。

成员函数功能
empty()如果 queue 中没有元素的话,返回 true。
size()返回 queue 中元素的个数。
front()返回 queue 中第一个元素的引用。如果 queue 是常量,就返回一个常引用;如果 queue 为空,返回值是未定义的。
back()返回 queue 中最后一个元素的引用。如果 queue 是常量,就返回一个常引用;如果 queue 为空,返回值是未定义的。
push(const T& obj)在 queue 的尾部添加一个元素的副本。这是通过调用底层容器的成员函数 push_back() 来完成的。
emplace()在 queue 的尾部直接添加一个元素。
push(T&& obj)以移动的方式在 queue 的尾部添加元素。这是通过调用底层容器的具有右值引用参数的成员函数 push_back() 来完成的。
pop()删除 queue 中的第一个元素。
swap(queue &other_queue)将两个 queue 容器适配器中的元素进行互换,需要注意的是,进行互换的 2 个 queue 容器适配器中存储的元素类型以及底层采用的基础容器类型,都必须相同。

和 stack 一样,queue 也没有迭代器,因此访问元素的唯一方式是遍历容器,通过不断移除访问过的元素,去访问下一个元素。

下面这个例子中演示了表 2 中部分成员函数的用法:

#include <iostream>
#include <queue>
#include <list>
using namespace std;
int main()
{
    //构建 queue 容器适配器
    std::deque<int> values{ 1,2,3 };
    std::queue<int> my_queue(values);//{1,2,3}
    //查看 my_queue 存储元素的个数
    cout << "size of my_queue: " << my_queue.size() << endl;
    //访问 my_queue 中的元素
    while (!my_queue.empty())
    {
        cout << my_queue.front() << endl;
        //访问过的元素出队列
        my_queue.pop();
    }
    return 0;
}

运行结果为:

size of my_queue: 3
1
2
3

表 2 中其它成员函数的用法也非常简单,这里不再给出具体示例,后续章节用法会做具体介绍。

C++ queue容器适配器模拟超市结账环节

前面章节介绍了 queue 容器适配器的具有用法,本节将利用 queue 模拟超市中结账环节运转的程序。

在超市营业过程中,结账队列的长度是超市运转的关键因素。它会影响超市可容纳的顾客数,因为太长的队伍会使顾客感到气馁,从而放弃排队,这和医院可用病床数会严重影响应急处理设施的运转,是同样的道理。

首先,我们要在头文件 Customer.h 中定义一个类来模拟顾客:

#ifndef CUSTOMER_H
#define CUSTOMER_H
class Customer
{
protected:
    size_t service_t {}; //顾客结账需要的时间
public:
    explicit Customer(size_t st = 10) :service_t {st}{}
    //模拟随着时间的变化,顾客结账所需时间也会减短
    Customer& time_decrement()
    {
        if (service_t > 0)
            --service_t;
        return *this;
    }
    bool done() const { return service_t == 0; }
};
#endif

这里只有一个成员变量 service_t,用来记录顾客结账需要的时间。每个顾客的结账时间都不同。每过一分钟,会调用一次 time_decrement() 函数,这个函数会减少 service_t 的值,它可以反映顾客结账所花费的时间。当 service_t 的值为 0 时,成员函数 done() 返回 true。

超市的每个结账柜台都有一队排队等待的顾客。Checkout.h 中定义的 Checkout 类如下:

#ifndef CHECKOUT_H
#define CHECKOUT_H
#include <queue> // For queue container
#include "Customer.h"
class Checkout
{
private:
    std::queue<Customer> customers; //该队列等到结账的顾客数量
public:
    void add(const Customer& customer) { customers.push(customer); }
    size_t qlength() const { return customers.size(); }
   
    void time_increment()
    {
        if (!customers.empty())
        { 
            //有顾客正在等待结账,如果顾客结账了,就出队列
            if (customers.front().time_decrement().done())
                customers.pop(); 
        }
    }
    bool operator<(const Checkout& other) const { return qlength() < other.qlength(); }
    bool operator>(const Checkout& other) const { return qlength() > other.qlength(); }
};
#endif

可以看到,queue 容器是 Checkout 唯一的成员变量,用来保存等待结账的 Customer 对象。成员函数 add() 可以向队列中添加新顾客。只能处理队列中的第一个元素。 每过一分钟,调用一次 Checkout 对象的成员函数 time_increment(},它会调用第一个 Customer 对象的成员函数 time_decrement() 来减少剩余的等待时间,然后再调用成员函数 done()。如果 done() 返回 true,表明顾客结账完成,因此把他从队列中移除。Checkout 对象的比较运算符可以比较队列的长度。

为了模拟超市结账,我们需要有随机数生成的功能。因此打算使用 头文件中的一个非常简单的工具,但不打算深入解释它。我们会在教程后面的章节深入探讨 random 头文件中的内容。程序使用了一个 uniform_int_distribution() 类型的实例。顾名思义,它定义的整数值在最大值和最小值之间均匀分布。在均匀分布中,所有这个范围内的值都可能相等。可以在 10 和 100 之间定义如下分布:

std::uniform_int_distribution<> d {10, 100};

这里只定义了分布对象 d,它指定了整数值分布的范围。为了获取这个范围内的随机数,我们需要使用一个随机数生成器,然后把它作为参数传给 d 的调用运算符,从而返回一个随机整数。 random 头文件中定义了几种随机数生成器。这里我们使用最简单的一个,可以按如下方式定义:

std::random_device random_number_engine;

为了在 d 分布范围内生成随机数,我们可以这样写:

auto value = d(random_number_engine);

value 的值在 d 分布范围内。

完整模拟器的源文件如下:

#include <iostream> // For standard streams
#include <iomanip>  // For stream manipulators
#include <vector>   // For vector container
#include <string>   // For string class
#include <numeric>  // For accumulate()
#include <algorithm> // For min_element & max_element
#include <random> // For random number generation
#include "Customer.h"
#include "Checkout.h"
using std::string;
using distribution = std::uniform_int_distribution<>;
// 以横向柱形图的方式输出每个服务时间出现的次数
void histogram(const std::vector<int>& v, int min)
{
    string bar (60, '*');                       
    for (size_t i {}; i < v.size(); ++i)
    {
        std::cout << std::setw(3) << i+min << " "    //结账等待时间为 index + min
        << std::setw(4) << v[i] << " "             //输出出现的次数
        << bar.substr(0, v[i])                     
        << (v[i] > static_cast<int>(bar.size()) ? "...": "")
        << std::endl;
    }
}
int main()
{
    std::random_device random_n;
    //设置最大和最小的结账时间,以分钟为单位
    int service_t_min {2}, service_t_max {15};
    distribution service_t_d {service_t_min, service_t_max};
    //设置在超市开业时顾客的人数
    int min_customers {15}, max_customers {20};
    distribution n_1st_customers_d {min_customers, max_customers};
    // 设置顾客到达的最大和最小的时间间隔
    int min_arr_interval {1}, max_arr_interval {5};
    distribution arrival_interval_d {min_arr_interval, max_arr_interval};
    size_t n_checkouts {};
    std::cout << "输入超市中结账柜台的数量:";
    std::cin >> n_checkouts;
    if (!n_checkouts)
    {
        std::cout << "结账柜台的数量必须大于 0,这里将默认设置为 1" << std::endl;
        n_checkouts = 1;
    }
    std::vector<Checkout> checkouts {n_checkouts};
    std::vector<int> service_times(service_t_max-service_t_min+1);
    //等待超市营业的顾客人数
    int count {n_1st_customers_d(random_n)};
    std::cout << "等待超市营业的顾客人数:" << count << std::endl;
    int added {};
    int service_t {};
    while (added++ < count)
    {
        service_t = service_t_d(random_n);
        std::min_element(std::begin(checkouts), std::end(checkouts))->add(Customer(service_t));
        ++service_times[service_t - service_t_min];
    }
    size_t time {};
    const size_t total_time {600};                 // 设置超市持续营业的时间
    size_t longest_q {};                           // 等待结账最长队列的长度
    // 新顾客到达的时间
    int new_cust_interval {arrival_interval_d(random_n)};
    //模拟超市运转的过程
    while (time < total_time)                      
    {
        ++time; //时间增长
        // 新顾客到达
        if (--new_cust_interval == 0)
        {
            service_t = service_t_d(random_n);         // 设置顾客结账所需要的时间
            std::min_element(std::begin(checkouts), std::end(checkouts))->add(Customer(service_t));
            ++service_times[service_t - service_t_min];  // 记录结账需要等待的时间
            //记录最长队列的长度
            for (auto & checkout : checkouts)
                longest_q = std::max(longest_q, checkout.qlength());
            new_cust_interval = arrival_interval_d(random_n);
        }
        // 更新每个队列中第一个顾客的结账时间
        for (auto & checkout : checkouts)
            checkout.time_increment();
    }
    std::cout << "最大的队列长度为:" << longest_q << std::endl;
    std::cout << "\n各个结账时间出现的次数::\n";
    histogram(service_times, service_t_min);
    std::cout << "\n总的顾客数:"
            << std::accumulate(std::begin(service_times), std::end(service_times), 0)
            << std::endl;
    return 0;
}

直接使用 using 指令可以减少代码输入,简化代码。顾客结账信息记录在 vector 中。结账时间减去 service_times 的最小值可以用来索引需要自增的 vector 元素,这导致 vector 的第一个元素会记录下最少结账时间出现的次数。histogram() 函数会以水平条形图的形式生成每个服务时间出现次数的柱状图。

程序中 checkouts 的值为 600,意味着将模拟开业时间设置为 600 分钟,也可以用参数输入这个时间。main() 函数生成了顾客结账时间,超市开门时等在门外的顾客数,以及顾客到达时间间隔的分布对象。我们可以轻松地将这个程序扩展为每次到达的顾客数是一个处于一定范围内的随机数。

通过调用 min_element() 算法可以找到最短的 Checkout 对象队列,因此顾客总是可以被分配到最短的结账队列。在这次模拟开始前,当超市开门营业时,在门外等待的顾客的初始序列被添加到 Checkout 对象中,然后结账时间记录被更新。

模拟在 while 循环中进行,在每次循环中,time 都会增加 1 分钟。在下一个顾客到达期间,new_cust_interval 会在每次循环中减小,直到等于 0。用新的随机结账时间生成新的顾客,然后把它加到最短的 Checkout 对象队列中。这个时候也会更新变量 longest_q,因为在添加新顾客后,可能出现新的最长队列。然后调用每个 Checkout 对象的 time_increment() 函数来处理队列中的第一个顾客。

下面是一些示例输出:

输入超级中结账柜台的数量:2
等待超市营业的顾客人数:20
最大的队列长度为:43

各个结账时间出现的次数:
2 13 *************
3 20 ********************
4 11 ***********
5 16 ****************
6 12 ************
7 18 ******************
8 17 *****************
9 18 ******************
10 10 **********
11 22 **********************
12 19 *******************
13 13 *************
14 16 ****************
15 18 ******************

总的顾客数:223

这里有 2 个结账柜台,最长队列的长度达到 43,已经长到会让顾客放弃付款。

以上代码还可以做更多改进,让模拟更加真实,例如,均匀分配并不符合实际,顾客通常成群结队到来。可以增加一些其他的因素,比如收银员休息时间、某个收银员生病工作状态不佳,这些都会导致顾客不选择这个柜台结账。

C++ STL priority_queue容器适配器详解

priority_queue 容器适配器模拟的也是队列这种存储结构,即使用此容器适配器存储元素只能“从一端进(称为队尾),从另一端出(称为队头)”,且每次只能访问 priority_queue 中位于队头的元素。

但是,priority_queue 容器适配器中元素的存和取,遵循的并不是 “First in,First out”(先入先出)原则,而是“First in,Largest out”原则。直白的翻译,指的就是先进队列的元素并不一定先出队列,而是优先级最大的元素最先出队列。

注意,“First in,Largest out”原则是笔者为了总结 priority_queue 存取元素的特性自创的一种称谓,仅为了方便读者理解。

那么,priority_queue 容器适配器中存储的元素,优先级是如何评定的呢?很简单,每个 priority_queue 容器适配器在创建时,都制定了一种排序规则。根据此规则,该容器适配器中存储的元素就有了优先级高低之分。

举个例子,假设当前有一个 priority_queue 容器适配器,其制定的排序规则是按照元素值从大到小进行排序。根据此规则,自然是 priority_queue 中值最大的元素的优先级最高。

priority_queue 容器适配器为了保证每次从队头移除的都是当前优先级最高的元素,每当有新元素进入,它都会根据既定的排序规则找到优先级最高的元素,并将其移动到队列的队头;同样,当 priority_queue 从队头移除出一个元素之后,它也会再找到当前优先级最高的元素,并将其移动到队头。

基于 priority_queue 的这种特性,因此该容器适配器有被称为优先级队列。

priority_queue 容器适配器“First in,Largest out”的特性,和它底层采用堆结构存储数据是分不开的。有关该容器适配器的底层实现,后续章节会进行深度剖析。

STL 中,priority_queue 容器适配器的定义如下:

template <typename T,
        typename Container=std::vector<T>,
        typename Compare=std::less<T> >
class priority_queue{
    //......
}

可以看到,priority_queue 容器适配器模板类最多可以传入 3 个参数,它们各自的含义如下:

  • typename T:指定存储元素的具体类型;
  • typename Container:指定 priority_queue 底层使用的基础容器,默认使用 vector 容器。

作为 priority_queue 容器适配器的底层容器,其必须包含 empty()、size()、front()、push_back()、pop_back() 这几个成员函数,STL 序列式容器中只有 vector 和 deque 容器符合条件。

  • typename Compare:指定容器中评定元素优先级所遵循的排序规则,默认使用
std::less<T>

按照元素值从大到小进行排序,还可以使用

std::greater<T>

按照元素值从小到大排序,但更多情况下是使用自定义的排序规则。

其中,std::less 和 std::greater 都是以函数对象的方式定义在 头文件中。关于如何自定义排序规则,后续章节会做详细介绍。

创建priority_queue的几种方式

由于 priority_queue 容器适配器模板位于<queue>头文件中,并定义在 std 命名空间里,因此在试图创建该类型容器之前,程序中需包含以下 2 行代码:

#include <queue>
using namespace std;

创建 priority_queue 容器适配器的方法,大致有以下几种。

  1. 创建一个空的 priority_queue 容器适配器,第底层采用默认的 vector 容器,排序方式也采用默认的 std::less 方法:
std::priority_queue<int> values;

  1. 可以使用普通数组或其它容器中指定范围内的数据,对 priority_queue 容器适配器进行初始化:
//使用普通数组
int values[]{4,1,3,2};
std::priority_queue<int>copy_values(values,values+4);//{4,2,3,1}
//使用序列式容器
std::array<int,4>values{ 4,1,3,2 };
std::priority_queue<int>copy_values(values.begin(),values.end());//{4,2,3,1}

注意,以上 2 种方式必须保证数组或容器中存储的元素类型和 priority_queue 指定的存储类型相同。另外,用来初始化的数组或容器中的数据不需要有序,priority_queue 会自动对它们进行排序。

  1. 还可以手动指定 priority_queue 使用的底层容器以及排序规则,比如:
int values[]{ 4,1,2,3 };
std::priority_queue<int, std::deque<int>, std::greater<int> >copy_values(values, values+4);//{1,3,2,4}

事实上,std::less 和 std::greater 适用的场景是有限的,更多场景中我们会使用自定义的排序规则。

由于自定义排序规则的方式不只一种,因此这部分知识将在后续章节做详细介绍。

priority_queue提供的成员函数

priority_queue 容器适配器提供了表 2 所示的这些成员函数。

成员函数功能
empty()如果 priority_queue 为空的话,返回 true;反之,返回 false。
size()返回 priority_queue 中存储元素的个数。
top()返回 priority_queue 中第一个元素的引用形式。
push(const T& obj)根据既定的排序规则,将元素 obj 的副本存储到 priority_queue 中适当的位置。
push(T&& obj)根据既定的排序规则,将元素 obj 移动存储到 priority_queue 中适当的位置。
emplace(Args&&… args)Args&&… args 表示构造一个存储类型的元素所需要的数据(对于类对象来说,可能需要多个数据构造出一个对象)。此函数的功能是根据既定的排序规则,在容器适配器适当的位置直接生成该新元素。
pop()移除 priority_queue 容器适配器中第一个元素。
swap(priority_queue& other)将两个 priority_queue 容器适配器中的元素进行互换,需要注意的是,进行互换的 2 个 priority_queue 容器适配器中存储的元素类型以及底层采用的基础容器类型,都必须相同。

和 queue 一样,priority_queue 也没有迭代器,因此访问元素的唯一方式是遍历容器,通过不断移除访问过的元素,去访问下一个元素。

下面的程序演示了表 2 中部分成员函数的具体用法:

#include <iostream>
#include <queue>
#include <array>
#include <functional>
using namespace std;
int main()
{
    //创建一个空的priority_queue容器适配器
    std::priority_queue<int>values;
    //使用 push() 成员函数向适配器中添加元素
    values.push(3);//{3}
    values.push(1);//{3,1}
    values.push(4);//{4,1,3}
    values.push(2);//{4,2,3,1}
    //遍历整个容器适配器
    while (!values.empty())
    {
        //输出第一个元素并移除。
        std::cout << values.top()<<" ";
        values.pop();//移除队头元素的同时,将剩余元素中优先级最大的移至队头
    }
    return 0;
}

运行结果为:

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

_queue 中位于队头的元素。

但是,priority_queue 容器适配器中元素的存和取,遵循的并不是 “First in,First out”(先入先出)原则,而是“First in,Largest out”原则。直白的翻译,指的就是先进队列的元素并不一定先出队列,而是优先级最大的元素最先出队列。

注意,“First in,Largest out”原则是笔者为了总结 priority_queue 存取元素的特性自创的一种称谓,仅为了方便读者理解。

那么,priority_queue 容器适配器中存储的元素,优先级是如何评定的呢?很简单,每个 priority_queue 容器适配器在创建时,都制定了一种排序规则。根据此规则,该容器适配器中存储的元素就有了优先级高低之分。

举个例子,假设当前有一个 priority_queue 容器适配器,其制定的排序规则是按照元素值从大到小进行排序。根据此规则,自然是 priority_queue 中值最大的元素的优先级最高。

priority_queue 容器适配器为了保证每次从队头移除的都是当前优先级最高的元素,每当有新元素进入,它都会根据既定的排序规则找到优先级最高的元素,并将其移动到队列的队头;同样,当 priority_queue 从队头移除出一个元素之后,它也会再找到当前优先级最高的元素,并将其移动到队头。

基于 priority_queue 的这种特性,因此该容器适配器有被称为优先级队列。

priority_queue 容器适配器“First in,Largest out”的特性,和它底层采用堆结构存储数据是分不开的。有关该容器适配器的底层实现,后续章节会进行深度剖析。

STL 中,priority_queue 容器适配器的定义如下:

template <typename T,
        typename Container=std::vector<T>,
        typename Compare=std::less<T> >
class priority_queue{
    //......
}

可以看到,priority_queue 容器适配器模板类最多可以传入 3 个参数,它们各自的含义如下:

  • typename T:指定存储元素的具体类型;
  • typename Container:指定 priority_queue 底层使用的基础容器,默认使用 vector 容器。

作为 priority_queue 容器适配器的底层容器,其必须包含 empty()、size()、front()、push_back()、pop_back() 这几个成员函数,STL 序列式容器中只有 vector 和 deque 容器符合条件。

  • typename Compare:指定容器中评定元素优先级所遵循的排序规则,默认使用
std::less<T>

按照元素值从大到小进行排序,还可以使用

std::greater<T>

按照元素值从小到大排序,但更多情况下是使用自定义的排序规则。

其中,std::less 和 std::greater 都是以函数对象的方式定义在 头文件中。关于如何自定义排序规则,后续章节会做详细介绍。

创建priority_queue的几种方式

由于 priority_queue 容器适配器模板位于<queue>头文件中,并定义在 std 命名空间里,因此在试图创建该类型容器之前,程序中需包含以下 2 行代码:

#include <queue>
using namespace std;

创建 priority_queue 容器适配器的方法,大致有以下几种。

  1. 创建一个空的 priority_queue 容器适配器,第底层采用默认的 vector 容器,排序方式也采用默认的 std::less 方法:
std::priority_queue<int> values;

  1. 可以使用普通数组或其它容器中指定范围内的数据,对 priority_queue 容器适配器进行初始化:
//使用普通数组
int values[]{4,1,3,2};
std::priority_queue<int>copy_values(values,values+4);//{4,2,3,1}
//使用序列式容器
std::array<int,4>values{ 4,1,3,2 };
std::priority_queue<int>copy_values(values.begin(),values.end());//{4,2,3,1}

注意,以上 2 种方式必须保证数组或容器中存储的元素类型和 priority_queue 指定的存储类型相同。另外,用来初始化的数组或容器中的数据不需要有序,priority_queue 会自动对它们进行排序。

  1. 还可以手动指定 priority_queue 使用的底层容器以及排序规则,比如:
int values[]{ 4,1,2,3 };
std::priority_queue<int, std::deque<int>, std::greater<int> >copy_values(values, values+4);//{1,3,2,4}

事实上,std::less 和 std::greater 适用的场景是有限的,更多场景中我们会使用自定义的排序规则。

由于自定义排序规则的方式不只一种,因此这部分知识将在后续章节做详细介绍。

priority_queue提供的成员函数

priority_queue 容器适配器提供了表 2 所示的这些成员函数。

成员函数功能
empty()如果 priority_queue 为空的话,返回 true;反之,返回 false。
size()返回 priority_queue 中存储元素的个数。
top()返回 priority_queue 中第一个元素的引用形式。
push(const T& obj)根据既定的排序规则,将元素 obj 的副本存储到 priority_queue 中适当的位置。
push(T&& obj)根据既定的排序规则,将元素 obj 移动存储到 priority_queue 中适当的位置。
emplace(Args&&… args)Args&&… args 表示构造一个存储类型的元素所需要的数据(对于类对象来说,可能需要多个数据构造出一个对象)。此函数的功能是根据既定的排序规则,在容器适配器适当的位置直接生成该新元素。
pop()移除 priority_queue 容器适配器中第一个元素。
swap(priority_queue& other)将两个 priority_queue 容器适配器中的元素进行互换,需要注意的是,进行互换的 2 个 priority_queue 容器适配器中存储的元素类型以及底层采用的基础容器类型,都必须相同。

和 queue 一样,priority_queue 也没有迭代器,因此访问元素的唯一方式是遍历容器,通过不断移除访问过的元素,去访问下一个元素。

下面的程序演示了表 2 中部分成员函数的具体用法:

#include <iostream>
#include <queue>
#include <array>
#include <functional>
using namespace std;
int main()
{
    //创建一个空的priority_queue容器适配器
    std::priority_queue<int>values;
    //使用 push() 成员函数向适配器中添加元素
    values.push(3);//{3}
    values.push(1);//{3,1}
    values.push(4);//{4,1,3}
    values.push(2);//{4,2,3,1}
    //遍历整个容器适配器
    while (!values.empty())
    {
        //输出第一个元素并移除。
        std::cout << values.top()<<" ";
        values.pop();//移除队头元素的同时,将剩余元素中优先级最大的移至队头
    }
    return 0;
}

运行结果为:

[外链图片转存中…(img-yFDseu9B-1715384571819)]
[外链图片转存中…(img-B1JPi6lb-1715384571820)]
[外链图片转存中…(img-QKUBnjPr-1715384571820)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值