
用过C STL的std::list的人应该都有过类似的困惑光构造函数就有好几种写法push_back、push_front、insert、erase这些接口看起来跟vector差不多偏偏又多出一堆merge、splice、unique、remove这些看起来有点陌生的成员函数。网上教程要么把所有接口罗列一遍像本字典要么只讲几个常用的就算了。结果就是每回要用到list的时候还得翻半天文档完了还不确定自己用得对不对。这篇文章我就把std::list从头到尾捋一遍。不是按文档顺序平铺所有方法而是按“这个接口到底解决什么问题”来分类把那些藏在名字背后、实际最常用的核心接口给拆开讲清楚。看完你应该能建立起一个清晰的list接口使用框架以后再遇到需要链表容器的场景不用查文档也能写对代码。1. 先搞清楚list到底是个什么容器1.1 双向链表不是魔改的vectorstd::list在标准库里的定位是双向链表doubly linked list它在内存里并不是一段连续空间而是一个个节点node通过指针串起来的链式结构。每个节点保存三样东西数据本身、指向前一个节点的指针、指向后一个节点的指针。这个底层结构决定了list最核心的三个特性任意位置插入、删除节点都是常数时间O(1)因为只需要改前后节点的指针指向。不支持随机访问你想拿第5个元素只能从头部或尾部一个个走过去时间复杂度O(n)。插入、删除操作不会导致其他元素的迭代器失效只有被删掉的那个节点的迭代器会失效。跟vector放在一起对比这两者的差异非常明显。vector底层是连续数组随机访问极快但中间插入要搬移数据list正好反过来插入删除便宜得很但你想访问中间某个元素就麻烦了。日常开发里这两个容器经常是二选一的关系搞清楚它们的区别才不会用错地方。1.2 什么时候该选list我在实际项目里总结出几个适合用list的场景你可以参考一下需要频繁在序列中间插入、删除元素比如维护一个经常增删的队列。对迭代器稳定性有要求比如某些缓存淘汰逻辑里你持有某个元素的迭代器不希望因为其他节点的操作导致它失效。容器元素本身比较大拷贝成本高而你又经常做插入删除操作时。典型的反面教材是明明只需要末尾追加数据、偶尔按下标访问却选了list然后到处std::advance跑来跑去性能差不说代码还别扭。这种场景vector或者deque明显更合适。2. 建造一个list构造与初始化接口2.1 六种构造方式对比list的构造函数数量看着多其实归纳起来就是“空表、填n个值、迭代器范围、拷贝、移动”这几类。我给它们排了个表构造方式代码示例说明默认构造std::listint l;创建一个空链表最常用填充构造std::listint l(10, 5);10个元素每个都是5指定个数std::listint l(10);10个默认值int就是0迭代器范围std::listint l(vec.begin(), vec.end());用其他容器的区间来构造拷贝构造std::listint l2(l1);复制另一个list移动构造std::listint l3(std::move(l1));转移所有权l1变空这里有个细节填充构造和指定个数是两个不同的构造函数。list(10)走的是explicit list(size_type n)而list(10, 5)走的是list(size_type n, const T value)。实际写代码的时候listint l(10)这种写法往往容易踩坑——如果T类型没有默认构造函数这行代码就编译不过。2.2 赋值操作与swap除了构造函数list还有赋值运算符和assign、swap两个常用的成员函数。assign和构造函数的区别在于它是构造完之后再重新填充内容std::listint l; l.assign(5, 100); // 清空后填入5个100 l.assign(vec.begin(), vec.end()); // 用迭代器区间重新赋值swap值得一提。它交换两个list的内容是O(1)操作只换头节点指针不像vector的swap还可能涉及内存操作。我自己写代码时如果只是想快速清空一个list并交换内容经常直接用一个临时list去swapstd::listint().swap(l); // 清空l内存也还给分配器这比循环pop_back高效一点如果后续会大量插入新元素也确实能回收之前的节点内存。3. 怎么拿元素容量与访问接口3.1 size、empty、max_size这三个接口都很基础一句话就能说清size()返回当前元素个数遍历计算复杂度O(n)。empty()判断是否为空复杂度O(1)。max_size()理论上容量上限实际开发很少用到。有个容易忽略的点list没有capacity()和reserve()这种接口因为链表不需要预留空间每个节点都是独立分配的。这也是它跟vector在内存行为上一个很关键的差异。size()虽然说是O(n)但libstdc的实现里list维护了一个哨兵节点加长度计数size()实际是O(1)。不过C标准允许实现用O(n)算法所以跨平台代码别依赖这个性能特性。3.2 front与back以及为什么不提供operator[]front()返回第一个元素的引用back()返回最后一个元素的引用。听起来没啥特别但有几个使用细节值得记住空list上调用front()或back()是未定义行为程序可能直接崩溃。这俩返回的是引用所以可以出现在赋值号左边l.front() 42;。如果list是const的那返回的是const引用。而list不提供operator[]的根本原因在于它本身不是随机访问容器下标操作无法在O(1)时间内完成。标准库设计哲学是容器接口的复杂度若达不到要求就不提供这个接口让编译期就阻止你犯错。这是C STL一个很值得玩味的设计原则也是为什么list没有at()、没有operator[]的原因。4. 增删改查修改元素的接口4.1 push/pop 与 insert/eraselist的增删接口分两个层级。最基础的是头尾操作push_back(val)在尾部插入O(1)。push_front(val)在头部插入O(1)。这是list相比vector最大的亮点vector的头部插入是O(n)。pop_back()/pop_front()删除尾节点/头节点O(1)。然后是任意位置操作。insert()和erase()接收迭代器作为位置参数auto it std::next(l.begin(), 3); // 找到第4个位置 l.insert(it, 99); // 在it之前插入 l.erase(it); // 删除it指向的节点insert有多种重载可以插单个元素、填充n个、插入一个区间、甚至插入一个初始化列表。它们都返回指向第一个新插入元素的迭代器。erase同样有多个版本支持删除一个节点或一个区间返回被删除元素之后那个位置的迭代器。这个返回值在循环删除时特别关键能避免迭代器失效的坑。4.2 clear和remove/remove_ifclear()清空所有元素一次性释放全部节点。操作完size()变0但之前拿到的迭代器全部失效。remove(val)按值删除remove_if(pred)按条件删除。这两个接口值得讲讲因为很多人会把remove和erase搞混。直接看代码std::listint l {1, 2, 3, 2, 4, 2}; l.remove(2); // 删除所有值为2的元素结果 {1, 3, 4} l.remove_if([](int x){ return x % 2 0; }); // 删除所有偶数注意list的remove是真正删除了元素它不像vector的erase-remove惯用法需要先std::remove把元素移到末尾再erase。list的remove内部就是遍历删除节点成员函数天然为自己优化过了。这也是为什么list提供了成员版remove和unique就是为了避免使用通用算法时还得自己处理删除逻辑。4.3 resize与uniqueresize(n)调整元素个数多了就尾插默认值少了就尾删多余节点。它的存在使list也能像vector那样快速缩扩容序列。unique()干掉相邻的重复元素只保留第一个。它有两个重载默认用判断也可以自定义二元谓词std::listint l {1, 1, 2, 2, 3, 1}; l.unique(); // 结果 {1, 2, 3, 1}注意末尾的1没被干掉因为它和前面不相邻 l.unique([](int a, int b){ return (a - b) % 2 0; }); // 自定义规则去重这个“相邻”二字是unique的隐藏陷阱。很多人以为unique()会全局去重实际它只比较前后相邻的两个元素。所以如果数据是{1, 2, 1}unique()不会删掉第二个1。想全局去重得先sort()再unique()这也印证了list的sort设计意图。5. 哨兵节点与迭代器稳定性list接口设计的地基5.1 哨兵节点是怎么回事要说清list很多接口的行为得先提哨兵节点sentinel node。libstdc的实现中list有一个不存储真实数据的头节点也常被称为end节点。begin()指向第一个真实数据节点end()指向这个哨兵。这样一来空表时begin() end()。插入、删除永远不需要特殊处理“头节点不存在”的情况统一操作即可。end()始终存在且稳定。这个设计是list容易学但不容易理解到位的地方。理解了哨兵节点你就能明白为什么list的insert在end()位置插入就是在末尾追加为什么erase(end())是未定义行为但一般不会立刻崩溃——它可能改写了哨兵节点的前后指针导致后续遍历出问题。5.2 迭代器不会轻易失效这个优势总结一下list迭代器稳定性的边界插入节点、删除其他节点不会让当前迭代器失效。删除当前迭代器指向的节点该迭代器失效但其他迭代器不受影响。sort、merge、reverse这些操作会重排节点但它们保证不会使任何迭代器失效注意不是保证迭代器还指向相同位置而是指向的元素仍然存在。移动构造后原list的迭代器处于未指定状态别再用。我在项目里维护过一个活跃连接列表每个连接对象对应一个list节点迭代器。某个连接需要断开时直接用它的迭代器erase其他连接的迭代器完全不受影响。这个特性用起来确实省心换成vector这种连续容器中间删一个节点后面所有迭代器全乱了。6. 原地搬迁的王者splice接口详解6.1 splice是什么以及为什么需要它splice是list独有、其他容器没有的接口它的作用是“把一个链表中的节点直接搬到另一个链表或另一个位置”不拷贝数据、不创建新节点只改指针。复杂度理论上是O(1)。它有三种常见形式// 形式一把other中pos位置的节点搬到this的it位置 l.splice(it, other, pos); // 形式二把other的[first, last)区间搬到this的it位置 l.splice(it, other, first, last); // 形式三把other整个链表搬到this的it位置 l.splice(it, other);听起来像insert但两者有本质区别。insert是复制新节点插入原来的链表还存在这些数据splice是“搬运”节点从原链表摘下来挂到新链表上原链表少一个节点。这意味着splice以后其他链表里的元素“消失”了但它们出现在当前链表里整个过程没有任何数据拷贝效率极高。6.2 实际场景窗口缓冲与任务队列我在做消息处理模块时常用splice来“合并”消息队列。主线程不停往一个临时list里塞收到的消息到了某个时机一次性把所有消息搬到处理队列尾部再统一处理std::listMessage recvQueue; // 主线程持续往里推 std::listMessage processQueue; // 工作线程处理 // 某个时机 processQueue.splice(processQueue.end(), recvQueue); // O(1)搬空recvQueue同样是合并两个列表如果不知道splice新手往往写成processQueue.insert(processQueue.end(), recvQueue.begin(), recvQueue.end()); recvQueue.clear();这段代码的问题是insert会把所有消息复制一份然后clear再释放原来的节点白白浪费一次拷贝和一次内存分配/释放。数据量大时这个差别非常明显。6.3 splice的实现与迭代器变化splice实现上只需要调整几个指针。因为底层是双向链表把一截节点从a链摘下来、挂到b链上需要修改的只是a和b相关节点的prev/next指针以及两个链表的size计数。所以O(1)不是吹的。搬移之后被搬节点上的迭代器仍然指向这些节点并且可以继续使用只是它们现在归属了目标链表。这个特性有时用来做容器间的“元素转移”非常趁手。但注意pos不能在other列表内部否则可能产生未定义行为。标准规定如果pos在other的范围内行为未定义实际可能造成链表环或死循环。我在踩过一次坑后才真正重视这个前置条件。7. 排序、反转与归并list的算法接口7.1 sortstable_sort的链表版list自带的sort()是稳定排序复杂度O(n log n)实现大致是归并排序。它有两个版本void sort(); // 升序 template typename Compare void sort(Compare comp); // 自定义比较器为什么list不能用std::sort因为std::sort要求随机访问迭代器list的迭代器是双向的不满足要求。所以标准库干脆让list实现自己的sort成员函数。这个细节导致很多人犯过错误std::sort(l.begin(), l.end())直接编译失败。实际使用时要注意自定义比较器写法别把std::lessint()写错了。比如降序l.sort(std::greaterint());或者lambdal.sort([](int a, int b) { return a b; });7.2 merge合并两个已排序的listmerge(another)把另一个已排序的list合并进当前list两个list都必须按同一排序规则排好。合并后另一个list变空。如果不想让参数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跟splice的区别merge要求两个列表有序splice不需要。merge内部是不断取两个链表头部较小者搬过去相当于有序合并splice则是简单把一段节点整体搬过去不做比较。7.3 reverseO(1)的实现思路reverse()反转链表。这个接口的实现可以在O(1)时间内完成因为双向链表反转只需交换头尾节点并反转每个节点的prev/next方向不对实际实现中reverse会遍历整个链表把每个节点的prev和next指针交换复杂度O(n)。虽然标准没规定复杂度但libstdc的实现确实是遍历。另一个骚操作是其实reverse还可以通过交换哨兵节点的prev和next来实现O(1)反转但标准库没这么做原因可能是要保持迭代器语义一致性。这里我不展开但你可以知道这个思路的存在面试时偶尔能派上用场。8. 常见问题与排查技巧实录8.1 编译报错没有匹配的operator[]这个是最经典的list错误。l[3]这种写法在vector上没问题在list上直接编译失败。解决办法是改用迭代器加上std::advanceauto it l.begin(); std::advance(it, 3); std::cout *it std::endl;或者使用std::next一步到位*std::next(l.begin(), 3)。频繁按下标访问的场景建议直接改用vector或dequelist就不是干这个的。8.2 迭代器失效问题erase循环怎么写很多新手在list上循环删除时会写出经典的崩溃代码。正确写法有两种我实际用下来第二种更简洁// 写法一使用erase的返回值 for (auto it l.begin(); it ! l.end();) { if (*it % 2 0) { it l.erase(it); } else { it; } } // 写法二remove_if一行搞定 l.remove_if([](int x) { return x % 2 0; });如果记不住两种我建议优先用remove_if。它语义清晰、不容易出错而且list提供的成员函数内部就是为这种操作优化的不会反复遍历。8.3 unique不是全局去重前面已经提到过unique()只处理相邻元素。很多人拿{1, 2, 1, 3, 1}做去重结果发现什么都没删掉就是这个原因。解决方案是第一步sort()再unique()或者自己维护一个哈希表手动去重。8.4 splice的pos必须在other之外splice文档里写着“如果pos指向other列表中的元素行为未定义”。我曾经在合并两个队列时误把other.end()和this.end()搞混导致链表成环。自查时最简单的判断方式合并前确认两个list确实是两个独立对象pos取自目标list而不是来源list。8.5 size()可能是O(n)别在循环里调用虽然libstdc的实现里size()是O(1)但标准并不保证。如果你在循环里反复调while (l.size() 0)跨平台代码可能性能掉坑。安全的写法是直接判空while (!l.empty()) { ... }8.6 经典坑sort要求严格弱序自定义比较器如果写得不严谨比如return a b;在标准库排序算法里可能引发未定义行为。正确的比较器必须满足严格弱序return a b;。这也是所有标准库排序的通用隐含规则。9. 把list融入你的工具箱9.1 一个清单什么时候用什么接口我把实际项目里list最常用的操作归纳成一张速查表需求接口备注创建空表listint l;最基础尾部加数据l.push_back(x)最常用头部加数据l.push_front(x)只有list方便做删除头部l.pop_front()队列场景删除尾部l.pop_back()栈场景在某个位置前插入l.insert(it, x)it用std::next定位删除某个位置l.erase(it)返回下一个迭代器按条件批量删除l.remove_if(pred)比手写循环强多了合并两个listl.splice(l.end(), other)零拷贝合并强烈推荐排序l.sort()别用std::sort去重l.sort(); l.unique();先排序再去重反转l.reverse()简单直接9.2 一个误区list真的比vector快吗这个问题在论坛里经常吵。答案是分场景。list的单个插入/删除确实O(1)但每个节点有额外内存开销两个指针遍历时缓存不友好vector的push_back虽可能触发扩容但均摊下来也是O(1)而且缓存命中率高。所以“数据量大、遍历多”的场景vector往往更快只有“大量中间插入删除、迭代器需稳定”的场景list才真正体现优势。我自己写过一次LRU缓存用的list哈希表结构list的splice作用无可替代——每次命中就把节点移动到链表头部这一下要是用vector代价不堪设想。所以list选得值不值看你会不会用它的splice和迭代器稳定性。9.3 进阶思考list对其他容器的启示理解了list的接口设计与哨兵节点再看标准库其他容器会有一种通了的感受。std::forward_list就是list的单向版本砍掉了O(1)从尾部访问这些特性换来了更小的空间开销。而对set/map这种红黑树容器接口设计同样遵循“底层结构决定接口复杂度”的哲学。如果链表底层的机制想明白了再学其他容器会快很多。我个人在实际项目里用list最多的场景一个是上面提到的消息缓冲队列另一个是处理需要频繁插入删除但偶尔顺序遍历的活跃对象集合。每次用splice把新到达的任务批量搬进处理队列那种“不拷贝、不改数据、就是挪个指针”的效率爽感是别人讲多少道理都替代不了的。如果你以前被list一堆接口搞晕过希望这篇能帮你把骨架搭起来构造、访问、增删、splice、算法五大块掌握了list在你手里就是一个顺手至极的工具。