1. 先搞清楚list到底是什么
C++标准库里的list是我们平时做开发最常用的容器之一。它的底层实现是一套双向循环链表,每个元素都被封装在一个独立的节点中,节点之间通过指针串联。你如果用过链表,一定知道它最爽的地方就是插入和删除不需要挪动其他元素,改改指针就能搞定,而且list把这个链表的操作全部封装好了,你不用手动去管理节点内存。
我第一次接触list的时候,第一反应是“这不就是个链表嘛,我已经会了”。真正上手才发现,自己手写链表和用STL的list完全是两回事。STL的list不仅把节点管理、迭代器访问、内存分配这些底层细节全部包好,还提供了非常容易上手的接口。更值得琢磨的是“使用”和“模拟实现”这两个方向完全不一样的姿态:用的时候你只关心它的行为和效率,模拟实现的时候你要真正理解它内部那一堆指针是怎么绕的。
这篇博文面向的是已经学完C++基本语法、类和对象、模板、动态内存管理等基础内容的小伙伴们。你将掌握list的常见接口用法、它和vector在行为和性能上的差异点,然后我会完整地手写一个简化版的list,把链表节点的构造、迭代器的封装、哨兵头节点的作用这些东西从头到尾讲透。我给这个模拟实现起了个名字,叫模拟项目X——它虽然只是个教学Demo,但基本还原了标准库list的核心机制。
读完之后你再回头用std::list,很多行为你就能想通它为什么那样设计了,debug的时候心里也会更有底。
注意:本文虽然初阶,但是模拟实现部分涉及一定的模板知识和类设计,建议先完成C++类和对象章节的基础学习,再读模拟实现部分,否则可能有点吃力。
2. list有哪些让你心动的接口
2.1 list的构造方式
list的构造函数和vector比较像,但有些细节值得琢磨。常用构造方式基本就这几种:
#include <list> #include <iostream> std::list<int> l1; // 空list std::list<int> l2(10, 5); // 10个值为5的元素 std::list<int> l3(l2.begin(), l2.end()); // 用迭代器区间构造 std::list<int> l4(l2); // 拷贝构造 std::list<int> l5(std::move(l2)); // 移动构造(C++11) std::list<int> l6{1, 2, 3, 4, 5}; // 列表初始化 for (auto& e : l6) { std::cout << e << " "; // 1 2 3 4 5 } std::cout << std::endl;那问题来了:list的拷贝构造是深拷贝还是浅拷贝?答案是深拷贝。这一点对链表来说尤为重要。因为浅拷贝只会把链表头指针复制过去,两个list对象会指向同一串节点,析构的时候double free,程序直接崩掉。所以无脑用默认拷贝构造的list,等于在给程序埋雷。
移动构造是个很实用的特性,它能把源对象的链表头指针原样拿过来,然后让源对象变空,不涉及任何元素的搬运,效率极高。写代码时尽量给大对象加上移动语义,能省很多开销。
2.2 插入和删除:list的看家本领
对于list这种链表结构,插入删除就是它的主场。几个高频接口如下:
std::list<int> l; l.push_back(1); l.push_back(2); l.push_front(0); l.push_back(3); // 此时 l: 0 1 2 3 auto it = l.begin(); ++it; // 指向1 it = l.insert(it, 100); // 在1之前插入100,it现在指向100 // 此时 l: 0 100 1 2 3 it = l.erase(it); // 删除it指向的100,it后移到下一个元素 // 此时 l: 0 1 2 3 l.pop_back(); l.pop_front(); // 此时 l: 1 2插入接口insert接收一个迭代器位置,把新元素插到那个迭代器指向元素的前面,并且返回指向新插入元素的迭代器。删除接口erase接收迭代器,删除当前这个元素,返回指向被删元素后继的迭代器。为什么要有返回值?为了配合循环遍历时边删边走的需求,避免迭代器失效问题。
list还有一个很实用的小技巧:push_back内部其实等价于在end()前面插一个元素,所以如果你写l.insert(l.end(), val),效果和push_back是相同的。反过来,push_front等价于在begin()位置插入。
2.3 容量、大小和空间管理:链表的自我认知
list的size和empty是高频接口:
std::list<int> l = {1, 2, 3}; std::cout << l.size() << std::endl; // 3 std::cout << l.empty() << std::endl; // 0可能有人会好奇,list没有capacity和reserve,这是为什么?因为capacity的本质是预分配连续内存空间,这个问题只对vector这种顺序容器才有意义。链表节点在内存中本来就是散落的,根本不存在“预留大量节点空间”这个说法。每次插入就单独向内存池要一个节点,用完了就释放,时间上是动态且精确的,你不需要担心扩容拷贝的问题。
2.4 三个容易混淆的正确遍历姿势
list的遍历有几个坑,尤其是想用下标访问的时候,你会发现list根本不支持operator[]。这是由它的物理结构决定的:单链表和双向链表想按下标找元素必须从头一个一个跳过去,时间复杂度O(N),那不如直接用迭代器或者范围for。
正确的遍历姿势:
std::list<int> l = {10, 20, 30}; // 方法1:迭代器 for (auto it = l.begin(); it != l.end(); ++it) { std::cout << *it << " "; } std::cout << std::endl; // 方法2:范围for(底层也是迭代器) for (const auto& e : l) { std::cout << e << " "; } std::cout << std::endl; // 方法3:反向迭代器 for (auto rit = l.rbegin(); rit != l.rend(); ++rit) { std::cout << *rit << " "; } std::cout << std::endl;这三种输出的顺序分别是正序、正序、逆序,使用时按需取用就好。
这里强调一句:list迭代器支持的是双向迭代,也就是支持++和--,但不支持+和+=这种随机访问操作。所以你要跳n个位置,只能自己循环n次,或者用std::next、std::advance这种辅助函数。
2.5 修改、查找与其他增补接口
list的修改相对粗暴,尤其是sort和unique这两个算法,很多人会搞混。list自己实现了sort成员函数,不能用std::sort去排序list,因为std::sort需要随机访问迭代器,list不满足这个条件。同理,std::unique需要相邻元素比较,list也有自己的unique成员函数可以做去重。
std::list<int> l = {5, 2, 8, 2, 5, 1, 1}; l.sort(); // 1 1 2 2 5 5 8 l.unique(); // 1 2 5 8,去掉了相邻重复项如果你希望去掉所有重复,而非仅去掉相邻重复项,那就需要先排序再unique,或者自己写个辅助逻辑。
merge接口可以把两个有序链表“缝合”成一个有序链表;splice可以把一个链表中的一部分节点“剪切”到另一个链表——这两个接口在其他容器里是没有对应功能的,是list的灵魂操作。splice的好处是常数时间就能完成,因为只是改指针,不涉及拷贝节点。
std::list<int> l1 = {1, 3, 5}; std::list<int> l2 = {2, 4, 6}; l1.merge(l2); // l1 = {1, 2, 3, 4, 5, 6}, l2为空 std::list<int> l3 = {100, 200}; auto it = l1.begin(); std::advance(it, 2); l1.splice(it, l3); // 把l3的节点整体剪切到l1的it位置之前,l3清空 // l1 = {1, 2, 100, 200, 3, 4, 5, 6}这两个接口平时用得相对少,但面试和比赛里很容易出场景题,值得动手测一遍。
3. 模拟实现list:我闭眼也能写出来的东西
3.1 模拟实现之前,先把框架想清楚
下面进入大家最喜欢的环节:手写list。
标准库的list是一个双向循环链表,并且带一个哨兵头节点(也叫头结点、哑结点)。这个哨兵节点不存储有效数据,它的存在让空链表和普通链表都统一成同一种状态,什么边界特殊情况都没有了。注意,标准库的list是带头节点的循环链表,而很多教材里的“带头单链表”是不循环的,这两个不要混。
模拟实现大概分三类模块:
- 节点结构ListNode:存一个数据,一个前驱指针,一个后继指针。
- 迭代器__list_iterator:封装节点的指针,重载*、->、++、--、==、!=等操作,让用户能像用指针一样遍历链表元素。
- 链表类list:封装节点的创建、连接、删除、返回迭代器等操作。
为什么需要专门包装一个迭代器呢?因为list的节点不是连续存储的,原生指针只能指向某个节点,没法满足我们想要的“*it表示元素数据”这种语义。迭代器就是把节点指针包装起来,通过重载各种运算符,让你遍历的时候感觉不到内部结构差异。
3.2 节点结构的定义
节点是整个链表的细胞。数据域用模板参数T表示,指针域就是前驱和后继两个指针。代码非常简单:
template <class T> struct ListNode { ListNode<T>* _prev; ListNode<T>* _next; T _data; ListNode(const T& val = T()) : _prev(nullptr) , _next(nullptr) , _data(val) {} };这里要注意构造函数里给一个默认参数T(),当你创建哨兵头节点时可以不传数据,直接用T()初始化数据域,对int就是0,对string就是空字符串,对自定义类就调用它的默认构造。不加这个默认参数,创建哨兵节点时会编译报错。
3.3 迭代器的完整封装
迭代器的设计是链表模拟实现里最有技术含量的一部分。你想让迭代器具备指针的感觉,至少要重载这些运算符:
template <class T, class Ref, class Ptr> struct __list_iterator { typedef ListNode<T> Node; typedef __list_iterator<T, Ref, Ptr> Self; Node* _node; __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++(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& it) const { return _node != it._node; } bool operator==(const Self& it) const { return _node == it._node; } };模板参数有T、Ref、Ptr三个。T是数据类型,Ref是引用的类型,Ptr是指针的类型。为什么要弄成三个参数而不是一个T?
因为当链表存储的数据是自定义类型的时候,你希望begin()返回普通迭代器可以修改数据,而const对象调用begin()时返回的迭代器不可以修改数据。如果用同一个迭代器模板实例化,就没办法区分这两种语义。标准库的惯用手法就是用Ref和Ptr这两个参数去区分——普通迭代器传T&和T*,const迭代器传const T&和const T*。这样编译器就能帮你做类型检查,防止在const容器上通过迭代器修改元素。
3.4 链表的整体框架和四个基本接口
list本体类的框架大致如下:
template <class T> class list { typedef ListNode<T> Node; public: typedef __list_iterator<T, T&, T*> iterator; typedef __list_iterator<T, const T&, const T*> const_iterator; // 构造、析构、拷贝、赋值 list(); // 迭代器接口 iterator begin(); iterator end(); const_iterator begin() const; const_iterator end() const; // 插入删除 void push_back(const T& val); void push_front(const T& val); void pop_back(); void pop_front(); iterator insert(iterator pos, const T& val); iterator erase(iterator pos); void clear(); size_t size() const; bool empty() const; };begin和end的设计里有个很精巧的地方,我当初学的时候绕了半天才转过来:
- begin返回的是哨兵头节点的后继,即第一个有效节点。
- end返回的是哨兵头节点本身。
这就形成了一个闭环。遍历时从第一个有效节点出发,一直走到头节点这个位置,就说明遍历结束了。正因为哨兵节点是循环回路的枢纽,我们的end()才能是一个有效的、确定的迭代器,不需要额外记录“最后一个节点的下一节点”——它根本不存在。
iterator begin() { return iterator(_head->_next); } iterator end() { return iterator(_head); }3.5 push_back和insert:修改指针的顺序是灵魂
push_back的实现思路是先构造一个新节点,然后把新节点挂到链表尾部,同时维护节点前后指针。我见过很多初学者写链表插入时改指针顺序不对,把某个节点的next先改了,导致后面想找它的后继时已经找不到了。
正确的思路是:先把新节点的前后指针都指向对应的邻居,然后让前驱和后继分别指向新节点。顺序上尽量先改新节点的指针,再改旧节点的指针。
void push_back(const T& val) { Node* new_node = new Node(val); Node* tail = _head->_prev; // 尾节点就是头节点的前驱 new_node->_prev = tail; new_node->_next = _head; tail->_next = new_node; _head->_prev = new_node; }为什么尾节点是头节点的前驱?因为这是循环链表,头节点的前驱一定是最后一个有效节点,或者当链表为空时头节点自己指向自己。这个性质理解透了,链表操作会简洁非常多,完全不需要花心思去判断边界。
insert接收一个迭代器位置,在它指向的节点之前插入新节点。和push_back的原理一样,甚至更通用:
iterator insert(iterator pos, const T& val) { Node* cur = pos._node; Node* prev = cur->_prev; Node* new_node = new Node(val); new_node->_prev = prev; new_node->_next = cur; prev->_next = new_node; cur->_prev = new_node; return iterator(new_node); }你看,有了哨兵头节点之后,insert在任意位置插入,代码居然一模一样,根本不需要额外判断“是不是在头部插”“是不是在尾部插”。这就是哨兵节点给我们带来的红利:把所有特殊情况都消灭在无形之中。
3.6 erase的实现:断开连接要干净利落
erase的关键是保存被删节点的前驱和后继,然后把前驱的后继指向后继,把后继的前驱指向前驱,最后delete掉目标节点。因为循环链表,所以这里的“前驱”“后继”始终都有值,没有空指针的危险:
iterator erase(iterator pos) { Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; return iterator(next); }代码很短,但需要注意一个细节:erase之后原来的pos迭代器就失效了,不能再解引用。所以我们一定要接收它的返回值,用返回的迭代器往后遍历。不要自作聪明地保存pos然后++,那样在debug版本下程序会直接断言失败,release版本可能悄悄出现悬垂指针。
3.7 clear和析构:千万不要搞双重释放
clear要做的是把所有有效节点全部释放,但保留哨兵头节点。析构函数要做的是先clear,再释放哨兵头节点。如果搞反了或者忘了保留头节点,就会造成内存管理混乱:
void clear() { iterator it = begin(); while (it != end()) { it = erase(it); } } ~list() { clear(); delete _head; _head = nullptr; }这里有个非常典型的双释放风险:有些同学在clear之后,又手动遍历一遍链表去delete节点,但节点已经被delete过了,再delete就是双释放。更隐蔽的情况是两个list对象浅拷贝了同一个_head指针,析构时各自delete一次,那是直接崩溃的。
解决办法很简单:拷贝构造和赋值运算符重载一定要做真正意义上的深拷贝,保证每个list对象都有自己的哨兵头节点和自己的节点链。
3.8 拷贝构造与赋值:深拷贝的正确姿势
拷贝构造的实现通常是先创建好哨兵头节点,然后挨个把源链表的元素尾插进来。为什么不用push_back遍历呢?因为push_back本身就是深拷贝逻辑的一环,它只负责插入,不涉及源对象。你要做的是基于迭代器遍历源list,把每个元素push_back到新list里。
list(const list<T>& lt) { _head = new Node(); _head->_prev = _head; _head->_next = _head; for (const auto& e : lt) { push_back(e); } }等等,这里有一个细节:push_back用到了_head,所以构造函数内部必须先分配哨兵头节点并让它自环。顺序不能反,否则push_back的时候_head是野指针,程序当场崩。初始化头节点自环这个动作,应该封装到构造函数里,让所有构造路径都统一走这一套初始化逻辑。
赋值运算符重载可以用“copy and swap”惯用法,这种写法简单又安全:
list<T>& operator=(list<T> lt) { swap(_head, lt._head); // 注意这里要交换头节点指针 return *this; }这里形参直接是list lt,用传值的方式调用拷贝构造,然后和当前对象交换头节点指针。函数出作用域时lt会析构,它带着原来这个对象的旧链表一并销毁。这写法干净利落,不用考虑异常安全的问题。
3.9 构造与初始化:头节点自环别漏了
所有构造函数,无论是默认构造还是迭代器区间构造,都要把哨兵头节点初始化成自环状态,然后再进行后续操作:
list() { _head = new Node(); _head->_prev = _head; _head->_next = _head; }如果不自环,空链表的end()返回的头节点它的前驱和后继都是空指针,那么你一旦调用push_back,代码里取tail = _head->_prev就会解引用空指针,程序崩得毫无征兆。链表最忌讳的就是边界调试,而自环头节点从源头上规避了这一整个维度的bug。
3.10 完整代码的串联演示
把上面的代码按顺序拼装起来,就是一个可以跑通的迷你版list。用它跑一段测试:
#include <iostream> #include <string> // 这里放前面写好的 ListNode, __list_iterator, list 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) { std::cout << e << " "; // 0 1 2 3 } std::cout << std::endl; auto it = lt.begin(); ++it; // 指向1 it = lt.insert(it, 100); // 在1前面插入100 for (auto e : lt) { std::cout << e << " "; // 0 100 1 2 3 } std::cout << std::endl; it = lt.erase(it); // 删除100 lt.pop_back(); for (auto e : lt) { std::cout << e << " "; // 0 1 2 } std::cout << std::endl; return 0; }我实际跑下来的输出和我预期完全一致,看起来虽然简单,但这段代码里涉及的每一个细节,包括模板实例化、迭代器运算符重载、节点内部指针维护,都是list这个容器最核心的底层机制。能独立写出并能调试通过,说明你对链表、模板和对象生命周期的理解已经跨上了一个台阶。
4. list的迭代器失效问题与避坑指南
迭代器失效是个老生常谈但非常容易踩的坑,初学list时大家普遍用vector的惯性思维来思考,结果容易踩坑的事情不少。
4.1 insert、erase之后的迭代器状态
开头我用list做insert的时候,强调过要使用它返回的新迭代器。因为内部结构发生了变化,老迭代器所指位置虽然还存在,但在erase的情况下,那个位置的内存已经被释放了,再使用就是悬垂指针。链表erase只影响被删节点对应的迭代器,其他节点的迭代器并不会失效——这一点和vector大不相同,vector erase之后后面所有迭代器全失效。
实际写代码时,为了让erase的使用更安全,我给自己立了一个规矩:凡是涉及循环中删除的代码,一律使用返回值重新赋值迭代器。比如:
list<int> l = {1, 2, 3, 4, 5}; for (auto it = l.begin(); it != l.end(); ) { if (*it % 2 == 0) { it = l.erase(it); // erase返回下一个迭代器 } else { ++it; } }这个模式通用于所有顺序容器,建议背下来。
4.2 与vector的选型对比
直接用一张图整理list和vector的使用场景差别:
| 维度 | list | vector |
|---|---|---|
| 底层结构 | 双向循环链表 | 连续存储数组 |
| 随机访问 | 不支持,O(N) | 支持,O(1) |
| 头部插入 | O(1) | O(N),所有元素后移 |
| 中间插入 | O(1),找到位置后改指针 | O(N),元素搬移 |
| 内存占用 | 每节点额外存两个指针 | 几乎无额外开销 |
| 缓存友好性 | 差,节点分散 | 好,连续访问快 |
| 迭代器类型 | 双向迭代器 | 随机访问迭代器 |
| 扩容机制 | 无扩容,按需分配 | 有扩容,容量翻倍式增长 |
选择原则很简单:如果代码里主要是“随机访问你想读第几个元素就读第几个”,选vector;如果主要是“频繁在中间或头部插入删除,但几乎不按下标访问”,选list。还要有一个判断点:对CPU缓存命中率有高要求的场景,vector通常表现更优秀,因为它的元素在内存中紧密排列,而list的节点分散分配,缓存命中率不如vector。
4.3 三类常见错误与排查心得
第一个常见错误是end()失效。很多人知道清空list要用clear(),但实际写循环的时候会写出这样危险的代码:
while (it != l.end()) { if (需要删除) { l.erase(it); // 错误:没有接收返回值,it已经失效了还要被用来比较 } ++it; }正确的做法在4.1已经给出来了。先要保留迭代器值再自增,否则删除后自增就是访问悬垂指针。
第二个常见错误是想当然地用下标遍历:
for (size_t i = 0; i < l.size(); ++i) { std::cout << l[i]; // 编译错误:list没有operator[] }list没有随机访问运算符是设计使然,不是遗漏。非要用下标索引,应该先转成vector或者用迭代器走n步。
第三个常见错误是把自定义类型的指向搞混。如果list里存储的是一个动态指针例如list<int*>,那你清空list时,系统只会释放节点本身,不会自动delete你那个int*指针指向的堆内存。这样容易泄漏内存空间。我建议,list栈上元素用RAII管理,要么用智能指针包裹,要么自己明确负责释放。这块最容易在“看起来内存管理正确”的情况下悄悄泄漏。
4.4 实际项目中我总结出两个细节
第一,list的size()在C++11之前可能是O(N)的,C++11之后标准要求必须O(1),所以写循环判断用size()的位置不再需要担心性能退化。
第二,list有专门的remove接口,可以直接按值删除所有匹配元素:
std::list<int> l = {1, 2, 3, 2, 4, 2}; l.remove(2); // 删除所有值为2的节点 // l: 1 3 4remove的实现本质是遍历+erase,但它替你封装好了迭代器失效的细节,初学阶段能用接口就先用接口,比自己写循环稳得多。
5. list的进阶接口:splice、merge、unique怎么用才优雅
5.1 splice:零拷贝的节点搬家
刚才提到splice可以把一个链表里的节点搬到另一个链表,并且不涉及任何数据拷贝,纯粹就是改几根指针。它的经典场景是把一个链表的一部分拆给另一个链表。比如有两个有序链表,想从第二个链表里把最小的两个节点摘给第一个链表当成新头,那就不需要拷贝,直接splice就行:
std::list<int> src = {1, 2, 3, 4}; std::list<int> dst = {10, 20}; auto it = dst.begin(); dst.splice(it, src, src.begin()); dst.splice(it, src, src.begin()); // 此时 dst: {1, 2, 10, 20},src只剩 {3, 4}注意参数顺序是你从哪里来、要谁、放到哪。splice的调用方是“目标链表”,第一个参数是目标位置,第二个参数是源链表,第三四个参数可以指定单个节点或一段区间。
5.2 merge:有序list的合并神器
两个分别有序的list可以通过merge合并成一个有序list。注意两个list都必须已经有序,否则行为未定义:
std::list<int> a = {1, 3, 5, 7}; std::list<int> b = {2, 4, 6, 8}; a.merge(b); // a: {1, 2, 3, 4, 5, 6, 7, 8} // b: 空merge之后,b会被清空。这个接口特别适合处理需要归并的场景,比手动while循环给你省很多事。要注意的是,merge对两个链表都要求“已经按operator<排序”,如果顺序不对,它只会机械地把节点链在一起,出来的链表整体不满足有序性,后续再unique、再查找都会出问题。
5.3 unique:只去相邻重复
unique的设计有点懒:它只移除“相邻且相等”的重复项。如果重复元素不相邻,例如{1, 2, 1},它不会去重。所以标准做法是先sort再unique。和合并列表一样,unique之后链表顺序不变,但这意味着只有排好序的list才能彻底去重:
std::list<int> l = {1, 2, 2, 3, 1, 1, 4}; l.sort(); l.unique(); // 此时 l: {1, 2, 3, 4}如果不sort,unique只会消掉相邻重复的部分,出现漏网。这个点我见过不止一次被误解,面试时也常被拿来当概念题考。
6. 我实际验证过的几个list操作技巧
6.1 在list中按条件删除部分元素
很多场景下不是按值删除,而是按条件删除,比如删掉小于某个阈值的所有元素。此时有一个更好用的成员函数叫remove_if:
std::list<int> l = {3, 1, 4, 1, 5, 9, 2, 6}; l.remove_if([](int x) { return x < 3; }); // 删除所有小于3的元素 // l: {3, 4, 5, 9, 6}它内部封装了遍历+erase的细节,一开始写代码时用这个,和别人讨论代码时也显得专业。
6.2 两个list按需合并再排序去重
我处理过一份业务场景(这里不暴露项目细节),需要用两个不同来源的数据联合处理,要求不能再有重复项。我的解决思路是:
std::list<int> list_a = {9, 3, 5}; std::list<int> list_b = {5, 1, 7}; // 先把list_b并入list_a的尾部 list_a.splice(list_a.end(), list_b); // list_a: {9, 3, 5, 5, 1, 7} // 排序 list_a.sort(); // list_a: {1, 3, 5, 5, 7, 9} // 去重 list_a.unique(); // list_a: {1, 3, 5, 7, 9}这组合拳打得很舒服,代码量不大,逻辑还清楚。核心是“先拼、再排、再去重”这个顺序不能乱,乱一个环节结果就是错的。
6.3 从list中取某个迭代器指向的元素
list没有operator[],但我们有std::advance和std::next可以做定步长移动:
std::list<int> l = {10, 20, 30, 40, 50}; auto it = l.begin(); std::advance(it, 3); std::cout << *it << std::endl; // 40 auto it2 = std::next(l.begin(), 2); std::cout << *it2 << std::endl; // 30std::next不会修改原迭代器,返回一个偏移后的新迭代器;std::advance是直接修改传入的迭代器。两者时间复杂度都是O(N),但这么写语义清晰,代码可读性比手工循环好很多。
7. 写list模拟实现过程中我踩过的坑
7.1 构造函数初始化顺序的坑
我第一次写list的默认构造的时候,构造函数体里只有一个new Node,忘了让头节点自环。结果第一次调用push_back,内部取得tail = _head->_prev时,得到的是空指针(Node的prev被我初始化为nullptr),随后的tail->_next = new_node直接访问空指针。程序崩溃之后查了很久,最后在push_back入口加输出才意识到是哨兵头没自环的锅。
解决办法就是把这套初始化逻辑彻底统一:分配头节点,然后把它的prev和next都指向它自己。只要涉及创建list,这一套动作必须pa执行。我现在写任何一个带哨兵节点的链表前,脑子里第一个浮现的就是“先自环,再干活”。
7.2 迭代器自增的返回引用问题
这是一个编译层面的坑。我给迭代器重载前置++时,最初写法是:
Self operator++() { _node = _node->_next; return *this; }看起来没问题,但仔细想,返回的是一个临时对象,是值返回,不是引用返回。这会导致两个问题:
- 语义上不符合前置++的惯例(前置++应该返回自增后的对象本身)。
- 在连续自增场景如++++it的时候,第一个++修改了当前对象,但返回的是副本;第二次++作用于副本,实际原本的it只前移了一次。
正确的写法是把返回类型改成Self&,返回*this。后置++保持值返回,返回自增前的副本,这样才能符合大家的直觉。这个细节可以通过打印对象地址观察验证:前置++返回的地址和原对象一致,后置++返回的地址是新副本。
7.3 拷贝赋值写成了浅拷贝
模拟实现阶段,我第一次没写拷贝构造函数和赋值运算符重载,而是直接使用编译器默认版本。结果连续两个list对象相互赋值后又各自修改,程序跑着跑着就无征兆地崩了。原因是默认拷贝只复制了_head指针本身,两个对象共用一个哨兵头节点和同一串节点,任何一方析构释放了内存,另一方就变成悬垂状态。
写容器类,牢记三/五法则:要么把拷贝构造、析构、拷贝赋值都实现,要么把它们显式删除。模仿list写一个类时,这三个一定得成组出现。
7.4 自定义类型作为list元素的注意事项
当T是自定义类时,list的节点构造会用T的默认构造函数初始化_data,所以你的自定义类型必须有可访问的默认构造函数。如果这个类定义了带参数的构造函数而没有默认构造,编译就会在ListNode的构造函数默认参数T()处爆出一堆错误,而且错误位置往往不在TreeNode,而是层层展开的模板实例化信息,初学者很容易看懵。
解决方式是给自定义类型补一个默认构造函数,或者在创建节点时显式传值。每次遇到模板展开的报错,建议先看最底下的“required from here”之类的提示行,那才是出错根源,比看上面几百行模板线索更高效。
8. 这些接口以后能做什么扩展
list作为一个典型的双向循环链表容器,除了教学价值之外,在实际项目里也是常客。常见扩展包括:
- LRU缓存:需要用list维护访问顺序,配合哈希表实现O(1)查找和更新。list的splice正好适合把某个节点搬到头部。
- 内存池:需要高效分配和释放节点时,可以自定义一个内存池管理器,list的节点分配就不再每次走系统new/delete。
- 垃圾回收器:某些语言的运行时管理对象列表时,会频繁插入删除对象,list的迭代器结构适合管理这种“强引用节点”的动态集合。
- 双端队列模拟:list天然支持两端插入删除,一些简单的任务队列可以直接用它实现。
如果你能自己把list的模拟实现完整写出来,再回头看这些场景,你就会发现:它们真正需要用的其实只是头尾操作、任意位置插入删除这几个基础能力,想到这一层,很多设计你就看明白了。
9. 最后的几点实操心得
从一个“会用list”到“能写出list”,中间隔的不是代码量,而是对节点、指针、迭代器这三个东西的掌控感。我建议学习者分三步走:
第一步,先把list的接口过一遍,每个都写小例子跑一遍。重点看sort前后迭代器位置变化、splice之后源链表的变化、merge之后的清空效果。这一阶段不急,慢就是快。
第二步,把模拟实现代码自己重写一遍,不要看答案,写不出来就回去看。这里最值得反复推敲的是insert和erase的指针顺序,以及迭代器为什么需要用Ref、Ptr两个模板参数。记不住就动手,动手就会被记忆。
第三步,给手写的list加一个自己设计的测试用例,把自定义类放进去,验证深拷贝、clear和析构,再测试迭代器遍历。这一步能暴露很多“以为自己会了”的问题。
我在实际学习和训练过程中发现,最容易拉开人与人差距的,不是方法技巧,而是“读完就动手”的执行力。这篇文章从list用法一路讲到模拟实现,代码我都贴出来了,尤其模拟实现的每一段都可以直接在本地编译器里编译运行。真正值得花时间的地方就是那几段自定义模板代码。把它敲进去,改掉所有坑,再自己加需求,比看十遍文档都有用。