尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++ list容器详解:从双向链表底层原理到splice与性能优化实战

C++ list容器详解:从双向链表底层原理到splice与性能优化实战 写这篇list容器的文章之前我先交代一下背景。最近在性能调优一个网关模块发现某个热点路径上用vector做队列头部的频繁插入单次延迟波动到几十毫秒。换掉容器策略之后耗时基本稳定在微秒级整个模块的P99直接降了一半。这个案例又一次印证了一个老判断不搞清楚容器的底层数据结构写出来的所谓“高效代码”大概率是自我安慰。list容器在C标准库里的地位有点微妙。很多初学者学了vector就直奔map和unordered_maplist被当成一个“知道有这么个东西”的容器只有面试前才翻一翻。但真正做过底层模块、做过中间件、写过对时间和内存敏感代码的人都会承认list在特定场景下的不可替代性。这篇文章我把list从底层原理到常用接口到踩坑经验一次讲透内容包括它和vector的本质差异、常用构造和遍历方式、 splice/remove/unique/merge这些独有的利器、排序规则、迭代器失效规则以及什么场景下该用它、什么场景下用它就是给自己挖坑。1. list的底层是双向链表理解这一点比背接口重要得多1.1 链式存储与连续存储的本质差异list容器的全称是doubly-linked list双向链表。底层实现中每个元素是一个独立的节点每个节点包含三个部分数据域、指向前一个节点的指针、指向后一个节点的指针。整个容器通过这一个个节点的前后指针串成一条链这就是它所有行为特征的根源。vector则是连续内存上的动态数组所有元素紧挨着排列。这两种存储结构决定了它们在几乎所有操作上的表现截然不同list的插入删除在任何位置都是O(1)因为只需要调整相邻节点的指针vector在头部或中间插入需要搬移后续所有元素。list随机访问是O(n)想取第k个元素只能从头往后走k步vector随机访问是O(1)。list每个元素额外扛着8字节32位系统4字节的前驱指针和8字节的后继指针vector只有在扩容时才有一次性搬移的开销平时没有额外内存负担。list对迭代器的稳定性极其友好vector一旦扩容或中间插入删除迭代器大面积失效。我见过很多人在简历里写“熟悉STL容器”问list和vector的区别只能说出“一个链表一个数组”但真到项目里选型就抓瞎。选容器就是在选数据布局策略布局决定了缓存命中率、内存碎片率、操作复杂度和迭代器稳定性这五个维度才是选型的真正依据。1.2 list节点在内存里长什么样这里我来拆一下list的节点结构。在不考虑定制分配器的情况下list的每个节点大体如下templatetypename T struct __list_node { __list_node* prev; // 前驱指针 __list_node* next; // 后继指针 T data; // 实际数据 };64位系统下一个存int的list节点指针占16字节int占4字节加上对齐填充总共24字节。也就是说存在list里的每个int实际消耗24字节内存。如果是vector存100万个int内存占用差不多4MB到8MB如果是list直接飙到24MB。这个账必须算清楚。另外还有一点list的节点是new出来的每次插入一个元素就要执行一次内存分配。100万次插入就是100万次分配即便用内存池缓存分配器分配的频率也远高于vector。vector是批量预分配元素轻轻松松在缓存里连续跑list的节点散落在堆的各个角落CPU缓存命中率天然吃亏。这就是为什么很多看起来list更合适的场景实测下来vector反而更快。1.3 list的唯一优势区间那list到底赢在哪些场景我自己归纳了三类一是需要频繁在序列头部或中部插入删除且序列元素本身比较大或者构造昂贵。这时候vector的搬移成本太高list的指针操作优势非常明显。二是在容器内持有稳定的外部指针或迭代器不允许被容器内部的插入删除操作搞失效。vector在扩容或中间插入时迭代器全部作废链表只让被删除节点的迭代器失效这个特性在复杂数据结构里极其宝贵。三是做LRU、空闲链表、任务队列这类天然需要把节点摘来摘去的数据结构。配合list的splice接口可以实现O(1)的节点搬移。这个下文专门讲。2. list的构造与基本遍历从代码层面建立肌肉记忆2.1 六种构造方式对照list的构造函数种类比vector还多一种这里做一下对比每种写一行实际可用的代码#include list using namespace std; listint l1; // 默认构造空链表 listint l2(10); // 10个默认值0 listint l3(10, 5); // 10个值为5的元素 listint l4(l3.begin(), l3.end()); // 迭代器区间构造 listint l5(l3); // 拷贝构造 listint l6 {1, 2, 3, 4, 5}; // 初始化列表构造这里面有个小细节值得注意list的拷贝构造和赋值操作符都是深拷贝也就是说l5和l3是两个完全独立的链表改l5不会动l3。这点和vector一致但容易和某些基于句柄的数据结构混淆。2.2 遍历的三种姿势与效率差异list遍历最常用的方式有三种迭代器、范围for、反向迭代器。// 方式一正向迭代器 for (auto it l6.begin(); it ! l6.end(); it) { cout *it ; } // 方式二范围for本质是迭代器的语法糖 for (auto val : l6) { cout val ; } // 方式三反向迭代器 for (auto rit l6.rbegin(); rit ! l6.rend(); rit) { cout *rit ; }范围for在底层就是begin/end迭代器遍历性能上没有区别纯粹是代码可读性的取舍。反向迭代器则是一个容易懵的点rbegin指向的是最后一个元素迭代方向是从尾到头但底层实现其实是用一个普通迭代器包裹操作内部换成了--。值得强调的是list的迭代器属于双向迭代器bidirectional iterator不是随机访问迭代器random access iterator。这意味着迭代器不支持加减整数it 2这种写法直接编译报错不支持operator[]也无法用std::sort排序。这个限制引出一个经典问题list排序不能直接调std::sort必须用list自身的sort成员函数原因就是算法层面的要求无法满足。3. 元素的插入与删除头尾操作和任意位置操作的全景对比3.1 头尾操作的时间消耗list和vector在头尾操作上最大的差别就在头部。vector没有push_front这个接口要在头部插入只能insert(begin(), val)每次都是O(n)的元素搬移时间成本随数据规模线性上升。list原生支持push_front在头部压入元素只需要改两个指针任何规模都是常数时间。listint lst; lst.push_back(1); // 尾插O(1) lst.push_front(0); // 头插O(1)vector不具备 lst.pop_back(); // 尾删O(1) lst.pop_front(); // 头删O(1)vector不具备四个基本操作全O(1)。对就是这么快这也是list在需要队列、栈、双端队列语义的场景能打的最大底气。但必须再次强调这个O(1)是指针层面的操作次数不是缓存层面的成本。如果只是尾部操作vector的push_back比list的push_back快一个数量级甚至更多因为vector只需要写连续内存list还要经历一次节点分配加上两次指针写入。3.2 insert和erase的设计意图list的insert和erase接受位置迭代器在指定位置前后插入或删除时间复杂度O(1)。这里有个和vector完全不同的认知转换vector的insert和erase位置迭代器是一个“目标地点”操作完成后该位置及之后的迭代器全部失效且元素搬移是线性时间。list的insert和erase位置迭代器是一个“路标”只影响指针重连位置迭代器除被erase指向的节点外全部保持有效。listint lst {10, 20, 30, 40}; auto it lst.begin(); it; // 指向20 lst.insert(it, 15); // 在20之前插入15{10, 15, 20, 30, 40} lst.erase(it); // 删除20{10, 15, 30, 40}这里有个C11之后新增的insert调用方式返回值是插入元素的迭代器可以用于链式插入auto it2 lst.begin(); it2 lst.insert(it2, 1); // 插入并返回新元素迭代器 it2 lst.insert(it2, 2); // 继续在1前面插入2 it2 lst.insert(it2, 3); // 继续在2前面插入3 // 结果{3, 2, 1, 10, 15, 30, 40}这个返回值迭代器非常方便省去重新查找位置的O(n)成本。3.3 resize、clear与assign的作用边界除了insert/erase还有三个高频接口值得整理一下lst.resize(3); // 截断或补默认值只保留前3个 lst.clear(); // 清空所有元素list变为空 lst.assign(5, 8); // 用5个8替换当前所有内容resize有两种行为如果新size大于当前size尾部补默认构造的元素如果新size小于当前size尾部的多余元素被销毁。clear之后list的size为0但底层的头节点分配器还在不会释放整个链表的元数据。4. splice、remove、unique、mergelist不可替代的四大杀手锏4.1 splice在链表之间搬节点成本低到离谱splice是list最具独占性的操作接口设计的核心思想是“把节点从一个链表嫁接到另一个链表”不是拷贝元素而是改指针。这件事的复杂度和两端位置的远近无关永远O(1)。listint src {1, 2, 3, 4, 5}; listint dst {100, 200}; auto it_src src.begin(); advance(it_src, 2); // 指向3 auto it_dst dst.begin(); it_dst; // 指向200 dst.splice(it_dst, src); // 把src全部元素搬到dst的200之前 // dst: {100, 1, 2, 3, 4, 5, 200}, src为空 // 只搬一个元素 src.splice(src.begin(), dst, it_dst); // 把dst里那个200搬到src头部 // src: {200}, dst: {100, 1, 2, 3, 4, 5} // 搬一段区间 auto start dst.begin(); advance(start, 1); auto end dst.end(); src.splice(src.end(), dst, start, end); // src: {200, 1, 2, 3, 4, 5}, dst: {100}splice是完全的指针重连不触发任何元素拷贝或移动构造。这点在处理大对象时优势极其夸张。比如你要在一个消息总线里把一批任务节点从一个优先级队列换到另一个优先级队列splice就是毫秒变微秒的差别。还有场景是缓存池中回收节点把空闲链表上的节点整体搬到活跃链表不用逐个创建和销毁。splice还有个极其细节但有坑的点如果两个list都是同一个list对象splice用于把一段区间搬到自身其他位置此时行为也是定义好的不会出现迭代器失效或死循环。这个场景在实现LRU时就经常用把命中节点摘到链表头部本质就是list内部的splice。4.2 remove和remove_if值删除和条件删除不只是便利remove接口用于删除所有等于指定值的元素remove_if则按谓词条件删除。两个接口的区别要和vector的erase配合std::remove区分开list是原地真删除vector还需要两步erase配合。这个方法论上的差异往往是老手判断候选人对STL理解深度的地方。listint nums {2, 4, 6, 8, 2, 10, 4}; nums.remove(4); // 结果{2, 6, 8, 2, 10} nums.remove_if([](int x) { return x % 2 0; }); // 结果{} 全删光了 nums {1, 2, 3, 4, 5, 6, 7, 8}; nums.remove_if([](int x) { return x 5; }); // 结果{1, 2, 3, 4, 5}需要注意的是remove_if的谓词是按值传入的如果想按引用修改外部状态可以用lambda捕获。另外remove和remove_if删除元素时迭代器只会让被删除节点的迭代器失效其他节点安定。这点也直接导致了在list上“遍历时删除元素”比vector安全得多。4.3 unique一个容易让人写错的去重接口unique的语义是“去除连续重复元素中的重复部分”。它只比较相邻元素如果一个值出现了非连续的多次unique不会把所有重复值全部清掉。listint dup {1, 1, 2, 2, 3, 1, 1, 4}; dup.unique(); // 结果{1, 2, 3, 1, 4}看到没有最后的1前面不是相邻的1所以保留了下来。这是unique和很多人直觉相悖的地方。如果想去掉所有重复值数据先排序再unique这是最常见组合拳。dup.sort(); dup.unique(); // {1, 1, 1, 2, 2, 3, 4} - {1, 2, 3, 4}unique还支持自定义二元谓词用于按指定规则判断“相邻相等”。比如按绝对值去重listint signed_list {-5, 5, 3, -2, -3}; signed_list.unique([](int a, int b) { return abs(a) abs(b); }); // 结果{-5, 3, -2}这个重载在按对象某个字段去重的场景里特别好用可以不用重载operator。4.4 merge两个已排序链表的归并merge把参数链表中的元素按序归并到当前链表前提是两个链表都已按同一规则排好序。区别于splicemerge不是单纯搬移而是按顺序穿插合并。listint a {1, 3, 5, 7}; listint b {2, 4, 6, 8}; a.merge(b); // a: {1, 2, 3, 4, 5, 6, 7, 8} // b: 空关键细节有两个第一merge之后参数链表b会被清空所有节点被移植进a。这个行为和splice一样不是拷贝是搬家。第二merge并不稳定。如果两个链表里有相等的元素C11之前的标准没有规定谁在前C11之后才要求相等元素的相对顺序保持为第一个链表的元素在前。实际开发中如果依赖稳定归并建议自己写或用std::list的官方保证。merge也支持自定义比较器用于归并自定义结构的链表listpairint, string left {{1, one}, {3, three}}; listpairint, string right {{2, two}, {4, four}}; left.merge(right, [](const auto x, const auto y) { return x.first y.first; });5. list排序与reverse自带接口背后的设计逻辑5.1 为什么不能用std::sortstd::sort需要随机访问迭代器list的迭代器是双向迭代器根本无法满足。强行使用会直接编译报错。这也是STL容器接口设计里一个很经典的自我约束接口通过迭代器类型把算法的适用范围限定了编译器在编译期就拦截掉这种误用。list自己提供了sort成员函数内部实现通常是归并排序或者改进的自底向上归并排序复杂度O(n log n)。这里有一个性能教训如果一个list只有数百个元素list.sort和先把元素拷贝到vector再用std::sort再拷回来的总时间相比可能后者更快。原因就是list的节点分布在缓存不友好的位置归并过程伴随大量指针跳转而vector排序是在连续内存上操作缓存命中率高得多。listint data {42, 7, 13, 99, 1, 66}; data.sort(); // 结果{1, 7, 13, 42, 66, 99} data.sort(greaterint()); // 结果{99, 66, 42, 13, 7, 1}实测经验元素数量超过5000且频繁排序的场景建议考虑用vector替代list进行排序排序完成再决定是否需要转回list。5000以上是我在自己机器上跑过的粗略分界值不同架构略有浮动但方向上不会差太多。5.2 reverse原地翻转的复杂度保证list的reverse是O(n)时间复杂度原地反转不需要额外空间。实现原理就是从第一个节点开始逐个把next和prev互换并移动头指针最终头尾颠倒。listint seq {1, 2, 3, 4, 5}; seq.reverse(); // 结果{5, 4, 3, 2, 1}reverse配合splice能高效实现很多高级操作。比如把一批节点按优先级调整时将某个范围的节点翻转后搬移全是指针操作比逐个move元素再重排要快得多。5.3 排序与去重组合的注意事项实际项目中sortuniquemerge是list离线处理的黄金组合。处理一批任务时可以先把多个来源的任务链表各自sort然后用merge归并成一个有序链表再unique去掉相邻重复项。整个过程几乎没有元素拷贝全操作在原节点上进行可以大幅减少内存分配次数。但要注意sort之后list每个节点内容没变只是节点的链接顺序变了。如果你在容器外部持有某个节点指针sort并不会让该指针失效但指针指向的元素在链表里的先后位置会改变。这既是list特性也是容易踩的坑下文细说。6. 迭代器失效规则与遍历删除的安全姿势6.1 弄懂list只让“被删节点”失效这件事STL容器的迭代器失效规则是面试常客也是实际开发里最难排查的bug来源。list的规则相对宽仁任何插入操作insert、splice、push_back等都不会使任何迭代器失效删除操作只使指向被删除元素的迭代器失效其他迭代器一概安全。这个规则的物理本质非常好理解链表的节点彼此独立删除一个节点只需要调整它相邻两个节点的指针其他节点的指针根本没有被触摸。而vector之所以大规模失效是因为内存搬移导致所有“指向老位置的迭代器”全部指向了幽灵地址。6.2 遍历时删除元素的正确写法在list上遍历并删除元素标准写法是listint data {1, 2, 3, 4, 5, 6}; for (auto it data.begin(); it ! data.end(); ) { if (*it % 2 0) { it data.erase(it); // erase返回下一个元素的迭代器 } else { it; } } // 结果{1, 3, 5}注意erase的返回值是C11才开始支持的它返回被删元素下一个元素的迭代器。很多老代码还在手动it data.erase(it); 这种写法在新标准下反而不必要。直接在删除后接收返回值是最简洁稳妥的姿势。remove_if本质上也摆脱了显式遍历但可读性和效率上都没问题。两者怎么选如果只是单纯的“满足条件就删”用remove_if更简洁如果删除之外还要做额外处理比如收集被删元素信息更新外部队列统计值就用循环配合erase。类似场景在splice搬运节点时也要注意在移动节点之后的迭代器指向的是被移动节点原来位置的新节点。比如将it指向节点splice到另一个链表后再对原链表进行*it xxx可能是直接访问到了新来的节点必须仔细确认迭代器归属。7. 关于内存、性能与场景选型的若干实战认知7.1 list和vector实际性能差距的定量感知我做过一个简单基准同样随机插入10万次到头部list耗时平稳在几毫秒级别vector把元素搬移的耗时直接放大到几十甚至上百毫秒。但是反过来在尾部追加100万个元素vector在连续内存上push_back比list快了差不多5到10倍。这个差距的根源不是谁的代码写得好而是谁更契合硬件的缓存架构。下面这张表是我根据多年经验和实测整理的选型对照可以直接作为决策参考维度listvector底层结构双向链表动态数组随机访问O(n)O(1)头部插入删除O(1)O(n)尾部插入删除O(1)平摊O(1)中间插入删除O(1)O(n)每元素内存开销2个指针约16字节 节点分配连续存储几乎无额外开销迭代器稳定性插入永远安全删除只影响被删节点扩容/插入/删除大量失效缓存友好性差极好典型用途LRU、任务队列、需要频繁搬移动态数组、排序、随机访问频繁的数据这里的“中间插入删除O(1)”有个前提你已经持有那个位置的迭代器。如果每次都要先查找位置查找本身O(n)就把复杂度吃回来了。实际做业务时很多时候list的昂贵成本不是操作本身而是“怎么找到要操作的节点”。7.2 节点分配器与内存碎片问题list每个节点独立分配这在长期运行的服务里会积累内存碎片。虽然默认的std::allocator底层有缓存机制但大量大小不一的节点分配释放仍然会导致碎片率上升。内存碎片对延迟敏感型服务是致命的它会拉高cache miss率严重时甚至出现可用内存充足却分配失败的情况。解决方案通常有两种一是换用定制分配器例如boost::pool_allocator把节点分配集中到内存池中既能加速又能减轻碎片。二是从架构层面减少list的使用用vector配合索引或其他数据结构替代。我个人的原则是list适合节点生命周期短的临时队列不适合长期驻留超大容量数据的场景。7.3 swap和assign的应用list的swap是把两个链表的头节点交换O(1)所有迭代器和引用在两个容器之间互换归属但不失效。这个特性在无锁架构或原子状态切换时很有用。比如生产端把一个满的list和空闲list交换消费端直接消费换出来的数据整个切换不涉及任何元素操作。listint active {1, 2, 3}; listint idle; idle.swap(active); // idle: {1, 2, 3}, active: 空assign和拷贝构造不同之处在于assign是先把现有内容清掉再用参数构造新内容。如果涉及大量重复元素填充assign(10, val)比先clear再循环push_back要高效因为底层可以直接复用节点或批量分配减少重复分配调用的数量。8. list使用中的常见误区和排查经验8.1 误区一认为remove会删除第一个匹配值list的remove是把所有匹配值全部删除不是只删第一个。这个和finderase的语义完全不同。想删除单个匹配值得这样写auto it find(lst.begin(), lst.end(), val); if (it ! lst.end()) { lst.erase(it); }如果想删除所有匹配值直接lst.remove(val)一行搞定。这两种语义混用是新手高频翻车点。8.2 误区二在排序过程中持有节点地址如前所述list.sort不会让节点指针失效但它会改变节点的相对顺序。如果你在外层用一个unordered_map把节点指针映射到某个业务的哈希槽排序后哈希槽对应的顺序全乱了。这种情况需要特别注意排序操作会破坏“物理顺序”和“业务索引”的对应关系此时要么重算索引要么根本不该把list排序当默认操作。8.3 误区三忽略迭代器的双向限制直接调std算法list的迭代器不能加减整数不能用std::sort不能使用std::reverse这个有reverse_iterator可用但std::reverse要求双向迭代器list其实可以用应该直接用成员reverse。list不可以用std::distance之外的通用算法实际上很多算法都有迭代器类别要求比如std::next、std::advance可以用于双向迭代器但std::lower_bound因为需要随机访问迭代器就无法用在list上。这一点在写模板代码时极其重要模板函数接收list迭代器时一定确认内部调用的算法是否满足双向迭代器的要求否则编译期会报一堆令人头大的SFINAE错误。8.4 一个典型的定位案例之前排查过一个线上抖动问题模块每秒要维护几千个task对象放进list里按优先级处理。某次大版本迭代后处理延迟出现周期性尖峰。一开始怀疑锁竞争排查半天无果。后来用perf看了看发现大量时间耗在free上内存分配器在疯狂回收list节点。结合代码定位新版本在处理完一个任务后会把它从list里erase并delete对象再去另一个list里创建一个等价的新对象。改成splice把节点搬过去复用后泥石流一般的分配释放直接消失延迟尖峰没了内存碎片率也明显下降。这个案例再次验证了list的核心使用心法能搬节点就别造节点能改指针就别动元素。9. 编译环境与工具链上的实用建议9.1 不同标准版本的接口差异写list代码时要注意C标准版本带来的接口差异这是很多人踩坑后被编译器各种警告折磨的根源C11之前erase不返回迭代器遍历删除时必须手动it lst.erase(it)这个写法在C11之后依然兼容但新代码不建议再写。C11之后支持初始化列表构造、移动构造和移动赋值insert/erase返回正确迭代器emplace系列接口可用。C14之后绝大多数list相关代码没有新语义变化主要是泛型lambda支持变多。C17之后std::list的splice和merge接口保持不变但有部分实现了节点句柄node_type可以通过extract接口把节点从list中取出构造出单独的节点句柄再insert到别的list中。这个能力在写侵入式容器时非常强大。node_type是C17带来的一个很有用的高级特性listint src {10, 20, 30}; listint dst {1, 2}; auto nh src.extract(src.begin()); // 取出20src变{10, 30} dst.insert(dst.end(), std::move(nh)); // 插入20dst变{1, 2, 20}extract和splice的区别在于extract可以明确拿到节点句柄并在之后决定是否插入或者直接销毁灵活性更高。9.2 在vscode里编译调试list代码的环境注意点用vscode写C时launch.json里配置编译器参数时尽量指定-stdc17或更高。因为很多list新接口node_type、emplace系列、移动构造优化在老标准下不可用编译报错信息很不直观。另外推荐在vscode中安装C/C扩展后开启“C_codeIndex”之类的索引选项能让头文件和模板代码的跳转更准确。list的源码实现比较绕要深入理解时能直接跳进头文件读源码比查二手资料高效得多。9.3 关于源码阅读的建议我强烈建议所有想把C用明白的人至少在遇到list相关疑问时读一遍libstdc或libc里list的实现。你会看到双向链表的头节点header是一个哨兵节点它不是链表元素而是begin是header-next、end是header的特殊设计。这个设计保证了空链表和非空链表在逻辑上统一让代码里少了一堆if判断。理解了哨兵节点再看splice为什么能做到O(1)会有豁然开朗的感觉。总的来说list容器不是万金油也不该被妖魔化。它是一个擅长“链式搬移”场景的专用工具核心价值在于节点操作的稳定性和O(1)的插入删除。用对了地方它能救你于水火用错了场景它就是内存和性能的黑洞。我个人的习惯是在面临容器选型时把“是否频繁随机访问”“是否需要在中间插入删除”“是否持有外部指针”“元素大小和数量级”四个问题问一遍答案指向谁就是谁比靠感觉选型可靠得多。最后分享一个实战小技巧如果你经常需要在list中按业务key快速定位某个节点不要每次find遍历维护一个unordered_mapKey, list ::iterator作为索引。插入时把迭代器存进map删除时从map里捞到迭代器再erase这样既保持了list的O(1)增删又把“查找”从O(n)降到了O(1)。很多开源中间件里的定时轮、id分配器都是这么干的。注意这个索引必须在每次插入删除后同步更新一旦漏掉一个节点后续定位就会错乱。最好把增删操作统一封装成几个内部函数所有业务入口都走这几个函数索引一致性就有保障了。
返回列表