C++:list(1)

1.list的介绍

我们通过官网文档list - C++ Reference可以观察到,标准命名空间里面的list也是一个模板,list的意思是:列表。它是 C++ 标准库中的双向链表容器(中文常称为 "列表"),它的底层基于双向链表实现,元素存储在互不连续的内存空间中,依靠每个元素自带的前后指针维护元素顺序,支持双向迭代但不支持随机访问,想要访问指定位置的元素必须从链表头部或尾部开始遍历。

它的核心优势是在已获取对应迭代器的前提下,能在序列任意位置以常数时间完成 插入、删除或移动元素的操作(这个操作的执行时间不随数据量的增加而变化,始终保持固定的时间长度(比如不管容器里有 10 个元素还是 10000 个元素,执行这个操作都只需要同样的时间),且这些操作不会导致除被删除元素外的其他迭代器失效,非常适合频繁进行任意位置增删元素的场景,不过它也存在一定缺点,一方面需要额外内存存储元素的前后链接信息,空间开销比 vector 这类连续内存容器略大,另一方面随机访问效率低下,访问元素的耗时与目标元素和起始遍历位置的距离成正比。

2.list的使用

2.1 list的构造函数

关于list的使用,首先必不可少的是构造函数,和前面的vector、string一样,list同样拥有自己的构造函数:

像前面比较普通的初始化就不详细介绍了,我们重点来看第三个迭代器区间构造函数,因为第三个参数,可以自动识别类型,所以我们对于迭代器区间既可以使用list的迭代器,也可以使用其他自定义类型的迭代器,就像这样:

不仅如此,对于第三个参数类型,我们除了传迭代器之外,还可以传一个指针:

可参数类型不是只有迭代器吗?为什么还可以传指针呢?

这是因为迭代器本质模拟的就是指向数组的指针,在我们前面讲vector的时候,模拟实现迭代器用的就是原生指针,所以说,一个指向数组的指针就是一个特殊的迭代器。而参数类型里面的迭代器是一个函数模板,大概就像这样:

所以当传两个指针的时候,也能实现我们的目的。

2.2 list的迭代器

关于list的迭代器有这些,我们先展示一下最普通的begin和end:

这里我们使用迭代器完成了打印的操作。经过前面的学习我们会发现,迭代器是一种通用的相似的遍历容器的工具,规避了容器间结构的差异,不需要我们再去了解容器底层的实现细节。迭代器一共分为三种:单向迭代器、双向迭代器和随机迭代器,即:forward,bidirectional和randomaccess。单向迭代器支持++遍历,双向迭代器支持++遍历和--遍历,随机迭代器支持++、--、+、- 共四种遍历方式。

我们可以观察到,list的迭代器是一个双向迭代器。并且迭代器到底是哪个种类,是根据其底层实现逻辑而决定的。这就导致了在我们使用一些算法函数的时候,因为对于绝大多数的算法函数都是通过迭代器进行实现的,既然迭代器会分不同的种类,所以并不是所有的迭代器都能够使用相同的算法函数,即:算法函数对迭代器是有要求的。

比如来看这个算法函数:

我们通过参数类型:RandomAccessIterator可以看出,这个算法函数就只能对随机迭代器进行排序。

所以当我对 vector 类型的 v1 和 list 类型的 lt1 同时调用 sort 函数的时候,对于 lt1 类型就无法找到匹配的重载函数,这就是因为 list 容器的迭代器是一个双向迭代器,而vector容器的迭代器是一个随机迭代器。所以大家后面还能看到,list容器自己实现了一个成员函数sort。

但是我们之前在讲string的时候用到了reserve逆置函数,但是对于reserve逆置函数来说,它所需要的迭代器是双向迭代器,可string是一个随机迭代器,这就要提到迭代器的另一个性质:这其实是一种继承关系,大家可以暂时理解为:单向<双向<随机。就是如果你这个算法函数在参数列表上写着你需要的是一个双向迭代器,那么用双向迭代器的容器和随机迭代器的容器都可以使用这个算法函数。但如果你是使用单向迭代器的容器,就不能使用这个算法函数。

2.3 list的修改函数

整体来看list的修改操作和前面学过的vector和string的修改操作几乎一模一样,没有什么特别的变化。这边简单演示一下头插和尾插:

2.4 list的操作函数

2.4.1 sort函数

这边需要提到的是:sort函数,我们前面讲到list容器不使用算法库里的sort函数是因为没有匹配的迭代器,现在我们要讲一下它的效率问题。先说结论:在处理数据容量比较大的数据时,全局sort函数的效率优于list的成员sort函数。

这是多重核心因素共同作用的结果,首先,两者依赖的迭代器类型不同,导致排序算法实现存在本质区别, std :: sort 要求容器提供随机访问迭代器,其通常实现为高效的introsort算法(会动态切换快速排序、堆排序和插入排序),而 std :: list 作为双向链表仅提供双向迭代器、不支持随机访问,其使用的专属的 sort( ) 成员函数只能采用归并排序。

其次,虽然两种算法的渐近时间复杂度都是O(n log n),但二者的常数因子存在巨大差异,这里的常数因子指的是:隐藏在渐近时间复杂度背后的、与具体算法实现和操作细节相关的固定开销系数。简单来说,就是完成同一量级的核心操作时,算法本身需要付出的额外执行成本。introsort(尤其是其中的快速排序)的常数因子远小于归并排序,这意味着在处理相同数据量时, std :: sort 的实际执行操作开销更低、运行更快。

再者, std :: sort 通常配合 std :: vector 等连续存储容器使用,连续的内存布局能够充分利用CPU缓存的局部性原理,大幅提升数据访问效率,而 std :: list 的节点是分散存储在内存中的,无法利用CPU缓存,大数据量下缓存未命中的概率会急剧升高,这是二者性能差距的核心来源。

这段话用一张图给大家解释一下:

首先大家要知道我们的数据是存储在内存中的,当我们想要获取这些数据时,是CPU从内存中获取数据,但是因为内存空间较大,CPU获取数据的速度较慢,于是就有了缓存的诞生。缓存相对于内存来说,空间大小更小,但是速度更快。所以当CPU要获取数据的时候,先去检查该数据在不在缓存当中,如果在缓存当中,那CPU可以一下子直接获取数据,这就叫命中。如果说数据一开始不在缓存当中,CPU不能直接获取数据,就叫不命中。

如果数据不在缓存当中,CPU会先将数据从内存中加载到缓存,然后再进行获取访问。比如对于上面的vector顺序表来说,CPU一开始想要获取存储的数据:数字 1 ,但数字 1 一开始并不在缓存当中,所以第一次获取没有命中。那CPU会将数字 1 后面的几个数据一起加载到缓存当中,那么后续再读取的时候,就能直接命中。

为什么CPU会将数字 1 后面的几个数据一起加载到缓存当中?因为CPU要获取数据也是需要耗费时间的。大家可以想象CPU就像是一辆公交车,它需要从内存当中拉乘客,也就是我们的数据,他去一个地方进行一次拉客需要耗费一定的时间,那最节省时间的方式当然是在同一个地方一次拉多名乘客。所以说:连续的内存布局能够充分利用CPU缓存的局部性原理,大幅提升数据访问效率。

而链表是一个一个的节点在内存当中连接而成的,它不一定是连续的内存布局,这就导致CPU需要访问内存中的不同位置,去把数据加载到缓存当中。

因此在数据容量比较大的时候,全局的sort函数的效率就会更加显著。

最后, std :: list :: sort( ) 在归并排序过程中,还需要频繁进行链表的拆分、合并和指针修改操作,这些额外开销会随着数据量的增大而不断放大,而 std :: sort 在连续内存上进行元素交换、比较,常数开销更低,这些差异在大数据量下被进一步凸显,最终使得 std :: sort 的排序速度远超 std :: list :: sort ,且数据量越大,这种差距就越明显。

2.4.2 merge函数

merge函数的作用是归并,是string和vector容器中所没有的函数。它可以将两个有序的链表归并成一个新的有序链表。

像这样我定义了两个链表一个是first,一个是second。依次插入有序的数据,然后再将second中的数据归并到first当中。注意此时,second中的数据就为空了,可以理解为是将second中的数据搬到了first当中。

2.4.3 unique函数

这个函数的作用就是去重,可以去掉后一个相同的数据,不过前提必须是一个有序的链表才行,就像这样:

在这里list类的对象first中存储了两个5,当调用unique函数的时候,会自动识别有一个重复的数字 5 然后删除后面一个的数字 5 。除此自外,unique还可以有参数:

我们首先实例化了一个对象,存储了一串double类型的数据,然后封装了一个函数类型为bool的same_integral_part函数,用于判断这个double类型数据的int部分是否相同。当调用unique函数的时候,用same_integral_part函数的返回值作为参数,当两个double类型的int部分相同时,就删除后面的一个数据。

2.4.4 splice函数

splice函数的核心作用是将一个list的元素(或部分元素)"剪切" 并 "拼接" 到另一个list(注意:是剪切而非拷贝,原容器中被操作的元素会被移除)。其应用场景主要包括快速合并两个链表且无需拷贝元素、调整同一 list 内部元素的顺序布局、从一个 list 中高效提取部分元素转移到目标 list 以完成链表的拆分与重组等等。

先来看一下样例,这里是实例化了两个对象:mylist1和mylist2,然后用一个迭代器接收mylist1的起始位置。然后再让迭代器向后移动一位,就指向了mylist1中的数字 2 。

然后大家看一下对应的三种参数类型的演示。第一种参数类型是:把mylist2中的数据,剪切到it迭代器的前面。此时it迭代器依然指向mylist1中的数字 2 。第二种参数类型是:把mylist1中的 迭代器 it 指向的内容剪切下来, 从 mylist2 的 begin 位置的迭代器开始放置。

然后it迭代器做出了位置调整,现在指向了mylist1中的数字 30 ,第三种参数类型是:把mylist1中的从 it 迭代器指向的位置开始,到mylist1的end迭代器指向的位置结束的这一部分内容剪切下来,从mylist1的begin迭代器的位置开始放置。

3. list和vector的对比

本文到此结束,感谢各位读者的阅读,如果有讲解不到位或者错误的地方,欢迎大家提出指正和批评。

相关推荐
小CC吃豆子2 小时前
如何在 VS Code 中调试 C++ 程序?
开发语言·c++
Overt0p2 小时前
抽奖系统(7)
java·开发语言·spring boot·redis·tomcat·rabbitmq
JANG10242 小时前
【Qt】项目打包
开发语言·qt
CoderCodingNo2 小时前
【GESP】C++五级/六级练习题(前缀和/动态规划考点) luogu-P1719 最大加权矩形
开发语言·c++·动态规划
xiaoye-duck2 小时前
吃透C++类和对象(中):const成员函数与取地址运算符重载深度解析
c++
学嵌入式的小杨同学2 小时前
循环队列(顺序存储)完整解析与实现(数据结构专栏版)
c语言·开发语言·数据结构·c++·算法
Yu_Lijing2 小时前
基于C++的《Head First设计模式》笔记——适配器模式
c++·笔记·设计模式
txinyu的博客2 小时前
C++ 单例模式
c++·单例模式
点云SLAM2 小时前
C++ 设计模式之工厂模式(Factory)和面试问题
开发语言·c++·设计模式·面试·c++11·工厂模式