很多人用C++标准库的list用得飞起,增删查改顺手就来了,但一提到自己动手模拟实现一个,立马就懵了。其实这很正常,因为list这类容器不像vector那样直观,它涉及指针的分散管理、节点的动态创建与释放,还有一套专门的迭代器逻辑。这篇文章我就带你从头手写一个带头双向链表的list模拟实现,把增删查改每一个操作都掰开揉碎讲清楚,不搞虚的,全是能让代码跑起来的干货。
这个项目适合谁?两类人。一类是正在学C++的初学者,你大概已经会用list了,但想知道它底层到底怎么组织的,面试题里常考的"手撕list"到底怎么撕。另一类是工作了一段时间的开发者,平时写业务代码不太碰这类底层东西,但遇到性能分析、内存问题排查时,如果不懂容器底层,很容易抓瞎。这篇文章会从架构设计、节点定义、增删查改实现到迭代器封装、深拷贝、内存管理,走一遍完整的模拟实现流程,保证你看完能自己写出来,也能给其他人讲明白。
1. 为什么要手写list:理解底层才是真正掌握
1.1 会用list和懂list是两码事
先聊一个常见现象:很多人学习C++容器,停留在"调用API"的层面。知道push_back在尾部插入,知道insert可以在任意位置插入,但问到"为什么list的insert迭代器不会失效"就答不上来了。这就是典型的"会用但不懂"。
list底层是带头双向链表,它的核心特点有两个:第一,每个元素是一个独立分配的节点,节点里存着数据以及指向前驱和后继的指针;第二,有一个不存储实际数据的头节点,用来统一空表和非空表的操作逻辑。这两个特点决定了list的插入删除操作只需要改动相邻节点的指针,不需要搬移数据,所以时间复杂度是O(1)。而vector在中间插入需要搬移后面的所有元素,这就是本质区别。
手写list最大的价值不在于"你也能写出标准库的水平",而在于你在实现过程中被迫去思考内存布局、指针指向、异常安全、迭代器生命周期这些底层问题。这些东西在纯使用层面是永远学不到的。
1.2 带头双向链表的结构拆解
先把这个结构在脑子里画清楚。假设我们有三个节点A、B、C,头节点叫head,那么它们的关系是:
- head->next指向A,A->prev指向head
- A->next指向B,B->prev指向A
- B->next指向C,C->prev指向B
- C->next指向head,head->prev指向C
这是一个环,头节点既是起点也是终点。判断链表为空的条件就是head->next == head,同时head->prev == head。判断链表到尾部的条件就是cur == head,也就是说从头节点的下一个节点开始遍历,走一圈回到head就结束了。
为什么要带头节点?我们想想如果不带头节点会怎样。链表为空时head是nullptr,那尾插要特殊处理,头插也要特殊处理,删除最后一个节点后还要把head置空。每一个操作都得分情况讨论,代码里全是if。带头节点之后,空表和非空表的操作逻辑完全统一,插入删除都不需要额外判断,这就是"哨兵位"的价值。
1.3 模拟实现的目标与边界
我们这次模拟实现的目标是什么?不是100%复刻标准库,那工程量太大,也意义不大。我们的目标是实现一个功能完整、逻辑正确、能通过基本测试的list模拟版本,核心接口包括:
- 构造:默认构造、拷贝构造、赋值运算符重载、析构
- 插入:push_back、push_front、insert
- 删除:pop_back、pop_front、erase、clear
- 查询:size、empty、front、back
- 迭代器:begin、end,以及迭代器的++、--、*、->、!=、==
实现过程中我用C++的模板来写,这样可以存储任意类型的数据。节点里存的是T类型的数据,这个T就是模板参数。代码结构分三个文件:list.h(声明与实现)、test.cpp(测试代码),其实为了演示方便也可以都写在同一个文件里,但在工程上建议分离。
2. 框架搭建:从节点定义到链表主体
2.1 节点结构设计
节点是链表的基本单位,它内部要存三样东西:数据、指向前驱的指针、指向后继的指针。用struct定义就够了,因为节点内部的成员默认公有,方便链表类直接访问。
template <class T> struct ListNode { ListNode<T>* _next; // 指向后继节点 ListNode<T>* _prev; // 指向前驱节点 T _data; // 存储的数据 ListNode(const T& data = T()) : _next(nullptr) , _prev(nullptr) , _data(data) {} };构造函数的参数给了默认值T(),这是T类型的默认构造结果。对int就是0,对double就是0.0,对string就是空字符串,对自定义类型就是调用自定义类型的默认构造函数。这样设计的好处是,创建头节点时不需要额外传参,直接ListNode ()就能得到一个数据为默认值、前后指针都为空的节点。
有人可能会问,头节点的数据域怎么办?实际上头节点的_data是没人用的,它就是个占位符,但在内存上它是真实存在的,这保证了结构的一致性和操作的同构性。
2.2 链表类骨架与成员变量
链表类的框架大概是这样的:
template <class T> class List { public: typedef ListNode<T> Node; // 迭代器类型 typedef ListIterator<T, T&, T*> Iterator; typedef ListIterator<T, const T&, const T*> ConstIterator; // 构造与析构 List(); List(const List<T>& lt); List<T>& operator=(const List<T>& lt); ~List(); // 迭代器接口 Iterator begin(); Iterator end(); ConstIterator begin() const; ConstIterator end() const; // 容量相关 size_t size() const; bool empty() const; // 访问元素 T& front(); T& back(); const T& front() const; const T& back() const; // 修改操作 void push_back(const T& x); void pop_back(); void push_front(const T& x); void pop_front(); Iterator insert(Iterator pos, const T& x); Iterator erase(Iterator pos); void clear(); private: Node* _head; size_t _size; };这里的关键点有两个。第一,成员变量不是裸的Node*,还有size_t _size来记录节点个数,这样size()接口才能O(1)返回,而不是遍历链表去数。标准库的list确实也是这样做的。第二,private里只放了_head和_size,没有其他冗余信息,所有操作都通过这两个成员展开。
2.3 构造函数、析构函数与初始化
构造函数的核心任务是创建头节点,让_head成为一个独立的哨兵节点,它的next和prev都指向自己:
template <class T> List<T>::List() : _head(new Node) , _size(0) { _head->_next = _head; _head->_prev = _head; }这里有一个很多人忽略的细节:new Node会调用Node的构造函数,Node的构造函数把_next和_prev都初始化为nullptr,所以创建好之后还要手动把头节点的前后指针指向它自己,形成一个"空环"。这个环形结构是所有后续操作的基础。
析构函数负责释放所有节点。注意不能只delete _head,否则其他节点就内存泄漏了。正确的做法是先调用clear()释放所有数据节点,再delete头节点:
template <class T> List<T>::~List() { clear(); delete _head; _head = nullptr; }clear()的逻辑是从第一个有效节点开始遍历,依次记录下一个节点的位置、删除当前节点,直到回到头节点:
template <class T> void List<T>::clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_next = _head; _head->_prev = _head; _size = 0; }这里为什么要先保存next再delete?因为delete之后cur就变成了悬垂指针,再访问cur->_next就是未定义行为。这个"先保存后删除"的习惯在链表操作里特别重要,后文的erase也会有同样的套路。
3. 增删查改核心实操:每个操作的指针变化都讲透
3.1 尾插与头插:头节点是哨兵,所有插入都变成通用操作
先看push_back。标准库的push_back是在尾部插入一个节点。在带头双向链表中,尾部就是头节点的prev。所以尾插的完整过程是:
template <class T> void List<T>::push_back(const T& x) { Node* newnode = new Node(x); Node* tail = _head->_prev; tail->_next = newnode; newnode->_prev = tail; newnode->_next = _head; _head->_prev = newnode; ++_size; }这个操作包含了四步指针修改:原来尾节点的next指向新节点,新节点的prev指回原来的尾节点,新节点的next指向头节点,头节点的prev指向新节点。注意顺序不能乱,如果先把_head->_prev改了再操作tail,tail就找不到了。
画图理解是所有链表操作的核心方法,我建议你在纸上把这个过程画一遍:画一个三节点的环,然后在尾部画一个新节点,用箭头表示四步指针修改,一步一个箭头,画完你就永远不会忘。
push_front的逻辑和push_back高度对称,只不过插入位置变成了头节点的next:
template <class T> void List<T>::push_front(const T& x) { insert(begin(), x); }这里我直接把push_front复用了insert,稍后会看到insert的实现。这种复用的思路在写库代码时非常常见,核心操作做对了,外围接口就是一层薄薄的包装。
3.2 insert的万能插入:在任意位置插入,迭代器不失效的秘密
insert是list最核心的插入操作,它能在任意迭代器位置前插入新节点。实现方式非常简单,因为双向链表天然支持在已知节点前插入:
template <class T> typename List<T>::Iterator List<T>::insert(Iterator pos, const T& x) { Node* cur = pos._node; Node* prev = cur->_prev; Node* newnode = new Node(x); prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; ++_size; return Iterator(newnode); }返回值是新插入节点的迭代器,这一点特别重要。标准库的insert返回值就是这样设计的,如果你忘了接收返回值,那么插入后原来的pos迭代器依然有效,但指向的位置发生了变化,可能会造成逻辑混乱。
为什么说list的insert不会导致迭代器失效?因为插入操作只改变了相邻节点的指针,已经存在的节点内存地址没有变,迭代器保存的节点指针仍然有效。这和vector的insert有本质区别:vector插入可能需要扩容搬移数据,所有迭代器全部失效。
3.3 erase删除:释放节点,返回下一个有效迭代器
erase用于删除指定位置的节点。这里有一个极其重要的点:返回值必须是下一个有效节点的迭代器。
template <class T> typename List<T>::Iterator List<T>::erase(Iterator pos) { Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; --_size; return Iterator(next); }删除一个节点只需要两步指针修改:前驱节点的next指向后继节点,后继节点的prev指向前驱节点。然后释放当前节点内存。为什么返回next?因为当前节点已经释放了,如果你还持有pos迭代器,那它就是悬垂的,访问它会出问题。返回next之后,你可以继续安全的遍历。
pop_back和pop_front就是对erase的复用:
template <class T> void List<T>::pop_back() { erase(--end()); } template <class T> void List<T>::pop_front() { erase(begin()); }这里注意pop_back的实现,end()返回的是头节点的迭代器,--end()得到的是最后一个有效节点的迭代器,删除它。如果链表为空,这个操作就会出问题,所以调用方要自己保证不为空,或者我们在内部加assert断言。
3.4 查找与修改:本质上是通过迭代器遍历和赋值
查找操作其实不是list的专属方法,标准库的find是算法,不是成员函数。但作为模拟实现,我们可以在外部用迭代器遍历来实现查找:
List<int>::Iterator it = lt.begin(); while (it != lt.end()) { if (*it == target) { // 找到了,进行修改 *it = new_value; break; } ++it; }这个循环做了三件事:遍历、查找、修改。修改的操作直接通过it赋值完成,这依赖迭代器的operator返回引用。之所以list的迭代器要返回引用而不是值,就是为了支持这种就地修改的场景。
修改操作在list里没有专门的modify函数,直接对*it赋值即可。但有一个要注意的点:如果你是遍历过程中删除元素,那必须用erase的返回值来更新迭代器,不能直接++it,因为it指向的节点已经被释放了。
4. 迭代器:list的灵魂组件
4.1 为什么要单独实现迭代器而不是用Node*
标准库容器的迭代器要具备和指针类似的行为:解引用得到元素、++移动到下一个、--移动到前一个、==和!=比较是否到达边界。对vector这种连续存储的结构,原生指针天然具备这些能力,所以vector的迭代器本质上就是T*。但对list,节点在内存中分散分布,Node*的++操作只会跳到下一个内存地址,而不是链表的下一个节点,完全无法使用。
因此list迭代器必须是一个自定义类,内部封装Node指针,重载operator++让它走_next,重载operator--让它走_prev,重载operator取数据。
4.2 迭代器类的核心实现
迭代器类的设计采用模板参数传引用和指针类型,这是一种经典写法,能同时支持普通迭代器和const迭代器:
template <class T, class Ref, class Ptr> struct ListIterator { typedef ListNode<T> Node; typedef ListIterator<T, Ref, Ptr> Self; Node* _node; ListIterator(Node* node = nullptr) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &(_node->_data); } Self& operator++() { _node = _node->_next; return *this; } Self operator++(int) { Self tmp(*this); _node = _node->_next; return tmp; } Self& operator--() { _node = _node->_prev; return *this; } Self operator--(int) { Self tmp(*this); _node = _node->_prev; return tmp; } bool operator==(const Self& s) const { return _node == s._node; } bool operator!=(const Self& s) const { return _node != s._node; } };这里的关键在于模板参数Ref和Ptr。当T是int时,普通迭代器就是ListIterator<int, int&, int*>,Ref对应int&,所以operator返回的是引用,可以修改;const迭代器是ListIterator<int, const int&, const int>,operator*返回const引用,只能读不能写。一套代码同时适配两种迭代器,这就是模板设计的好处。
operator->的实现很多人一开始想不通。比如迭代器指向一个自定义类型T,要访问它的成员变量,通常写法是it->member,其实这个调用等价于(it.operator->())->member,两层箭头。第一层是迭代器重载的operator->返回T*,第二层是T*本身的->访问成员。
4.3 begin和end的设计
begin返回第一个有效节点的迭代器,end返回头节点的迭代器:
template <class T> typename List<T>::Iterator List<T>::begin() { return Iterator(_head->_next); } template <class T> typename List<T>::Iterator List<T>::end() { return Iterator(_head); }为什么用头节点作为end?因为遍历的时候,从头节点的下一个节点开始,经过所有有效节点之后会再次回到头节点,此时迭代器和end()相等,循环结束。用头节点作为哨兵,遍历的终止条件变得非常简单,不需要额外记录size来判断。
如果支持const迭代器,注意const版本的begin和end必须返回ConstIterator,所以两个版本都写一份,或者通过模板技巧统一。我们这里为了清晰直接写两份。
5. 深拷贝、赋值与内存管理
5.1 拷贝构造:不是拷贝指针,而是拷贝链表
默认的拷贝构造会把_head指针直接复制过来,导致两个List对象指向同一个头节点,这显然是灾难。我们需要深拷贝:创建一个新的头节点,然后把原链表的所有节点依次尾插到新链表。
template <class T> List<T>::List(const List<T>& lt) : _head(new Node) , _size(0) { _head->_next = _head; _head->_prev = _head; for (ConstIterator it = lt.begin(); it != lt.end(); ++it) { push_back(*it); } }这里为什么能用push_back?因为push_back内部会创建新节点、拷贝数据、调整指针,这些操作互不干扰。遍历原链表用的是ConstIterator,因为lt是const对象,不能通过普通迭代器修改它。
拷贝构造写完之后,赋值运算符就简单多了。现代C++推荐用copy-and-swap风格,或者先清空自己再尾插:
template <class T> List<T>& List<T>::operator=(const List<T>& lt) { if (this != <) { clear(); for (ConstIterator it = lt.begin(); it != lt.end(); ++it) { push_back(*it); } } return *this; }自我赋值检查是很重要的一个细节,如果传入的是自己,clear()之后数据就没了,再遍历就崩溃了。虽然使用场景中很少出现lt = lt这种写法,但防御性编程做到位,能省去很多调试时间。
5.2 内存管理的几个公认规范
写链表代码最重要的是记住"谁new谁delete"的原则。内存管理有四个要点:
第一,所有节点都必须通过new创建,且创建之后必须保证在合适的时机被delete。链表析构时如果只delete头节点,其他节点全泄漏,这是最典型的错误。
第二,insert和erase是对称操作。insert new了一个节点,erase delete了一个节点,只要成对出现,内存就不会泄漏。如果你的代码里new了节点但忘记erase,或者erase了但忘记delete,那问题迟早暴露。
第三,clear之后链表应该恢复到和默认构造一样的状态:头节点的next和prev都指向自己,size为0。
第四,析构函数里clear()之后还要delete头节点,因为头节点也是new出来的,不释放就泄漏了一个节点。头节点虽然在逻辑上是哨兵,但在内存上没有任何特殊之处,一样要释放。
我在实际测试中发现,很多初学者在写clear的时候会忘记重置头节点的next和prev。如果不重置,clear之后链表虽然size是0,但头节点的指针还指着已经释放的内存,后续再用就会崩溃。
6. 常见崩溃场景与调试经验速查
6.1 迭代器失效的经典误区
很多人在写list遍历删除时会这样写:
for (auto it = lt.begin(); it != lt.end(); ++it) { if (*it == val) { lt.erase(it); // 大错特错 } }这段代码的问题在于,erase(it)执行后it指向的节点被释放了,但循环体里紧接着++it,这相当于对一个悬垂指针做++,结果是未定义行为。轻则产生错误结果,重则直接段错误。
正确写法是:
for (auto it = lt.begin(); it != lt.end(); ) { if (*it == val) { it = lt.erase(it); } else { ++it; } }这里的关键是吃掉erase的返回值,让it指向被删除节点的下一个节点。如果你不删除元素,才手动++it。这种"先判断再决定前进方式"的循环模式,是遍历链表操作的基本功。
6.2 空表操作与边界条件
另一个高频崩溃点是空表时调用front()或pop_back()。链表为空时,begin()返回的就是end(),front()取begin()的数据,实际上访问的是头节点的_data,虽然在我们的实现里data有默认值不会崩,但在标准库的调试版本里会直接断言报错。
解决办法很简单:使用前先判断empty(),或者用断言强制校验:
assert(!lt.empty()); T val = lt.front();我见过很多线上bug,追根溯源都是对空容器做了非法操作。任何时候从容器拿数据,先问自己一句:这个容器一定是非空的吗?不确定就加判断,成本很低,收益很高。
6.3 内存泄漏的检测与排查
链表写完之后,建议用工具检查一下内存泄漏。在Windows的调试模式下,可以用_CrtDumpMemoryLeaks(),或者用Valgrind(Linux)检测。如果你看到泄漏报告里new分配的位置指向ListNode,那几乎可以肯定是某个节点没有被delete。
排查思路是先看clear,再看erase。clear有没有循环到所有节点?erase之后有没有把前后节点的指针接好?每次insert之后有没有对应的一次erase?顺序排查,基本能找出问题。
调试链表还有一个好用的小技巧:在关键位置打印节点地址。比如每次push_back打印新节点地址,每次erase打印被删除的节点地址,然后把日志对齐,看看有没有节点new了但没有delete。这个方法虽然土,但特别有效。
举个我实际调试过的例子,曾经有个链表在析构时崩溃,我加了打印后发现问题出在clear的循环条件上:我的循环条件是while (cur != _head),但有个地方把某个节点的next指向了nullptr,循环跑到nullptr后继续访问cur->_next,直接段错误。后来查明原因:有个函数直接用Node*操作,绕过了迭代器,把本应该指向头节点的指针置成了nullptr。这就是不统一使用接口的代价。
这类问题其实很好避免,从头到尾都用迭代器和类接口操作链表,不要在外面碰裸的_node指针,就不会出这种低级问题。当然,为了调试看结构,打印地址是没问题的,但修改结构一定要走接口。
6.4 测试驱动:写完就测,用边界数据轰炸
写链表模拟实现,测试环节万万不能省。我的测试习惯是准备一组边界数据,把每个接口都打一遍。
第一轮测试空链表:默认构造后调用empty和size,确认空表结构正确。再对一个空链表连续做push_back和push_front,观察首尾插入是否正常。
第二轮测试单个元素:插入一个数据后,检查前后指针是否都指向头节点,front和back返回是否一致,删除后是否恢复空表。
第三轮测试大量数据:插入10000个元素,检查size是否准确,遍历是否完整,随机位置插入删除是否引起崩溃。大量数据能暴露很多隐蔽问题,比如指针没有正确回环,或者size计数出错。
第四轮测试拷贝与赋值:拷贝构造链表、赋值已有链表,确认源链表数据不变,新链表数据完全独立。修改新链表的数据,源链表不能有任何变化。
我建议每个接口至少测两次,一次是正常场景,一次是极端场景。比如insert插在begin()位置、插在end()位置、插在中间;erase删第一个节点、删最后一个节点、删中间节点。这些边界场景往往就是bug最密集的地方。
7. 完整代码与使用示例
把上面的内容汇总一下,给出一份可以直接编译运行的完整示例:
#include <iostream> #include <cassert> using namespace std; template <class T> struct ListNode { ListNode<T>* _next; ListNode<T>* _prev; T _data; ListNode(const T& data = T()) : _next(nullptr), _prev(nullptr), _data(data) {} }; template <class T, class Ref, class Ptr> struct ListIterator { typedef ListNode<T> Node; typedef ListIterator<T, Ref, Ptr> Self; Node* _node; ListIterator(Node* node = nullptr) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &(_node->_data); } Self& operator++() { _node = _node->_next; return *this; } Self operator++(int) { Self tmp(*this); _node = _node->_next; return tmp; } Self& operator--() { _node = _node->_prev; return *this; } Self operator--(int) { Self tmp(*this); _node = _node->_prev; return tmp; } bool operator==(const Self& s) const { return _node == s._node; } bool operator!=(const Self& s) const { return _node != s._node; } }; template <class T> class List { public: typedef ListNode<T> Node; typedef ListIterator<T, T&, T*> Iterator; typedef ListIterator<T, const T&, const T*> ConstIterator; List() : _head(new Node), _size(0) { _head->_next = _head; _head->_prev = _head; } List(const List<T>& lt) : _head(new Node), _size(0) { _head->_next = _head; _head->_prev = _head; for (ConstIterator it = lt.begin(); it != lt.end(); ++it) push_back(*it); } List<T>& operator=(const List<T>& lt) { if (this != <) { clear(); for (ConstIterator it = lt.begin(); it != lt.end(); ++it) push_back(*it); } return *this; } ~List() { clear(); delete _head; _head = nullptr; } Iterator begin() { return Iterator(_head->_next); } Iterator end() { return Iterator(_head); } ConstIterator begin() const { return ConstIterator(_head->_next); } ConstIterator end() const { return ConstIterator(_head); } bool empty() const { return _size == 0; } size_t size() const { return _size; } 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; } void push_back(const T& x) { insert(end(), x); } void push_front(const T& x) { insert(begin(), x); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); } Iterator insert(Iterator pos, const T& x) { Node* cur = pos._node; Node* prev = cur->_prev; Node* newnode = new Node(x); prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; ++_size; return Iterator(newnode); } Iterator erase(Iterator pos) { assert(pos != end()); Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; --_size; return Iterator(next); } void clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_next = _head; _head->_prev = _head; _size = 0; } private: Node* _head; size_t _size; }; int main() { List<int> lt; lt.push_back(1); lt.push_back(2); lt.push_back(3); lt.push_front(0); for (auto e : lt) cout << e << " "; cout << endl; auto it = lt.begin(); ++it; it = lt.insert(it, 99); for (auto e : lt) cout << e << " "; cout << endl; it = lt.begin(); ++it; it = lt.erase(it); for (auto e : lt) cout << e << " "; cout << endl; List<int> lt2(lt); lt2.pop_back(); cout << "lt size: " << lt.size() << endl; cout << "lt2 size: " << lt2.size() << endl; return 0; }这份代码可以直接复制去验证。当然,工程中还需要补充更多细节,比如移动构造、右值引用版本、initializer_list构造等等,但核心骨架和核心操作都已经覆盖了,先掌握这些,再逐步扩展。
8. 从模拟实现到理解标准库:再往前走几步
写完这份模拟实现,再回头用标准库list,你会明显觉得"心里有底了"。比如你看到insert返回迭代器,知道它为什么这么设计;看到erase之后迭代器失效,知道它底层做了什么;看到排序、merge、splice这些成员函数,也能猜到它们是基于链表节点的指针搬运实现的。
顺着这个方向,还有几个可以继续深入的课题。第一个是splice函数,它能实现链表节点的拼接,把一段链表从一个链表转移到另一个链表,这完全是纯指针操作的搬移,没有任何数据拷贝。第二个是list的sort实现,标准库的list::sort用的是归并排序,因为链表随机访问性能差,快排类算法不适合。第三个是list和vector的性能对比:list插入删除快,但随机访问是O(n),vector随机访问O(1)但中间插入慢,合理选用容器本身就是程序员的基本功。
我在实际项目中见过不少因为乱用容器导致的性能问题,比如数据量不大却用list存了还要频繁随机访问下标,结果遍历了无数次;或者需要频繁头插尾插却用了vector,导致频繁搬移数据。理解了list的底层结构,你选型时就不盲目了。
这次模拟实现最大的收获在于,把链表这个抽象结构还原成了具体的内存和指针操作。建议你也动手敲一遍代码,跑一遍测试,改一改尝试加新功能,比如加一个reverse接口、写一个find成员、试试存储自定义结构体。实操一遍之后,链表、迭代器、内存管理这些C++核心概念,都会上一个台阶。