list 模拟实现
- 回顾
- 准备
- 构造析构函数的构造
- 构造方法
- 析构方法
- 赋值运算符重载
- 容量相关接口
- 元素获取
- 元素修改相关接口
- push 、pop
- insert
- erase
- 清空
- 交换
- 迭代器 **(重点)
- 迭代器基本概念
- 迭代器模拟实现
回顾
在上一篇博客中我们大致了解了 list 相关接口的使用方法并进行了一系列的测试练习,那么这一小节就来模拟实现一些接口吧~
list 是一个带头双向循环的链表:
在模拟实现这些接口之前我们需要先进行节点信息的创建
template<class T> //采用模板------可以定义不同数据类型的 list 链表
struct ListNode {
ListNode(const T&value = T()) //构造方法
:prev(nullptr), next(nullptr), val(value)
{}
ListNode* prev; //前驱节点
ListNode* next; //后继节点
T val; //值域
};
准备
为了与类中 list 进行区分,我们可以自定义一个命名空间,在该命名空间内部来进行接口的模拟实现:
namespace xx {
template<class T>
struct ListNode //创建节点信息
{
ListNode(const T& value = T())
:prev(nullptr), next(nullptr), val(value)
{}
ListNode* prev;
ListNode* next;
T val;
};
}
为了便于测试,我们在自己的命名空间中定义一个打印信息的函数:
template<class T>
void PrintList(const list<T>& L)
{
for (auto e : L)
cout << e << " ";
cout << endl;
}
定义链表信息:
//定义链表信息
template<class T>
class List {
typedef ListNode<T> Node; //取别名
public:
//构造方法
//析构方法
//接口模拟实现
private:
Node* _head; //头节点
};
构造析构函数的构造
构造方法
由于 list 链表是一个带头的双向循环链表,并且已知 _head 头节点,因此在创建链表时候要注意 prev 与 next 的指向
创建任何类型的构造方法之前,我们首先要定义出一个带头结点的空链表:
void CreateList()
{
//创建头节点
_head = new Node();
_head->next = _head->prev = _head; //构造循环
}
(1)构造空链表
创建一个空链表也就是创建一个只有头节点的链表,因此我们可以直接在构造方法内部调用头节点创建的函数即可:
List() {
CreateList();
}
(2)构造具有 n 个值为 val 的链表
构造一个具有 n 个相同节点的链表,我们可以复用尾插(或头插都可以)来进行
List(int n, const T& val = T())
{
CreateList(); //首先创建头节点信息
//构造 n 个值为 val 的节点,我们可以采用 n 次尾插来进行
for (int i = 0; i < n; ++i) {
push_back(val);
}
}
注意:
(3)区间构造
template<class Iterator>
List(Iterator first, Iterator last)
{
CreateList(); //创建头节点
auto it = first;
while (it != last) {
push_back(*it); //同样采用多次尾插方法来构造,*it 代表获取节点值域
++it; //迭代器 it 的自增------表示获取下一个节点的位置
}
}
注意:
在进行参数构造时候,我们参数类型定义均为模板类型 Iterator ,因此对于上述构造 n 个值相同的节点信息的链表,倘若我们使用 size_t 类型来传入 n 参数,会使得 编译器在进行类型推演时候默认为两个变量类型不一致导致编译器调用错误,这也就解释了为什么将 n 变量类型定义为 int 的原因。
(4)拷贝构造
用一个已有的链表来创建新的链表信息:
List(const List<T>& L)
{
CreateList(); //创建头节点
for (auto e : L) {
push_back(e); //遍历 L 链表同时将遍历到的节点值插入到 新构造的链表中
}
}
析构方法
~List()
{
clear(); //清空所有节点信息
delete _head; //删除头节点
_head = nullptr;
}
赋值运算符重载
我们采用现代版的写法,传递的参数为值类型的参数(会调用一次拷贝构造函数来构造出该参数),然后将该参数与 this 指针指向的内容进行交换即可(可以参考深浅拷贝 : 添加链接描述):
List<T>& operator=(const List<T> L)
//以值的方式传参会调用一次拷贝构造方法来创建参数(具有自己独立的地址空间),并在函数调用结束自动销毁临时空间
{
this->swap(L); //交换之后,L 中空间改变为原来的 this 空间,并在函数调用结束自动进行了析构销毁
return *this;
}
容量相关接口
由于 list 链表结构为带头双向循环链表,我们只知道 头节点 _head 的信息,因此在进行容量判断时候需要对整个链表进行遍历------注意遍历条件!!!
(1)size 接口
size_t size()const
{
//统计节点个数
int count = 0;
Node* cur = _head->next;
while (cur != _head) { //循环链表的 next 指针域一定是不为空的,因此遍历条件应该是判断是否回到头节点位置
++count;
cur = cur->next;
}
return count;
}
(2)判空
循环链表为空时,也就是只有一个头节点存在,故判断条件应为:
bool empty()const
{
return _head == _head->next;
}
(3)resize 修改有效节点的个数
当缩小有效节点个数为 newsize 时,我们需要将 newsize 之后的节点进行删除;
当扩大有效节点个数为 newsize 时,我们需要在原有的节点尾部插入 newsize-oldsize 个新的节点,且新节点的值为 val ;
void resize(size_t newsize,const T& val=T())
{
size_t oldsize = size(); //统计现有的节点个数
//当缩小节点
if (newsize < oldsize) {
for (int i = newsize; i < oldsize; ++i)
pop_back(); //进行尾删
}
else {
//增大节点个数
for (int i = oldsize; i < newsize; ++i)
push_back(val); //进行尾插操作
}
}
元素获取
(1)获取首节点信息
//非 const 类型表示可以对节点信息进行修改操作
T& front()
{
//获取首节点值
return _head->next->val; //_head 为头节点,它所存储的数据不是有效数据
}
const T& front()const //只读
{
//获取首节点值
return _head->next->val; //_head 为头节点,它所存储的数据不是有效数据
}
(2)获取尾节点信息
T& back()
{
return _head->prev->val; //_head 为头节点,它的前驱节点为尾节点
}
const T& back()const
{
return _head->prev->val; //_head 为头节点,它的前驱节点为尾节点
}
元素修改相关接口
push 、pop
由于头部或尾部插入新元素都可以直接复用 insert 任意位置插入方法,因此我们这里直接调用 insert 接口来实现:
void push_front(const T& val)
{
insert(begin(), val); //begin() 指向首节点,因此进行头插 ,直接在第一个节点前插入新节点
}
void push_back(const T& val)
{
insert(end(), val); //end() 指向头节点,因此进行尾插,直接在 end() 之前插入新节点
}
由于头部或尾部删除之间可以调用 erase 任意位置删除,所有这里也直接调用erase 接口:
void pop_front()
{
if (empty())
return; //链表为空,不能进行删除
//头删------删除首节点
erase(begin()); //删除 begin() 位置节点
}
void pop_back()
{
if (empty())
return; //链表为空,不能进行删除
//尾删------删除最后一个节点
erase(end());
}
注意看这两段代码是否有问题?
能这么问,当然是有问题啦~
具体什么问题我们来看看:
仔细观察 begin() 与 end() 的位置,我们发现 begin() 指向的就是第一个节点的位置,因此进行头删时候直接可以进行删除,并且删除之后并不会影响之后元素的访问
而 end() 指向的是头节点的位置,而我们要删除的是最后一个有效节点,也就是 end() 的前一个节点位置,因此此处的尾删函数的实现是不对的,具体修改如下:
void pop_back()
{
if (empty())
return; //链表为空,不能进行删除
//尾删------删除最后一个节点
auto pos = end();
--pos; //迭代器向前移动到尾节点位置
erase(pos); //删除 end() 前一个位置节点
}
list 接口测试中,我们谈到 在插入节点时不会导致迭代器的失效,而在删除元素时候会引发迭代器失效,但是不会导致迭代器位置之后的元素的访问,因此一般在删除节点操作之后我们会接收返回值的信息来防止迭代器失效。
(list 接口使用中我们已经进行了测试,忘记的宝子参考:添加链接描述)
insert
在任意位置进行元素的插入,首先我们需要创建出一个新节点信息,然后对节点的指向进行修改即可:
Iterator insert(Iterator pos, const T& val)
{
//在给定的节点位置 pos 之前进行新节点的插入
Node* newnode = new Node(); //创建新节点
newnode->val = val;
newnode->next = pos;
newnode->prev = pos->prev;
pos->prev->next = newnode;
pos->prev = newnode;
return newnode; //返回新插入的节点位置
}
画个图来理解一些吧~
erase
Iterator erase(Iterator pos)
{
//删除给定的 pos 位置的节点
if (pos == _head)
return _head; //头节点不能进行删除
Node* cur = pos->next; //记录下一个节点位置
//修改指向
cur->prev = pos->prev;
pos->prev->next = cur;
delete pos;
return cur; //返回删除节点的位置-------此时迭代器 pos 已经失效了
}
删除节点与插入节点过程很类似,注意修改指向的顺序,读者可以自己画画图来理解
清空
clear 是将 list 链表中所有节点进行删除,我们可以采用头删的方法来进行:
void clear()
{
Node* cur = _head->next; //进行头删
while (cur != _head) {
cur->next->prev = _head;
_head->next = cur->next;
delete cur;
cur = _head->next; //修改要删除的节点位置
}
_head->next = _head->prev = _head; //最后删除头节点
}
交换
void swap(List<T>& L) {
std::swap(_head, L._head); //之间采用全局 swap 函数,交换头节点位置即可
}
迭代器 **(重点)
迭代器基本概念
string 、vector 还有现在学习的 list 当中,我们都有使用到迭代器,那么迭代器到底是什么呢?
在前边的学习中我们提到 ,迭代器可以看作是原生态的指针类型,在模拟接口中我们发现,定义的迭代器变量我们可以对其进行以下操作:
1)解引用 *
2)自增自减
3)迭代器的比较
例如在遍历时我们使用到的迭代器(vector 容器下的迭代器):
因此,我们在模拟实现迭代器时候也要能够进行这三种基本操作。
接下来,我们来模拟实现一些迭代器吧~
迭代器模拟实现
在前边模拟实现 string 以及 vector 时,我们将迭代器处理为原生态的指针类型,发现在整个模拟接口测试过程中是没有任何问题的,说明在之前的模拟实现中 ,迭代器就是被当作指针来进行处理的,那么在 list 中我们是否也可以这么处理?
同样将迭代器看作是原生态的指针类型:
typedef Node* Iterator;
当我们进行迭代器的解引用以及自增自减:
auto it = begin();
while (it!=end())
{
cout << *it << " ";
++it;
}
cout << endl;
那么为什么在 string 和 vector 当中,迭代器可以被处理成为 原生态的指针并且可以正常使用,在这里就不行了呢?(大家可以思考思考)
解答
回顾我们在学习 string 和 vector 容器中,使用的是顺序结构,也就是说所采用的容器空间是连续的,因此进行自增自减可以直接获取到它相邻的前后元素位置
而在 list 中,我们知道 list 是多个节点构成的链表,而每一个节点都是在使用时才创建(new)出来的,然后将创建的新节点链接到我们的链表当中,由此可见 list 当中的结构并不一定连续的,故不能直接进行迭代器的自增自减操作;
其次,假如将迭代器定义为原生态的指针类型 Node* ,在进行解引用操作时候取到的类型是 Node 而并非是当前迭代器指向位置的值域(val)信息,因此迭代器不能被简单的处理为 原生态的指针;
再者,我们提到迭代器可以进行比较,而原生态指针类型定义出来的迭代器类型都是一致的,类型间如何进行比较?
由此可见,在 list 中模拟实现迭代器时,我们首先需要对迭代器进行封装操作:
正向迭代器的封装
//封装迭代器类-------------要求能够使用指针解引用,并能够进行自增自减操作,并能够进行迭代器的比较
template<class T,class Ptr,class Ref>
class ListIterator {
typedef ListNode<T> Node;
public:
typedef Ptr Ptr; //类型重命名,指明当前 Ptr 是类型而非变量
typedef Ref Ref;
typedef ListIterator<T, Ptr, Ref> Self; //因为节点当中包含值域和指针类型,因此我们设计迭代器时需要能够返回不同的类型的数据信息
public:
ListIterator(Node* pNode = nullptr) :_pNode(pNode)
{}
// 解引用
Ref operator*() //返回值信息应该是节点当中的数据类型
{
return _pNode->val; //解引用也就是获取当前节点中的值域信息
}
Ptr operator->() //返回值信息为当前节点数据域的地址
{
return &(_pNode->val); //在自定义类型中体现很明显
}
/// 自增自减
Self& operator++() //返回值为迭代器自身类型
{
_pNode = _pNode->next; //自增操作也就是将迭代器的位置向后移动---------获取下一个位置的节点
return *this;
}
Self operator++(int) //后置++
{
Self tmp(*this); //后置++:先使用,后对其进行修改
_pNode = _pNode->next;
return tmp;
}
Self& operator--()
{
_pNode = _pNode->prev; //自减操作也就是将迭代器向前移动--------获取前一个位置的节点
return *this;
}
Self operator--(int) //后置--
{
Self tmp(*this);
_pNode = _pNode->prev;
return tmp;
}
迭代器能够比较
bool operator!=(const Self& s)const
{
return _pNode != s._pNode;
}
bool operator==(const Self& s)const
{
return _pNode == s._pNode;
}
Node* _pNode;
};
反向迭代器的封装
在前边我们介绍到,正向迭代器 begin() 的位置与反向迭代器 rend() 位置相同,正向迭代器 end() 位置与反向迭代器 rbegin() 位置相同,两种迭代器正好相反,因此进行反向迭代器封装我们可以复用正向迭代器的方法:
template<class Iterator>
struct ListReverseIterator {
//typename 是为了说明 Ref Ptr 是属于正向迭代器 Iterator 中的类型而不是静态成员变量
typename typedef Iterator::Ref Ref;
typename typedef Iterator::Ptr Ptr;
typedef ListReverseIterator<Iterator> Self;
public:
ListReverseIterator(Iterator it) :_it(it)
{}
Ref operator*()
{
Iterator tmp = _it; //rbegin() 指向 _head 头节点,不需要进行打印,因此 解引用 获取到的是第一个节点的值域,即需要将 rbegin 向后(++操作)移动-----------即需要将正向迭代器的 end() 向前移动(--操作)
--tmp;
return *tmp; //返回值是节点数据域中的数据类型
}
Ptr operator->() //获取当前节点数据域的地址信息
{
return &(_it->pNode->val);
}
Self& operator++() {
--_it; //反向迭代器的前置++ 等价于 正向迭代器的前置--
return *this;
}
Self operator++(int)
{
_it--; //反向迭代器后置++ 等价于 正向迭代器的后置--
return *this;
}
Self& operator--() {
++_it; //反向迭代器的前置-- 等价于 正向迭代器的前置++
return *this;
}
Self operator--(int)
{
_it++; //反向迭代器后置-- 等价于 正向迭代器的后置++
return *this;
}
///
bool operator!=(const Self& s)const
{
return _it != s._it;
}
bool operator==(const Self& s)const
{
return _it == s._it;
}
Iterator _it;
};
封装之后我们需要在我们自己定义的 List 类中进行声明:
typedef ListIterator<T,T*,T&> Iterator; //迭代器封装之后要能够返回不同的数据类型(节点值域,节点的地址等)
typedef ListIterator<T, const T*, const T&> const_Iterator; //const 迭代器-----只读
typedef ListReverseIterator<Iterator> reverse_iterator;
typedef ListReverseIterator<const_Iterator> const_reverse_iterator; //const 迭代器-----只读
则迭代器的模拟接口实现如下:
Iterator begin() //begin() 获取首节点的位置
{
return Iterator(_head->next); //返回值类型的临时对象
}
Iterator end()
{
return Iterator(_head);
}
//反向迭代器
reverse_iterator rbegin()
{
return reverse_iterator(end());
}
reverse_iterator rend()
{
return reverse_iterator(begin());
}
//const 迭代器
const_Iterator begin()const
{
return const_Iterator(_head->next);
}
const_Iterator end()const
{
return const_Iterator(_head);
}
const_reverse_iterator rbegin()const
{
return const_reverse_iterator(end());
}
const_reverse_iterator rend()const
{
return const_reverse_iterator(begin());
}
由于我们对迭代器进行了封装而并非原始方式定义为原生态的指针类型,因此采用迭代器来进行插入删除元素的函数也要进行相应的修改操作:
Iterator insert(Iterator Itpos, const T& val)
{
//在 pos 位置之前进行插入
Node* pos = Itpos._pNode;
//此时的迭代器 Itpos 所指向的并不是当前节点的位置信息(而是包含三种数据结构的信息),因此需要进行取节点操作
//后续的代码与前边是相同的
Node* newnode = new Node();
newnode->val = val;
newnode->prev = pos->prev;
newnode->next = pos;
pos->prev->next = newnode;
pos->prev = newnode;
return newnode;
}
Iterator erase(Iterator Itpos)
{
//删除 pos 位置的元素
Node* pos = Itpos._pNode;
//此时的迭代器 Itpos 所指向的并不是当前节点的位置信息,因此需要进行取节点操作
//后续的代码与前边是相同
if (pos == _head)
return pos;
Node* cur = pos->next;
pos->prev->next = cur;
cur->prev = pos->prev;
delete pos;
return cur;
}
好了,今天的学习就到这里啦
对于迭代器部分的理解可能比较困难,读者可以自己在代码当中多调试调试看看
关于本节具体的代码实现请参考(mylist 文件):添加链接描述
有任何问题欢迎评论留言哦!