1. list的介绍
- list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
- list的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向其前一个元素和后一个元素。
- list与forward_list非常相似:最主要的不同在于forward_list是单链表,只能朝前迭代,已让其更简单高效。
- 与其他的序列式容器相比(array,vector,deque),list通常在任意位置进行插入、移除元素的执行效率更好。
- 与其他序列式容器相比,list和forward_list最大的缺陷是不支持任意位置的随机访问,比如:要访问list的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间开销;list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这可能是一个重要的因素)
2. list的使用
在此只介绍几个list的常见接口使用,具体查看list的文档介绍
list为啥要单独实现sort排序呢?为啥不采用算法库当中的sort(lt.begin(),lt.end()),通过传迭代器区间进行排序呢?
因为list的结点都是单独开辟的,算法库当中通过两个迭代器相减来计算的数据个数,但是list并不能通过迭代器相减确定元素个数(vector和sting都是可以的,因为他们的迭代器就是原生指针),所以list要单独实现sort功能
库中在实现list时,提供了一些功能性接口:排序/去重… 但是在实际运用中使用不多
在比较算法性能时,不要使用debug版本,而是采用release版本,因为debug版本下优化没到位,结果可能与实际中的不一致
所以,在日常生活中是不会使用链表来进行排序的,sort已经如此,其他接口更是少用
3. list的模拟实现(简易版本)
因为之间在数据结构阶段实现过带头双向循环链表,这里就不过多赘述功能(大致介绍一下即可)
3.1 结点list_node
template <class T>
struct list_node
{
list_node* _prev;
list_node* _next;
T _data;
// 结点初始化
list_node(const T& x)
:_next(nullptr)
,_prev(nullptr)
,_data(x)
{}
};
3.2 链表初始化
template<class T>
class list
{
typedef list_node<T> node;
public:
list()
{
//node里的data是T类型 T()构造
_head = new node(T());
_head->_next = _head;
_head->_prev = _head;
}
private:
node* _head;
};
链表中只有一个哨兵位头结点,无参构造(初始化),要使得_next和_prev都指向自己
(带头双向循环链表的自洽,这里不理解的可以去参考数据结构中最开始的链表初始化,思路是一致的)
3.3 push_back
void push_back(const T& x)
{
node* newnode = new node(x);
node* tail = _head->_prev;
// _head tail newnode
_head->_prev = newnode;
tail->_next = newnode;
newnode->_next = _head;
newnode->_prev = tail;
}
创建出来的newnode新结点调用node(x) 使用node的构造函数来创建结点
(不再像数据结构中还需要调用BuyListNode接口malloc空间来初始化,代码如下所示)
LTNode* BuyListNode(LTDataType x)
{
LTNode* node = malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail");
exit(-1);
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
3.4 迭代器 (重点)
迭代器是属于内嵌类型的,那什么是内嵌类型呢? – 定义在类里面的就叫做内嵌类型
C++的内嵌类型分为两种
- 定义在类里面的内部类,内部类是外部类的友元
- typedef定义的类型名
编译器在查找类型时,默认都是去全局找,而对于内嵌类型定义在类内部,那么在全局当中是找不到的,所以对于内部类和typedef的数据使用都需要声明类域
3.5 类的封装
typedef __list_iterator<T> iterator;
3.5.1 类成员及构造
template <class T>
struct __list_iterator
{
typedef list_node<T> node;
node* _pnode;
// 构造函数
__list_iterator(node* p)
:_pnode(p)
{}
};
既然原生指针不支持,那么就亲自上手写一个满足自身需求的封装类,首先我们的需求是要遍历链表,所以需要一个结点的指针负责指向结点 ( node* _pnode
), 怎么初始化指针呢? – 根据所传参数初始化即可
3.5.2 operator* 重载解引用
解引用想要获取当前指针_pnode
所指向的数据
//解引用 operator*
T& operator*()
{
return _pnode->_data;
}
3.5.3 operator++ 重载++
++想指向当前指针的next(下一结点)
__list_iterator<T>& operator++()
{
_pnode = _pnode->_next;
return *this;
}
返回值为啥要写成__list_iterator<T>&
类型呢?
迭代器++后还是迭代器
3.5.4 operator!= 重载不等于
bool operator!=(const __list_iterator<T>& it)
{
return _pnode != it._pnode;
}
3.6 迭代器区间
iterator begin()
{
//匿名对象
return iterator(_head->_next);
}
iterator end()
{
//匿名对象
//iterator it(_head);
//return it;
return iterator(_head);
}
begin就是哨兵位的下一位(_head->_next) ,end就是哨兵位(_head) 因为是带头双向循环,而且[ begin(),end() ) 是左闭右开区间,访问不到end() 正好契合迭代器区间的要求_head->prev就是尾结点
到这里简易版本的list就实现了,测试接口
4. 深刻理解迭代器
4.1 从逻辑结构分析:
迭代器的价值是什么?
如果说我们不采用迭代器的方式(STL库)来实现这些数据结构(vector/list),我们在提供这些访问方式是需要暴露底层的实现细节,在使用时必须告诉使用者我们底层实现的方式。同样的使用者在使用的过程中很可能不小心对底层的逻辑结构进行更改导致出现问题(非常不安全)
STL的大佬就提出了迭代器的概念(STL库中六大组件之一)
- 将底层实现封装起来,不暴露底层的实现细节
- 提供统一的访问方式,降低使用成本
C++是怎么做到可以提供统一的方式来访问呢?
类的独特价值,对于内置类型解引用直接访问到数据,而对于自定义类型无法做到,那么我们就进行运算符重载(按照自己的想法实现),
这其中C++的引用的作用也不可替代,传引用返回更改对应的数据
对于C语言来说,没有类,没有模板,没有运算符重载,没有引用,是无法实现的。
其实就相当于迭代器帮我们承受了底层的实现细节,我们在上层调用时才能轻松/一致
4.2 从内存的角度分析:
list的迭代器其中只包含node* _pnode的指针,所占字节为4字节,尽管list迭代器当中包含大量的自定义实现的函数接口,但这些接口都不占用内存空间,所以归根结底 list迭代器占用内存空间大小和vector的原生指针一致
vector的迭代器就是T*类型的原生指针,所占字节为4字节
其中it = lt.begin() 时,发生了拷贝构造,又因为我们自身并未实现对迭代器的拷贝构造,所以编译器自动生成浅拷贝it和begin()所指向一个结点(符合要求)
4.3 通过调试来深刻理解:
5. 完善list功能
5.1 删除pos位置
void erase(iterator pos)
{
// 不能将哨兵位的头结点删除
assert(pos != end());
node* prev = pos._pnode->_prev;
node* next = pos._pnode->_next;
prev->_next = next;
next->_prev = prev;
delete pos._pnode;
}
但是这种删除无法有效解决迭代器失效的问题,所以需要记录迭代器的返回值
iterator erase(iterator pos)
{
// 不能将哨兵位的头结点删除
assert(pos != end());
node* prev = pos._pnode->_prev;
node* next = pos._pnode->_next;
prev->_next = next;
next->_prev = prev;
delete pos._pnode;
// 拿next来构造迭代器对象
return iterator(next);
}
5.2 在pos位置前插入
// 在pos位置之前插入 newnode
iterator insert(iterator pos, const T& x)
{
node* newnode = new node(x);
node* cur = pos._pnode;
node* prev = pos._pnode->_prev;
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
// 更新迭代器
return iterator(newnode);
}
5.3 复用接口(头/尾-插入/删除)
// 尾插->也就是在end之前插入
void push_back(const T& x)
{
insert(end(), x);
}
// 头插->在begin之前插入
void push_front(const T& x)
{
erase(begin(),x);
}
// 头删 删除begin()位置
void pop_front()
{
erase(begin());
}
// 尾删 删除end前位置
void pop_back()
{
erase(--end());
}
5.4 析构函数
~list()
{
clear();
delete _phead;
_phead = nullptr;
}
void clear()
{
iterator it = lt.begin();
if (it != end())
{
// 这边erase过后迭代器不能++,因为迭代器已经失效了
// 但是我们记录下的erase过后迭代器的位置,所以用it =
it = erase(it);
}
}
5.5 拷贝构造(传统写法)
// 模拟stl库实现一下封装
void empty_initialize()
{
_head = new node(T());
_head->_next = _head;
_head->_prev = _head;
}
// 拷贝构造
list(const list<T>& lt)
{
empty_initialize();
for (const auto& e : lt)
{
push_back(e);
}
}
传统写法的深拷贝 – 复用接口
先构造出带头双向循环的初结构,不断的进行push_back()
5.6 赋值重载(传统写法)
// lt1 = lt3
list<T>& operator=(const list<T>& lt)
{
if (this != <)
{
clear(); // (this->)clear();
for (const auto& e : lt)
{
push_back(e); // (this->)push_back(e);
}
}
return *this;
}
lt1(this) 当中是存在数据的,那么首先需要将lt1当中的数据清空 再不断的push_back尾插数据
其中const auto& e是重点 不采用引用取别名的方式,e是需要拷贝构造出来的临时对象
5.7 拷贝构造(现代写法)
5.8 赋值重载(现代写法) – 推荐
// 拷贝构造 -- 现代写法
// 参数一定不能传引用
list<T>& operator=(const list<T> lt)
{
// 不传引用 lt就是拷贝构造的临时对象
// 二者交换以后,都正常析构
swap(lt);
return *this;
}
调用拷贝构造创建出临时对象并不会影响lt本身,所以二者的交换也就是跟临时对象的交换,这种写法明显优于传统写法
传统写法是先清空数据再不断尾插,而现代写法是让编译器拷贝构造个临时对象再交换数据
6. const 迭代器(重点)
void print_list(const list<int>& lt)
{
const list<int>:: iterator cit = lt.begin();
}
提问:在普通迭代器前加const进行修饰是否就是const迭代器?
要实现const迭代器那么就是解引用返回const类型即可,解引用就无法更改数据(达到const迭代器的需求)
但是我们无法这样实现,因为迭代器对象就是被const所修饰无法进行++操作,只能解引用
除非我们对operator++也进行重载专门重载成const T& operator++() const
但是operator++是需要修改迭代器的,所以无法实现对operator++的重载
那么真正的const迭代器该如何实现呢?
6.1 简易版本
直接创建一个const版本的迭代器(跟正常的迭代器区分开)
// 跟普通迭代器的区别
// 可以遍历 但是不能解引用修改 *it
template <class T>
struct __list_const_iterator
{
typedef list_node<T> node;
node* _pnode;
// 构造函数
__list_const_iterator(node* p)
:_pnode(p)
{}
// 解引用 operator*
// 返回const T&
const T& operator*()
{
return _pnode->_data;
}
__list_const_iterator<T>& operator++()
{
_pnode = _pnode->_next;
return *this;
}
__list_const_iterator<T>& operator--()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator!=(const __list_const_iterator<T>& it)
{
return _pnode != it._pnode;
}
};
唯一的区别就是operator* 返回的是 const T&
operator++ 还是用的普通迭代器的方式
6.2 大佬版本(模板的力量)
STL库是由大佬实现的,大佬是不会允许代码出现如此冗余的现象,迭代器和const迭代器的区别就在类名和operator*的返回值上,其它完全一致
对于vector<int> / vector<char> / vector<string>
会根据类模板实例化出三个不同的类型
// 模板的力量:
// 增加1个模板参数
// typedef __list_iterator<T, T&> iterator;
// typedef __list_iterator<T, const T&> const_iterator;
template <class T, class Ret>
struct __list_iterator
{
typedef list_node<T> node;
//typedef的力量
typedef __list_iterator<T, Ret> Self;
node* _pnode;
// 构造函数
__list_iterator(node* p)
:_pnode(p)
{}
// 解引用 operator*
Ret operator*()
{
return _pnode->_data;
}
// iterator it
// *it
// ++it;
//const T& operator*() const
//{
// return _pnode->_data;
//}
Self& operator++()
{
_pnode = _pnode->_next;
return *this;
}
Self& operator--()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator!=(const Self& it)
{
return _pnode != it._pnode;
}
};
这种大佬写的版本本质就是利用类模板会根据参数的不同实例化出不同类型的对象
增加一个Ret参数,根据传入的参数是T&还是const T&生成不同的迭代器
本质上跟我们自己实现两个迭代器没有区别,只是这个操作交给编译器(实例化两个对象) – 代码复用
这也是typedef的力量 typedef __list_iterator<T, Ret> Self;
一个简简单单的typedef就可以实现两个类型
对于类名和类型的区别:
普通类 类名等价于类型
类模板 类名不等价于类型
比如:list模板当中 类名叫list 而类型list
类模板中可以用类名来代替类型,但是不建议这样用
// lt2(lt1)
// list(const list<T>& lt) --推荐
list(const list& lt) // 不建议
{
empty_initialize();
list<T> tmp(lt.begin(), lt.end());
swap(tmp);
}
// lt3 = lt1
// list<T>& operator=(list lt) --推荐
list& operator=(list lt) // 不建议
{
swap(lt);
return *this;
}
虽然说在类内部这样写不会报错,但说到底这种写法不规范并没有真正意义上的将类型名作为参数 而是使用的类名
所以不推荐,但是需要说明一点在类外使用模板实例化对象必须说明类型不然会报错滴!!!
// list lt; -- 报错
list<int> lt;
7. 完善迭代器功能
7.1 operator–(单参模板)
__list_iterator<T>& operator--()
{
_pnode = _pnode->_prev;
return *this;
}
但是此时出现了两个模板参数,所实现的类型名就分两种(__list_iterator<T>
和 __list_const_iterator<T>
) 该怎么办呢?
还是运用模板和typedef的力量
7.2 多参模板
// 前置++ 用传引用返回
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)
{
// 先拷贝出tmp对象
Self tmp(*this);
// 再对this对象--
_pnode = _pnode->_prev;
// 返回--前的tmp对象
return tmp;
}
通过这里的代码也可看出如果前置或者后置都可以时,尽量选择前置操作,因为前置采用的是传引用返回,而后置采用传值返回
后置相较于前置多了两次拷贝构造 tmp – 1次,返回值 – 2次
所以前置的性能更好
bool operator!=(const Self& it)
{
return _pnode != it._pnode;
}
operator != 也用到了typedef Self
7.3 operator->(重载->)
// 只适用于普通迭代器
T* operator->()
{
return &_pnode->_data;
}
为了契合const迭代器,需要在模板参数当中再添加一个值(因为const迭代器->指向的数据也不能被修改)
template <class T, class Ret,class Ptr>
struct __list_iterator
{
typedef list_node<T> node;
//typedef的力量
typedef __list_iterator<T, Ret, Ptr> Self;
// 解引用 operator*
Ret operator*()
{
return _pnode->_data;
}
Ptr operator->()
{
return &_pnode->_data;
}
// .....
}
typedef __list_iterator<T, T&,T*> iterator;
typedef __list_iterator<T, const T&,const T*> const_iterator;
8. 总结:
vector和list的区别
迭代器失效问题
vector不管是插入还是删除都会出现迭代器失效的问题
插入可能扩容造成迭代器失效
删除越界访问造成迭代器失效
list是删除会出现迭代器失效的问题
删除越界访问造成迭代器失效
为啥在string当中不谈迭代器失效的问题呢?
因为string对象都是使用的pos(下标)来进行操作,通常不使用迭代器
同样的string也存在迭代器失效的问题(跟vector类似)