博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
好文推荐:
【C++进阶】STL容器与迭代器 - 01 STL 容器先解决元素放在哪里
【C++进阶】STL容器与迭代器 - 02 vector 为什么是一段会长大的连续数组
【C++进阶】STL容器与迭代器 - 03 string、array 和 deque 各自守住什么边界
有一个流传很广的口诀:频繁插入删除就用链表。但它漏掉了最关键的前提,你已经拿到了要插入或删除的位置。如果你需要先找到那个位置,而寻找本身需要遍历链表,那么查找期间的指针跳转成本可能远超链表操作本身省下的时间。理解 std::list 和 std::forward_list,就要从这个前提条件开始:链表的核心价值是"在已有位置的条件下,插入删除只改几根指针,不碰任何其他元素"。
节点:每个元素都有自己的一小块领地
vector 把元素整齐地码在一段连续内存里,删掉中间那个元素时,后续所有元素都要往前填补空缺。list 的做法完全不同:每个元素都住在自己独立的节点里,节点在堆上分配,节点之间通过指针相连。一个节点包含至少三样东西:指向前一个节点的指针(前驱)、指向后一个节点的指针(后继)、以及元素本身的数据。
在 std::list(双向链表)中,从头走到尾,每个节点都能看到前驱和后继,所以你可以正着走,也能反着走。在 std::forward_list(单向链表)中,每个节点只有后继指针,你只能往前看,永远找不到身后那个节点是谁。这个简化为 forward_list 省下了每个节点一个指针的空间,代价是丧失了双向遍历能力和很多操作的便利性(比如不能在某个位置之前插入,因为找不到前驱)。

这种节点结构的核心特性是位置稳定性。删除 list 中间的一个节点时,容器只做了三件事:把被删节点的前驱的后继指针指到被删节点的后继,把被删节点的后继的前驱指针指到被删节点的前驱,然后销毁被删节点本身。旁边的两个节点动都没动,它们还住在原来的地址上,指向它们的指针、引用、迭代器全部有效。插入操作同理:在某个位置前插入一个新节点,只需要调整插入点前后两个节点的指针,不会触及其他任何元素的移动。

对比 vector 删除中间元素的情景:被删位置之后的所有元素依次向前移动一个位置,每个移动的元素地址都变了,所有指向这些位置的指针、引用和迭代器统统失效。这种差异决定了:如果你的程序逻辑要求持有某个元素的引用并在容器其他位置频繁修改时该引用仍然有效,list 是唯一能给这个保证的顺序容器。
list 的双向能力
std::list<T> 提供双向迭代器,这意味你可以从任意位置前后移动。除了从头到尾遍历,双向能力还支撑了一些在单向链表上做不到或做得很别扭的操作:
push_back 和 push_front 都是常数时间,尾部有前驱指针,头部有后驱指针,各自一步到位。pop_back 和 pop_front 同理。
insert(pos, value) 在 pos 之前插入新元素,返回指向新插入元素的迭代器。注意是在之前插入,这意味着 insert(v.end(), value) 等价于 push_back(value),而 insert(v.begin(), value) 等价于 push_front(value)。
erase(pos) 删除 pos 指向的元素,返回指向被删元素之后那个元素的迭代器。这个返回值在循环删除中至关重要,如果你不知道接住它,你的循环迭代器就会指向一个已经销毁的节点,继续使用它就是未定义行为。
splice 是 list 独有的能力:把另一个 list 中的一段节点"剪下来",直接粘贴到当前 list 的指定位置。整个过程不拷贝、不移动元素数据,只是调整了几个指针的指向。源 list 中那段节点被移除(size() 减小),目标 list 获得那段节点(size() 增大)。被剪下的节点的地址不变,原来持有这些节点迭代器的代码仍然持有有效的迭代器,只不过这些迭代器现在属于目标 list 了。
cpp
std::list<int> a{1, 3, 5};
std::list<int> b{2, 4, 6};
// 把 b 的全部元素拼到 a 的末尾
a.splice(a.end(), b);
// 现在 a = {1,3,5,2,4,6}, b 为空
// 整个过程没有任何元素被拷贝或移动
splice 的 O(1) 移动任意数量元素的能力,在需要频繁重组元素顺序的场景中非常强大,比如维护一个 LRU 缓存,把最近被访问的元素迅速移到链表头部,splice 做这件事只需要改 6 根指针。

forward_list 的单向简化
std::forward_list<T> 每个节点只有一个后继指针,这种极简设计源于 C++11 对标 C 风格单向链表的需求。它是所有标准容器中占用最小、也最受限制的成员,没有 size()(在某些实现中为了保持单向性质不存储 size 计数)、没有 push_back()(在单向链表末尾插入需要从头遍历到尾,故意不让写)、没有 insert() 和 erase() 的任意位置版本(取而代之的是 insert_after() 和 erase_after(),在指定位置之后操作)。
前缀 _after 的操作方式反映了单向链表的物理现实:要在一个位置之前插入,你需要它前驱的后继指针,但在单向链表里,你从某个节点出发找不到它的前驱是谁。所以最自然的操作是"在这个位置之后插入":你手里捏着当前节点,它的后继指针可以直接改写。
这个限制使得 forward_list 适合的场景非常狭窄:极轻量的正向扫描和就地操作,内存极度紧张时(每个元素只省一个指针,但在大多数场景里这个空间省得没有意义),或者你从 C 代码迁移过来、单向链表就是原有的数据结构范式。对于绝大多数日常 C++ 编程,list 的双向能力和更自然的接口都比 forward_list 更值得选择。
缓存不信链表
list 的遍历效率比 vector 差,原因不只在于链表遍历要多解引用指针,指针解引用本身很快;真正昂贵的是节点分散在堆上,每次访问下一个节点都是一次不可预测的内存跳转。现代 CPU 严重依赖缓存预取来隐藏内存延迟:当你顺序读取一块内存时,CPU 的预取器能提前把接下来可能要访问的地址上的数据拉进缓存,让你在后续访问时命中 L1/L2。但对于链表,下一个节点的地址藏在当前节点的后继指针里,不读完当前节点根本不知道下一个节点在哪,预取器猜不出来。
结果是:遍历一个 list<int> 的 100 万个元素,大部分时间都花在等待主存返回数据上,每次跳转大约 100-300 个 CPU 时钟周期,而访问 vector 时的 L1 缓存命中可能只要 4-5 个周期。这个差距大到足以抵消 list 在插入删除上的常数优势:就算在中间每遍历几步就插入一个元素,vector 的批量搬动(通常在缓存内完成,速度极快)加上顺序遍历的缓存优势,经常比 list 的逐个访问加上 O(1) 插入的总时间还要短。
这也解释了为什么 Effective STL 的作者 Scott Meyers 有一句名言:"在大多数情况下,vector 比 list 更快,即使操作本身看起来更适合 list。"这句话保留了链表的价值,同时提醒你:缓存局部性在真实硬件上的权重远高于理论复杂度分析中的假设。

正确的判断框架是:先考虑数据结构的需求特征,再测量,而不是用"插入删除就用链表"的粗粒度规则。绝大多数情况下,需求特征指向的是 vector。只有在同时满足以下条件时,list 才真正值回票价:
- 你确实需要在已有位置的条件下频繁插入或删除,而不是每次都要先查找。
- 你需要持有元素的稳定引用或迭代器,在容器其他位置被修改后它们仍然有效。
- 你需要用
splice把一段节点整体移动到另一个位置或另一个容器。 - 元素的体积很大(移动和拷贝成本高),而中间插入/删除的搬动成本就成为实打实的负担。
以上条件全部满足才考虑 list;只要有一条不满足,vector 通常更划算。至于 forward_list,除非你在嵌入式环境中内存极度紧张且严格只需要正向扫描,否则基本不值得优先考虑,它的额外限制换来的空间节省微乎其微。

不使用 list 的常见场景
有几种看起来"适合链表"的场景,实际上用 vector 更好:
"需要中间插入"不等于"需要 list"。如果你每次中间插入前都需要先遍历找到插入位置,list 的 O(n) 遍历成本可能比 vector 的 O(n) 搬动成本还高(遍历 list 是逐节点跳跃,遍历 vector 是连续扫描)。同时,vector 的搬动在现代硬件上非常高效,连续内存中的批量移动可以利用 SIMD 指令,而且发生在缓存内部。实际测量中,在几十到几百个元素的规模上,vector 的中间插入往往比 list 快。
"需要频繁排序"不等于"需要 list"。std::sort 要求随机访问迭代器,不能用于 list。list 有自己的 sort() 成员函数,但它是基于归并排序的链表实现,常数因子比 vector 上的 std::sort 大得多。把元素放进 vector 再排序,在几乎所有场景中都比直接在 list 上排序快。
"需要删除特定元素"要用 erase,不是 remove。std::remove 算法(配合 erase 的 remove-erase 惯用法)移动元素而不是删除元素,它在 list 上同样能用,但 list 有自己的 remove() 成员函数,直接删除匹配的节点,不需要 remove-erase 两步走。原因在于:对于 vector,remove 时我们不能真的删除元素(那会让迭代器失效),所以只能把"要保留的"往前挪,"要删除的"留在末尾,再用 erase 一起删掉。而 list 可以安全地删除单个节点,所以一步到位。这是接口上的差异,不是推荐 list 的理由,除非你的删除操作确实频繁到需要省掉 remove-erase 中的额外移动。

cpp
#include <iostream>
#include <list>
#include <vector>
#include <algorithm>
int main() {
std::list<int> lst{1, 2, 3, 4, 5, 6};
// 保存指向元素 4 的迭代器
auto it = std::find(lst.begin(), lst.end(), 4);
// 在链表头部插入
lst.push_front(0);
// 删除链表尾部的 6
lst.pop_back();
// 检查之前保存的迭代器 still valid
std::cout << "原来指向 4 的迭代器现在指向: " << *it << '\n'; // 仍然是 4
// splice 演示
std::list<int> extra{100, 200};
lst.splice(lst.begin(), extra); // 把 extra 全部移到 lst 头部
std::cout << "splice 后: ";
for (int x : lst) std::cout << x << ' '; // 100 200 0 1 2 3 4 5
std::cout << '\n';
// 删除所有偶数,用 list 的 remove_if 成员,一步到位
lst.remove_if([](int x) { return x % 2 == 0; });
std::cout << "移除偶数后: ";
for (int x : lst) std::cout << x << ' '; // 1 3 5
std::cout << '\n';
}
这个例子展示了 list 最核心的两个保证:插入和删除不影响已有迭代器的有效性,以及 splice 零拷贝移动节点。如果在 vector 上做同样的事,push_front 后检查旧迭代器、或者从中间拼接一段到头部,需要大量元素搬动,旧迭代器也早就失效了。

双向可以退化为单向吗
list 确实能覆盖 forward_list 的功能,毕竟双向迭代器包含前向迭代器的全部能力。但有两个情况下 forward_list 仍然值得考虑:一是在极度受限的嵌入式平台上,每个节点省一个指针确实有关键影响;二是你已经有一个 C 风格的 struct node { node* next; T data; },并且要维持这个内存布局,用 forward_list 可以在不引入前驱指针的前提下获得 STL 算法和范围操作的便利。不过第二种场景中,C++20 的 ranges 和 span 往往提供了更现代的替代方案。
从节点到树节点
list 和 forward_list 放弃了"按位置排名"的效率,换来了位置的稳定性。但它们在查找方面并没有提供任何帮助,想找一个特定值,仍然只能线性扫描。如果你有一堆键值对,你关心的是"键为 X 的那个元素对应的值",第几个元素已经不重要,那么无论是 vector 的连续扫描还是 list 的指针跟随,都不是体面的方案。
这种需求指向了关联容器。它们保留了节点式存储介质,同时改变了组织节点的逻辑。在有序关联容器中,节点不再按插入顺序连成链,而是按键的大小关系组织成一棵树,每个节点的"位置"由键决定,遍历的路径恰好产生按键排序的结果。
链表最值得被记住的场景通常带有"外部长期持有位置"这个特征。比如一个编辑器维护文档中的一串段落,光标、选区、批注都可能长期指向某个段落;再比如一个 LRU 缓存同时维护哈希表和访问顺序链表,哈希表按 key 找到节点,链表负责把最近访问的节点移动到头部。这里链表的价值不是单独的插入删除复杂度,而是多个结构之间可以共享同一个节点位置,节点移动不需要搬动对象本体,也不会让其他节点的地址变化。
splice 是理解 list 的关键成员函数。普通容器的"移动一段元素"通常意味着构造、移动、销毁,甚至可能触发分配;list::splice 则是在两个链表之间改指针,把现有节点接到新位置。节点里的对象不被移动,指向这些对象的引用仍然指向同一个对象。这种操作在队列调度、缓存淘汰、任务优先级调整里很有用,因为它把"改变顺序"和"移动对象"分离开了。只要你看到需求里有"把已有元素搬到另一个位置,但元素本身不能动"这样的信号,list 就有进入候选名单的资格。
forward_list 的价值更窄,但也更明确。它省掉前驱指针,节点更小,结构更接近传统单链表。在极端内存场景中,这个指针差异会被放大;在需要和 C 风格单链表结构互通的代码里,forward_list 也更贴近底层模型。代价是操作更别扭:你经常需要前驱位置才能删除当前节点,所以它提供 before_begin()、insert_after()、erase_after() 这套"在某个节点之后操作"的接口。这个接口看起来不如 list 自然,但它诚实反映了单链表的结构事实:没有前驱指针,就不能从当前节点直接找到前一个节点。
因此,别把 list 当作 vector 的通用替代品。它不是给"数据很多"准备的,也不是给"我可能会插入删除"准备的;它给的是稳定节点、低成本拼接、已有位置上的局部修改。只要你的代码仍然需要大量按下标访问、大量顺序遍历、频繁查找某个值,链表都很难赢。链表适合的是位置已经被别的结构记住、对象不能随便搬、顺序经常被重排的场景。
链表的内存成本也要算清楚。一个 list<T> 节点至少要保存前驱指针、后继指针和对象本体,很多实现还会因为对齐和分配器元数据付出额外空间。对于一个 int,节点里的两个指针可能比数据本身大得多;对于一百万个小对象,这些额外指针和分配开销会把内存占用放大好几倍。forward_list 省掉一个前驱指针,能减轻这个问题,但它换来的接口不便也是真实成本。内存紧张时,不要只看元素大小,还要看容器结构为每个元素额外付出了什么。
节点分配还有运行时成本。vector 扩容时一次申请一整段内存,之后连续构造元素;list 插入时通常为每个节点单独分配。频繁的小块分配会增加分配器压力,也更容易造成堆碎片。可以通过自定义分配器或内存池缓解这个问题,但那已经说明 list 的使用场景进入了更复杂的工程层。普通业务代码如果只是为了"以后也许会插入删除"而选择链表,往往会提前支付这些额外成本,却没有得到真正用得上的稳定性收益。
链表的另一个限制是算法支持。std::sort 要求随机访问迭代器,list 只能提供双向迭代器,所以不能把 list.begin()、list.end() 直接传给 std::sort。list 提供自己的成员函数 sort(),它能利用链表的节点结构重新接线完成排序,避免移动元素本体。这个设计再次说明:容器的结构决定了算法的可用形式。同样叫排序,在 vector 上是交换连续数组里的元素,在 list 上是调整节点链接。写泛型代码时,迭代器类别就是你能调用哪些算法的边界。
最后要记住,链表稳定的是节点,不是业务逻辑。如果你保存了一个指向某个节点的迭代器,这个节点没被删时迭代器有效;但如果业务上这个节点已经不该被使用,比如它对应的任务被取消、对象状态已经过期、外部索引没有同步更新,迭代器有效也不能证明逻辑正确。容器只能保证结构层面的稳定,不能替你维护业务层面的生命周期。把这两层分清,才能真正安全地使用节点容器。
链表还会改变你设计索引的方式。常见 LRU 缓存通常不是单独一个 list,而是 list<Key> 或 list<Entry> 加上一张 unordered_map<Key, list<Entry>::iterator>。哈希表负责按 key 找到节点,链表负责维护访问顺序;每次访问命中时,用 splice 把节点移动到头部,哈希表里保存的迭代器仍然指向同一个节点。这个组合很好地说明了链表的真实价值:它经常作为另一个结构的稳定顺序层,而非单独承担全部查找工作。
如果你只用一个 list 做查找、插入、删除、遍历,通常会很快撞到线性查找的墙。链表擅长在已知位置上操作,不擅长找到这个位置。把"找位置"交给哈希表、树或外部索引,把"移动位置"交给链表,结构分工才清楚。反过来,如果你每次都从头扫描链表找到目标,再执行 O(1) 删除,那前面的扫描已经吃掉了优势。
forward_list 的单向特性也会影响算法表达。删除当前节点时你必须掌握前驱位置,所以循环经常写成"观察 next,操作 after"的形式。这个写法不如双向链表直观,却能逼你面对单链表的事实:当前节点不知道谁指向自己。很多底层数据结构、空闲链表、侵入式列表都建立在这个模型上。理解 forward_list 的意义不在于日常大量使用它,而在于看懂那些只保存 next 指针的结构为什么会这样设计。
链表进入公共接口时要特别谨慎。接口返回 list<T>::iterator,调用方会以为它可以长期保存这个位置;接口暴露 splice 能力,调用方会以为节点可以在容器之间低成本转移;接口承诺元素地址稳定,后续想换成 vector 就会很困难。因此,只有当位置稳定真的是业务能力的一部分时,才应该让链表形态穿透接口。内部临时实现可以换,接口承诺一旦放出就很难收回。
调试链表性能时,最有用的办法是把"找位置"和"改链接"分开计时。很多链表代码声称插入删除很快,实际耗时都花在从头扫描目标节点上;真正的 O(1) 插入删除只发生在你已经持有目标位置的情况下。把扫描时间单独测出来,链表是否合适通常一眼就能看出来。如果大部分时间都在找节点,应该优先考虑索引结构,而不是继续优化链表本身。
链表还会让内存局部性变得难以预测。两个逻辑上相邻的节点,物理地址可能相隔很远;一轮遍历可能不断触发缓存未命中。现代 CPU 对连续数组的优化很强,对指针追逐很无力。这个硬件事实解释了为什么很多理论上"中间删除更便宜"的链表程序,实际跑起来输给了 vector。链表赢的前提是删除位置已知,并且稳定性价值足够高。
所以学习链表容器时,最重要的工作是记住它提供了一种稳定节点模型。节点模型适合被其他索引引用、适合低成本重排、适合对象地址不能变的场景;它不适合盲目替代连续数组,也不适合承担高频线性遍历。把这个边界守住,list 和 forward_list 就会从"课本里的链表"变成可判断的工程工具。
码字不易,欢迎大家点赞,关注,评论,谢谢!