ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

std::list 核心实战:双向链表、迭代器稳定性与 splice 技巧

2026/10/6 17:28:22 拓冰建站 浏览量
std::list 核心实战:双向链表、迭代器稳定性与 splice 技巧 聊起 C STL很多人的印象是 vector 用得多、map 方便、list 存在感低。但真正要靠迭代器稳定性和节点拼接写业务逻辑的时候std::list 反而是最省心的那个。这个容器实现的是双向链表元素被装在节点里节点之间用指针相连内存天然不连续。它不提供随机访问下标运算符直接就没有我把这篇文章定位成“一份能直接拿去查手册的 list 实操总结”。你会看到它的设计意图、底层节点结构、迭代器行为和大量真实代码也会看到我在项目里踩过的几个和 list 相关的坑。无论是刚开始学 STL还是写了两三年 C 却一直没系统整理过这个容器按顺序读完基本就够了。有一点先说明白我聊的 list 主要指 C11 之后的std::liststd::forward_list这种单链表只有前向迭代器很多写法不一样不混在一起讲。1. 先搞清楚 list 的设计意图双向链表不是万金油1.1 节点内存布局注定它的强项与软肋std::list 的每个元素都会被单独包裹成一个节点节点里至少包含前驱指针、后继指针和你存放的数据。链表的内存分布完全依赖分配器在堆上给出的地址相邻节点并不一定挨在一起所以它没办法像 vector 那样直接“起始地址 下标 * sizeof(T)”就能取到第 n 个元素。这一条底层事实基本决定了 list 的性格。插入和删除一个已有迭代器指向的位置时只需要改前后两个邻居的指针不需要搬运其它元素理论上是常数时间。反过来想拿到某个位置的迭代器只能从头或者从尾部逐步走std::find、std::advance全是线性时间。也就是说list 把“定位”的成本放大了把“改动”的成本缩小了这是一个很典型的内存布局换收益的决策。很多人会忽略的是list 的迭代器稳定性强得多。向 list 插入一个节点不会让任何已存在的迭代器失效删除节点时也只会让指向被删节点的迭代器失效其它迭代器照常使用。如果你写的是一个长期保存“游标”的应用层数据结构比如编辑器里的光标位置、服务器连接列表里的当前处理项这种稳定引用能省掉不少同步逻辑。1.2 三个常用容器选型对比什么时候才轮到 list容器内存连续性随机访问中间插入/删除迭代器稳定性vector连续O(1)O(n)扩容或插入删除后容易失效deque分段连续O(1)两端 O(1)中间 O(n)中间插入会失效两端不失效list离散节点无已有迭代器前提下 O(1)除被删除节点外稳定我看到很多人选容器只看这一张表觉得 list 中间插入是 O(1) 就一定快。真实的工程结论没那么简单。数据量小的时候随便选都无所谓数据量一大主要矛盾往往不是“插入赋值了几次”而是“缓存命中率有多高”。list 每访问一个节点都要跳一次内存地址vector 是一次顺序扫描60 字节的缓存行能装下十几个 intlist 可能只装一个节点的一部分。同样是对几百万个 int 求和vector 和 list 的时间差距能达到一个数量级光看复杂度表完全看不出来。我的选型经验是三个问题答完再决定。第一业务里是否存在大量“按下标/按随机位置访问”的代码有就选 vector 或 deque。第二是否频繁在序列中间插入删除同时又长期持有一批迭代器只有这种情况 list 是明显赢家。第三如果只是头尾操作、不要求随机访问deque 往往比 list 更均衡因为它分段连续缓存表现好也支持下标。list 不是不能用但要用在它真正适合的场景里。2. 核心细节解析节点、迭代器与内存布局2.1 一个节点到底占了多少内存直接看实现会更直观。在 libstdc 里std::list的节点内部结构大致是下面这样template typename T struct _List_node { _List_node_base* _M_next; _List_node_base* _M_prev; T _M_data; };前驱和后继两个指针在 64 位环境下各占 8 字节两个就是 16 字节再加上 T 本身和对齐字节。也就是说如果你在一个std::listint里放 100 万个 int每个 int 本身只占 4 字节但每个节点普遍要占 24 或 32 字节内存开销可能是 vector 的四到八倍。std::list的对象本身也并不是零成本标准实现通常会在内部留一个哨兵节点代表 end()空列表同样要付出这个哨兵的开销。链表通过哨兵节点把整个结构变成环形begin() 是哨兵的下一个节点end() 就是哨兵自己这样从头和从尾遍历都不需要特判空指针。这个内存模型带来的第二个影响是分配频率。每插入一个元素就是一次独立的堆分配百万级数据插入等于百万次 new。堆分配器不是免费午餐锁竞争、碎片整理、空闲块查找都会拉高实际耗时。所以有些老 C 项目用 list 用得很难受不是 list 本身慢而是默认分配器在规模上来以后撑不住。2.2 迭代器为什么不是指针以及哪些操作会杀死迭代器list 的迭代器是双向迭代器属于前向迭代器的强化版能 能 --但没有随机下标也不能做it n。这个类型能力决定了标准算法库的兼容范围std::sort要求随机访问迭代器所以直接拿l.begin(), l.end()去调std::sort会编译报错std::advance(it, n)在 list 上也是老老实实逐跳 n 次不是一步到位。这些不是编译器 bug而是迭代器分类在起作用。迭代器失效规则比 vector 简单但对写代码非常有帮助insert不会让任何已存在的迭代器失效包括新插入位置之前的迭代器。erase只让被删除位置的迭代器失效其它位置不受影响。splice不会让迭代器失效但被搬走的节点会在另一个容器里出现迭代器依然指向那个节点只是归属变了。clear或整个 list 析构之后所有指向它的迭代器都会变成空悬状态。只要把这四条背熟绝大多数和 list 相关的崩溃都能在写代码阶段发现。很多人崩溃的原因不是迭代器规则复杂而是根本没意识到erase之后原来的迭代器不能再用。2.3 缓存与分配器list 的真实速度取决于内存布局前面提到过 O(1) 插入的理论复杂度这里展开讲为什么“不一定快”。你往 list 中间插一个节点本身确实只改指针但如果你的业务逻辑每次都要先std::find从头部找到插入点find 是 O(n)找到之后 insert 才是 O(1)两个环节合起来还是 O(n)。vector 的中间插入是整体搬移但 memmove 每次连续搬一大块CPU 预取和缓存行都能配合得很好T 是小对象时可能反而比 list 的“O(n)O(1)”更快。另一个隐蔽点是内存局部性。你连续往 list 里 push_back每次的 node 大概率来自不同内存页遍历时每次比 vector 多等待几十甚至上百个周期。我在本机拿 500 万个 int 做测试vector 求和大概是十几毫秒list 停在一百毫秒上下差距就出在这里。如果你要遍历 list 多次那这个差距还会成倍放大。想缓解一是减少遍历次数二是在数据量大时认真考虑自定义分配器把节点尽量放在紧挨着的内存池里后面会看到具体思路。3. 从初始化到 splicelist 实操代码全解析3.1 初始化、插入与删除的正确姿势先列初始化最常用的写法#include list std::listint l1; // 空表 std::listint l2(10, 1); // 10 个 1 std::listint l3 {1, 2, 3, 4, 5}; // 初始化列表 std::listint l4(l3.begin(), l3.end()); // 区间构造插入删除时list 提供的是push_back、push_front、insert、emplace_back、emplace_front。如果你已经拿到迭代器insert 可以在该位置前面插入auto it l3.begin(); std::advance(it, 2); // 指向数字 3 l3.insert(it, 99); // 变成 1 2 99 3 4 5std::advance在 list 上是线性走的所以上面这个看似普通的代码其实已经花掉了 O(n)。如果要频繁在“当前位置之后插入”最好连迭代器位置一起保存不要每次都从头 advance。删除单个已知位置的元素直接用erase并接收返回值auto it l3.begin(); std::advance(it, 3); l3.erase(it); // 删除 l3 第 4 个元素注意list::erase返回的是被删除节点的下一个节点迭代器。这个返回值的价值主要体现在循环删除里我在下一节专门展开。如果是要按值删除所有匹配元素别绕remove和remove_if是成员函数直接变成std::listint nums {1, 2, 2, 3, 4, 4, 5}; nums.remove(2); // 删掉所有 2 nums.remove_if([](int x) { return x % 2 0; }); // 删掉所有偶数remove和remove_if的成员版本会真的把节点删掉不是全局算法的“把存活元素搬到前面、再把尾部残留节点 erase 一下”那种玩法。这个差异在 list 上特别明显全局std::remove只能通过赋值移动元素list 的迭代器又不支持随机访问代码里容易写出又慢又别扭的组合。说到自定义类型和 emplace顺便给一个常用示范。假设有一个订单结构初始化、插入是这样struct Order { int id; double amount; Order(int i, double a) : id(i), amount(a) {} }; std::listOrder orders; orders.emplace_back(1, 100.5); // 直接构造省一次拷贝 orders.push_back(Order{2, 200.0});emplace_back直接把构造参数转发给 Order 的构造函数省掉临时对象的构造和拷贝当元素本身比较大号或者带复杂资源时这比push_back更划算。list 的节点构造能力让它能很好配合这类“只移动、不拷贝”的对象但要注意 list 的 insert 在需要拷贝时仍然要求类型可拷贝只有 emplace 一族可以在原地构造。3.2 splice 合并节点list 独有的高效玩法list 有一个 vector 怎么都做不出来的操作叫splice直接“拼接”。它的作用是直接把一个 list或其中一个区间里的节点整体搬到另一个 list 的指定位置整个过程不拷贝不搬移只是重新改几个指针。只要你知道目标位置复杂度就是 O(1)。std::listint a {1, 2, 3, 4}; std::listint b {100, 200}; auto it a.begin(); std::advance(it, 2); // it 指向 3 a.splice(it, b); // 把 b 所有节点插到 it 之前 // a: 1 2 100 200 3 4 // b: 空splice还有一个常用重载只搬一个迭代器指向的节点或者搬一段迭代器区间 [first, last)。它经常出现在缓存维护、任务队列、窗口管理这类需求里。比如做 LRU 时命中的节点要从链表中间摘出来再放到头部如果用 erase push_front要先拷贝或移动元素再重新构造节点开销不小如果用 splice变成std::listint cache {1, 2, 3, 4, 5}; auto hit cache.begin(); std::advance(hit, 2); // 假设命中 3 cache.splice(cache.begin(), cache, hit); // cache: 3 1 2 4 5注意 splice 同一个列表里的区间时目标位置不能落在被搬的 [first, last) 范围内否则行为未定义。写代码前先判断一下这个条件哪怕只是 review 时多想一秒也能避免很多诡异现象。3.3 排序、去重、合并这些成员函数比 STL 算法更值得用list 提供了一组成员算法分别是 sort、merge、unique、reverse。使用时要记住它们的适用前提。排序用l.sort()。默认按operator升序也可以传自定义比较std::listint nums {4, 1, 7, 2, 9}; nums.sort(); // 1 2 4 7 9 std::listOrder orders {Order(1, 100.0), Order(2, 50.0), Order(3, 80.0)}; orders.sort([](const Order a, const Order b) { return a.amount b.amount; });std::list::sort和标准算法库的std::sort不是一回事因为后者需要随机访问迭代器。list 的 sort 内部用的是归并思路实现但还是那句老话尽量避免在大 list 上反复 sort。真到了大 list可以先把元素搬到 vector 排序再搬回来T 可拷贝时往往更快。去重用unique()它只消除“相邻且相等”的连续重复项。想要去掉所有重复值必须先排序std::listint raw {3, 1, 3, 2, 1, 3}; raw.sort(); // 1 1 2 3 3 3 raw.unique(); // 1 2 3合并用merge。它要求两个 list 都已经按相同排序规则排好序结果也保持有序。比手动循环 insert 高效得多能在两个序列上线性完成std::listint a {1, 3, 5}; std::listint b {2, 4, 6}; a.merge(b); // a: 1 2 3 4 5 6b 被清空reverse 很简单反转整个链表。单链表要想高效反转还麻烦一点list 因为是双向的直接改头尾方向即可。这几个成员函数能覆盖大部分“对 list 做遍历修改”的需求不需要硬套标准算法库。4. 常见问题与排查技巧实录4.1 在循环里删除元素为什么 erase 返回值这么重要这是 list 新手最容易踩的坑。写法看起来没问题// 错误示范删除所有偶数 for (auto it nums.begin(); it ! nums.end(); it) { if (*it % 2 0) { nums.erase(it); } }问题在于erase 会让it失效可循环末尾还是要it这个自增操作访问了一个已经失效的迭代器结果就不可预期了。小数据量可能侥幸没崩溃数据量一上来就是访问野指针表现可能是随机崩溃、死循环或者数据错乱还查不出来。正确做法是每次删除后拿 erase 的返回值续上位置for (auto it nums.begin(); it ! nums.end(); ) { if (*it % 2 0) { it nums.erase(it); // 直接拿到下一个有效迭代器 } else { it; } }如果只是“删除符合条件的所有元素”更简单的是直接调 remove_ifnums.remove_if([](int x) { return x % 2 0; });这个成员函数本身就是遍历并定位、删除的组合性能通常比手写循环更好代码也更短。能用成员函数解决的场景没有必要自己写循环。4.2 迭代器失效自查清单六个高发场景我把平时排查时最常遇到的六种情况列成清单写代码时过一眼能省很多 debug 时间。erase 后继续 被删迭代器已经失效循环里必须立刻从返回值重建游标。用 vector 存 list 的迭代器list 结构一变容器内保存的迭代器可能仍然有效但删除过的那个就失效了继续用依然崩。splice 之后使用迭代器迭代器本身有效但指向的元素可能已经在另一个 list 里逻辑归属变了。对 end() 执行 eraseend() 代表哨兵节点没有实际元素对它 erase 是未定义行为。clear 之后保存的迭代器list 析构或 clear 后所有迭代器全部失效立刻成为野指针。把需要随机访问的算法硬套在 list 迭代器上比如 std::sort、std::nth_element长得像能用编译期就报错这种符号不匹配反而是最好查的。前五条都和数据竞争无关纯粹是迭代器生命周期问题。写容器代码时把“迭代器也是一种引用只是多了类型能力”记在脑子里就不会犯低级错误。4.3 别让“O(1) 插入”骗了你性能问题复盘我讲一个真实案例。之前有个消息分发模块每条消息要按优先级插到队列中段一开始很自然选了 list因为中间插入理论 O(1)。结果压测时插入频繁后整体吞吐掉得厉害日志一看大部分时间耗在寻找插入位置上。代码里每次插入都是先std::find从头找到合适位置这步是 O(n)。找到位置之后 insert 确实 O(1)但单位时间内 O(n) 的查找次数远多于 O(1) 的插入总耗时自然上去了。优化办法主要在减少定位成本。如果业务允许保存一个动态更新的“游标迭代器”每次插入从游标附近开始找如果插入点随业务规律偏移不大甚至可以直接用std::next(elemIt, k)做局部移动避开整表遍历。当时还顺手加了一个简单的内存池分配器把节点分配次数从百万次降到几十块大块内存的切分实测性能才真正稳定下来。做性能分析时要记住STL 源码里写的“O(1)”指的是操作本身不是整个业务路径。你从找到位置到插入完成所走过的完整链路才是用户感知到的成本。list 适合的是“有定位迭代器、频繁改动指针”的场景而不是“每次从头开始找一遍再插”的场景。后者的真实复杂度依旧是 O(n)没有任何算法能救。4.4 ABA 问题与并发场景的警示std::list本身不是线程安全的多个线程同时读写同一个 list 必须自己加锁这是最基础的一条。可如果你研究过高性能无锁数据结构一定熟悉“ABA 问题”在一个线程对某个链表地址做 CAS 比较时另一个线程把该节点删掉又新建节点恰好落到同一地址CAS 认为“值没变”实际节点内容已经完全不同于是旧逻辑拿着过期信息继续操作最终状态就错了。虽然这是无锁链表编程里的经典问题不是 std::list 的锅但理解它对你用 list 有帮助。单线程下不需要考虑 ABA链表结构的两次内存分配虽然地址可能复用但操作是串行的不会出现“读到一半地址又被占用”的窗口。如果是并发链表通常不是加锁就是做无锁加节点版本号/引用计数回收工程里大多数场景直接用互斥锁反而简单可靠。这个提醒价值在于看到某些“优化到位的无锁链表”代码时你能判断它处理没处理 ABA而不是把问题带到线下数据结构设计里。5. 实战经验我用 list 做过的几件事和操作纪律5.1 用 splice 做任务队列的节点转移我之前在服务端写过一个多优先级队列普通队列里的任务可能因为外部通知被临时升级。如果任务数据放在 vector做升级就是一次 erase 加一次 insert中间大量元素移动用 list 再加一张“任务 ID 到迭代器”的映射表升级时直接 splice 两个队列之间的节点整条逻辑稳定、耗时也基本可忽略。大致形态是这样任务创建时 push_back 到普通队列同时把返回的尾迭代器存进一个std::unordered_mapTaskId, std::listTask::iterator。收到升级通知后从 map 里拿出迭代器定位到节点然后执行priorityQueue.splice(priorityQueue.end(), normalQueue, it)。这里之所以敢长时间保存 list 迭代器正因为 list 恰恰是一类不会因为其它节点插入删除而集体失效的容器。换成 vector 做这件事就要开始动下标和拷贝代码复杂度直接上一个台阶。这个案例想说的不是 list 碾压一切而是“底层特性匹配业务模型”时代码能写得更自然。你手里存了迭代器就拥有了 O(1) 随机“摘除插入”能力这是其它连续结构给不了的组合。5.2 自定义分配器与节点池默认列表节点是每个元素一次独立 new量大时分配器压力不小。如果节点总数可控、且程序生存周期中反复创建删除可以写一个自定义分配器预申请一块或多块连续内存分配时从自由槽位里取释放时把槽位回收到空闲链表。代码如下示意template typename T class SimplePoolAllocator { public: using value_type T; SimplePoolAllocator() default; template typename U SimplePoolAllocator(const SimplePoolAllocatorU) noexcept {} T* allocate(std::size_t n) { // 实际实现里从这里返回内存池中的一块连续地址 return std::allocatorT{}.allocate(n); } void deallocate(T* p, std::size_t n) noexcept { // 实际实现里把内存标记为空闲等待下次复用 std::allocatorT{}.deallocate(p, n); } template typename U struct rebind { using other SimplePoolAllocatorU; }; };使用方改成std::listint, SimplePoolAllocatorint l;即可。要点是要写rebind因为 list 真正分配的是内部节点不是 int 本身STL 分配器依赖 rebind 转换成节点分配器。技术细节略多我不建议新手第一版就上自定义分配器先用默认分配器把逻辑跑通压测确实发现分配成为瓶颈后再考虑节点池。5.3 我给自己定的几条 list 使用纪律写 C 多年容器选型大多靠经验不靠死记。下面这五条是我自己长期使用的判断标准也建议初学者写代码时先自查需要随机访问、下标遍历直接排除 list能用 vector 用 vector。需要频繁在中间插入删除且能稳定持有一批迭代器才把 list 列为候选。遍历场景多且数据量大时list 的缓存劣势会吃掉它的插入优势认真对比实测数据。删除多个匹配元素用 remove/remove_if循环删除要记得用 erase 的返回值续迭代器。涉及跨容器转移节点时先想 splice不要先想“erase 再 insert”。最后再分享一个实际操作中的小技巧当你拿不准一个容器该不该用 list 时先把最坏情况的插入、删除、遍历次数写在一张纸上估算一下。很多所谓“list 性能问题”根本不是 list 的问题而是“确定插入点”的路径成本太高反过来很多“vector 不够用”的抱怨其实是提前保存了错误的下标或者错误地持用了迭代器。容器本身只是工具真正的分水岭是你有没有把底层内存布局、迭代器生命周期和业务路径三者对齐。我不建议把这份总结当八股背下来更推荐把它当成一份排查手册遇到具体问题再回来对照。也希望这份东西能让你下次选择容器时少走几个我从前走过的弯路。