list的访问与模拟实现
目录
坑点一:迭代器只读的设计................................................................................ 7
坑点二:编译器带来的自定义类型->的特殊规则................................................. 8
坑点三:显示定义迭代器(这个是使用上的).................................................... 9
<list>
底层
如下方C++官方规定,list容器的底层是双向循环带头链表

也正因为底层的特性,在实现成员函数的操作时,也和vector/string有所区别。

多了时间复杂度低的pop_front和push_front,删去了一次性开辟多空间的reserve(底层设计决定无法记录未存值的结点),同时resize函数功能不变,但是分类移动到了数据操作的modifier那一栏。新增了sort/merge/unique/remove/splice一系列针对链表的操作
使用
1.构造和访问
由于底层变为了链表,无法随机访问,历遍访问方法仅有iterator和范围for两种了。
同时element access支持了front和back,返回结点内容的引用
eg:
//库内链表的使用
void test1()
{
//构造函数
//默认
list<int> a;
for (auto e : a)
{
cout << e << " ";
}
cout << endl;
//n个值构造
list<int> b(3,20);
list<int>::iterator i = b.begin();
while (i!=b.end())
{
cout << *i << ' ';
i++;
}
cout << endl;
//迭代器区间构造
vector<char> s = { 'a','b','c' };
list<int> c(s.begin(),s.end());
for (auto e : c)
{
cout << e << " ";
}
cout << endl;
//initializer_List构造
list<int> d{1,2,3,4};
for (auto e : d)
{
cout << e << " ";
}
cout << endl;
2.库函数使用
库函数(algorithm)使用主要就是注意迭代器种类要求,下面我们就举一个例子
// 库函数使用
int n[4] = {2,1,3,4};
vector<int>v1{ 2,1,3,4 };
sort(n, n+4);
sort(v1.begin(), v1.end());
// list不能用,要求是random迭代器
//sort(c.begin(), c.end());
reverse(c.begin(), c.end());
reverse(v1.begin(), v1.end());
3.成员函数使用
这里我们主要介绍list独有的(相对于vector/string)的成员函数
sort
从上面的使用可以知道,list的迭代器不符合库函数的要求,因此库重新实现了一个成员函数sort来实现排序,不过这个排序效率极低,谨慎使用
在介绍缘由前,我们先看一段代码与运行结果
这个是百万数据的排序结果,debug版本,可以看到list库自带的排序算法时间大概是接近vector排序的两倍,甚至我用O(n)进行拷贝到vector来排序再拷贝回list的时间都比list库自带排序时间减少了1/4。

再看release下(函数栈帧优化全开时)

差距更为明显了,差距到了2倍以上
为什么会有这么大的差异?
回到底层算法,list库实现sort使用的是归并排序,algorithm库实现sort使用的是快排,同为O(logn)的算法,二者在理论上的差异应该较少
跟本缘由是缓存命中率不同,即物理层存储带来的读取速度不同
CPU缓存读取数据时会读一段长度的连续存放的数据存入缓存,这时候,如果数组,那么后续要访问的数据也进缓存了,CPU查找数据时先看缓存是否有,有(命中)就用,没有(未命中)就让缓存去再次读取(耗时长)。(详见数据结构与算法)

而链表的物理存储结构确定了无法命中,应为后续连续空间储存的不是他的结点,每次都要去内存读取到缓存再返回,使得时间消耗极大。
那为什么用release后速度差距还会变大?
release版本不需要在函数栈帧中插入各种调试信息,而快排本质依赖函数递归,这样函数栈帧的建立速度变快了,那么时间消耗就低了。
void merge(list& x)
该函数会合并两个有序数组(必须有序,可以先sort一下),后一个list(也就是x)将变为空list。
splice(list& x)

这个函数是选定链表(x)(一个区间),然后将这个链表(区间)截出放置到pos位置,然后再链接链表尾端与原链表的pos位置的结点。
综合应用代码
//成员函数使用
std::list<double> first, second;
first.push_back(3.1);
first.push_back(2.2);
first.push_back(2.9);
second.push_back(3.7);
second.push_back(7.1);
second.push_back(1.4);
first.sort();
second.sort();
first.merge(second);
std::list<double> lt1 = { 1,2,3,4,5,6,2,3,2 };
lt1.remove(2);
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
//
std::list<double> lt2 = { 1,2,3,4,5,6 };
for (auto e : lt2)
{
cout << e << " ";
}
cout << endl;
// 把5转移到头部位置
auto it = find(lt2.begin(), lt2.end(), 5);
lt2.splice(lt2.begin(), lt2, it);
for (auto e : lt2)
{
cout << e << " ";
}
cout << endl;
模拟实现
代码链接:等待更新
前言:
在下面的叙述中,我们挑相对vector和string不同的地方,有突破的地方进行讲解,重点在于增删查改,对于专门为list设计的功能不进行模拟实现,算作算法部分。(想要了解模拟实现string与vector基础的可以看作者前面的文章)
同样为stl部分的设计,增删查改其实思想都一样,设计思想是一样的,但是由于底层结构的不同(为双向循环链表),想要达到这种设计还是有所区别(重点)。
在模拟实现时,作者定义了Node为结点类型,iterator/const_iterator为迭代器类型,list为主类型
坑点一:迭代器只读的设计
我们知道作为list的迭代器,其应该是bidirection类型的迭代器,需要支持*/->/++/--,由于底层不是数组,仅仅靠原生指针以及其原生操作很难实现了,因此我们需要定义一个类来实现封装。
显然我们需要保留储存类型的模板(T),同时基于指针实现,我们使用struct类,因为iterator的设计上无法让我们得到指向结点类的指针,我们如果把iterator里面的指针设为私有,在主类里面用iterator实现增删时,会使得无法进行节点间链接关系的改变。
template<typename T>
struct _list_iterator {
Node* _ptr;
}
这就够了吗?我们回到iterator的设计思想上,其是支持const的,这里的const也是一个设计思想,其是支持iterator进行移动,但是不支持iterator指向的内容进行修改。
如果我们直接用const _list_iterator,我们无法对内嵌的Node* _ptr;进行修改了,那怎么实现++/--,显然是走不通的。
我们显然不能直接限制指针,而是需要在*的重载返回值上进行限定,要实现const与非const,显然第一考虑就是重载,但是参数相同(设计规定*为单操作数的操作符,都只要this指针),无法重载。显然还是不可以。
那怎么办?很直接粗暴的想法就是再定义一个const_iterator类,这样,在不同的类中返回值分别设定为T与const T。
这个路,能走,但是代码重复度太高,不够优雅,省事,维护困难。
eg:
template<typename T>
struct _list_iterator {
Node* _ptr;
T& operator*();
}
template<typename T>
struct _list_const_iterator {
Node* _ptr;
const T& operator*();
}
仅仅是类型不同,代码一样,那不就是该模板救场了嘛,我们不妨多定义一个模板参数,抽象为const与非const的返回值类型,这样,更具传入的参数自动推演实现,维护方便。出现类似问题的还有->操作符返回的指针,一样可以用此方法解决。
eg:
template<typename T,typename Ref, typename Ptr>
struct _list_iterator {
Node* _ptr;
Ref operator*();
Ptr operator->();
}
坑点二:编译器带来的自定义类型->的特殊规则
->运算符设计的思想是通过一个指针拿到其中的值。而编译器会进行的优化是:
C++规定:当编译器看到对象->成员时,会按以下逻辑处理:
如果对象是原生指针(如 T*),直接访问成员。
如果对象是类类型,则调用其operator->()。
如果返回值是原生指针,则通过该指针访问成员。
如果返回值仍然是类类型,则 继续对该返回值调用 operator->(),直到最终得到原生指针为止。
这个过程由编译器自动、递归地完成,直到达到终止条件(原生指针)。。
eg:
//假设结点存储的内容是一个结构体(两个成员变量_row和_col)
//为了可读性,这里省略了一个->
cout << it->_row << ":" << it->_col << endl;
//也可以显式写为cout << it.operator->()->_row << ":" << it.operator->()->_col << endl;
坑点三:显示定义迭代器(这个是使用上的)
//编译器看到这里时,会产生二义性,dr::list<int>::iterator,
//这里list<int>并没有实例化,跟了一个::,其作用就变成了声明,不实例化,不知道里面有什么,我们知道类中全局变量也可以这么访问,自定义类型名也可以这么指定
//这时候typename比class就多了用武之处,在前面写上typename(class 不行),声明我确定这里这个是类型不是变量,告诉编译器就这么编译。
typename dr::list<int>::iterator m = ls1.begin();
还需要注意的是,typedef还受访问限定符的限制,如果你的iterator在类中是private的,即使能够找到,也无法使用。
模拟实现代码链接:待更新
持续优化ing…………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………

浙公网安备 33010602011771号