
如果你写过一段时间的 C那 STL 里的 list 基本是绕不开的。它本质是一个双向链表容器底层用节点串起来每个节点各自保存数据和前驱、后继指针。和 vector 那种连续内存的数组结构完全不同list 在任意位置插入和删除都是 O(1)但代价是不支持随机访问想取第 n 个元素只能从头往后走。这篇东西是我这些年在实际项目里用 list 的完整记录包括接口怎么用、为什么这么用、哪些场景千万别用适合刚学 STL 的读者也适合想把手头代码写得再稳一点的老手。先提醒一句这里的 STL 是 Standard Template Library跟 3D 打印那个 STL 文件格式完全两码事搜资料的时候别搞混。1. 先搞懂 list 到底是什么1.1 双向链表的本质和内存布局list 在 C 标准里定义在list头文件里模板签名是std::listT, Allocator第二个参数默认是std::allocatorT日常写代码基本用不到。它底层是一个双向循环链表每个节点包含三部分数据元素、指向前一个节点的指针prev、指向后一个节点的指针next。因为是循环链表通常还带一个哨兵节点这个哨兵不存有效数据只用来统一边界判断让begin()和end()的实现变得非常干净。关键点在于list 的每个节点都是独立分配的节点之间在内存里不一定连续。这在两方面带来明显影响第一插入和删除只需要改几个指针不需要搬移任何元素第二list 天然没有扩容这个概念vector 那种内存不够了重新分配一块更大的再把旧元素搬过去的现象在 list 身上根本不存在。这个特性让你在元素数量不确定、需要频繁增删的场景里不用担心因为扩容导致迭代器、指针、引用全部失效。用个生活类比vector 是一排连着的储物柜你想拿第 10 个柜子里的东西抬脚直接走过去list 是一列火车车厢每节车厢知道自己的前一节和后一节是谁但你要找第 10 节车厢得从车头一节一节数过去。前者快在找后者强在改。我试过很多次跟人解释 list最后发现这个类比最管用。很多人刚开始学的时候总是纠结为什么 list 的operator[]不存在其实就是因为它在物理上不支持一步跳过去硬凑一个 O(n) 的随机访问接口也没有意义。标准库设计者不想给你一个看起来能用、实际性能很差的接口干脆就不提供。1.2 为什么需要这样一个容器每次技术选型我习惯先问解决什么问题list 解决的痛点有这三个。第一需要频繁在头部和中间插入、删除元素。vector 在尾部插入是快的但头部插入要把所有元素后移一位复杂度 O(n)删除头部同理。而 list 的push_front、pop_front、insert、erase都只动指针O(1) 完成。你在实现任务队列、消息缓冲、撤销栈这类结构时这个特性会非常舒服。第二对迭代器和引用的稳定性有硬性要求。C 标准明确保证 list 的插入操作不会使任何已有迭代器、引用、指针失效删除操作只会让被删节点的迭代器和引用失效。这一点在写多线程或缓存系统时特别重要你不需要担心一个插入操作把后台正在遍历的位置搞崩。第三元素数量动态变化极大且单个元素拷贝成本很高。list 的节点里数据是直接构造在节点内存里的不会因为扩容而额外拷贝。如果你存的是体积很大的对象又不断增删用 list 可以避免 vector 扩容时那几次大拷贝。当然这些优势都有代价代价我后面在性能章节细说。先记住一句话list 的定位是操作密集、位置灵活、稳定性要求高而不是我觉得链表很高级所以用它。2. 核心接口拆解每个常用操作背后的原理2.1 构造与初始化你至少得知道这五种list 的构造方式很常规但初学者经常在带元素个数构造这个点上翻车。我在这里把实际常用的列出来直接给可运行的代码。#include list #include vector #include string #include iostream struct Task { int id; std::string name; }; int main() { // 1. 默认构造空的 list std::listint empty_list; // 2. 构造 n 个元素每个元素值初始化 std::listint n_default(5); // 5 个 0 std::liststd::string n_str(3); // 3 个空字符串 // 3. 构造 n 个元素每个元素都是 val std::listint n_val(5, 42); // 5 个 42 // 4. 迭代器范围构造 std::vectorint src {1, 2, 3, 4}; std::listint from_range(src.begin(), src.end()); // 5. 初始化列表构造 std::listint init_list {1, 2, 3, 5}; // 6. 拷贝构造和移动构造 std::listint copy_of_init(init_list); std::listint moved std::move(copy_of_init); // 移动后 copy_of_init 为空 return 0; }这里有个细节值得展开std::listint n_default(5)里元素是怎么构造的在 C11 之前容器要求元素类型是默认可构造的否则编译不过。C11 之后标准库内部使用完美转发 就地构造emplace 系列效率提升很明显。这也牵出一个经验如果你需要往 list 里放自定义类型比如上面的Task尽量不要用push_back(Task{...})这种先构造再拷贝的方式而是用emplace_back(1, hello)直接在节点内存里构造省掉一次拷贝构造至少省掉一次移动。代码会更简洁。assign这个成员函数也经常被忽略。它可以把一个已经构造好的 list 整体替换成另一组元素对应的参数形式和构造函数完全一致assign(n, val)、assign(begin, end)、assign(initializer_list)。区别在于 assign 作用于已有对象避免你多定义一个临时变量再赋值。std::listint lst; lst.assign({7, 8, 9}); // lst 变成 7,8,9 std::vectorint v {1, 2, 3}; lst.assign(v.begin(), v.end()); // lst 变成 1,2,32.2 插入与删除O(1) 背后的真相list 最引以为豪的就是插入删除。常用接口有这些push_back(val)/emplace_back(args...)尾插push_front(val)/emplace_front(args...)头插insert(pos, val)/emplace(pos, args...)在 pos 之前插入pop_back()/pop_front()删除尾部/头部节点erase(pos)删除指定位置的节点erase(first, last)删除一段区间remove(val)删除所有值等于 val 的元素remove_if(pred)删除所有满足谓词条件的元素unique()/unique(pred)删除相邻重复元素很多人看到insert是 O(1)就以为随便往 list 里插东西都很快。这里有个必须拎清楚的前提insert(pos, val)本身只做指针操作确实是 O(1)但你要先找到 pos。寻找 pos 的过程如果是从头遍历那就是 O(n)。所以 list 真正的开销瓶颈往往在查找上而不是在插入上。remove和remove_if是我特别喜欢用的两个接口。它们做的事情是遍历整个 list把所有满足条件的节点从链表里摘除并销毁整个过程只遍历一次复杂度 O(n)而且每删一个节点都是 O(1) 的指针操作。很多新手不知道 list 自带remove还在那里写循环加 erase既啰嗦又容易把迭代器搞失效。std::listint nums {1, 2, 3, 4, 3, 2, 1}; nums.remove(3); // 删除所有 3剩下 1,2,4,2,1 nums.remove_if([](int x) { return x % 2 0; }); // 删掉所有偶数剩下 1,1unique也要注意它只能删除连续出现的重复元素不是全局去重。比如{3, 1, 3}这个序列unique一个都不会删因为 3 和 3 不相邻。如果你要全局去重得先sort再unique后面我会给出完整示例。2.3 迭代器双向不代表万能list 的迭代器类型是BidirectionalIterator中文叫双向迭代器。它支持和--但不支持 n、- n这种随机跳跃。很多从 vector 转过来的人会在这地方编译报错比如lst.begin() 3编译器直接报错。这不是你写错了是 list 根本没这个能力。遍历 list 有四种主流方式std::listint lst {1, 2, 3, 4}; // 方式一范围 for最推荐 for (int x : lst) { std::cout x ; } // 方式二迭代器手动遍历 for (auto it lst.begin(); it ! lst.end(); it) { std::cout *it ; } // 方式三反向迭代器 for (auto rit lst.rbegin(); rit ! lst.rend(); rit) { std::cout *rit ; } // 方式四C20 的 std::ranges需要编译器支持 // for (int x : lst | std::views::reverse) { ... }迭代器类型这个特点也解释了为什么std::sort不能直接用在 list 上。std::sort要求随机访问迭代器list 的迭代器不满足。所以 list 提供了自己的成员函数sort()内部用归并排序实现稳定且不额外分配临时节点。这一点我后面实操章节会展开。还有一个细节修改 list 中元素的值通过迭代器直接*it new_value是允许的。但如果元素类型是const那就不能这么改这是常识但在代码审查里经常看到有人犯。3. 实操三个真实场景中的 list3.1 场景一用 list 实现一个简单的消息队列假设你在写一个网络服务收到的消息放进缓冲区工作线程从缓冲区里取出来处理。这个场景非常经典生产者往尾部放消费者从头部取中间还可能因为优先级插队。list 在这里是天然合适的选择因为头部操作push_front/pop_front和尾部操作push_back/pop_back都是 O(1)而且消息对象通常比较大拷贝代价高用 list 可以避免扩容搬移。#include list #include mutex #include string class MessageQueue { public: void push(std::string msg) { std::lock_guardstd::mutex lock(mtx_); queue_.push_back(std::move(msg)); } // 优先消息直接插到队头前面 void push_urgent(std::string msg) { std::lock_guardstd::mutex lock(mtx_); queue_.push_front(std::move(msg)); } bool pop(std::string out) { std::lock_guardstd::mutex lock(mtx_); if (queue_.empty()) { return false; } out std::move(queue_.front()); // 移动赋值避免拷贝 queue_.pop_front(); return true; } private: std::liststd::string queue_; std::mutex mtx_; };这里有个关键点out std::move(queue_.front());这行很多人会写成out queue_.front()。如果你的消息类型支持移动语义std::string当然支持用std::move可以避免一次深拷贝。更重要的是移动之后queue_.front()处于有效但未指定的状态紧接着pop_front()会销毁它所以完全安全。push_urgent这种优先级插入用 vector 实现时你可能需要insert(v.begin(), msg)那会把后面所有元素往后搬O(n)。list 直接push_frontO(1)。这个差别在上百万条消息时会非常明显。3.2 场景二splice 在任务调度里的妙用splice是 list 独有、其他序列容器没有的接口它也是 STL 里被低估程度最高的“神级函数”。作用是把一个 list 里的节点直接转移到另一个 list过程中不拷贝、不移动元素只改指针复杂度 O(1)。list::splice有三个常用重载void splice(const_iterator pos, list other); // 把 other 整个移过来 void splice(const_iterator pos, list other, const_iterator it); // 只移 other 中的一个节点 void splice(const_iterator pos, list other, const_iterator first, const_iterator last); // 移一段区间注意被转移节点的所属 list 会变但节点本身的数据和指针不变这就是为什么全程 O(1)。这也意味着迭代器、引用、指针在转移后依然有效指向同一个东西只是现在属于目标 list 了。这个性质违反很多人的直觉却是标准明确保证的。我用 splice 做过一个任务调度器多个生产者线程往各自的待处理队列里加任务一个消费者线程定期把各队列里的任务批量合并到自己的执行列表里。如果靠insert拷贝一个一个搬任务对象大时性能极差用 splice 直接改指针几十万任务合并几乎零成本。std::listTask running_queue; // 消费者真正执行的队列 std::listTask incoming_queue; // 生产者刚提交的队列 // 消费者合并把 incoming 整个搬过来插到 running_queue 末尾 running_queue.splice(running_queue.end(), incoming_queue);还有 LRU 缓存。传统实现要用到双向链表 hash map当你命中一个缓存项时需要把它从链表当前位置挪到链表头部表示最近使用过。用手写链表做这件事要改四个指针加两个边界判断容易出错用 list 的 splice 一行搞定std::listCacheEntry lru_list; std::unordered_mapint, std::listCacheEntry::iterator table; // 命中 key 后把它对应节点移到头部 auto it table[key]; lru_list.splice(lru_list.begin(), lru_list, it); // 把 it 从原位置挪到 begin 之前 table[key] lru_list.begin();用 splice 维护的这个 LRU代码明显比手写的稳而且可读性好得多。3.3 场景三sort、unique、merge 做数据清洗list 自带sort()这是很多新手的误区来源。我见过有人写std::sort(lst.begin(), lst.end())编译直接报错。原因是std::sort要求随机访问迭代器。list 不满足所以 C 标准给 list 设计了成员函数sort()。list 的sort()内部是归并排序稳定等价元素相对顺序不变时间复杂度 O(n log n)额外空间 O(log n)递归栈。它还支持自定义比较器std::listint nums {5, 2, 8, 1, 9, 2, 5}; nums.sort(); // 升序 nums.sort(std::greaterint()); // 降序unique()和merge()是另外两个常用成员函数它们通常和sort()配合。完整的数据清洗流程如下。std::listint scores {88, 55, 92, 55, 67, 88, 88, 100}; scores.sort(); // 先排序让重复元素相邻 scores.unique(); // 删除相邻重复元素得到 {55,67,88,92,100} std::listint other {66, 90}; other.sort(); scores.merge(other); // 把有序的 other 合并进有序的 scores合并后仍有序 // 现在 scores {55,66,67,88,90,92,100}merge的前提是两个 list 都已按同一规则排序。合并过程同样是纯指针操作不拷贝元素。数据量小时我常用它合并两个已排好序的分片数据量大时我也用它做外部归并的中间步骤。这里有个容易踩的坑unique()是不带谓词版本时用operator判断是否相等如果你存的是自定义结构体想按某个字段去重就要写谓词版本struct Student { int id; std::string name; }; std::listStudent students { {1, Alice}, {2, Bob}, {1, Alice} }; students.sort([](const Student a, const Student b) { return a.id b.id; }); students.unique([](const Student a, const Student b) { return a.id b.id; // 按 id 去重 });4. 常见问题与排查技巧实录4.1 迭代器失效最容易出 bug 的地方迭代器失效是 list 使用中最常见的坑。基础规则我再强调一次list 的insert和splice不会使任何已有迭代器失效erase只会让指向被删元素的迭代器失效。这意味着 list 可以说是 STL 序列容器里迭代器稳定性最好的一个但也不是完全免疫。最常见的错误写法是在遍历过程中删除元素然后直接it。因为erase之后it已经失效再对失效迭代器自增属于未定义行为轻则崩掉重则静默产生错误数据。错误的写法std::listint lst {1, 2, 3, 4, 5}; for (auto it lst.begin(); it ! lst.end(); it) { if (*it % 2 0) { lst.erase(it); // 错误erase 后 it 失效循环里还会 it } }正确的写法有两种。第一种是用erase的返回值更新迭代器标准保证erase返回指向被删元素下一个元素的迭代器for (auto it lst.begin(); it ! lst.end();) { if (*it % 2 0) { it lst.erase(it); // it 更新为下一个 } else { it; } }第二种是直接用前面讲过的remove_if一行搞定lst.remove_if([](int x) { return x % 2 0; });我个人强烈推荐remove_if因为手写循环时应该 erase 还是 it这个分支条件本身就是最容易看错的地方而remove_if把意图直接写清楚还少写一半代码。在代码评审里我发现手写 erase 循环的问题率远高于remove_if。还有一个更隐蔽的坑在多线程里一个线程调用splice把节点从 A list 移到 B list另一个线程正用迭代器遍历 A list。标准说splice不使迭代器失效但那是指“同一个线程内的逻辑保证”如果两个线程同时对同一个 list 或相关 list 操作没有同步就是数据竞争迭代器不失效也不能救你。务必加锁或用无锁结构。4.2 性能陷阱list 不是万能加速器很多初学者有“链表插入快所以链表万能”的错觉。我在实际项目中见过多次性能回退最后查下来都是滥用 list 导致的。list 的隐藏成本至少有三个。第一CPU 缓存不友好。这是最容易被忽略的性能杀手。list 节点分散在堆内存各处遍历一个 100 万个节点的 listCPU 需要不停地切换内存页分支预测也容易失败。而 vector 的元素连续排列遍历时缓存命中率高实测在同一个数据集上遍历vector 往往比 list 快一个数量级。所以如果只是遍历、读取别用 list。第二每节点额外内存开销。每个 list 节点至少多两个指针64 位平台上就是 16 字节。如果你存的是int4 字节每个节点实际要占 24 字节左右考虑对齐内存开销是数据的 6 倍。1000 万个 int 的 list光节点指针就吃掉 160MB。这种时候你要认真考虑是不是该用vectorint。第三小元素和高频默认构造的代价。std::listNode在插入时总要做一次构造节点内部数据采用就地构造确实高效但频繁的new/delete本身不便宜。对比之下vector 的扩容虽然偶尔搬移但平均每次插入的成本很低。我做过一个测试往容器里 push_back 1000 万个 int。vector 毫秒级完成list 要花几百毫秒甚至几秒取决于分配器。如果你的需求只是不断往尾部加数据最后按顺序读一遍list 永远不是你的第一选择。4.3 调试技巧gdb 和 vscode 怎么看 listlist 的节点分散在内存里调试器默认显示和 vector 完全不同。vector 在 gdb 里可以直接p vec看到所有元素list 用p lst只能看到一个头节点和一堆指针。这里分享几个实用技巧。用 gdb 的话可以写一段 Python 美化脚本直接p lst展开成列表视图。如果你用的环境没有脚本另一个替代方案是在代码里临时加辅助函数把 list 转成 vector 或数组再输出。虽然有点土但在定位问题时效率很高。std::listint lst {1, 2, 3}; std::vectorint tmp(lst.begin(), lst.end()); // 调试时临时加这行用 vscode 调试 C 时默认的变量查看器对 list 的展开也不友好经常是一大堆 next/prev 指针。我建议在launch.json里配置好调试器之后直接在监视窗口右键选择Watch Expression输入lst的表达式配合调试器的 STL 美化功能gdb 的 pretty-printer 默认开启就能看到可读的结构。如果你遇到vscode 里 C 所有函数和变量都没办法跳转这种问题多半是 IntelliSense 没有正确索引编译参数检查c_cpp_properties.json里的includePath与编译器路径把标准库头文件目录加进去就能缓解。5. 容器选型什么时候该用 list5.1 一张表看懂 list、vector、deque、forward_list我自己做选型时会把序列容器按四个维度拉一个对比表非常直观。对比维度vectordequelistforward_list内存布局连续分段连续节点分散节点分散随机访问O(1)O(1)不支持不支持头部插入O(n) 搬移O(1)O(1)O(1)尾部插入O(1) 均摊O(1)O(1)O(1)中间插入O(n) 搬移O(n)查找 O(n)指针 O(1)查找 O(n)指针 O(1)迭代器类型随机访问随机访问双向单向迭代器稳定性扩容时失效插入不影响失效情况复杂插入不失效插入不失效每元素额外开销接近零接近零两个指针一个指针典型场景随机访问、遍历头尾操作中间增删、稳定性内存敏感、单向遍历deque 值得单独说一句很多人只盯着 vector 和 list忽略了 deque。deque 是双端队列头尾插入都是 O(1)而且可以通过下标访问在只需要头尾操作但偶尔要随机访问的场景里deque 经常比 list 更合适。我曾经把一个误用 list 实现的任务队列改成 deque 后内存占用降了 60%耗时降了 30%因为节点分配少了缓存命中率也上来了一些。所以别再默认队列就是 list。5.2 我个人的选型决策路径看到这里你可能觉得我在劝退 list其实不是。list 在需要迭代器和引用长时间保持有效的场景里是其他容器完全替代不了的。我一般按这个顺序思路选择容器第一你需要随机访问吗需要就 vector 或 deque。list 直接排除。第二你频繁在中间位置插入删除吗如果很少vector 几乎总是更优因为它缓存友好。第三你需要频繁头尾操作吗需要优先考虑 deque除非你还要中间操作或者需要迭代器稳定性。第四你的数据元素很大拷贝成本高并且需要反复插入删除吗这时候 list 的优势才真正体现。因为它不需要搬移元素节点上对象生命周期稳定配合 emplace 可以避免无谓拷贝。第五内存非常紧张吗如果每个元素都很小用forward_list单向链表可以省一半指针开销缺点是只能单向遍历。最后再提一个性能优化技巧如果确实决定用 list可以给std::listT换一个内存池分配器或者用 Boost 的container::list配合boost::pool_allocator能显著减少每次new/delete的开销。在写低延迟服务器时这一步往往比微调业务代码提升更明显。6. 我踩过的几个坑和最终体会实战里踩过最深的坑是过度相信 O(1)。有次我负责一个消息中间件上游把消息推给下游数据量很大我图省事用 list 作为缓冲队列结果延迟高得离谱。当时怎么也想不通插入删除不是 O(1) 吗后来用 profiler 一看瓶颈根本不在插入删除而在遍历一个循环每隔几秒扫一次全队列检查超时消息。list 的遍历因为缓存不友好慢得感人。后来我把队列改成 deque消息节点的超时字段单独用一个小堆维护瞬间就流畅了。第二个坑是splice和迭代器之间的细微语义。有次我写完跨队列转移后旧队列的迭代器还在用以为原节点被搬走后就没了。实际上 splice 后原迭代器依然指向那个节点而且节点转移后属于新 list此时再用旧 list 的范围判断来比较这个迭代器就会得到错误结果。文档写得很清楚但实际编码时很容易忽略。第三个坑是关于size()的复杂度。C11 之前标准允许list::size()是 O(n)C11 之后必须 O(1)。如果你在旧标准环境里做高性能代码不要在循环条件里反复调size()否则可能 O(n^2)。现在新项目基本都在 C17/20 上这个问题不大了但维护老代码时还是要留意。分享一个我还在用的小技巧如果你想按顺序输出一个 list但又要最小化拷贝可以用std::for_each配合std::ostream_iterator写进std::cout但 list 没有operator[]所以ostream_iterator需要迭代器支持*和list 的双向迭代器完全满足。实际写起来是这样的:std::listint lst {3, 1, 4, 1, 5, 9}; std::copy(lst.begin(), lst.end(), std::ostream_iteratorint(std::cout, ));list 在 C 的容器体系里说不上是最常用的但绝对是关键时刻能救命的那个。它不追求所有操作都快而是在任意位置增删和引用稳定性这两件事上做到极致。理解它的每一次操作背后到底发生了什么比死记接口清单要有用得多。先搞懂你的数据访问模式再决定用不用 list这个顺序永远不会错。