c++STL——list的使用和模拟实现
文章目录
- list的使用和模拟实现
- 使用部分
- list的结构声名
- 默认成员函数
- initializer_list
- 容量和访问操作
- 修改操作
- 其他接口
- list的迭代器
- 迭代器的种类
- list的模拟实现
- 明确基本结构
- 预处理函数
- 迭代器部分(重点)
- 思路
- 进一步考虑
- 最终代码
- operator->的重载
- 总结
- begin和end
- 访问接口
- 修改操作
- 默认成员函数
list的使用和模拟实现
学习完vector后,我们将进入一个新的容器的学习,就是list,其实就是我们很熟悉的链表。
但是链表我们之前讲到有8种,根据是否带头节点、单向或双向、是否循环来分类。
而STL库种list的实现是哪一种呢?答案是:双向带头循环链表。正巧的是,在学习c语言数据结构部分的时候就已经实现过这个逻辑了。所以对于链表的操作部分我们已经很熟悉了。
使用部分
由于c++的高封装性,所以使用部分也是很简单的。我们今天的重点不在于使用,而是在于模拟实现。所以使用仅仅只简单带过一下即可。
学习的时候最好可以配合上文档进行使用:https://cplusplus.com/reference/list/list/
list的结构声名
list也是一个类模板,可以针对于不同类型的数据进行构建链表。c++种还有一个叫forward_list的链表,经查阅文档后发现,forward_list就是单链表,只可以向前遍历。这不就是以前学过的单链表。只不过效率上肯定是不如双链表的。
默认成员函数
我们只需要了解一下有哪些方式进行构造链表即可:
有不传参的默认构造,构造出的是空的链表(但其实有头节点)。还有传入n个相同的值的构造,还支持迭代器区间的构造。再加上一个拷贝构造,这些在以往的容器学习中早已接触过,就不再赘述了。
对于其他的默认成员函数,对于使用来讲都是换汤不换药的,所以也不再过多讲解。
initializer_list
还有一个是我们需要了解并且学习使用的,这个学会了构造的时候会非常方便:
就是图中标示的initializer_list,这个是c++11后才有的内容。
其实这个我们经常看见到过,比如我们在刷题网站刷题的时候,我们要传一个链表,输入实例一般都是{1,5,9,4,3,2,4},这样所示。这就是int类型的initializer_list。
我们来具体看一下initializer_list是什么:
其实也是一个类模板,也是支持构造,迭代器的使用的。
所以以后我们构造一个链表想一次性输入不同的数据的时候就不用一直使用push_back接口了,可以直接将initializer_list走隐式类型转换,将initializer_list的一次赋值给list:
我们发现是可以正常使用的,那这样子就很方便了。
容量和访问操作
除了max_size是不常用的,其他的还是比较常用。使用的用法也没有什么太大的区别。就是访问的时候就不再支持[]的运算符重载了。
这涉及到迭代器的原理。我们先简单说一下,等下将迭代器部分的时候会重点讲解。
最大的原因就是因为链表的物理空间不连续。对于string和vector两个容器来讲,它们本质其实就是顺序表,顺序表物理空间连续,是可以很快速的通过原生指针结合[]来进行数据的随机访问。这是效率很高的。
而链表是由一个又一个节点构成,每个节点存储着前后节点的地址。但是链表的节点都是在堆上开辟空间存储的,极大概率下是不会有连续的地址的。如果想要遍历到某个节点,就得从头开始不断地找下一个节点直到能找到,对这一个机制进行[]的函数重载。这个其实是效率很低的。时间复杂度是O(N),而顺序表的这个操作是O(1)。所以这里只支持front和back两个接口,用去获取链表头尾的元素。4
修改操作
注意当前来说:emplace_back、emplace_front、emplace和push_back、push_front、insert也没太大区别。具体的用法需要等以后来讲。当前姑且先一样的用法即可。
其他的接口其实和以前学的都是一样的用法。这里就很好的体现了封装的优点了。我们只需要管如何调用这些接口。这是非常方便的。
其他接口
还有一些对链表操作的接口,又或是关系比较的全局函数,这些用的其实都不多,需要用的时候只需要查阅一下文档即可。
list的迭代器
对于链表来说,最重要的就是迭代器。因为不能再使用原生指针进行操作了。所以对于迭代器的一系列操作我们都需要进行一些特殊的处理,使其能够达到以往一样的使用效果。所以对于list来讲,迭代器其实就是封装成了一个类,类里面对各种成员函数进行重载达到指定要求。
当然,具体的讲解还是会放在模拟实现部分进行讲解。如果只是使用的话和其他容易的表层使用都是一样的,只不过对于底层的实现是有很大的区别。
我们仔细查阅一下文档发现,list类中是特意实现了一个sort函数,这是为什么呢?
迭代器的种类
这个时候就得稍微了解一下迭代器的种类。
最主要的三种就是:
迭代器名称 | 种类 |
---|---|
Random Access | 随机迭代器 |
Bidirectional | 双向迭代器 |
Forward | 单向(向前)迭代器 |
什么意思呢?
对于string和vector来说,它们的底层空间连续,支持随机访问,所以它们的迭代器是隶属于随即迭代器部分的。
而对于list这个双向链表来说,很明显就是双向的迭代器。
单向迭代器就是只能单向遍历的容器,如forward_list,即单向链表。
而还有Input和Output是什么意思呢?这些具体内容都是继承的时候才会细讲,现在先做了解。
它们其实代表的是任意的迭代器,当然Input用的多。就在list的迭代器区间构造函数就能看到
它们的关系是这样子的:
其实就是集合包含的关系。
而alogrithm库中的sort函数是只支持随即迭代器的,所以要求是很高的。而list的迭代器是双向迭代器,所以只能内部自行实现了。
当然,链表的排序效率是很低的,数据较多的情况下都是将数据传入到vector中排序后,再将数据传回list。
对于list的使用就不再讲那么多了,重点是学会如何模拟实现。
list的模拟实现
现在来进入本篇文章的重点——如何手撕一个list
list的实现和string、vector是有很大的不同,甚至难度会更加困难。
明确基本结构
namespace MyList {template<class T>struct list_node {};template<class T>class list {public:typedef list_node<T> Node;private:Node* _head;size_t _size; };
定义一个自己的类模板list,由于链表是由一个个节点组成的。所以需要再声名节点的结构。节点的结构其实也是类,但是我们使用struct来声名。这是因为其默认内部所有成员都是公有。再加上我们肯定是要不断地访问节点中的内容的,所以直接使用struct就可以。
对于list这个类,我们使用class来声名。私有成员变量分别是指向链表头节点的指针和当前链表有效数据个数。
构造链表的时候,我们会new很多个节点,会调用list_node的默认构造函数。没写就是编译器自己生成。但是我们希望达到的效果是:
通过传值生成一个节点,将节点的数据赋值为我们传入的数据。所以是需要我们自己写默认构造函数的。
所以我们最终调整一下基本结构:
namespace MyList {template<class T>struct list_node {T _data;list_node<T>* _prev = nullptr;list_node<T>* _next = nullptr;list_node(const T& Val = T()):_data(Val),_prev(nullptr),_next(nullptr){}};template<class T>class list {public:typedef list_node<T> Node;private:Node* _head;size_t _size; };
预处理函数
还是一样的,我们希望可以多复用一些已有的接口,有些接口会被多次调用,所以我们可以把一些比较重要的接口先行实现:
对于双链表的操作早已学习过,所以不再进行过多的讲解。
1.push_back接口
void push_back(const T& x) {Node* newnode = new Node(x);Node* tail = _head->_prev;newnode->_prev = tail;newnode->_next = _head;tail->_next = newnode;_head->_prev = newnode;++_size;
}
2.size接口
size_t size() {return _size;
}
3.empty接口
bool empty() {return _size == 0;
}
4.swap接口
void swap(list& lt) {std::swap(_head, lt._head);std::swap(_size, lt._size);
}
5.list()构造函数
这个函数主要是构造出一个头节点(哨兵位节点),具体的实现:
list() {_head = new Node;_head->_prev = _head;_head->_next = _head;_size = 0;
}
6.print_container全局函数
这个函数已经在vector的模拟实现中讲到过了,就是为了方便测试的:
void Print_container(const Container& con) {for (auto& x : con) {cout << x << " ";}cout << endl;
}
迭代器部分(重点)
因为list的迭代器并不是原生指针了,所以是需要特殊处理的。我们在前面介绍使用部分的时候就说了,链表的迭代器其实是一个类,经过封装的。
思路
我们来厘清一下思路:
对于链表的迭代器,对其解引用其实就是获得节点的数据。对其重载++或–运算符,其实就是移动到下一个或上一个节点。判断两个迭代器是否相等也就是判断是否指同一个节点。
这不也是指针的逻辑吗?只不过对于某些操作我们需要进行重载后封装在类里面。这个类我们叫他list_iterator,在list内调用的时候对其typedef一下变为iterator即可。
但是现在还有一个问题就是,这样写当然可以。但是如果要实现const_iterator这么办?
那有的人就说,直接在前面加个const不就好了。但实际不然,这样是大错特错。有这样的想法完全是之前实现原生指针给误导了,我们之前在实现原生指针迭代器的时候,const_iterator就是const T *,iterator就是T *,看着就是在前面加const一样。
这是因为这使用指针实现的,这样直接在前面加const确实是能够限制指定的数据不被修改。但是我们现在实现的迭代器是一个类。这个类前面加const就代表这个类(本身)不能被修改,这就错误了。我们需要的是指向的数据不被改,也就是我们访问的时候数据返回的都是const引用或者const指针。
我们可以写出一个基础版本:
template<class T>
struct list_iterator{typedef list_node<T> Node;typedef list_iterator<T> Self;list_iterator(Node* node = nullptr){_node = node;}T& operator*() {return _node->_data;}T* operator->() {return &(_node->_data);}Self& operator++() {_node = _node->_next;return *this;}Self& operator--() {_node = _node->_prev;return *this;}Self operator++(int) {list_iterator tmp(*this);_node = _node->_next;return tmp;}Self operator--(int) {list_iterator tmp(*this);_node = _node->_prev;return tmp;}bool operator==(const Self& x) {return _node == x._node;}bool operator!=(const Self& x) {return _node != x._node;}Node* _node;
};
进一步考虑
对于const_iterator,它和普通迭代器的区别也就是访问的时候返回值做了特殊处理。其他逻辑完全是一样的。如果按照上面那个思路去写,那会导致我们需要写两遍极其类似的代码。非常冗余。
我们学过模板,模板就是为了这种情况而生的,我们也没有办法能够让编译器自行来决定返回值带不带const呢?
当然是可以的,我们可以多声名几个模板参数嘛:
template<class T, class Ref, class Ptr>
//T代表数据类型(节点中存储的数据
//Ref代表对数据的引用 区分const和非const
//Ptr为指向数据的指针 也是区分const和非const
struct list_iterator{typedef list_node<T> Node;typedef list_iterator<T, Ref, Ptr> Self;Node* _node;
};
我们来看看基本结构:
我们把返回的引用变为Ref参数,返回的指针变为Ptr参数。具体的返回值就看我们传什么。
如果我们显示实例化:
typedef list_iterator<T, T&, T*> iterator;
typedef list_iterator<T, const T&, const T*> const_iterator;
这样子不就能区分迭代器的不同了吗?
所以哦我们也发现,其实参数也是可以传给声名的模板的参数的。这点早已说过。因为模板参数列表和函数参数列表是很像的,只不过一个传具体数据,一个传类型。只不过类型的推导就是依靠编译器去做,走的是按需实例化的操作。
最终代码
template<class T, class Ref, class Ptr>
struct list_iterator{typedef list_node<T> Node;typedef list_iterator<T, Ref, Ptr> Self;list_iterator(Node* node = nullptr){_node = node;}Ref& operator*() {return _node->_data;}Ptr operator->() {return &(_node->_data);}Self& operator++() {_node = _node->_next;return *this;}Self& operator--() {_node = _node->_prev;return *this;}Self operator++(int) {list_iterator tmp(*this);_node = _node->_next;return tmp;}Self operator--(int) {list_iterator tmp(*this);_node = _node->_prev;return tmp;}bool operator==(const Self& x) {return _node == x._node;}bool operator!=(const Self& x) {return _node != x._node;}Node* _node;
};
这里还是需要写一下构造函数的。因为我们需要让迭代器指向具体的节点,其本质也就是让迭代器这个类内部的指针指向具体的节点。所以我们是需要将节点的指针赋值给类里面的指针的。所以我们需要写一个构造函数。
operator->的重载
很多人看不明白,为什么要声名这个运算符,也不明白为什么写的如此奇怪。
这是因为,链表中的_data数据类型很可能不是内置类型,可能是struct这种自定义类型。那我们可能希望使用->来获取其内部的数据。
struct AA {AA(int a = 1, int b = 1) :_a(a),_b(b){}int _a;int _b;
};list<AA> lta;
lta.push_back({ 1,2 });
lta.push_back({ 3,4 });
lta.push_back({ 5,6 });
lta.push_back({ 7,8 });ita = lta.begin();
while (ita != lta.end()) { cout << ita->_a << ita->_b << " "; ++ita;
}
我们希望达成这个效果。
我们回忆一下这个操作符的使用。使用在结构体的指针的,通过箭头来访问数据。也就是说:箭头的左边是指针,右边是数据。
所以我们重载这个运算符返回指针是很合理的。但是不应该是这么写吗:
ita.operator->() - >_a
,这样子才更符合逻辑。
这里就得讲到特殊情况了,我们希望达到的效果是直接通过重载运算符就能直接使用,就像上面代码展示的那个情况一样。编译器是做了优化的,即自动省略,即不能写两个->,写一个即可,这一点我们需要特别注意。
总结
迭代器部分就是这些要注意的内容,它的方式是很新颖的,我们这是刚学习,所以需要多花时间进行理解。
begin和end
实现了迭代器后,我们就需要提供迭代器的相关接口:
//iterator//
iterator begin() {return _head->_next;
}
iterator end() {return _head;
}
const_iterator begin() const{return _head->_next;
}
const_iterator end() const{return _head;
}
//
我们这里走的是隐式类型转换,直接将节点指针通过转换赋值给迭代器。
当然不嫌麻烦地可以调用一下构造函数然后再返回。
访问接口
//access//
T& front() {assert(!empty());return _head->_next->_data;
}
T& back() {assert(!empty());return _head->_prev->_data;
}
const T& front() const{assert(!empty());return _head->_next->_data;
}
const T& back() const{ assert(!empty());return _head->_prev->_data;
}
//
修改操作
iterator insert(iterator pos, const T& val) {Node* newnode = new Node(val);Node* prev = (pos._node)->_prev, *next = pos._node;prev->_next = newnode;newnode->_prev = prev;newnode->_next = next;next->_prev = newnode;++_size;return newnode;//隐式类型转换返回
}iterator erase(iterator pos) {assert(pos != end());Node* POS = pos._node;Node* prev = (pos._node)->_prev, *next = (pos._node)->_next;prev->_next = next;next->_prev = prev;delete POS; return next;
}void clear() {list<T>::iterator it = begin();while (it != end()) {it = erase(it);}_size = 0;
}void push_front(const T& val) {insert(begin(), val);
}void pop_front(){erase(begin());
}
有了迭代器,我们就能很轻松的在某个位置进行插入或者删除了。具体的流程参考以往的双向链表增删查改操作。
对于清空数据,尾删,头插,头删操作,我们都可以复用一下erase和insert的操作就可以了,这是十分简单的。
当然可以把尾插的操作也进行简单复用修改,我在这里就不做演示了。
默认成员函数
由于很多情况下的构造,都是需要进行创建头节点,故可专门的实现一个创建头节点的函数:
void HeadNode() {_head = new Node;_head->_prev = _head;_head->_next = _head;_size = 0;
}
这其实就是预处理的时候的默认构造函数。
构造函数:
//默认构造
list() {HeadNode();
}//构造出n个相同值的list
list(int n, const T& value = T()) {HeadNode();for (int i = 0; i < n; ++i) {push_back(value);}
}//拷贝构造
list(const list<T>& l) {HeadNode();typename list<T>::const_iterator it = l.begin();while (it != l.end()) {push_back(*it);++it;}
}//迭代器区间构造
template <class PushIterator>
list(PushIterator first, PushIterator last) { HeadNode();PushIterator it = first;while (it != last) {push_back(*it);++it;}
}//initializer_list构造 上面讲过
template <class T>
list(const initializer_list<T>& x) {HeadNode();typename initializer_list<T>::iterator it = x.begin();while (it != x.end()) {push_back(*it);++it;}
}
析构函数:
~list() {clear();delete _head;_head = nullptr;
}
可以把数据情况后再将指向链表的头节点的指针释放并置空。
赋值重载:
list<T>& operator=(list<T> tmp) {swap(tmp);return *this;
}
现代写法,这样子可以不用判断是否是自己给自己赋值。