直接进入正题。今天想聊的是C++ STL里一个既基础又常被误读的容器——list。说基础,是因为链表这个概念从数据结构课上就讲过;说误读,是因为很多朋友对list的理解停留在“链表嘛,插入删除快,访问慢”这种粗粒度印象,真到用的时候才发现一堆细节没搞明白:为什么sort用起来那么别扭?为什么往list里塞元素之后迭代器莫名其妙失效?为什么模拟实现时自己写的迭代器总报编译错误?这篇文章就把list的使用和模拟实现放在一起讲,前半部分从接口使用入手讲清楚每个操作的底层代价,后半部分手写一个精简版双向循环链表,把节点、迭代器、内存控制这些关键环节逐一拆开看。
整篇内容适合几类人:正在系统学STL的C++学习者、准备面试需要手撕链表相关问题的求职者,以及写项目时想搞明白“到底该选vector还是list”的开发者。我会尽量用能直接跑的代码、踩过的坑和对比数据来说话,不讲虚的。
1. list的核心底色:先搞清楚它到底是一个什么样的容器
1.1 双向循环链表,不是普通双向链表
不少同学对list底层的认知是“双向链表”,严格来说这不算全对。STL中的list实际是双向循环链表,它有一个不存储有效数据的哨兵节点(也常被称为头节点、dummy node),这个哨兵节点的next指向第一个有效节点,prev指向最后一个有效节点,同时第一个节点的prev和最后一个节点的next都回指哨兵节点。这样形成闭环之后,从两端插入删除都只需要常数时间,而且begin()和end()的迭代器语义能获得极大简化——end()迭代器指向的正是那个哨兵节点,而不是nullptr。
这个设计很关键。如果你自己写一个普通双向链表,遍历到末尾时通常用nullptr来判断结束,而在STL list里,遍历判断的是“当前节点是否等于头节点”。区别看似很小,却统一了“空链表”和“有数据的链表”的处理方式:空链表的哨兵自己指向自己,begin()等于end(),一切操作都有明确的节点可用,不需要频繁判空。这也是我在模拟实现时极力推荐保留哨兵节点的原因。
1.2 list与vector、deque的关键差异
用list之前,最好先弄清楚它和vector、deque的差异,不然很容易选错容器。我把三者的核心特性放在一张表里对比:
| 对比维度 | vector | list | deque |
|---|---|---|---|
| 底层结构 | 连续内存数组 | 双向循环链表 | 分段连续缓冲区 |
| 随机访问 | O(1),直接下标 | 不支持,只能迭代器移动 | O(1),但多一次间接跳转 |
| 头部插入/删除 | O(n),需要搬移数据 | O(1) | O(1) |
| 尾部插入/删除 | 均摊O(1),但可能扩容 | O(1) | O(1) |
| 中间插入/删除 | O(n),数据搬移 | O(1),仅改指针 | O(n),数据搬移 |
| 迭代器失效规则 | 扩容或erase后相关迭代器失效 | erase后仅被删节点迭代器失效 | 插入不失效,erase后相关迭代器失效 |
| 内存碎片 | 低,连续大块 | 高,每个节点独立分配 | 中 |
从这里能直接得到一个结论:list最擅长的是“在已知位置的频繁插入删除”和“多次头部操作”。它解决了vector头部插入效率差的问题,但代价是没有随机访问,而且每个节点要额外存储两个指针,内存开销比vector高不少。实际项目中如果90%的操作都是遍历和尾插,list大概率不是最优解,deque甚至vector可能更合适。这个判断不是靠感觉,而是看操作分布。
2. list的接口使用:每个操作背后都有讲究
2.1 构造与赋值:别忽略初始化列表
list的构造方式和vector非常相似,支持默认构造、填充构造、迭代器区间构造、拷贝构造以及初始化列表构造。很多人在初始化列表构造上栽过跟头,尤其从C++11才开始接触STL的同学,习惯用push_back一个个加,却不知道能直接写:
#include <list> #include <iostream> int main() { std::list<int> li1; // 空list std::list<int> li2(5, 100); // 5个100 std::list<int> li3(li2.begin(), li2.end()); // 区间拷贝 std::list<int> li4{1, 2, 3, 4, 5}; // 初始化列表 std::list<int> li5(li4); // 拷贝构造 for (int x : li4) { std::cout << x << ' '; } // 输出: 1 2 3 4 5 return 0; }填充构造时有一点值得注意:list的assign(n, val)可以反复使用,和vector一样,它会把原有内容整个替换掉,而不是追加。如果你期望“保留旧数据并追加几个新值”,那是理解偏差,正确做法是用insert或push_back。
2.2 迭代器与遍历:const迭代器的江湖规矩
list提供的迭代器类型是双向迭代器(Bidirectional Iterator),不是随机访问迭代器。这句话意味着很多在vector上驾轻就熟的操作直接不成立:不能it + 2,不能it - 1,连it < li.end()这种比较都不行。只能++it、--it、it != li.end()这样走。
初次从vector转list的人最容易写出这类编译错误。比如:
for (auto it = li.begin(); it < li.end(); ++it) // 错误!双向迭代器不支持<比较正确写法是it != li.end()。这个“小坑”背后的原因是list节点在内存中不连续,迭代器之间没有整数偏移可言,自然不支持随机跳跃。模板编程里,迭代器类型的不同直接影响了算法选择,后面讲sort时还会再遇到。
另外还要注意begin()和rbegin()的区别。rbegin()返回反向迭代器,指向最后一个有效元素,rend()指向前第一个元素的位置。反向遍历时,++rit实际上让指针往前移动,新手容易在这里绕晕。还有一个隐藏点:li.erase(rit)这类混合使用反向迭代器和普通迭代器时要先转换,否则容易出错,我后面在迭代器失效部分还会细说。
2.3 增删改查:insert和erase的迭代器失效法则
list的增删操作是它的立身之本。常用接口包括:
push_back(val)/push_front(val):尾插/头插pop_back()/pop_front():尾删/头删insert(pos, val)/insert(pos, n, val)/insert(pos, first, last):在pos前插入erase(pos)/erase(first, last):删除一个区间remove(val):删除所有等于val的节点unique():删除连续相等的重复元素,注意是“连续相等”,不是全局去重
其中insert的返回值是新插入节点的迭代器,erase的返回值是删除节点之后那个节点的新迭代器。这两条返回值规则特别重要,很多“迭代器失效”问题都可以通过使用返回值来规避。例如需要循环删除所有奇数元素,新手写法往往是:
// 错误示范:erase后迭代器失效,继续++未定义行为 for (auto it = li.begin(); it != li.end(); ++it) { if (*it % 2) { li.erase(it); } }这段代码是典型的未定义行为。erase(it)之后,it已经被释放,再执行++it就是访问野指针。正确写法是:
// 正确写法:erase返回下一个有效迭代器 auto it = li.begin(); while (it != li.end()) { if (*it % 2) { it = li.erase(it); // it被更新为被删节点的后继 } else { ++it; } }这个用返回值的模式几乎是list遍历删除的标准范式,建议形成肌肉记忆。vector也有同样的要求,只是list失效粒度更小:list的insert不会让任何既有迭代器失效(除非迭代器指向被插入的节点本身,当然那也不存在),erase只会让指向被删除节点的迭代器失效,其他节点的迭代器完好无损。这条性质是list在实时系统、嵌入式场景中受欢迎的重要原因。
2.4 list的独门接口:splice、merge、unique、sort、remove
list不只是“链表版的vector”,它还有一批自己独有的接口,这些接口能高效地操作链表,是vector完全不具备的:
| 接口 | 功能 | 时间复杂度 |
|---|---|---|
splice(pos, other) | 把other整个链表拼接到pos前,other变空 | O(1) |
splice(pos, other, it) | 把other中it指向的节点拼接到pos前 | O(1) |
splice(pos, other, first, last) | 把other中[first,last)区间拼接到pos前 | O(1)(C++11后按节点转移) |
merge(other) | 合并两个已排序链表,结果仍有序 | O(n+m) |
remove(val) | 删除所有等于val的节点 | O(n) |
unique() | 删除连续重复节点 | O(n) |
sort() | 对链表排序 | O(n log n),但常系数较小 |
reverse() | 反转链表 | O(n) |
splice是我在实际开发中使用频率很高的操作,它最大的价值是O(1)地把一个链表的一部分或全部转移到另一个链表中,而不涉及任何元素拷贝。比如设计一个任务调度器时,把“超时任务”从一个等待队列整体转移到“就绪队列”,用splice完全可以做到只改指针。我第一次拿到这个接口时恍然大悟:原来STL容器不只是存数据用的,它还能做高效的“数据结构手术”。
sort()这里有个特殊之处:list的sort不调用标准的std::sort,而是成员函数li.sort()。原因是std::sort要求随机访问迭代器,list的双向迭代器不满足,所以STL为list单独实现了基于归并排序的成员函数。而且list的sort()默认按升序排列,排序是稳定排序(stable sort),如果元素相等,相对顺序不会被打乱。这种“同名的sort在不同容器上有不同实现”的细节,很容易被忽视,但面试官特别喜欢问。
2.5 迭代器失效:哪个操作会让迭代器“报废”
迭代器失效是list使用中绕不开的话题。虽然list的失效规则比vector宽松,但并不是没有雷区。总结一下:
- erase操作:指向被删除节点的迭代器失效,其他迭代器不受影响。
- clear操作:清空所有节点后,所有迭代器失效。
- resize操作:如果list被缩小,被删除部分的迭代器失效;如果扩大,原迭代器不受影响。
- swap操作:两个list交换后,迭代器仍指向各自原来的元素节点(只是元素归属变了,迭代器本身不失效),但如果你持有的迭代器是
pos1指向第一个链表的某个节点,swap之后它依然指向那个节点,只是那个节点已经属于另一个list了。这个细节容易把人绕晕,实际编码时最好交换后重新取得迭代器。
还有一类容易忽略的失效场景:在list迭代器指向的节点被splice转移后,迭代器仍然有效,但它所指向的节点已经不属于原来的容器。如果你继续通过这个迭代器访问并修改元素,改动会作用在新容器里。这在多链表移动节点时是特性而不是bug,但只用一个迭代器同时操作两个容器,一旦糊涂就会踩坑。
3. 手写一个精简版list:从节点到迭代器再到容器
模拟实现list是理解STL容器内部机制的最好方式之一。这里我给出一套精简但完整的实现思路,不追求和标准库完全一致,但核心机制——哨兵节点、迭代器封装、插入删除、拷贝控制——全部保留。
3.1 节点的设计:一件小事里藏着的工程细节
先定义节点结构。标准库list的节点和用户存储的数据是分开的,用户看到的是list<T>,底层每个节点除了T类型的值,还要额外维护两个指针:
template <typename T> struct ListNode { T data; // 存储的数据 ListNode* prev; // 前驱指针 ListNode* next; // 后继指针 ListNode(const T& val = T()) : data(val), prev(nullptr), next(nullptr) {} };注意构造函数里默认参数const T& val = T(),这样哨兵节点可以用默认构造方式创建,不需要给定一个具体的值。真实标准库会把节点设计成包含一个T value成员,然后让list通过模板参数决定是否预分配节点空间,原理上是类似的。
有一点在这里就要想明白:哨兵节点中的数据是“沾染”的,不被用户访问。如果不提供默认构造,那么创建哨兵时就必须传一个T值,这对某些没有默认构造函数的类型不友好。所以用T()或通过allocator去构造是一个更严谨的做法。我写的这个简化版用了T(),足够说明问题。
3.2 迭代器封装:为什么list迭代器要自己写
这是模拟实现里最值得展开的部分。list的迭代器不能直接使用原生指针,因为原生指针++只能在一个连续地址空间内移动,而list的节点散落在内存各处。于是必须写一个迭代器类,它内部封装一个ListNode<T>*,把++、--、*、->、==、!=这些操作重载成移动节点指针的行为。
template <typename T, typename Ref, typename Ptr> struct ListIterator { using Self = ListIterator<T, Ref, Ptr>; using Node = ListNode<T>; Node* node; ListIterator(Node* n = nullptr) : node(n) {} Ref operator*() const { return node->data; } Ptr operator->() const { 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& other) const { return node == other.node; } bool operator!=(const Self& other) const { return node != other.node; } };这里用到了模板参数Ref和Ptr,它们用来区分普通迭代器和const迭代器:普通迭代器Ref是T&,Ptr是T*;const迭代器Ref是const T&,Ptr是const T*。这样一套模板可以同时生成两种迭代器,避免把普通和const两个类重复写一遍。标准库的stl_list.h里采用的就是类似思路。
一个容易被忽略的细节是operator->的返回值。很多初学朋友以为迭代器就应该返回node->data的地址,确实是这样,但对于自定义类型,it->member会被编译器解释成it.operator->()->member,所以返回&node->data后,链式调用就对了。
3.3 list主体:构造、析构、拷贝控制
list类本身维护一个哨兵节点指针,以及一个记录节点数量的size变量(C++11之后标准库list的size是O(1)的,这在早期版本是O(n))。哨兵节点在构造函数中初始化:
template <typename T> class list { public: using Node = ListNode<T>; using iterator = ListIterator<T, T&, T*>; using const_iterator = ListIterator<T, const T&, const T*>; private: Node* head; // 哨兵节点 size_t sz; void empty_init() { head = new Node(); head->next = head; head->prev = head; sz = 0; } public: list() { empty_init(); } ~list() { clear(); delete head; head = nullptr; } // ... };析构函数是先clear清掉所有有效节点,再删除哨兵节点。顺序千万别反,否则你会遍历到已经释放的内存。
拷贝构造和赋值重载是实现时最容易出bug的部分。正确思路是“先构造一个空list,再用原list的元素依次push_back”,而不是去逐个new节点后再手动拼接。因为push_back内部已经包含了节点创建和指针链接的所有逻辑,复用即可,降低出错概率:
list(const list& other) { empty_init(); for (const auto& val : other) { push_back(val); } } list& operator=(const list& other) { if (this != &other) { list tmp(other); swap(tmp); } return *this; }赋值重载用拷贝并交换(copy-and-swap)模式,这里面有个好处:临时对象tmp在函数结束时自动释放,原来this里的旧节点也被一并清理,不用自己写释放旧内存的代码。这种写法不只是简洁,更是异常安全:即使构造tmp抛异常,this还能保持原状。
3.4 核心操作实现:insert和erase的内部逻辑
insert是list所有插入操作的基础,push_back、push_front、任意position插入最终都走它。标准库的insert在pos之前插入;pos为end()时,就是在尾部插入,这正好和push_back等价。所以我们可以用统一的insert函数简化所有插入路径:
iterator insert(iterator pos, const T& val) { Node* cur = pos.node; Node* pre = cur->prev; Node* new_node = new Node(val); new_node->prev = pre; new_node->next = cur; pre->next = new_node; cur->prev = new_node; ++sz; return iterator(new_node); } void push_back(const T& val) { insert(end(), val); } void push_front(const T& val) { insert(begin(), val); }这里的关键是四步指针链接的顺序。我的习惯是:先让新节点的prev和next各自指向邻居,再修改两个邻居的指针指向新节点。只要保证“新节点的指针无条件先设置,再动其他节点的指针”,就不会出现断链。
erase则是逆过程,删除pos指向的节点并返回后继迭代器。注意边界:删除唯一元素时,被删除节点的pre和next其实都是哨兵节点,处理好指针链接后,链表自然回到“哨兵自环”的初始状态,不需要额外特殊判断。这也是哨兵节点带来的巨大简化。
iterator erase(iterator pos) { Node* cur = pos.node; Node* pre = cur->prev; Node* nxt = cur->next; pre->next = nxt; nxt->prev = pre; delete cur; --sz; return iterator(nxt); }很多人写erase时记不住返回值。返回值的意义在于连续删除时需要快速拿到“下一个有效位置”,否则你删除一个节点后还得先找到下一个节点再继续删。既然nxt已经在断开链接时拿到了,顺手作为返回值返回是零成本的操作。
3.5 模拟实现中的常见坑与调试心得
第一,begin()的指向问题。哨兵节点的next指向第一个有效元素,所以begin()应返回iterator(head->next),end()应返回iterator(head)。你写begin的时候一不小心写成head->next->next,正好把哨兵和第一个元素跳过去了,遍历结果少一个元素,特别隐蔽。
第二,clear要循环erase。一个常见偷懒写法是while (head->next != head) { erase(begin()); },这没问题,但别忘记删完后把head->prev重新指向head。如果你在erase里没维护哨兵节点的prev和next,clear循环到最后一个节点时,哨兵指针可能指向已释放内存。这里建议在每个修改头尾的操作后立刻检查哨兵的两个指针是否都指向自己。
第三,const迭代器与普通迭代器的混用。如果你自己实现了迭代器,一定要提供从“普通迭代器”到“const迭代器”的隐式转换,否则const list<int>&遍历时会直接编译失败。标准库通过模板参数和转换构造函数解决了这个问题,手动实现时也要给ListIterator增加一个:
ListIterator(const ListIterator<T, T&, T*>& other) : node(other.node) {}这行代码只对const迭代器版本有效(因为Ref=const T&时能把普通迭代器转过来),而普通迭代器版本不匹配,不会错误转换。
第四,把所有插入删除都统一到insert/erase。很多初学朋友写模拟实现时,push_back单独写一套节点分配逻辑,insert再写一套,逻辑重复不说,还容易在某个分支漏改指针。标准库为什么把insert作为内部公共入口?因为它能统一处理包括哨兵在内的所有位置,空链表、头插、尾插全部覆盖。这个设计思想同样适用于我们自己写代码——公共逻辑收敛到一个函数,其他接口只是它的壳。
4. 打怪升级:list使用时的高频问题与避坑清单
4.1 多场景下的迭代器失效,我帮你汇总成速查表
我把自己实际开发中遇到的迭代器失效场景整理成一张速查表,方便你排查问题时快速对照。这里要特别强调:很多问题不是“编译错误”,而是“运行时诡异表现”,比如漏数据、死循环、乱码,这才是最难调的。
| 操作 | 哪些迭代器会失效 | 推荐写法 |
|---|---|---|
| 向list插入节点 | 所有迭代器均不失效 | 随便用 |
| 删除单个节点 | 仅指向被删节点的迭代器失效 | 用erase返回值续走 |
| 删除一个区间 | 区间内所有迭代器失效 | 用返回值或重构循环 |
| 清空list | 所有迭代器失效 | 清空后重新获取begin() |
| swap两个list | 迭代器仍指向原节点,但属于新容器 | 若语义改变需重新取得迭代器 |
| list被销毁 | 所有迭代器失效 | 禁止任何解引用 |
实际排查时,我强烈建议在每次erase之后,不要再用旧的迭代器做任何操作,哪怕它看起来还“能用”。标准库的调试模式(如libstdc++的-D_GLIBCXX_DEBUG)可以在越界操作时输出明确断言,上线前值得开一下。养成“erase后用返回值”这个习惯,比任何花哨的技巧都重要。
4.2 list的sort为什么特殊?C++版本之间还有差异
前面提过list不提供随机访问迭代器,因此不能使用std::sort,只能调用成员函数sort()。在C++11之前,list::sort通常基于归并排序,且内部实现还会对“是否已按升序排列”做一个快速判断,如果已经有序则不做无谓操作。C++11之后标准库对list::sort的实现没有强制规定,但主流库仍然选择归并排序,原因是归并排序对双向链表友好、稳定且最坏情况复杂度可控。
还有一个很多人忽略的trap:std::sort要求容器支持随机访问,但你编写泛型算法时,如果只接受iterator概念而不做类型约束,很可能在list上爆出“为满足模板编译而出的一堆看不懂的报错”。现代C++可以用std::iterator_traits<It>::iterator_category来判断迭代器类型,SFINAE或者C++20的concept可以做得优雅,但这是另一个话题。这里想说的是:当你在一个list上调std::sort编译失败,不要怀疑编译器,回头检查迭代器类型是否合适。
4.3 性能误区:list真的“增删快”吗?别只看复杂度
很多入门的观点是“vector插入慢、list插入快”,这个结论如果脱离场景,很容易被现实数据打脸。list在任何位置的插入都只需要常数时间改指针,但一次插入的成本包括:为每个节点走一次operator new(慢于vector扩容时的批量分配)、可能触发T的拷贝/移动构造、额外的内存碎片、以及缓存命中率低带来的访问延迟。vector的中间插入虽然要和后面所有元素“搬移”,但只要按连续内存走,搬移的cache效率非常高,在数据量不大时整体往往更快。
我实测过一个10万元素规模、在头部循环插入1万次的小实验。vector直接vector.insert(begin)是灾难级别,大概会比list慢几十倍;但如果改在尾部插入,vector反而略快于list。插入位置、元素大小、容量是否足够都会左右结果。所以我的建议是:
- 头部频繁增删、不要求随机访问:优先list或deque。
- 尾部频繁增删、偶尔随机访问:优先vector,否则deque。
- 中间频繁插入但对迭代器稳定性要求高:list是合理选择。
- 仅仅需要随机访问和缓存友好:vector是默认答案。
换句话说,复杂度衡量的是“渐进趋势”,常数因子在真实场景里往往更致命。拿list当“万能容器”不仅浪费内存,性能也可能适得其反。
4.4 实际项目中list的典型应用场景
结合我接触过的业务场景,list真正发光的领域主要有这么几类:
第一是**实现LRU Cache(最近最少使用淘汰)**类的缓存结构。Get时把节点移到头部,Set时淘汰尾部节点,配合哈希表定位节点,整个操作O(1),list的splice或“先erase再push_front”都能实现“移动到头部”的操作。如果不用list,自己维护双向链表会非常繁琐。
第二是实现活跃对象/事件订阅者列表。比如图形引擎的帧循环中要遍历所有要更新状态的组件,组件可能动态增删。用list保存组件指针,迭代器在组件被销毁时能保持其他组件指向不受影响,还能安全地“先移除,再继续遍历”,这是vector做不到的。
第三是实现“最近打开文件”“消息历史”等限期保留队列。往尾部追加,超过容量后从头部淘汰,list的push_back+pop_front组合非常干净。
第四是实现需要“稳定引用”的链表结构本身,比如实现HashMap的链地址法冲突链、图算法里的邻接表。如果这些结构用vector存储子节点,扩容会导致指针或下标无效,而list则能忍住不搬移——这也是某些底层库宁可浪费内存也选list的原因。
5. 最后再聊几个真正有参考价值的实操体会
如果你已经读到这儿,说明你对list的兴趣不只是在“背接口”层面了。我再分享几个写代码外的经验总结,可能对你更实用。
第一个体会是:模拟实现list最大的收益不是“会写链表”,而是学会“读懂STL源码的结构思路”。比如插入操作由insert统一收敛、所有迭代器操作都封装在迭代器类内部、哨兵节点让边界情况彻底消失——这些设计思想放到任何涉及指针、句柄、资源管理的代码里都通用。我当年手撕list之后去读vector和deque源码,明显感觉没那么吃力,因为“迭代器封装”“公共入口统一”这些模式是跨容器复用的。
第二个体会是:写模拟实现时,先写会报错的代码,再一步步修,比空想更高效。尤其是指针链接的代码,纸上画再久都不如实际跑一次push_back、erase循环。调指针问题有个笨但可靠的办法:每次修改链表结构后,写一个断言函数验证闭环完整性——从头节点出发沿next走一圈能回到自己,沿prev走一圈也能回到自己,且步数和size一致。把这种断言编译到DEBUG版本里,很多隐蔽问题在“出错现场”就能暴露,而不是等到崩了才回头找。
第三个体会是:迭代器失效不是list的bug,而是设计的一部分。理解它,不是死记硬背哪些操作会失效,而是搞清楚“失效的本质是什么”——迭代器本质上是一个节点指针的封装,节点被释放或者容器被销毁,迭代器的底层指针就成了悬垂指针,操作就是未定义行为。想通了这一点,你会发现即使某个操作在文档里没写明失效规则,你也能用“这个操作会不会释放我指向的内存”来自行判断。
最后一个小技巧,也是我最近写代码时经常用到的:如果你需要遍历一个list并在过程中删除部分元素,但你不想自己维护循环变量,可以把“删除条件”提取成一个lambda,配合std::list::remove_if一行搞定。remove_if内部帮你做了“先记录后继、再删除当前、继续推进”的脏活,而且它和erase(begin(), end())配合,能实现“删除所有满足条件的元素”这一高频需求。我见过太多新手在这种场景里陷入迭代器失效的泥潭,其实STL早就给你造好了工具,只是要你肯翻开文档去用它。
list的很多“坑”,本质上都不是list本身的问题,而是对“链式结构的内存与迭代器模型”理解不够深。把这篇文章里的实现框架亲手敲一遍,再把splice、sort、remove_if这几个容易用错的接口各写一个小例子跑通,你基本就能从“听说过list”变成“真正用明白了list”。容器不难,难的是你真的肯动手拆开看它一眼。