平时写 C++ 的时候,std::list是个让人又爱又恨的容器。面试里反复考,项目里却经常被人用错:有人拿它当 vector 的平替存了一堆数据,结果遍历慢到怀疑人生;也有人在该用它的时候选了 vector,导致中间插入删除了大量节点。这个容器对应的底层数据结构,就是教材里的带头双向链表。这篇文章我就从底层结构开始,把std::list的增删查改完整拆开讲一遍,每个操作都配上能直接跑的代码,再把我这些年踩过的坑和总结的取舍经验一并放出来。打算系统学 STL、准备面试,或者正在纠结到底该不该用 list 的读者,都可以参考。
1. 先弄清节点结构:带头双向链表的“头”到底是什么
很多教程讲链表,一上来就画一堆方框和箭头,看着简单,但真到写代码的时候很多人还是懵:头节点是第一个节点吗?空链表怎么表示?end()到底指向哪里?这些问题不搞透,后面写增删改查一定出 bug。
1.1 单向链表、双向链表和“带头”之间的递进关系
最基本的单向链表,每个节点只存一个next指针,遍历只能从头往后走,想删除某个节点必须知道它的前驱,否则链就断了。为了删除方便,只能在遍历时维护一个prev指针变量,代码写起来很别扭。双向链表给每个节点多了prev指针,解决了“往前找”的问题,但同时带来一个新麻烦:空链表时没有节点,所有操作都要先判断head == nullptr,插入和删除的边界条件特别容易写漏。
“带头”就是专门用来消灭这些边界判断的。这里的头不是第一个有效元素,而是一个哨兵节点(sentinel node / header node),它不存业务数据,只充当链表的入口。有哨兵之后,空链表和非空链表的操作逻辑完全统一,插入删除都不用再单独判空。哨兵节点的next指向链表第一个有效节点,prev指向最后一个有效节点。如果链表为空,哨兵的next和prev都指向它自己。
1.2 一个简化版的带头双向链表节点
为了看清楚内部结构,我先把标准库的封装剥掉,写一个最简节点定义:
template <typename T> struct ListNode { T data; // 数据域 ListNode* prev; // 前驱指针 ListNode* next; // 后继指针 };这个结构里一共三个字段:一个数据,两个指针。看似简单,但它背后藏着一个关键事实:每个节点在内存里是独立分配的,数据域和指针域绑定在一起,节点之间用指针串联,不要求内存连续。这一点是 list 和 vector 最本质的区别,也是后面分析性能时要反复提到的点。
真实的std::list为了支持自定义分配器、空基类优化等,节点结构会更复杂,通常会有一个统一的节点基类只管理指针,然后派生出一个带T的节点类。但从理解角度,上面这个ListNode已经足够说明问题。
1.3 std::list 的真实底层:双向循环链表加哨兵
标准规定std::list是双向链表,但没有强制要求循环。不过主流编译器的实现(比如 libstdc++ 和 libc++)都把它实现成带头节点的双向循环链表。也就是说,哨兵节点的next指向第一个元素,prev指向最后一个元素,最后一个元素的next又指回哨兵,第一个元素的prev也指回哨兵。整个链表首尾相接,成环。
这个环形结构带来的好处非常直接:
push_back等价于在哨兵节点之前插入一个节点,push_front等价于在哨兵节点之后插入一个节点,都能在 O(1) 内完成。begin()就是哨兵的next,end()就是哨兵本身。所以end()不是一个不存在的末尾位置,而是一个真实存在的节点指针,只是不存数据。- 遍历时判断条件统一写成
it != end(),不需要额外保存长度信息或者判断空指针。
很多人在 debug 里看到end()的地址和普通节点不太一样,会觉得奇怪,其实就是这个哨兵节点。这个概念在 STL 里叫“past-the-end”,但 list 的实现上是真实节点。
1.4 手动构造一个最小带头双向链表
写一个完整可用的带头双向链表要几十行,但初始化逻辑其实很简单。下面是我用来验证思路的最小示例,只包含初始化和头部插入:
template <typename T> class MiniList { ListNode<T>* header; public: MiniList() { header = new ListNode<T>; header->next = header; header->prev = header; } void push_front(const T& value) { ListNode<T>* node = new ListNode<T>{value, header, header->next}; header->next->prev = node; header->next = node; } ~MiniList() { while (header->next != header) { ListNode<T>* p = header->next; header->next = p->next; delete p; } delete header; } };注意看push_front:新节点的next指向原来的第一个节点,prev指向哨兵;然后把原第一个节点的prev改成新节点,哨兵的next改成新节点。整个过程不需要判断链表是否为空,因为空链表时header->next == header,新节点插入后自己指向自己,逻辑依然一致。这就是“带头”的魅力。
2. list 的构造与迭代器:拿到容器后的第一件事
节点结构搞清楚之后,再看std::list的接口就不会觉得陌生了。std::list的构造方式、迭代器类型和 vector 有很大不同,这些差异直接影响后续所有操作的写法。
2.1 六种构造方式,从空表到移动构造
std::list的构造函数很多,我平时最常用的有六种:
#include <list> #include <iostream> int main() { std::list<int> l1; // 1. 空链表 std::list<int> l2(5, 42); // 2. 5 个 42 std::list<int> l3 = {1, 2, 3, 4, 5}; // 3. 初始化列表 std::list<int> l4(l3.begin(), l3.end()); // 4. 迭代器范围 std::list<int> l5(l3); // 5. 拷贝构造 std::list<int> l6(std::move(l5)); // 6. 移动构造 std::cout << l6.size() << '\n'; // 5,l5 被移动后通常为空 }这里有个值得注意的点:移动构造是 C++11 引入的。移动一个std::list只需要把源链表的哨兵节点指针接过来,再把源链表置空,复杂度 O(1),不涉及任何一个元素节点的拷贝。如果你拿一个很大的 list 作为函数返回值,只要编译器没有做 RVO,移动构造也能兜底保证效率;而 C++11 之前这种场景会深度拷贝全部节点,非常痛。
2.2 迭代器类型:bidirectional_iterator_tag 决定了什么
std::list的迭代器类型是双向迭代器(bidirectional iterator)。在 STL 迭代器分类里,它比输入/输出迭代器高一级,但比随机访问迭代器低一级。具体限制是:
- 支持
++it、it++、--it、it--,能前移和后移。 - 支持
*it、it->,能读能写。 - 不支持
it + n、it - n、it[n],也不支持<、>这类比较(只能用==、!=)。
这个限制不是接口设计缺陷,而是链表物理结构的必然结果。因为节点内存不连续,你无法通过“首地址加偏移”直接算出第 n 个节点的位置,只能沿着指针一步步走。
2.3 三种遍历写法
遍历 list 有三种常见写法,效果相同,但风格不同:
std::list<int> values = {10, 20, 30, 40}; // 写法一:传统迭代器 for (std::list<int>::iterator it = values.begin(); it != values.end(); ++it) { std::cout << *it << ' '; } // 写法二:范围 for(底层就是迭代器) for (int v : values) { std::cout << v << ' '; } // 写法三:标准算法 #include <algorithm> #include <iterator> std::copy(values.begin(), values.end(), std::ostream_iterator<int>(std::cout, " "));我个人的建议是:只需要读的时候用范围 for,简单清晰;需要修改元素、删除元素或者记录位置时用迭代器写法,因为范围 for 拿不到迭代器本身。std::copy加ostream_iterator这种写法在刷题或写日志时很爽,但项目里如果只为了打印,可读性未必比范围 for 好。
2.4 为什么 list 没有 operator[]
很多人从 vector 转过来,第一反应是list[3]为什么编译不过。原因前面已经提过:operator[]要求 O(1) 随机访问,而 list 的节点在物理内存上不连续,没有记录每个元素的地址,无法通过下标直接定位。就算设计一个operator[],也只能从头部开始遍历,时间复杂度 O(n),那就违背了 STL 容器接口的语义约定。STL 的做法是:需要随机访问就用vector、deque,需要链表语义就用list、forward_list,各司其职。
3. 增删改查四类操作的完整写法:能跑通才是硬道理
接下来进入正题。增删改查这四类操作,我用最简单直接的方式逐个拆解,每个操作都给出标准写法,说明复杂度,然后再补一个综合示例,展示它们如何配合使用。
3.1 增:push_back、push_front、insert 与 emplace 系列
std::list在头部和尾部插入元素都是 O(1),这是它相对 vector 的明显优势。vector 的push_front是 C++11 才支持的,而且时间复杂度 O(n),因为要搬移所有元素;而 list 只需要改几个指针。
std::list<int> tasks; tasks.push_back(1); // 尾部插入:1 tasks.push_front(0); // 头部插入:0 1 tasks.insert(tasks.end(), 2); // 在 end 之前插入,等价于尾插:0 1 2insert的语义是在“指定位置之前”插入新元素,位置由迭代器给出,插入本身是 O(1)。但这里有个新手很容易忽略的坑:插入本身是 O(1),但“找到插入位置”很可能不是。比如你想在链表的中间某个值之后插入新节点,必须先遍历找到那个值的位置,这步是 O(n)。我在项目里见过有人写出下面这种代码:
for (int i = 0; i < 100000; ++i) { auto it = std::find(list.begin(), list.end(), target); list.insert(it, new_value); }这个写法在功能上没错,但每次插入前都从头遍历一遍,整体复杂度退化成了 O(n²)。这种“定位 O(n) + 插入 O(1)”的组合,是链表使用中最容易被误判的地方。
C++11 引入了emplace系列函数:emplace_back、emplace_front、emplace。它们和对应的 push/insert 的区别是:push 系列接收的是“已经构造好的对象”,emplace 系列接收的是“构造对象需要的参数”,在链表的节点内存里直接就地构造对象,省掉一次临时对象的拷贝或移动。
struct Task { int id; std::string name; Task(int i, std::string n) : id(i), name(std::move(n)) {} }; std::list<Task> tasks; tasks.emplace_back(1, "write report"); // 直接在节点内存构造 tasks.emplace_front(2, "fix bug"); // 同左对于int这种内置类型,emplace 和 push_back 几乎没有差别;但对于std::string、自定义结构体这种有构造成本的对象,emplace 的收益很明显。我建议从 C++11 开始,凡是“用参数构造后放入容器”的场景,直接优先用 emplace。
3.2 删:pop_back、pop_front、erase、remove、clear
删除操作同样丰富多彩。尾部删除pop_back和头部删除pop_front都是 O(1)。
erase是核心删除接口,它有两种重载:删除单个迭代器指向的节点,以及删除一个迭代器区间[first, last)。C++11 之后,erase会返回被删除元素的下一个迭代器;C++11 之前返回void。这个返回值非常重要,后面讲迭代器失效时会重点演示。
std::list<int> nums = {1, 2, 3, 4, 5}; auto it = nums.begin(); ++it; // it 指向 2 it = nums.erase(it); // 删除 2,it 指向 3 // nums: {1, 3, 4, 5}和 insert 一样,erase单个节点是 O(1),因为只需要改前后两个节点的指针。但如果要“先找到再删”,查找过程是 O(n)。
remove和remove_if是 list 很实用的成员函数,和std::remove算法有本质区别。std::remove通过覆盖搬移把目标元素挪到末尾,不真正删除元素,必须搭配erase使用,也就是所谓的 erase-remove 惯用法;但std::list的成员remove会真正把匹配的节点释放掉,内部基于节点的删除实现,并不需要搬移元素。
std::list<int> nums = {1, 2, 3, 2, 4, 2}; nums.remove(2); // 删除所有等于 2 的元素 // nums: {1, 3, 4} nums.remove_if([](int n) { return n % 2 == 0; }); // 删除所有偶数 // nums: {1, 3}clear()会清空整个链表,释放所有节点的内存。注意清空后链表的哨兵节点仍然存在,所以这个 list 还能继续使用,size()变成 0,begin() == end()。
3.3 改:通过迭代器修改元素
修改元素很简单,拿到迭代器后直接解引用赋值即可:
std::list<int> nums = {10, 20, 30}; auto it = nums.begin(); ++it; *it = 99; // 把第二个元素改成 99 // nums: {10, 99, 30}但要注意,迭代器只能用来修改“元素的值”,永远不应该尝试通过指针去修改链表的连接关系。比如说,如果it指向 20,你绝对不应该写it->prev->next = it->next之类的东西去“手动删节点”。STL 的内部结构是封装好的,节点指针的类型、哨兵节点的存在都依赖具体实现,直接操作内部指针只会让程序崩溃。要删除、移动、拼接,一律调用对应的成员函数。
如果需要“找到某个值再修改”,那就把查找和赋值组合起来:
auto it = std::find(nums.begin(), nums.end(), 99); if (it != nums.end()) { *it = 100; }3.4 查:find、find_if 与手动遍历
std::list本身不提供find成员函数,这一点常被初学者误以为“链表不支持查找”。事实上std::find这个通用算法适用于任何提供迭代器的容器,list 当然可以用,只是复杂度是 O(n)。
std::list<int> nums = {5, 15, 25, 35}; auto it = std::find(nums.begin(), nums.end(), 25); if (it != nums.end()) { std::cout << "found: " << *it << '\n'; } else { std::cout << "not found\n"; }如果查找条件更复杂,用std::find_if传入一个谓词:
auto it = std::find_if(nums.begin(), nums.end(), [](int n) { return n > 20; });查找本身没什么要强调的,只想提醒一点:如果你频繁需要按值查找,list 并不是合适的数据结构。O(n) 的查找在数据量小的时候无所谓,数据量上升到几十万级别后就会非常明显。这种情况下该用std::set、std::unordered_set或者排序后的 vector,而不是硬扛 list。
3.5 一个综合示例:用 list 实现一个简单的任务队列
把上面的增删改查串起来,我用 list 实现一个非常简单的任务队列,展示四类操作如何协同:
#include <list> #include <string> #include <iostream> #include <algorithm> struct Task { int id; std::string desc; bool done; }; int main() { std::list<Task> queue; // 增:加入几个任务 queue.emplace_back(Task{1, "write report", false}); queue.emplace_back(Task{2, "fix bug", false}); queue.emplace_front(Task{0, "review code", false}); // 查:找 id 为 2 的任务 auto it = std::find_if(queue.begin(), queue.end(), [](const Task& t) { return t.id == 2; }); if (it != queue.end()) { std::cout << "found: " << it->desc << '\n'; } // 改:把 id 为 0 的任务标记为已完成 for (auto& t : queue) { if (t.id == 0) { t.done = true; } } // 删:移除所有已完成任务 queue.remove_if([](const Task& t) { return t.done; }); // 输出剩余任务 for (const auto& t : queue) { std::cout << t.id << ": " << t.desc << '\n'; } return 0; }这个例子覆盖了四个操作,实际项目中还会涉及线程安全问题,但作为理解 list 增删改查的起点已经足够。注意emplace_back(Task{...})其实和push_back(Task{...})一样,都是传已构造对象;真正体现 emplace 优势的是直接传构造参数,例如queue.emplace_back(Task{...})这种写法本质上没有省掉临时对象。如果想省,应该给 Task 写个构造函数后直接queue.emplace_back(3, "write doc", false);。
4. 增删之外的高级操作:splice、sort、unique 的正确姿势
如果只看增删改查,list 和其他容器差别不大。真正让 list 在 STL 里独树一帜的,是一批链表专属操作:splice、sort、unique、merge、reverse。这些操作在 vector 上要么不存在,要么复杂度完全不同。
4.1 splice:O(1) 的链表拼接
splice是 list 最有特色的操作,它能把一个 list 中的节点“嫁接”到另一个 list,整个过程不拷贝、不移动元素,只是改指针。这是真正的 O(1) 操作(如果拼接区间,则耗时与区间长度有关),也是 list 相对其他容器的“杀手锏”。
std::list<int> src = {1, 2, 3, 4, 5}; std::list<int> dst = {10, 20}; auto it = dst.begin(); ++it; // 指向 20,我们要把 src 插到 10 和 20 之间 dst.splice(it, src); // 把 src 所有节点拼接到 dst 中 it 之前 // dst: {10, 1, 2, 3, 4, 5, 20} // src: 空注意两个细节。第一,splice 之后源 list 会被“掏空”,源中的迭代器仍然指向对应元素,但现在这些元素已经属于目标 list;第二,splice 有三个重载:拼接整个链表、拼接单个迭代器指向的元素、拼接一个迭代器区间。C++11 后又增加了 rvalue 引用版本。日常写代码,拼接整个链表最常用。
splice在处理“把一批元素从一个列表搬到另一个列表,且要求保持迭代器/引用有效”的场景下,是 vector 和 deque 完全做不到的。
4.2 sort:list 自带的归并排序
std::sort要求随机访问迭代器,所以它无法用于 list。list 有自己的sort成员函数,底层通常实现为归并排序,时间复杂度 O(n log n),而且是稳定排序。
std::list<int> nums = {5, 2, 8, 1, 9}; nums.sort(); // 升序 nums.sort(std::greater<int>()); // 降序有个容易混淆的点:很多人认为“list 排序比 vector 慢”,这个说法不准确。从复杂度来看两者都是 O(n log n),但常数因子差别很大。list 的归并排序需要频繁操作节点指针,而且访问内存不连续,实际上比同样数据量的 vector 排序要慢不少。如果数据只是在程序初始化时排一次序,后续不涉及链表优势场景,把数据拷到 vector 排完再拷回来,有时候反而更快。这个取舍没有绝对答案,要先测试。
4.3 unique:去除连续重复元素
unique成员函数会删除链表中相邻的重复元素,只保留第一个。注意“相邻”这两个字:未排序的 list 里,相同元素如果隔开了,unique不会去重。所以标准的去重姿势是先sort再unique。
std::list<int> nums = {1, 1, 2, 3, 3, 3, 4, 2}; nums.unique(); // nums: {1, 2, 3, 4, 2},注意最后一个 2 没有被删除,因为它和前面的 2 不相邻 nums.sort(); nums.unique(); // nums: {1, 2, 3, 4}unique也可以接受自定义二元谓词,用来判断“相邻元素算相等”的条件。比如只关心绝对值是否相等,就可以自定义。
4.4 merge 与 reverse
merge将另一个已排序的 list 合并进当前 list,合并后依然有序。它和 splice 一样会清空参数链表。很多踩坑点都在这里:a.merge(b)之后,b 变成空列表,所有元素都搬到了 a 里。如果 b 中还有指向这些元素的迭代器,那这些迭代器现在指向的是 a 的元素,使用时要格外小心。
std::list<int> a = {1, 3, 5}; std::list<int> b = {2, 4, 6}; a.merge(b); // a: {1, 2, 3, 4, 5, 6} // b: 空reverse把链表反转,O(n)。它比 splice 简单,但在某些场景下(比如从尾到头遍历)很实用,不需要像 vector 那样手动反向迭代。
4.5 高级操作综合演示
下面这段代码展示了 sort、unique、merge 和 splice 的组合:
std::list<int> evens = {2, 4, 6}; std::list<int> odds = {5, 3, 1, 3}; odds.sort(); // {1, 3, 3, 5} evens.sort(); // {2, 4, 6} odds.unique(); // {1, 3, 5} evens.merge(odds); // {1, 2, 3, 4, 5, 6},odds 变空 std::list<int> head = {0}; head.splice(head.end(), evens, evens.begin(), evens.end()); // head: {0, 1, 2, 3, 4, 5, 6}splice指定区间时,区间内节点会被整个搬过去。这个操作的时间复杂度是 O(k),k 是区间长度,因为要遍历区间定位最后一个节点。但即使如此,它也比“逐个插入”省去了大量构造析构和指针调整。
5. 迭代器失效与性能边界:真正决定“该不该用 list”的三件事
网上关于 list 的讨论,十个里有八个在争论它到底快不快。这个问题的答案不在 list 本身,而在使用场景。我从迭代器失效规则和内存布局两个角度,把这个问题说透。
5.1 迭代器失效规则:list 最核心的优势
迭代器失效是 STL 容器面试的经典考点。list 的规则比 vector 友好得多:
- 插入操作:
insert、push_back、push_front、emplace、splice不会使任何现有迭代器或引用失效。不像 vector,一旦扩容,所有迭代器全废。 - 删除操作:
erase只会使指向被删除节点的迭代器失效,其他迭代器依然有效。这非常关键:你可以维护一个指向链表中间某个元素的迭代器,删掉它旁边的元素,这个迭代器继续用。 remove、unique、clear:被移除元素的迭代器和引用失效,其他不受影响。clear全部失效。sort、merge、reverse:这些操作会改变节点顺序,但不会使迭代器失效。迭代器仍然指向它们原来指向的元素,只是元素的位置变了。这一点非常反直觉,也经常被低估。
举个例子说明这个优势有多实用:你有一个正在被多个模块引用的对象列表,某个模块持有指向特定对象的迭代器。在 vector 里,只要别的地方插入了一个元素导致扩容,你这个迭代器就悬空了;在 list 里,只要你自己不删那个节点,迭代器永远有效。这也是为什么某些全局对象管理、事件监听器注册表这类场景,即使数据量不大,也会选择 list 或 forward_list。
5.2 缓存不友好:list 为什么“遍历慢”
list 最大的劣势是内存布局。vector 的元素存储在连续内存里,遍历时 CPU 缓存按顺序预取,命中率极高。list 的每个节点单独分配,节点之间靠指针相连,它们在物理地址上随机分布。遍历链表时,CPU 缓存几乎每访问一个节点都可能 miss,必须到更慢的内存层级去取数据。
我做过一个很简单的对比:向 vector 和 list 里各放入 100 万个 int,然后分别遍历求和。在一台普通 x86 机器上,vector 遍历大约耗时几毫秒,list 遍历可能到几十毫秒,差距可以达到一个数量级。这还只是 int,如果是更大的结构体,差距只会更夸张。
所以网上说“list 很慢”,其实说的不是插入删除慢,而是遍历和随机访问慢。插入删除单看指针操作确实很快,但如果一个场景需要频繁遍历查找,那么再快的插入删除也补不回来。
5.3 什么场景该用 list,什么场景不该用
根据性能和迭代器规则,我把自己的选型标准总结成几条:
该用 list 的场景:
- 需要频繁在已知位置插入或删除节点,且操作位置遍布链表各处。如果只操作两端,用 deque 往往更好。
- 需要持有指向元素的迭代器/引用,且这些持有得跨越很长生命周期,期间容器还会频繁插入删除其他元素。
- 需要 splice 两个列表,或者把一个列表中间的一块搬走,这种“指针搬家”是 list 独有的能力。
- 数据量不大,遍历成本在可接受范围内。
不该用 list 的场景:
- 需要随机访问,比如按下标取第 k 个元素,那直接 vector。
- 主要操作是遍历和查找,很少在中间插入删除,那 vector 缓存优势碾压 list。
- 只是当队列用,只在头和尾操作,deque 更合适。
- 对单个元素的内存开销敏感,list 每个节点至少多两个指针(16 字节),比 vector 存储相同数据多得多。
- 需要频繁排序,或者存储超大对象时,考虑用 vector 存指针方案。
5.4 常见顺序容器对比表
我在面试和写代码时,经常用下面这张表快速过一遍选型逻辑:
| 容器 | 随机访问 | 头部插入 | 尾部插入 | 中间插入/删除 | 迭代器失效规则 | 额外内存 |
|---|---|---|---|---|---|---|
| vector | O(1) | O(n) | 均摊 O(1) | O(n) | 扩容时全部失效 | 少量预分配 |
| deque | O(1) | O(1) | O(1) | O(n) | 两端插入不影响已有迭代器(但可能使迭代器失效?规则复杂) | 分段连续,中等 |
| list | 不支持 | O(1) | O(1) | 已知位置 O(1),查找 O(n) | 除被删节点外均稳定 | 每节点两个指针 |
| forward_list | 不支持 | O(1) | O(n) | 已知前后位置 O(1),查找 O(n) | 除被删节点外均稳定 | 每节点一个指针 |
deque 的迭代器失效规则在标准里相对复杂:在中间插入会使所有迭代器失效,在两端插入可能使迭代器失效但引用不失效。这里不展开,但选型时不要想当然。
6. 实用技巧与踩坑记录:那些文档里不会写的事
最后这部分,我整理几个自己实际写代码时反复踩过的坑和总结出来的技巧。每一条都是真实场景里验证过的,不是从文档搬来的套话。
6.1 erase 结合条件删除的推荐写法
C++20 引入了一个非常省事的函数:std::erase_if,可以直接用于 list:
#include <list> #include <iostream> int main() { std::list<int> nums = {1, 2, 3, 4, 5, 6}; std::erase_if(nums, [](int n) { return n % 2 == 0; }); // nums: {1, 3, 5} }如果你还在用 C++17 或更早的标准,最常见的写法是循环 erase 配合返回值:
for (auto it = nums.begin(); it != nums.end(); ) { if (*it % 2 == 0) { it = nums.erase(it); } else { ++it; } }关键点:erase之后不能直接使用++it,因为it已经失效了;必须先保存返回值,或者把it更新为下一个迭代器。很多人写nums.erase(it++);也能跑,但在 C++11 之前和之后语义有区别,容易藏 bug,不如统一用“先接收返回值,else 里才 ++”的写法。
另一种更简单也更推荐的做法是直接用remove_if,前提是你不需要在删除的同时积累其他信息:
nums.remove_if([](int n) { return n % 2 == 0; });6.2 对 list 使用 std::remove 的典型错误
STL 算法里的std::remove和 list 的成员remove行为不同,这一点非常容易踩坑:
std::list<int> nums = {1, 2, 3, 2, 4}; // 错误的做法:std::remove 不真正删除节点 auto new_end = std::remove(nums.begin(), nums.end(), 2); nums.erase(new_end, nums.end()); // 这能工作,但白白搬移了节点指针 // 推荐的用法 nums.remove(2); // 直接用成员函数std::remove需要先“逻辑删除”把目标元素移到末尾,再用 erase 删除尾部区间。对 list 来说,这个搬移是多余的,因为 list 的节点本来就是靠指针连接的。所以遇到 list 时,能调用成员remove/remove_if/unique/sort就不要调用同名算法。STL 的设计是“成员函数更懂容器内部布局”,这个原则在其他容器上也适用。
6.3 节点内存开销远比你想象的大
每个 list 节点除了数据,还要存储prev和next两个指针。在 64 位系统上,两个指针就是 16 字节。如果你存的是一个 4 字节的 int,链表的“管理开销”是数据本身的 4 倍。100 万个 int 的 list 大约要额外消耗 16MB 内存,而 vector 几乎不消耗额外内存。这个开销在嵌入式环境、或者处理大量小对象时是致命的。
我在一个网络网关项目里就吃过这个亏。当时用一个 list 缓存几千条小型报文元数据,每条元数据才 40 字节,结果内存比预估的高了快一半。后来排查到原因:每个节点两个指针 + 分配器对齐 + 堆管理头,实际开销远超理论值。最后换成了std::deque或者直接预分配数组,内存立刻降下来了。
6.4 自定义类型放入 list 的要求
放进 list 的自定义类型,要求并不苛刻,但有些操作会隐式要求特定能力:
- 默认构造、拷贝构造、移动构造、析构函数:插入节点时需要构造,删除节点时需要析构,这是基础要求。
emplace用参数就地构造,可以减少对拷贝/移动的依赖。sort默认用operator<,自定义类型需要重载<,或者给sort传自定义比较器。remove默认用operator==,自定义类型需要重载==,或者用remove_if替代。unique默认也是用operator==比较相邻元素。
如果你定义了一个只有int和std::string的结构体,编译器生成的默认拷贝/移动/比较操作通常够用。但如果结构体里有裸指针、文件句柄之类的资源,就需要自己管理拷贝和移动语义,否则浅拷贝会让多个节点指向同一块资源,析构时 double free。
6.5 调试 list 的几个小技巧
list 在调试器里看,比 vector 麻烦得多。gdb 里直接print some_list会打印一堆节点指针,非常痛苦。我的实用技巧是:
- 先看
size()和empty(),确认链表状态。 - 需要看元素时,不要尝试展开节点,直接写个小循环打印,或者用 gdb 的 Python 美化器。visual studio 的调试器对 STL 容器支持不错,但 gdb 默认显示比较原始。
- 在检查迭代器失效 bug 时,记录迭代器的
_M_node(libstdc++ 内部实现)或类似内部指针,如果它和某个节点的地址一致,说明迭代器还指着这个节点;如果地址变成一个悬空值,那就是已经失效了。 - 如果程序在释放 list 时崩溃,优先怀疑是不是有元素被拷贝出了循环引用,或者自定义类型的析构函数有问题。
我自己最常用的一招,是在测试代码里故意插入/删除大量节点后,调用std::distance(list.begin(), list.end())和list.size()对比,如果不一致,说明某个操作把链表结构搞坏了。标准库实现通常不会出这种问题,但如果你自己写链表,这个检查可以帮你快速定位边界条件 bug。
6.6 最后再分享一次我用 list 的真实体会
我记得有一次维护一个老项目,里面用 list 管理一堆网络连接对象,每个连接对象在多个模块里都有人持有着迭代器。当时有人提议“遍历太慢了,改成 vector”,我劝住了。因为连接对象会被其他模块在任意时刻增删,vector 扩容一次,所有外部持有的迭代器和引用全部悬空,改造成本极大。相比之下,list 的插入删除稳定性和迭代器稳定性是压倒性优势。真正的性能问题不在迭代,而在每次查找连接时都要 O(n)。后来我把“按 id 查找连接”这个高频操作单独加了一个std::unordered_map<int, iterator>缓存,list 本身不动,查找变成 O(1),老代码几乎没改,性能问题就解决了。
这件事让我对 list 的态度很明确:它不是一个“慢容器”,而是一个“特定场景的利器”。用对了,它能让代码简洁又高效;用错了,它就是内存和性能的黑洞。希望这篇文章能让你在下次看到std::list时,不再只想到“单向还是双向”,而是能真正根据场景做出正确的选择。