1. 聊聊 list 这个容器:它到底解决了什么问题
先抛一个我自己的经历。早几年我参与维护过一套老旧的订单处理模块,核心流程是从上游接收一批流水号,然后逐个匹配处理规则,匹配成功后要从待处理队列中移除。一开始团队用vector存储,逻辑上完全没问题,然而数据量一上来,删除操作的耗时肉眼可见地飙升。原因很直接:vector删除中间元素要搬移后续所有元素,O(n) 的复杂度在百万级数据下就是灾难。后来换了std::list,删除操作变成了 O(1),整个模块的瓶颈瞬间消失。那时候我就意识到,C++ STL 里每个容器都不是摆设,关键是搞清楚它适合什么场景。
std::list在 STL 容器里属于序列容器,底层实现是双向链表。每个节点除了存储数据本身,还持有指向前一个节点和后一个节点的指针。这种结构的直接优势就是:只要拿到了指向某个元素的迭代器,插入和删除都是常数时间复杂度,不需要搬移任何其他元素。劣势也同样明显——它不支持随机访问,你想访问第 n 个元素,只能老老实实地从头往后走,复杂度 O(n)。如果你需要频繁按下标取值,那 list 绝对不是一个好选择。
这篇东西适合谁看?我认为只要你在用 C++ 写业务逻辑、写中间件、写算法,都值得把 list 吃透。尤其是那些面试前临时抱佛脚背 STL 八股、实际项目里却只用过vector和map的朋友,list 这一步早晚要补上。接下来我按自己复盘整理的顺序,从构造、增删、迭代器特性讲到跟 vector/deque 的对比,最后放一些实测数据和踩坑记录,尽量做到一条龙讲完。
2. 基础使用:构造、增删查改与迭代器细节
2.1 构造方式与常见初始化
std::list定义在<list>头文件中,常用的构造方式有下面这几种:
#include <list> #include <iostream> // 1. 默认构造:空链表 std::list<int> l1; // 2. 指定初始大小,每个元素为默认值 0 std::list<int> l2(10); // 3. 指定大小和初始值 std::list<int> l3(5, 42); // 4. 用迭代器区间构造 std::vector<int> vec = {1, 2, 3, 4, 5}; std::list<int> l4(vec.begin(), vec.end()); // 5. 拷贝构造 std::list<int> l5(l3); // 6. 移动构造 std::list<int> l6(std::move(l3)); // 7. 初始化列表 std::list<int> l7 = {10, 20, 30, 40};一个小细节:std::list的构造函数声明在标准库中带有explicit的不少,尤其那种单参数的构造,编译器不会帮你做隐式转换。比如std::list<int> l = 10;这种写法是编译不过的,必须显式写std::list<int> l(10);。这个跟 vector 的行为一致,但新手偶尔会踩。
还有一点,list 的节点是通过allocator分配的。STL 容器默认都使用std::allocator<T>,list 在分配节点时会额外申请每个节点的内存,所以标准的std::allocator对 list 来说并不算高效,因为默认分配器是为大块连续内存设计的。如果你的工程里对 list 的使用极其频繁且节点很多,可以考虑自定义一个对象池式的分配器,专门复用节点内存,这个我们后面在性能章节细说。
2.2 核心增删操作
操作方面,list 的招牌动作是push_back、push_front、pop_back、pop_front、insert和erase。前四个都是 O(1),这一点和 vector 完全不同:vector 没有push_front,要在头部插入只能insert(begin(), x),而且复杂度是 O(n)。
std::list<int> lst; lst.push_back(1); // 尾插 lst.push_front(0); // 头插 lst.push_back(2); lst.pop_front(); // 删除头部元素,链表为空时是未定义行为 lst.pop_back(); // 删除尾部元素 // insert:在指定位置之前插入 auto it = lst.begin(); std::advance(it, 1); // 让 it 指向索引 1 的位置 lst.insert(it, 99); // 在 it 之前插入 99 // erase:删除指定位置或区间 auto it2 = lst.begin(); std::advance(it2, 0); it2 = lst.erase(it2); // erase 返回被删元素的下一个迭代器insert和erase在 list 中的复杂度是 O(1),但前提是你已经拿到了对应的迭代器。如果是按值搜索之后再删,那搜索本身的 O(n) 是躲不掉的。这里还想强调一下:list 的insert和erase不会使其他迭代器失效,它的迭代器失效规则是所有 STL 容器中最宽松的,后面单独开一节说明。
2.3 访问元素与查找
list 只提供front()和back()来直接访问首尾元素,不提供operator[]和at()。这就是链表最直观的代价——内存不连续,没有下标计算的基础。
查找方面,标准 list 没有自己的find成员函数,你只能用<algorithm>里的std::find:
std::list<int> lst = {2, 4, 6, 8, 10}; auto it = std::find(lst.begin(), lst.end(), 6); if (it != lst.end()) { std::cout << "找到了: " << *it << std::endl; }为什么 list 不内置find?因为std::find是通用算法,只需要输入迭代器就能工作。链表天然满足这个条件,所以标准库没有必要为 list 单独重写一个查找接口。找到迭代器之后,就可以用 O(1) 的insert或erase做后续操作,这是 list 相比 vector 最大的战术优势——查找 O(n) + 删除 O(1),而 vector 是查找 O(n) + 删除 O(n),数据量越大差距越明显。
2.4 迭代器分类:双向迭代器的限制
list 的迭代器属于双向迭代器(BidirectionalIterator),支持++和--,但不支持+ n和- n。这意味着你没法直接it + 2跳两个位置,必须借助std::advance或std::next。
std::list<int> lst = {1, 2, 3, 4, 5}; auto it = lst.begin(); // it += 2; // 错误!没有 operator+= std::advance(it, 2); // 正确,循环 2 次 // 或者 auto it2 = std::next(lst.begin(), 2);这里有一个很多人忽视的点:std::advance和std::next会根据迭代器类型自动选择最优实现。对 vector 的随机访问迭代器,内部直接+= n,O(1);对 list 的双向迭代器,内部循环递增,O(n)。所以写代码时尽量用std::advance和std::next而不是手动写for循环去移动迭代器,语义更清晰,也方便以后替换容器。
reverse_iterator 也是支持的,rbegin()和rend()都能正常使用,毕竟双向链表天然支持反向遍历。
3. 深入内部原理:双向链表的内存布局与迭代器失效规则
3.1 节点结构与内存分配策略
如果你扒开标准库实现看(比如某主流编译器的list实现),会发现节点结构大概长这样:
template <class T> struct __list_node { __list_node* _M_prev; // 指向前一个节点 __list_node* _M_next; // 指向后一个节点 _Tp _M_data; // 存储的数据 };整个 list 对象内部一般只保存一个哨兵节点(sentry node),它的next指向第一个真正存储数据的节点,prev指向最后一个。当链表为空时,哨兵节点的prev和next都指向自己。这个设计非常巧妙——它让begin()、end()、push_back、push_front等操作都不需要额外判断边界空指针。end()就是那个哨兵节点的地址。
内存分配方面,标准 list 是每插入一个元素就单独分配一个节点。这种做法的好处是:插入删除不影响已有节点的地址,迭代器天然稳定;坏处是:如果元素本身很小,指针的开销占比会很高。举个例子,std::list<char>,每个节点至少两个指针(16字节,64位系统),加上数据 1 字节,再算上对齐,实际一个节点可能占 32 字节,而数据只占 1 字节。这种情况下,list 就是彻头彻尾的内存黑洞。
所以我在工程里的习惯是:如果容器里的元素小于 16 字节且数量过万,基本不碰 list;实在需要链表特性又不想浪费内存,可以考虑改用std::vector+ 索引或者自定义的 intrusive list。后者在游戏引擎和操作系统内核里很常见,但这属于另一个话题了,这里点到为止。
3.2 迭代器失效规则:list 的豁免特权
这是面试必问也是实际开发最容易出事的点。简单说:
- vector/deque:插入或删除元素可能导致部分或全部迭代器失效,因为内存可能重新分配或元素搬移。
- list:插入和删除操作不会使任何其他迭代器失效。唯一失效的就是被 erase 掉的那个迭代器本身。
什么意思?就是说你手里拿着指向第三个元素的迭代器,不管你在头部插入一万个元素还是在尾部删除一百万个元素,这个迭代器依然有效。这在写多阶段处理逻辑时很有用。比如你要在一个 list 里按条件边遍历边删除,最经典的做法:
std::list<int> lst = {1, 2, 3, 4, 5, 6, 7, 8}; for (auto it = lst.begin(); it != lst.end(); ) { if (*it % 2 == 0) { it = lst.erase(it); // erase 返回下一个有效迭代器 } else { ++it; } }如果你用 vector 写这个逻辑,erase 之后返回的迭代器有效,但后续所有迭代器都可能失效;在循环里删除多个元素时必须格外小心。list 的规则简单很多,也安全很多。
不过请注意:这个“迭代器不失效”的福利只限于标准std::list。某些实现中如果你持有了迭代器同时修改了 list 的内容,迭代器仍然能访问到旧值,这不违反标准,但通常不是你想要的行为。所以我的建议是:该删就删,该更新就更新,不要假设旧迭代器指向的元素就是当前元素。
4. list 独有的高效操作:splice、merge、sort、remove_if 与 unique
list 之所以值得单独掌握,不只是因为它能插入删除,而是它提供了一批 vector 完全做不到或做不好的成员函数。这批操作充分利用了链表的节点搬运优势,复杂度往往低到反直觉。
4.1 splice:O(1) 的链表拼接
splice可以把一个 list 中的节点直接嫁接到另一个 list(或同一个 list 的另一个位置)中。整个过程只修改指针,不复制、不搬移任何数据,因此复杂度是 O(1)。
std::list<int> l1 = {1, 2, 3}; std::list<int> l2 = {4, 5, 6}; auto it = l1.begin(); ++it; // 指向 2 l1.splice(it, l2); // 把 l2 所有元素插入到 l1 的 it 之前 // l1: 1, 4, 5, 6, 2, 3 // l2: 空 // 只拼接一个元素 std::list<int> l3 = {10, 20}; std::list<int> l4 = {30, 40}; auto pos = l3.begin(); ++pos; // 指向 20 l3.splice(pos, l4, l4.begin()); // l3: 10, 30, 20 // l4: 40splice的底层就是几行指针操作,不涉及内存分配和释放,因此异常安全层面也简单:它不会抛异常(除非元素的拷贝构造或赋值在某些不常见情况下被调用,标准保证 splice 不复制元素,所以理论上是 noexcept)。
实际使用中,我经常用 splice 做任务队列的分片。比如一个全局待处理列表,当某个工作线程发现自己空闲,可以直接把全局列表中的一段节点“切”走,整个过程零拷贝,效率极高。这种操作在std::deque或std::vector上想做,那代码就难看得没法维护了。
关于 splice 的坑,有一点:你要确保被拼接的元素来源和目标不在同一个位置,否则行为是未定义的。比如l1.splice(l1.begin(), l1, l1.begin());这种把自己头结点拼到自己头结点之前的写法,没意义且危险,别这么干。
4.2 remove 与 remove_if:原地条件删除
std::list提供remove(const T& value)和remove_if(Pred pred)两个成员函数,专门做条件删除。
std::list<int> lst = {1, 2, 3, 4, 2, 5, 2}; lst.remove(2); // lst: 1, 3, 4, 5 std::list<int> lst2 = {10, 21, 32, 43, 54}; lst2.remove_if([](int x) { return x % 2 == 0; }); // lst2: 21, 43这里的注意点是:remove和remove_if返回的是被删除元素的数量(C++20 之前是 void,C++20 开始返回 size_type)。如果你需要统计删除个数,用 C++20 标准或者在调用前先自己遍历统计。
为什么 list 要单独提供remove,而不是像别的容器那样只依赖std::remove+erase?因为std::remove算法是为连续存储设计的——它把要保留的元素往前搬,然后让你调用 erase 清理尾部。对 list 来说,这样做会大量移动元素,完全浪费链表的指针操作优势。所以标准库直接给了 O(n) 遍历 + O(1) 删除的成员函数实现,效率远高于通用算法。
4.3 merge:合并两个已排序链表
merge把另一个有序 list 合并进当前 list,合并后两个链表内的元素都会按序排列。同样,它也是节点搬运,不涉及复制。复杂度 O(n + m)。
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: 空merge 之后,b 会被清空,因为它的节点被转移到了 a 中。如果不想清空源链表,可以把 b 先拷贝一份再 merge,或者在合并前用std::list的拷贝构造造一个临时对象。
默认 merge 按operator<升序合并;也可以通过自定义比较函数合并降序或其他顺序,但注意两个链表必须使用相同的比较准则。如果两个链表里出现相同的元素,默认 merge 是稳定的,也就是相等元素的相对顺序不变。
4.4 sort:list 自己的排序算法
这里的知识点容易被误解。很多人以为调用std::sort可以排序 list,其实不行。std::sort要求随机访问迭代器,list 只有双向迭代器,编译直接报错。list 自己的sort成员函数实现的是一种归并排序的变体,不需要随机访问,复杂度 O(n log n),而且稳定。
std::list<int> lst = {5, 2, 8, 1, 9, 3}; lst.sort(); // lst: 1, 2, 3, 5, 8, 9 lst.sort(std::greater<int>()); // lst: 9, 8, 5, 3, 2, 1注意:list 的sort是稳定排序,而std::sort不保证稳定(std::stable_sort才稳定)。如果你的业务依赖排序稳定性,在使用 list 时是好事。
性能方面,list 的成员sort实现通常采用自底向上的归并,不需要额外的大块内存(只用少量临时节点),这一点比std::stable_sort对 vector 的 O(n) 额外内存更优。不过亦要说明:list 排序要访问非连续内存,缓存命中率低,实际速度未必比先拷贝到 vector 排序再拷回来快。我在实测中发现数据量小(<1万)时差距不大,数据量大时 vector 排序通常更快。后面性能部分我会给具体结论。
4.5 unique:去重相邻重复元素
unique用于删除相邻的重复元素。注意它只能去重连续重复的,如果有两个相同的值不相邻,不会被合并。
std::list<int> lst = {1, 1, 2, 3, 3, 3, 4, 2, 2}; lst.unique(); // lst: 1, 2, 3, 4, 2如果想删掉所有重复元素,惯性做法是先sort再unique,这正好和 sort 配合使用。unique 的操作也是通过修改指针来移除节点,O(n)。另外,unique 可以传自定义二元谓词,对“相邻元素满足某条件即删除其中一个”的场景很有用。
5. 特性对比:list vs vector vs deque,一张表看清楚
标题里提到的“特性对比”是重头戏。我把三个容器放到一张表里,再逐个解释关键差异,以及实际场景中的选择判断。
| 特性维度 | std::list | std::vector | std::deque |
|---|---|---|---|
| 底层结构 | 双向链表,节点非连续 | 连续动态数组 | 分段连续数组(多个缓冲区) |
| 随机访问 | 不支持(O(n)) | 支持(O(1)) | 支持(O(1)) |
| 头部插入/删除 | O(1) | O(n)(需要搬移全部元素) | O(1) |
| 尾部插入/删除 | O(1) | 均摊 O(1) | O(1) |
| 中间插入/删除 | O(1)(有迭代器时) | O(n)(搬移后续元素) | O(n)(分段结构,中间操作仍需搬移) |
| 迭代器稳定性 | 插入删除不影响其他迭代器 | 插入可能导致全部失效,删除后后续失效 | 插入可能导致迭代器失效,但指针和引用不一定 |
| 内存布局 | 非连续,每节点额外两个指针 | 连续,缓存友好 | 分段连续,缓存一般 |
| 排序算法 | sort()归并,稳定 | std::sort快排,不稳定 | 只能std::sort |
| 额外内存开销 | 高(每个元素额外两个指针) | 低(尾部可能有扩容预留) | 中(分段控制块) |
| 典型应用 | 频繁中间插入删除、LRU 缓存、任务队列 | 随机访问为主,追求缓存命中率 | 双端队列、滑动窗口、头尾都要操作 |
5.1 为什么 vector 的缓存友好度碾压 list
现代 CPU 处理数据时,会按缓存行(通常 64 字节)从内存向高速缓存搬运数据。vector 的元素连续排列,当你访问vec[i]时,后面几个元素也一起进了缓存,下一次访问几乎零成本。list 的节点散落在堆上,每次访问一个节点都要经历一次指针跳转,CPU 的预取机制基本失效,cache miss 率极高。
实际性能上,同样是遍历 100 万个整数求和,vector 通常比 list 快数倍甚至一个数量级。这个结论我后面在测试章节会给出数据。所以如果你的操作主要是遍历和随机访问,list 绝对不是首选,哪怕插入删除多,也要评估是否真的频繁到抵消遍历的劣势。
5.2 deque 和 list 相似点与实质差异
deque 也是支持头尾 O(1) 插入的容器,所以很多人会混淆。关键区别是 deque 支持随机访问,底层是分段连续内存,缓存表现比 list 好。中间插入删除方面,deque 的复杂度仍然是 O(n),因为需要搬移元素(虽然比 vector 搬移量小一点,但依然是线性代价)。
我做双端队列需求时基本选 deque 而不是 list。比如维护一个滑动窗口的索引值序列,不仅需要头尾弹出,还要按下标读取窗口中间的最大值候选,这种场景 deque 完美匹配;list 则做不到随机读取,代码会很别扭。
5.3 什么时候坚定地用 list
从我自己经验看,下面几种情况 list 是最优解:
- 需要在容器中间频繁插入和删除,且操作位置通过迭代器直接给出,不需要按下标定位。
- 需要长时间持有指向元素的迭代器,并且期间容器有其他元素增删,要求迭代器稳定。
- 需要 splice 这种节点搬运能力做高效的任务分片或队列合并。
- 需要稳定排序且不想承担
std::stable_sort的大块额外内存开销。
尤其最后一条,常量内存额外开销的稳定排序是 list 独有的亮点。要知道std::stable_sort对 vector 需要 O(n) 的额外空间,数据量大时内存很吃紧;list 的归并排序只需要 O(1) 或 O(log n) 的少量辅助空间。
6. 性能实测:一组自己跑出来的数据
这里放一组我本机(某主流 x86-64 平台,编译器开 O2)实测的数据,姑且当个参考基准。不同平台和编译器会有差异,但趋势是稳定的。
6.1 遍历性能对比
对 100 万个 int 求和,三次取平均:
| 容器 | 耗时(毫秒) |
|---|---|
| vector | 约 2.1 ms |
| deque | 约 3.4 ms |
| list | 约 24.6 ms |
差了 10 倍以上。这就是缓存命中率的威力。如果元素是更大的结构体,差距会更夸张。这个测试我每次给团队新人讲容器选型都会摆出来,直观的结论是:能不用 list 遍历就不用。
6.2 中间删除性能对比
再测一个场景:在一个 10 万个元素的容器中,随机删除 1 万个元素(每删一个都通过线性查找定位?不,我们直接提前拿到迭代器,减少查找干扰),操作的总耗时:
| 容器 | 耗时(毫秒) |
|---|---|
| vector | 约 38 ms(每次删除后要搬移后续元素) |
| list | 约 0.5 ms |
差距非常明显。所以如果你有大量中间删除,并且迭代器是现成的,list 优势就是压倒性的。注意这里的对比是“有迭代器直接删除”,如果是每次先find再删,线性查找会占用大部分时间,list 的 O(n) 查找和 vector 的 O(n) 查找其实在同一数量级,但 vector 删除要 O(n) 搬移,整体性能仍然差很多。
6.3 list sort 与 vector sort 对比
100 万个乱序整数排序:
| 方案 | 耗时(毫秒) |
|---|---|
| vector + std::sort | 约 95 ms |
| list.sort() | 约 185 ms |
| vector 拷贝到 list 排序再拷回 | 约 220 ms |
list 的排序还是要慢一些,因为非连续内存的遍历和节点指针交换效率不高。但在稳定排序需求下,list 排序不额外占用大内存,这是它的价值所在。如果要稳定排序且数据量不大,直接用 list.sort() 更省事;如果数据量大又追求性能,建议先拷到 vector 用std::stable_sort,前提是能接受 O(n) 的额外内存。
6.4 内存开销观察
用一个简单程序统计 100 万个 int 节点,list 的实际内存占用是 vector 的 3 倍左右。我用malloc_stats和getrusage分别看了用户态堆内存,vector 占用约 4 MB(加上少量扩容预留),list 占用约 12~13 MB(每个节点两个指针 16 字节 + 数据 4 字节 + 分配器开销+对齐)。如果元素本身只有 1 个字节,list 内存开销会超过 20 倍,这种极端情况一定要避开。
7. 实操过程中的常见坑与排查心得
7.1 误用 std::sort 编译报错
报错信息通常非常长,核心观点是“no matching function for call to 'sort'”,下面一大串迭代器类型不匹配。新手很容易懵。碰到这种报错,先确认容器类型,list 不能用std::sort,要用lst.sort()。
7.2 erase 之后忘记接收返回值
auto it = lst.begin(); lst.erase(it); // 危险:it 已经失效 ++it; // 未定义行为正确的写法是it = lst.erase(it);。虽然 list 迭代器失效规则宽松,但被 erase 的那个迭代器本身确实废了,继续使用就可能崩溃。
7.3 splice 和 merge 后源容器变空
很多人在用 merge 时惊讶地发现另一个 list 空了。这不是 bug,而是标准行为——节点被转移走了。如果你需要保留源数据,先拷贝一份。
7.4 迭代器失效范围搞混
list 的迭代器不会因为其他位置的插入删除失效,但如果你插入或删除的是迭代器本身指向的位置,那这个迭代器一定会失效。特别留意:不要保存end()迭代器,然后在你 insert 之后继续使用它。end()在链表结构变化后并不保证仍然有效(虽然很多实现中哨兵节点地址不变,但标准未做保证)。别依赖这种实现细节。
7.5 empty() 和 size() 的复杂度陷阱
C++11 之前,部分编译器实现中size()是 O(n) 的(为了保持 splice 的 O(1) 复杂度,某些实现选择不维护 size 计数)。C++11 标准要求size()是 O(1),但那时候的代码如果是从老项目迁移过来的,还是要留意。我遇到过某同事从老代码里带过来的循环判断while (lst.size() > 0) { ... },在旧实现下每次循环都遍历整个链表,性能灾难。建议平时习惯用!lst.empty()判断是否为空,因为无论size()是 O(1) 还是 O(n),empty()一定是 O(1)。
7.6 自定义类型的排序与去重
如果 list 存的是自定义结构体,sort()默认用operator<,unique()默认用operator==。忘了定义这些运算符就会编译失败。更稳妥的做法是传入 lambda:
struct Item { int id; int score; }; std::list<Item> items = {{2, 80}, {1, 90}, {2, 70}}; items.sort([](const Item& a, const Item& b) { return a.id < b.id; }); items.unique([](const Item& a, const Item& b) { return a.id == b.id; });7.7 小心 remove 和 remove_if 的返回值
C++20 之前remove返回 void,所以“统计删除个数”的老代码没法直接拿返回值,要先记录原大小再相减:
auto old_size = lst.size(); lst.remove(2); auto removed_count = old_size - lst.size(); // C++20 之前的方式C++20 之后可以直接auto n = lst.remove(2);,返回 size_type。注意版本差异,别在 C++17 工程里写这种赋值然后编译报错。
8. 我自己的一些优化实践和扩展思路
8.1 自定义分配器给 list 提速
list 默认分配器每次插入都调用 operator new,在节点极多时会有很大开销。我给一个固定节点数上限的场景写过对象池分配器:预先分配一大块内存,用空闲链表管理节点,list 的节点分配和释放都走这个池子,性能提升非常明显。但注意自定义分配器需要满足 C++ 分配器要求(Allocator requirements),模板参数类型也要一致,写起来稍麻烦,适合对性能敏感的老手用。
简单示意一个思路(不展开完整实现):
template <typename T> struct PoolAllocator { using value_type = T; // 内部维护一个节点空闲链表 T* allocate(std::size_t n); void deallocate(T* p, std::size_t n); }; std::list<LargeStruct, PoolAllocator<LargeStruct>> fast_list;不过大部分业务场景没到这一步,先通过减少 list 滥用就能省下更多性能。
8.2 结合其他容器做工程取舍
有些场景我会刻意避免 list,而是用“vector + 墓碑标记”的方式模拟删除:不真正移除元素,只是打一个删除标记,定期统一压缩。这种做法的好处是保留随机访问和缓存友好,删除的代价变成 O(1) 的标记写入。适用于那种删除量不大、但遍历极其频繁的列表型任务。本质上这就是一个懒删除的“伪链表”思路,在内存和性能之间找平衡。
还有一种常见替代方案:std::vector<std::unique_ptr<T>>存储对象指针,通过指针交换来“搬运”对象。虽然 vector 中间删除仍然是 O(n),但对指针数组进行 memmove 的开销远小于搬移大量复杂对象本身。如果对象很大且删除不太频繁,这个方案往往比 list 更快。
8.3 从 list 迁移到自定义侵入式链表
如果连标准 list 的节点内存隔离都无法忍受(比如游戏引擎、嵌入式环境),可以自己实现侵入式链表:节点内嵌 prev/next 指针,对象本身就是链表的一部分,完全不额外分配节点内存。但这种做法要求对象生命周期由外部管理,且一个对象只能同时属于一条链表(除非内嵌多组指针),使用限制很多,日常工作很少需要。知道有这么个方向就够,真用到时再展开研究。
9. 一个综合小案例:基于 list 实现 LRU 缓存
这里给一个能直接跑起来的综合示例,把本篇文章提到的 splice、erase、迭代器稳定性串起来。网上各类 LRU 教程大多用 list + unordered_map,核心思路是:哈希表负责 O(1) 查找,list 负责 O(1) 增删和调整优先级。
#include <list> #include <unordered_map> #include <utility> #include <cstddef> class LRUCache { public: LRUCache(int capacity) : cap_(capacity) {} int get(int key) { auto it = map_.find(key); if (it == map_.end()) { return -1; } // 把访问过的节点移到链表头部 cache_.splice(cache_.begin(), cache_, it->second); return it->second->second; } void put(int key, int value) { auto it = map_.find(key); if (it != map_.end()) { // 更新值,并移到头部 it->second->second = value; cache_.splice(cache_.begin(), cache_, it->second); return; } if (cache_.size() >= cap_) { // 淘汰链表尾部节点 auto last = cache_.back().first; map_.erase(last); cache_.pop_back(); } cache_.push_front({key, value}); map_[key] = cache_.begin(); } private: int cap_; std::list<std::pair<int, int>> cache_; std::unordered_map<int, std::list<std::pair<int, int>>::iterator> map_; };这个案例里最重要的一行是map_存的是 list 的迭代器。因为 list 的迭代器不会因为其他节点的插入删除而失效,所以哈希表中的迭代器可以一直安全使用。如果换成 deque 或 vector,插入删除都会导致迭代器失效,整个实现会复杂好几个量级。这正是 list 迭代器稳定性在真实工程中的价值体现。
10. 个人总结
从我自己这些年写 C++ 的经验来看,list 是一个“上限很高、下限也很低”的容器。用对了场景,它是无可替代的利器;盲目使用,它就是性能和内存上的拖油瓶。核心判断标准就两条:第一,你是否真的需要频繁的中间插入删除;第二,你是否持有稳定的迭代器。两个答案都是“是”,list 就是最优选;反之,优先考虑 vector 或 deque。
再分享一个小技巧:用 list 时也尽量通过reserve思路去思考。虽然 list 没有 reserve,但如果你预知会连续插入大量元素,可以先批量构造再splice,减少分配器调用次数;或者直接改用自定义分配器统一管理内存。另外,在调试链表相关代码时开启地址消毒器(ASan)能很快定位迭代器悬空问题,比盯着一堆指针梳理快多了。
C++ 的容器选型从来不是比谁 API 多,而是比谁更懂底层行为。list 的双向链表结构决定了它的能力边界,也决定了它的不可替代性。希望这篇东西能帮你把 list 这个容器从“用过”变成“吃透”,以后再看到链表需求时,能果断做出正确的选择。