探索解析C++ STL中的 list:双向链表的高效实现与迭代器
前引:链表作为一种基础数据结构,其非连续内存存储特性使其在频繁的插入和删除操作中具有O(1)的时间复杂度优势,这与数组类型容器形成了鲜明对比。然而,这种优势的背后也伴随着随机访问性能的牺牲和额外的内存开销。本文将从底层实现原理出发,深入探讨`std::list`的内存模型、迭代器特性、以及与其他STL容器的性能对比。我们将通过详细的代码示例和性能分析,帮助读者全面理解何时选择`std::list`,如何高效使用其接口,以及在实际项目中如何权衡其优缺点。无论您是希望深化对STL理解的C++进阶开发者,还是正在学习数据结构与算法的学生,本文都将为您提供系统性的技术洞察和实践指导。让我们一同探索这一经典数据结构在现代C++中的精妙实现
【注意:本文的重点一是知道list的结构,二是迭代器的实现】
目录
List介绍
List实例化
尾插元素
访问元素
指定位置前插入
指定位置删除
排序(升序)
编辑 排序(降序)
拼接
去重
反转
注意
模拟实现
list的结构
尾插
尾删
头插
头删
拷贝构造
赋值运算符重载
迭代器
迭代器结构
begin()、end()
关系比较 !=
运算符重载 *
运算符重载 ++
无法修改的迭代器
方法(1)
方法(2)
总结:
List介绍
List也是C++标准模板库(STL)中的一种容器,它的内存存储特点与string、vector不同,它的存储不是连续的,List将元素存储在不连续的内存中,通过指针连接前一个节点和后一个节点。综合来说:就是另一种vector:只是内存存储变成了不连续,下面我来介绍它的各种常用接口!
List实例化
list<类型> 就是它的数据类型,后面跟上变量名即可,例如:
//list实例化
list<int> V;
//list实例化
list<int> V{ 1,2,3,4,5 };
如果想在实例化的时候放上元素,不能和vector、string那样,例如:
尾插元素
使用和之前的两个容器string与vector一样,例如:
//list实例化
list<int> V;
//尾插元素
V.push_back(1);
V.push_back(2);
V.push_back(3);
访问元素
在C++中支持迭代器的容器那就支持auto,而不支持迭代器的容器很少:
stack queue priority_queue
所以我们可以通过迭代器去访问元素,例如:
问:为何不是连续的空间,可以使用“++”?
首先“++”是在迭代器里面用了运算符重载(因此只支持在迭代器里面使用),例如:
//迭代器读\写
auto it = V.begin();
while (it != V.end())
{cout << *it << " ";it++;
}
cout << endl;
//auto的底层就是迭代器
for (auto e : V)
{cout << e << " ";
}
cout << endl;
指定位置前插入
这个就是 insert 接口,我们直接看:
//指定位置前插入
V.insert(V.begin(), 5);
注意:list 的内存是不连续的,不能直接使用“+”,例如:
注意:insert 之后对于List的迭代器是不会失效的,例如:
指定位置删除
//指定位置删除
V.erase(V.begin());
注意:使用erase之后,如果不接收返回值,那么迭代器就会失效,例如:
排序(升序)
List的排序属于稳定排序(n logn),例如:
v.sort();
排序(降序)
V.sort(greater<int>());
拼接
拼接支持:整体拼接、单个元素拼接、范围拼接,具体的我们实战的时候再去了解
拼接我们在合成多个链表时可以使用,比如:先拼接多个链表再用排序,这样就实现了整体有序
整体拼接:
//拼接V2在V1的末尾
V1.splice(V1.end(), V2);
单个元素拼接:
例如:将 V2中 it2 指向的元素拼接到 V1的 it1 前面
去重
去重的前提是元素已经有序
V2.unique();
反转
V2.reverse();
注意:
pos找到的是5的位置,而reverse翻转的是“闭区间,开区间”的元素,也就是0~4
例如:
注意
对于连续存储的 string、Vector我们可以通过 ++ 来移动获得下一个元素\地址
虽然对于 list 属于非连续的存储,我们还是可以使用 ++ ,这是因为支持了运算符重载,++ 的本质是 ->next,比如我们获取 pos 位置的下一个地址应该用 ++ 而不是 +1
模拟实现
list的结构
List属于不连续的存储,通过指针来连接每个空间,这有点像我们的链表,所以我们需要一个节点结构:节点包含 prev 、next、val
//节点结构
template<class T>
struct list_node
{
public://构造list_node(const T& date = T()): next(nullptr), prev(nullptr), val(date){}//节点成员list_node<T>* next;list_node<T>* prev;T val;
};
为何使用struct?因为这是节点,后面有头节点访问这个节点,私有的不能被访问到
除了节点结构外,我们还需要一个指针去指向这个节点,这个节点我们就为头节点:
//头节点template<class T>class list{typedef list_node<T> Node;public:list():Head(nullptr){//开辟头节点Head = new Node;Head->next = Head;Head->prev = Head;}private:Node* Head;};
}
节点和头节点的关系梳理:
尾插
双向链表的尾插很简单,头节点的前一个就是尾
//尾插
void push_back(const T& date)
{//先开辟空间Node* tmp = new Node(date);//确认关系Node* pc = Head->prev;pc->next = tmp;tmp->prev = pc;tmp->next = Head;Head->prev = tmp;
}
效果展示:
尾删
尾删注意:如果只有一个头结点的情况下,是不能删除的
我们只需要找到尾cur,然后再标记尾的前一个节点pc,再连接pc和头节点即可,最后释放
//尾删
void push_front()
{//如果只有一个节点assert(Head->next != Head);//找尾Node* cur = Head->prev;//标记Node* pc = cur->prev;//重新连接pc->next = Head;Head->prev = pc;//释放delete cur;cur = nullptr;
}
效果展示:
头插
头插需要找到头结点的下一个节点,然后连接即可,例如:
注意:我们这里使用的是迭代器,因此在最后需要更新迭代器
//头插
iterator pop_back(iterator it, const T& date)
{//先标记头节点的下一个节点Node* pc = Head->next;//新节点Node* cur = new Node(date);//连接Head->next = cur;cur->prev = Head;cur->next = pc;pc->prev = cur;return it = it._node->prev;
}
头删
头删还是先标记头节点的下一个节点cur,和cur的下一个节点,连接删除即可(注意更新迭代器)
//头删
iterator pop_front(iterator it)
{//如果只有一个头节点assert(Head->next != Head);//标记Node* cur = Head->next;Node* pc = cur->next;//连接Head->next = pc;pc->prev = Head;//释放delete cur;cur = nullptr;return it._node = pc;
}
效果展示:
拷贝构造
(1)根据当前存在的链表拷贝构造出新链表,注意深浅拷贝:
如果是内置类型:那么使用字节拷贝
如果是自定义类型:那么使用浅拷贝(编译器默认的拷贝构造)
(2)调用拷贝构造函数时候,不会去再之前调用构造函数,这点很重要,关乎头结点
//拷贝构造
list(const list<T>& V)
{//开辟头节点Head = new Node;Head->next = Head;Head->prev = Head;list<T>::const_iterator it = V.begin();while (it != V.end()){//插入push_back(*it);it++;}
}
这里由于 V 的类型是 const 类型,需要支持 const 的迭代器
//begin()
iterator begin()const
{return Head->next;
}
//end()
iterator end()const
{return Head;
}
效果展示:
赋值运算符重载
这里需要注意:空间的问题
我们还是采用新方法:拷贝构造出一个临时空间,再采用交换指针的方法
//交换
void swap(const list<int>& Vt)
{//拷贝构造临时对象list<int> Vs(Vt);//交换std::swap(Vs.Head, Head);
}
//赋值运算符重载
list<T>& operator=(const list<int>& V)
{swap(V);return *this;
}
效果展示:
迭代器
迭代器的效果:如果是解引用,那么需拿到元素;如果是++,那么需要跳到下一个节点
因此:迭代器的指针不能是固定的,否则解引用拿到的就是节点而非数据(运算符重载)
我们先观察库里面迭代器的特点:
list<int>::iterator it = V.begin();
while (it != V.end())
{std::cout << *it << " ";it++;
}
迭代器结构
这里 ++ 与 * 为了表示出不同的效果,我们使用的是运算符重载;对于非连续的空间的迭代 器实现,最好的方法是单独封装为一个类(大家记住即可),下面是迭代器的实现类结构
//迭代器
template<class T>
struct __list_iterator
{typedef __list_iterator<T> iterator;typedef list_node<T> Node;Node* _node;//构造__list_iterator(Node* node):_node(node){}//解引用T& operator*(){return _node->val;}//后置++iterator& operator++(int){iterator tmp(*this);_node = _node->next;return tmp;}//判断是否相等bool operator!=(const iterator& it){return _node != it._node;}
};
注意:iterator 被重定义了,使用 iterator 其实就是调用 类模板
begin()、end()
第一行会先执行 V.begin(),它的调用解析如下:
过程解析:
先是调用 V.begin(),拿到节点,然后发生隐式转换,调用 iterator 类模板构造,参数是节 点,完成转换,返回值再交给 it ,同理 V.end()也是一样
这里的返回值是隐式类型转化,无法使用 &
关系比较 !=
过程解析:
V.end() 作为参数先在类模板被初始化,然后直接调用运算符重载函数,返回 bool 值
运算符重载 *
过程解析:
it 的类型是 iterator 类型,它的改变就是下面的 ++ 操作
运算符重载 ++
为方便识别前置后置++:有 int 参数就是后置,无参数就是前置
无法修改的迭代器
对于无法修改的迭代器的效果:只可以访问,不能修改访问元素
方法(1)
const 的修饰效果:
所以我们可以直接在返回值这里加上 const 修饰,这是最直接的方法:
方法(2)
我们发现 const T 跟 T 只是类型不同,其它都是相同的,那么我们可以通过类模板的参数去修改它
而 节点 的类型模板参数是 T ,所以我们得保证它跟迭代器的第一个参数是吻合的,比如:
不然节点的类型参数和迭代器的对不上,那就报错了
所以我们const类的迭代器和非const的迭代器,可以如下定义:
typedef list_iterator<T, T&> iterator;
typedef list_iterator<T, const T&> const_iterator;
原因:我们的类模板重定义了,由于两个不同的类参数: <T T&> <T const T&>
会形成两个不同的类:iterator<T T&> iterator<T constT&>
所以我们只需要控制类模板的第二个参数去返回就可以达到“修改”和“不可修改”的效果了
const与非const迭代器转化图:
这里最右边的第二个构造函数的参数我们现在只需要看就行了,这比较超前,我们后面再说!
总结:
对于迭代器const与非const的转化,以及其它类型的转化,我们都是调用构造函数来完成的,比如
一个节点类型 转化为 迭代器类型 一个迭代器 非const类型 转化为 const类型
这期间可能涉及到构造函数的重载,都是区分构造函数的参数来实现