多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++ list深度解析:从双向链表原理到splice/merge实战,掌握容器选型与迭代器失效边界

C++ list深度解析:从双向链表原理到splice/merge实战,掌握容器选型与迭代器失效边界 很多朋友第一次接触C list时都容易产生一个错觉这不就是个能push/pop的vector吗接口长得差不多用法也差不多。直到某天在项目里想把“队列中间一个节点快速挪到头部”用vector搬了整段数据才发现list真正的价值。C标准库里的list是一个双向链表容器它把每个元素放在独立的节点里通过前后指针串起来。今天我结合自己在几个小工具组件里的踩坑经验把C list核心接口和实战技巧一次说清楚尤其是splice、merge这种冷门但能救命的接口。不管你是刚学到容器的在校生还是正在维护老项目的开发者只要你的场景里有“频繁在中间插入删除”“需要把元素从一条链表搬到另一条链表”或者“用不上随机访问”这三个特征list都值得你认真看一眼。下面我会从底层节点结构讲起把接口背后的设计逻辑和实际项目中的取舍都摊开来说。1. list的底层性格双向链表如何决定接口行为1.1 节点结构从“哨兵节点”说起list的源码实现因标准库厂商不同会有差异但底层逻辑是一致的链表由一个个独立分配的节点构成每个节点至少存三样东西前驱指针prev、后继指针next、以及元素本身。对普通用户来说最直观的一点是——list里的每个元素都有自己的“住处”互不连续。如果你在源码里翻过list的实现一定会遇到一个叫“哨兵节点”sentinel node的东西。它不存实际数据却在链表构造时被创建出来专门用来标记链表的头和尾。最简单的理解方式哨兵节点让“空链表”也有一个稳定的节点这样插入、删除、遍历时就不用反复判断“是不是第一次操作”代码里也能少写很多if。这种设计带来的实际影响是什么举个例子我之前写某个转发组件时需要构造一个空链表再不断从头部插入消息如果依赖无参构造之后直接push_front底层的哨兵节点已经就位入口逻辑一路通畅。如果自己手写链表没有哨兵的情况下每次push都要检查head是不是nullptr麻烦得多。1.2 构造与容量接口六个构造方式、empty与sizelist的构造方式比很多人想的多下面这几类是我在实际代码里都见过的std::listint l1; // 空链表 std::listint l2(10); // 10个默认构造的0 std::listint l3(5, 42); // 5个42 std::listint l4(l3.begin(), l3.end()); // 迭代器区间拷贝 std::listint l5(l3); // 拷贝构造 std::listint l6(std::move(l3)); // 移动构造之后l3未指定但通常为空 std::listint l7{1, 2, 3, 4}; // initializer_list构造其中l6移动构造是C11之后特别值得用的一个。移动构造list的代价基本是O(1)它只是把哨兵节点和几个指针从源链表交接给目标链表不会一个元素一个元素地拷贝。我见过不少代码还在用拷贝构造传list里边的对象一多性能就白丢了。容量接口里size()在C标准里保证了O(1)复杂度这对性能敏感的场景很重要。但要注意empty()永远比size() 0更推荐因为有些容器的size是O(n)list虽然不是但养成用empty()的习惯没有坏处。顺带说一句list没有capacity()和reserve()因为这个概念在链表里没有意义每个节点独立分配不存在“连续内存要不要预留”的问题。1.3 元素访问只有front和back没有at和operator[]list不提供随机访问这一点从接口上就能看出来它只有front()和back()没有operator[]也没有at()。很多人第一次用list时会觉得“这API也太简陋了”但只要想到链表的内存布局就明白了——想要第k个元素只能从头或者尾开始一个一个走过去复杂度O(n)。这是容器设计上的刻意取舍。C标准库给list的定位就是让你付出的代价无法随机访问换来插入删除的高效稳定。所以如果你写了一行list[i]编译不过不是编译器在刁难你而是在提醒你这个场景可能选错容器了。1.4 修改接口push/pop/insert/erase/clear/resize单看修改类接口list确实和vector长得像push_back、pop_back、push_front、pop_front、insert、erase、clear全部都有。但它们背后的成本逻辑完全不同。std::listint lst{1, 2, 3}; auto it std::next(lst.begin()); // 指向2 it lst.insert(it, 10); // 在2前面插入10返回指向新元素的迭代器 lst.erase(it); // 删除刚插入的10之后it失效 lst.resize(2); // 保留前两个元素后面的被销毁 lst.clear(); // 全部删除在list上insert和erase的时间都是O(1)前提是你已经拿到了插入位置或删除位置的迭代器。这一点和vector完全不同vector在中间insert会把后面的元素整体往后挪erase会把后面的元素整体往前挪。但list的每个节点独立分配插入就是new一个节点然后接上四个指针删除就是断开指针然后delete节点前后元素的存储地址一个都不会动。这里有一个小细节值得讲resize会构造新元素或者删除多余元素。先看删除的情况——它会真正销毁节点所以如果你之前保存了指向被删节点的迭代器resize之后千万不能用。再看增加的情况——新插入的元素的默认状态取决于list的模板参数如果是类类型就调用对应的构造函数。2. splice、merge、sort三个链式操作的底层逻辑与实战这一节我宁愿多花点笔墨因为list真正区别于vector的核心价值就藏在这三个接口里。2.1 spliceO(1)搬移list的灵魂splice是list独有的接口它的作用是把一个list中某段节点直接“剪”到另一个list中。注意我说的是“剪”而不是“拷贝”整个过程中元素没有复制、没有移动构造只是指针的重新指向。因此无论元素有多大、节点有多少splice都只花常数时间。splice有三种常见重载std::listint src{1, 2, 3, 4}; std::listint dst{0}; // 1. 把src整个搬到dst.begin()之前 dst.splice(dst.begin(), src); // 2. 把src中it指向的元素搬到dst.begin()之前 auto it std::next(src.begin()); dst.splice(dst.begin(), src, it); // 3. 把src中[first, last)区间搬到dst.begin()之前 auto first std::next(src.begin()); auto last std::next(std::next(src.begin())); dst.splice(dst.begin(), src, first, last);第一种用法之后src变成空dst变成{1,2,3,4,0}。第二种用法只搬一个元素src里原来的it会失效吗答案是“迭代器不会失效只是它现在指向的元素已经属于dst了”。这是splice和erase最大的区别erase让迭代器彻底失效splice只是把元素的“归属权”换了个容器。我在一个缓存演示项目里就靠这个特性省了大功夫。当时要做“命中某key后把这个key对应的节点挪到队列头部”用vector实现就是erase加insert中间所有元素都要跟着挪用list加splice一行代码搞定// cacheList是std::listKit是map里保存的迭代器 cacheList.splice(cacheList.begin(), cacheList, it);这行代码的时间复杂度是O(1)不搬运任何数据。注意最后那个it参数它指向的是同一个list里的元素也就是所谓的“self-splice”标准库允许把元素在同一个链表中搬来搬去这不会改变元素数量只是调整顺序。2.2 merge合并有序链表的前提与结果merge接口用于把另一个有序list合并到当前list中合并后两个list里的元素按顺序排列。它同样不拷贝元素而是通过不断比较节点值来决定谁接谁复杂度是线性O(n m)。关键点在于“有序”这个前提。如果两个list都是升序那么merge后也是升序如果其中哪个不是升序行为是未定义的。另一个容易忽略的点是merge完成后作为参数传进去的那个list会被搬空。std::listint a{1, 3, 5}; std::listint b{2, 4, 6}; a.merge(b); // a: 1 2 3 4 5 6 // b: 空默认情况下merge使用operator比较也就是默认升序合并。要是你需要自定义排序规则比如按对象里的某个字段比较可以传一个比较器进去peopleList.merge(otherPeopleList, [](const Person x, const Person y){ return x.id y.id; });注意这里的比较器必须和两个链表当前的排序标准一致否则merge完就乱套了。2.3 sort与unique为什么list要自带sortlist不能直接调用std::sort因为std::sort要求随机访问迭代器list的迭代器是双向的不满足条件。所以标准库给list单独提供了成员函数sort()。它内部用的是归并排序并且C11之后标准明确保证稳定排序。std::listint l{9, 1, 8, 2, 7}; l.sort(); // 升序 l.sort(std::greaterint{}); // 降序和unique配合是高频操作。unique做的事情是去掉“相邻且相等”的重复元素注意“相邻”这个词——如果你先不排序就直接unique重复元素不相邻一个都去不掉。正确的姿势一定是先sort再uniquel.sort(); l.unique(); // 此时链表中不会再有相邻相等节点unique还有一个带谓词的版本可以自定义“什么算重复”。比如按对象的某个字段判断可以使用l.unique([](const Person a, const Person b){ return a.id b.id; });。我在实际排错时遇到过一种情况先调用了std::unique(list.begin(), list.end())结果元素数量一个没少还把重复值打乱了。原因是std::unique同样只对相邻重复有效而且它只是把不重复元素移到前面不会缩小容器。list成员函数unique才是真正删除节点两者要分清楚。同理std::remove_if对list也只做“伪删除”最后还得配合eraselist成员函数remove_if则直接删节点这两个我在下一节展开讲。3. 迭代器失效规则与erase的正确姿势3.1 list vs vector的失效策略容器最让人头疼的就是迭代器原本指向一个元素某个操作之后它突然就不可用了。vector和list在这一点的反差最大vector中任何插入或删除操作都可能让后面的迭代器全部失效因为底层数组要搬内存list中除了被erase、clear、resize删除掉的那个节点对应的迭代器其他所有迭代器都继续保持有效。这个特性非常适合做“带标识的缓存节点”。我在某个存储组件里用一个list保存活跃用户ID再用一个unordered_map保存“用户ID到list迭代器”的映射。因为list删除某个节点不会影响其他人map里保存的其他迭代器都是安全的不需要做整体重建。但这也带来一个反面教训list的迭代器在插入元素后依然有效会导致一些开发者误以为“所有迭代器永远有效”结果自己想删除的恰好是被erase的节点仍然会踩中失效问题。失效的边界必须精确到“被删除的那个节点本身”。3.2 remove与remove_iflist自带的删除算法标准算法std::remove是“移动元素”对list来说并不真正释放内存需要erase搭档处理尾部。而list成员函数remove和remove_if是真正的“查找并删除节点”它会遍历链表逐个销毁符合条件的节点时间O(n)。std::listint l{1, 2, 3, 4, 3, 5}; l.remove(3); // 删除所有值为3的节点l变成{1,2,4,5} l.remove_if([](int x){ return x % 2 0; }); // 删除偶数l变成{1,5}注意两者过程中你手头保存的任何指向被删除元素的迭代器都会失效但指向其他元素的迭代器不受影响。这种“定向删除”在回调式框架里尤其好用。我曾经维护过一段网络连接管理代码每个连接对象对应一个list迭代器断开连接时直接conns.erase(it)由于list的erase不搬动任何其他节点其他连接持有的迭代器全部安全。3.3 erase循环erase返回迭代器的威力C11之前list::erase的返回类型是void想要在for循环里安全删除得用老办法it it;或者c.remove_if。C11之后erase返回“被删除节点之后的下一个有效迭代器”这让循环删除变得干净很多// 推荐写法 auto it lst.begin(); while (it ! lst.end()) { if (shouldDelete(*it)) { it lst.erase(it); // erase返回下一个有效迭代器 } else { it; } }另一种常见写法是lst.erase(it)它也能正确删除因为“it”返回的是删除前的迭代器但it本身已经前移到下一个节点了。不过这种写法对没大量写过C的人可读性略差我个人的习惯是优先用“it erase(it)”语义更直白。顺带提一句C20之后可以用std::erase和std::erase_if它们对list也有直接删除的效果写法更简单std::erase_if(lst, [](int x){ return x 0; });如果你的编译器支持C20这类需求可以少写不少样板代码。3.4 splice与merge后的迭代器有效性splice和merge因为不删除节点所以“被移动元素”的迭代器不会失效它只是归属到了目标容器。这常被拿来当作“安全搬运”的杀手锏。举个实际场景我有两个list一个保存“待处理任务”一个保存“正在处理的任务”。当任务从等待队列被取出时可以用splice把对应节点直接搬到处理队列不需要构造副本也不需要处理两个链表之间元素的拷贝语义。关键是有其他模块提前保存了一个指向该任务的迭代器在splice之后这个迭代器依旧能用只是现在它属于“正在处理”的list。唯一要注意的是如果你依赖“迭代器属于某个特定容器”这种对容器身份的判断在splice之后就要重新从使用层面区分清楚。标准库保证迭代器有效但不保证它属于原来那个容器。4. 选型与性能什么时候才该用list4.1 cache与内存碎片是list的“天敌”list的每个节点都是单独new出来的连续push_back 100万个整数vector可能就一块连续内存搞定list却要分配100万次小内存。这些节点散落在堆上遍历的时候CPU缓存命中率会非常难看。我经常把vector比作“一整排邻居门牌号连续挨家挨户敲门很快”list则是“一栋栋分散的别墅虽然每家的地址都记录在上一家的口袋里但要串一遍门得跑遍整个小区”。对现代CPU来说缓存不命中的代价常常比复制几个字节还高。所以哪怕list的“中间插入”是O(1)只要你的数据量在十万级以上且会频繁遍历实测下来带缓存局部性的vector往往更占优势。这不是理论复杂度能体现的必须用性能评测说话。4.2 复杂度的另一面中间插入也要先找到位置很多人只看那张“list插入O(1)”的对比表忽略了一个前置条件你得先有指向那个位置的迭代器。如果每次插入都要从头遍历找到位置查找成本O(n)会把插入的O(1)优势吞掉。反过来说如果你维护着一个“指向某个元素”的迭代器或指针并且后续要反复在它旁边做插入删除list就是不可替代的选择。比如LRU缓存里unordered_map中保存了对应节点的迭代器每次命中都能直接拿到位置这才是list发挥威力的完整前提。4.3 一个简单的性能对比表下面这张表是我在做选型时常用的参考比较的是vector、deque、list在核心操作上的特点。注意“中间插入”那一行list的条件是“已持有迭代器”。容器随机访问中间插入/删除头部插入/删除遍历缓存友好性典型场景vectorO(1)O(n)元素移动差O(n)很好需要频繁随机访问、尾部增删dequeO(1)O(n)分块移动O(1)较好首尾都要增删且偶尔随机访问list不支持O(1)前提是已有迭代器O(1)差大量中间插入删除、需要splice/merge4.4 内存优化自定义分配器来救场如果场景确实需要list又担心节点分配开销过大第一步不是着急换容器而是看看能不能给它配一个自定义分配器。常见做法是用一个内存池分配器把大量节点内存集中预分配成几大块每次new节点都从池子里取这样分配速度会快很多也能减少碎片。标准库的allocator模板参数给了这个变身窗口。不过我自己的建议是先做基准测试确认“大量小内存分配”确实是性能瓶颈再上内存池。这一层优化属于锦上添花别在项目一开始就陷入过早优化的泥潭。5. 实战案例用list配合unordered_map实现LRU缓存5.1 为什么这个组合经典LRULeast Recently Used最近最少使用的核心需求是命中一个key时把它提到“最近使用”的位置缓存满了删除“最久未使用”的key。如果只用vector命中后要把后面的元素全挪到前面代价很大如果只用unordered_map它只解决查找问题无法维护“使用顺序”。list加unordered_map的组合正好补齐两者的短板。这个组合好在哪里list维护使用顺序unordered_map保存“key到list迭代器”的对应关系这样无论是命中后调整顺序还是容量满了删除尾部都能用O(1)时间完成。如果没有list光是“把任意位置的元素移动到头部”这一个操作vector就要付出O(n)的搬移成本。5.2 核心代码实现下面是一个最小可用的LRU缓存示例。模板参数key、value写死部分细节重点看list和map的配合方式#include list #include unordered_map #include utility template typename K, typename V class LruCache { public: using Entry std::pairK, V; using Iter typename std::listEntry::iterator; LruCache(size_t capacity) : capacity_(capacity) {} V get(const K key) { auto it map_.find(key); if (it map_.end()) { return V{}; } // 命中后把对应节点搬到链表头部 list_.splice(list_.begin(), list_, it-second); return it-second-second; } void put(const K key, const V value) { auto it map_.find(key); if (it ! map_.end()) { // key已存在更新value并搬到头部 it-second-second value; list_.splice(list_.begin(), list_, it-second); return; } if (list_.size() capacity_) { // 容量满了删除最久未使用的尾部节点 auto backIt list_.end(); --backIt; map_.erase(backIt-first); list_.erase(backIt); } list_.push_front(std::make_pair(key, value)); map_[key] list_.begin(); } private: size_t capacity_; std::listEntry list_; std::unordered_mapK, Iter map_; };这段代码里最有价值的是put函数中“容量满时先删尾部再插头部”的处理。有人会写成“先push_front再pop_back”表面看一样实际会多出一次节点的构造和析构还可能在极边缘场景下让map和list状态不一致。先删尾部、再插头部逻辑更清晰每个节点只经历一次生命周期。5.3 关于splice和迭代器转发的几个细节get和put都依赖splice把节点搬到头部。你可能注意到splice的第二个参数传的是list_自己也就是self-splice。这个动作不会改变容器大小只改变节点顺序复杂度O(1)。map中存储的迭代器类型是std::listEntry::iterator注意不是const_iterator。为什么不能是const_iterator因为splice需要非const迭代器来确定搬运位置。如果你在外部接口里只用const引用访问缓存内部仍然要保存非const迭代器否则编译会报错。我在写这段代码时踩过一次坑直接返回了it-second-second的引用结果外部修改了这个引用指向的value而map里对应的迭代器还指向原节点看起来数据“对不上”。后来我明确约定“value只能通过put更新外部拿到的是拷贝”问题就消失了。5.4 再谈list在缓存场景的边界LRU里list的位置很舒服因为它只需要“头部插入、尾部删除、已知迭代器的任意提位”这三个操作正好全是list的主场。如果用deque代替list头部插入和尾部删除是O(1)但“任意位置的节点提到头部”就没法直接做还是得靠erase加push_front复杂度O(n)。相比之下list加unordered_map的组合确实更契合这类场景。6. 常见误用自查清单6.1 误用一把list当索引容器如果代码里反复出现std::advance(it, k)然后访问元素说明你需要的是vector或deque。list的遍历成本高且不支持随机访问硬当索引容器用等于用短板打长板。我在代码评审里看到过几次这种用法最后都改成vector性能提升非常明显。6.2 误用二erase之后继续用旧迭代器即使list“其他迭代器不受影响”被删除节点对应的迭代器依然会失效。常见翻车写法是auto it lst.begin(); lst.erase(it); lst.insert(it, 42); // UBit已经失效这种错误的隐蔽性在于有时程序恰好不崩溃因为被释放的内存还没被别的节点占用但它是彻头彻尾的未定义行为。判断一个迭代器是否有效的原则很简单它指向的那个节点是否真的还在容器里。6.3 误用三merge前不检查排序merge的前提是双方都已按相同规则排序。如果不确定先各自sort再merge。有一次我在调试一个数据合并功能merge出来的顺序时对时错查到最后发现有个分支路径提前给其中一个list做了逆序处理源头数据没保持升序。标准库在merge时不做排序校验这类问题只能靠调用方自律。6.4 误用四高频小对象push_back频繁往list里push小对象每次都伴随着一次堆分配。如果一个函数每秒要调用几百次堆分配压力会很突出。我的习惯是先用vector收集一批数据再批量转移到list如果一定要实时分配就考虑自定义分配器批量发内存。不要小看这个影响真实项目中它往往比算法本身的差异更明显。最后分享一个我自己的排查习惯看到list容器时先问三个问题——我需要splice吗我需要merge吗我的操作是否频繁在已知迭代器位置插入删除如果三个问题的答案都是“否”那list多半不是最优选择。反过来如果其中有任何一个答案是“是”那list可能就是最合适的那个容器。作为双向链表list的定位从来不是“全能容器”而是“在小范围精准操作上做到极致的特种兵”想清楚这一点再用它很多问题就迎刃而解了。
返回列表