恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

C++ list详解:从接口使用到双向循环链表模拟实现

  • 首页
  • 资讯中心
  • /
  • C++ list详解:从接口使用到双向循环链表模拟实现

相关资讯

AI生成答辩PPT指南:用PaperXie AI从逻辑到排版全搞定 2026/10/18 18:02:28
ACM基础算法模板工程化:图论、数据结构与字符串常见坑位全梳理 2026/10/18 18:02:28
Win11局域网共享从入门到排障:SMB协议、网络发现与权限配置全解析 2026/10/18 18:02:28

最新资讯

《WiFi 嵌入式物联网开发全套实战》| 第 40 章 无线网卡驱动参数调优、缓冲区、速率自适应优化
本地优先工作台:把编码Agent从编辑器搬到文件系统
LSTM锂电池剩余寿命预测:Matlab实现与避坑指南
ECC全解析:SAP ECC、纠错码与PFCG权限实战指南
Git推送命令全解析:从基础操作到冲突处理与团队协作
从Selenium到Playwright:Web UI自动化测试框架选型与实战指南

今日推荐

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

C++ list详解:从接口使用到双向循环链表模拟实现

发布时间:2026/10/18 18:07:29
C++ list详解:从接口使用到双向循环链表模拟实现 1. 先搞清楚list到底是什么C标准库里的list是我们平时做开发最常用的容器之一。它的底层实现是一套双向循环链表每个元素都被封装在一个独立的节点中节点之间通过指针串联。你如果用过链表一定知道它最爽的地方就是插入和删除不需要挪动其他元素改改指针就能搞定而且list把这个链表的操作全部封装好了你不用手动去管理节点内存。我第一次接触list的时候第一反应是“这不就是个链表嘛我已经会了”。真正上手才发现自己手写链表和用STL的list完全是两回事。STL的list不仅把节点管理、迭代器访问、内存分配这些底层细节全部包好还提供了非常容易上手的接口。更值得琢磨的是“使用”和“模拟实现”这两个方向完全不一样的姿态用的时候你只关心它的行为和效率模拟实现的时候你要真正理解它内部那一堆指针是怎么绕的。这篇博文面向的是已经学完C基本语法、类和对象、模板、动态内存管理等基础内容的小伙伴们。你将掌握list的常见接口用法、它和vector在行为和性能上的差异点然后我会完整地手写一个简化版的list把链表节点的构造、迭代器的封装、哨兵头节点的作用这些东西从头到尾讲透。我给这个模拟实现起了个名字叫模拟项目X——它虽然只是个教学Demo但基本还原了标准库list的核心机制。读完之后你再回头用std::list很多行为你就能想通它为什么那样设计了debug的时候心里也会更有底。注意本文虽然初阶但是模拟实现部分涉及一定的模板知识和类设计建议先完成C类和对象章节的基础学习再读模拟实现部分否则可能有点吃力。2. list有哪些让你心动的接口2.1 list的构造方式list的构造函数和vector比较像但有些细节值得琢磨。常用构造方式基本就这几种#include list #include iostream std::listint l1; // 空list std::listint l2(10, 5); // 10个值为5的元素 std::listint l3(l2.begin(), l2.end()); // 用迭代器区间构造 std::listint l4(l2); // 拷贝构造 std::listint l5(std::move(l2)); // 移动构造C11 std::listint l6{1, 2, 3, 4, 5}; // 列表初始化 for (auto e : l6) { std::cout e ; // 1 2 3 4 5 } std::cout std::endl;那问题来了list的拷贝构造是深拷贝还是浅拷贝答案是深拷贝。这一点对链表来说尤为重要。因为浅拷贝只会把链表头指针复制过去两个list对象会指向同一串节点析构的时候double free程序直接崩掉。所以无脑用默认拷贝构造的list等于在给程序埋雷。移动构造是个很实用的特性它能把源对象的链表头指针原样拿过来然后让源对象变空不涉及任何元素的搬运效率极高。写代码时尽量给大对象加上移动语义能省很多开销。2.2 插入和删除list的看家本领对于list这种链表结构插入删除就是它的主场。几个高频接口如下std::listint l; l.push_back(1); l.push_back(2); l.push_front(0); l.push_back(3); // 此时 l: 0 1 2 3 auto it l.begin(); it; // 指向1 it l.insert(it, 100); // 在1之前插入100it现在指向100 // 此时 l: 0 100 1 2 3 it l.erase(it); // 删除it指向的100it后移到下一个元素 // 此时 l: 0 1 2 3 l.pop_back(); l.pop_front(); // 此时 l: 1 2插入接口insert接收一个迭代器位置把新元素插到那个迭代器指向元素的前面并且返回指向新插入元素的迭代器。删除接口erase接收迭代器删除当前这个元素返回指向被删元素后继的迭代器。为什么要有返回值为了配合循环遍历时边删边走的需求避免迭代器失效问题。list还有一个很实用的小技巧push_back内部其实等价于在end()前面插一个元素所以如果你写l.insert(l.end(), val)效果和push_back是相同的。反过来push_front等价于在begin()位置插入。2.3 容量、大小和空间管理链表的自我认知list的size和empty是高频接口std::listint l {1, 2, 3}; std::cout l.size() std::endl; // 3 std::cout l.empty() std::endl; // 0可能有人会好奇list没有capacity和reserve这是为什么因为capacity的本质是预分配连续内存空间这个问题只对vector这种顺序容器才有意义。链表节点在内存中本来就是散落的根本不存在“预留大量节点空间”这个说法。每次插入就单独向内存池要一个节点用完了就释放时间上是动态且精确的你不需要担心扩容拷贝的问题。2.4 三个容易混淆的正确遍历姿势list的遍历有几个坑尤其是想用下标访问的时候你会发现list根本不支持operator[]。这是由它的物理结构决定的单链表和双向链表想按下标找元素必须从头一个一个跳过去时间复杂度O(N)那不如直接用迭代器或者范围for。正确的遍历姿势std::listint l {10, 20, 30}; // 方法1迭代器 for (auto it l.begin(); it ! l.end(); it) { std::cout *it ; } std::cout std::endl; // 方法2范围for底层也是迭代器 for (const auto e : l) { std::cout e ; } std::cout std::endl; // 方法3反向迭代器 for (auto rit l.rbegin(); rit ! l.rend(); rit) { std::cout *rit ; } std::cout std::endl;这三种输出的顺序分别是正序、正序、逆序使用时按需取用就好。这里强调一句list迭代器支持的是双向迭代也就是支持和--但不支持和这种随机访问操作。所以你要跳n个位置只能自己循环n次或者用std::next、std::advance这种辅助函数。2.5 修改、查找与其他增补接口list的修改相对粗暴尤其是sort和unique这两个算法很多人会搞混。list自己实现了sort成员函数不能用std::sort去排序list因为std::sort需要随机访问迭代器list不满足这个条件。同理std::unique需要相邻元素比较list也有自己的unique成员函数可以做去重。std::listint l {5, 2, 8, 2, 5, 1, 1}; l.sort(); // 1 1 2 2 5 5 8 l.unique(); // 1 2 5 8去掉了相邻重复项如果你希望去掉所有重复而非仅去掉相邻重复项那就需要先排序再unique或者自己写个辅助逻辑。merge接口可以把两个有序链表“缝合”成一个有序链表splice可以把一个链表中的一部分节点“剪切”到另一个链表——这两个接口在其他容器里是没有对应功能的是list的灵魂操作。splice的好处是常数时间就能完成因为只是改指针不涉及拷贝节点。std::listint l1 {1, 3, 5}; std::listint l2 {2, 4, 6}; l1.merge(l2); // l1 {1, 2, 3, 4, 5, 6}, l2为空 std::listint l3 {100, 200}; auto it l1.begin(); std::advance(it, 2); l1.splice(it, l3); // 把l3的节点整体剪切到l1的it位置之前l3清空 // l1 {1, 2, 100, 200, 3, 4, 5, 6}这两个接口平时用得相对少但面试和比赛里很容易出场景题值得动手测一遍。3. 模拟实现list我闭眼也能写出来的东西3.1 模拟实现之前先把框架想清楚下面进入大家最喜欢的环节手写list。标准库的list是一个双向循环链表并且带一个哨兵头节点也叫头结点、哑结点。这个哨兵节点不存储有效数据它的存在让空链表和普通链表都统一成同一种状态什么边界特殊情况都没有了。注意标准库的list是带头节点的循环链表而很多教材里的“带头单链表”是不循环的这两个不要混。模拟实现大概分三类模块节点结构ListNode存一个数据一个前驱指针一个后继指针。迭代器__list_iterator封装节点的指针重载*、-、、--、、!等操作让用户能像用指针一样遍历链表元素。链表类list封装节点的创建、连接、删除、返回迭代器等操作。为什么需要专门包装一个迭代器呢因为list的节点不是连续存储的原生指针只能指向某个节点没法满足我们想要的“*it表示元素数据”这种语义。迭代器就是把节点指针包装起来通过重载各种运算符让你遍历的时候感觉不到内部结构差异。3.2 节点结构的定义节点是整个链表的细胞。数据域用模板参数T表示指针域就是前驱和后继两个指针。代码非常简单template class T struct ListNode { ListNodeT* _prev; ListNodeT* _next; T _data; ListNode(const T val T()) : _prev(nullptr) , _next(nullptr) , _data(val) {} };这里要注意构造函数里给一个默认参数T()当你创建哨兵头节点时可以不传数据直接用T()初始化数据域对int就是0对string就是空字符串对自定义类就调用它的默认构造。不加这个默认参数创建哨兵节点时会编译报错。3.3 迭代器的完整封装迭代器的设计是链表模拟实现里最有技术含量的一部分。你想让迭代器具备指针的感觉至少要重载这些运算符template class T, class Ref, class Ptr struct __list_iterator { typedef ListNodeT Node; typedef __list_iteratorT, Ref, Ptr Self; Node* _node; __list_iterator(Node* node nullptr) : _node(node) {} Ref operator*() { return _node-_data; } Ptr operator-() { return (_node-_data); } Self operator() { _node _node-_next; return *this; } Self operator(int) { Self tmp(*this); _node _node-_next; return tmp; } Self operator--() { _node _node-_prev; return *this; } Self operator--(int) { Self tmp(*this); _node _node-_prev; return tmp; } bool operator!(const Self it) const { return _node ! it._node; } bool operator(const Self it) const { return _node it._node; } };模板参数有T、Ref、Ptr三个。T是数据类型Ref是引用的类型Ptr是指针的类型。为什么要弄成三个参数而不是一个T因为当链表存储的数据是自定义类型的时候你希望begin()返回普通迭代器可以修改数据而const对象调用begin()时返回的迭代器不可以修改数据。如果用同一个迭代器模板实例化就没办法区分这两种语义。标准库的惯用手法就是用Ref和Ptr这两个参数去区分——普通迭代器传T和T*const迭代器传const T和const T*。这样编译器就能帮你做类型检查防止在const容器上通过迭代器修改元素。3.4 链表的整体框架和四个基本接口list本体类的框架大致如下template class T class list { typedef ListNodeT Node; public: typedef __list_iteratorT, T, T* iterator; typedef __list_iteratorT, const T, const T* const_iterator; // 构造、析构、拷贝、赋值 list(); // 迭代器接口 iterator begin(); iterator end(); const_iterator begin() const; const_iterator end() const; // 插入删除 void push_back(const T val); void push_front(const T val); void pop_back(); void pop_front(); iterator insert(iterator pos, const T val); iterator erase(iterator pos); void clear(); size_t size() const; bool empty() const; };begin和end的设计里有个很精巧的地方我当初学的时候绕了半天才转过来begin返回的是哨兵头节点的后继即第一个有效节点。end返回的是哨兵头节点本身。这就形成了一个闭环。遍历时从第一个有效节点出发一直走到头节点这个位置就说明遍历结束了。正因为哨兵节点是循环回路的枢纽我们的end()才能是一个有效的、确定的迭代器不需要额外记录“最后一个节点的下一节点”——它根本不存在。iterator begin() { return iterator(_head-_next); } iterator end() { return iterator(_head); }3.5 push_back和insert修改指针的顺序是灵魂push_back的实现思路是先构造一个新节点然后把新节点挂到链表尾部同时维护节点前后指针。我见过很多初学者写链表插入时改指针顺序不对把某个节点的next先改了导致后面想找它的后继时已经找不到了。正确的思路是先把新节点的前后指针都指向对应的邻居然后让前驱和后继分别指向新节点。顺序上尽量先改新节点的指针再改旧节点的指针。void push_back(const T val) { Node* new_node new Node(val); Node* tail _head-_prev; // 尾节点就是头节点的前驱 new_node-_prev tail; new_node-_next _head; tail-_next new_node; _head-_prev new_node; }为什么尾节点是头节点的前驱因为这是循环链表头节点的前驱一定是最后一个有效节点或者当链表为空时头节点自己指向自己。这个性质理解透了链表操作会简洁非常多完全不需要花心思去判断边界。insert接收一个迭代器位置在它指向的节点之前插入新节点。和push_back的原理一样甚至更通用iterator insert(iterator pos, const T val) { Node* cur pos._node; Node* prev cur-_prev; Node* new_node new Node(val); new_node-_prev prev; new_node-_next cur; prev-_next new_node; cur-_prev new_node; return iterator(new_node); }你看有了哨兵头节点之后insert在任意位置插入代码居然一模一样根本不需要额外判断“是不是在头部插”“是不是在尾部插”。这就是哨兵节点给我们带来的红利把所有特殊情况都消灭在无形之中。3.6 erase的实现断开连接要干净利落erase的关键是保存被删节点的前驱和后继然后把前驱的后继指向后继把后继的前驱指向前驱最后delete掉目标节点。因为循环链表所以这里的“前驱”“后继”始终都有值没有空指针的危险iterator erase(iterator pos) { Node* cur pos._node; Node* prev cur-_prev; Node* next cur-_next; prev-_next next; next-_prev prev; delete cur; return iterator(next); }代码很短但需要注意一个细节erase之后原来的pos迭代器就失效了不能再解引用。所以我们一定要接收它的返回值用返回的迭代器往后遍历。不要自作聪明地保存pos然后那样在debug版本下程序会直接断言失败release版本可能悄悄出现悬垂指针。3.7 clear和析构千万不要搞双重释放clear要做的是把所有有效节点全部释放但保留哨兵头节点。析构函数要做的是先clear再释放哨兵头节点。如果搞反了或者忘了保留头节点就会造成内存管理混乱void clear() { iterator it begin(); while (it ! end()) { it erase(it); } } ~list() { clear(); delete _head; _head nullptr; }这里有个非常典型的双释放风险有些同学在clear之后又手动遍历一遍链表去delete节点但节点已经被delete过了再delete就是双释放。更隐蔽的情况是两个list对象浅拷贝了同一个_head指针析构时各自delete一次那是直接崩溃的。解决办法很简单拷贝构造和赋值运算符重载一定要做真正意义上的深拷贝保证每个list对象都有自己的哨兵头节点和自己的节点链。3.8 拷贝构造与赋值深拷贝的正确姿势拷贝构造的实现通常是先创建好哨兵头节点然后挨个把源链表的元素尾插进来。为什么不用push_back遍历呢因为push_back本身就是深拷贝逻辑的一环它只负责插入不涉及源对象。你要做的是基于迭代器遍历源list把每个元素push_back到新list里。list(const listT lt) { _head new Node(); _head-_prev _head; _head-_next _head; for (const auto e : lt) { push_back(e); } }等等这里有一个细节push_back用到了_head所以构造函数内部必须先分配哨兵头节点并让它自环。顺序不能反否则push_back的时候_head是野指针程序当场崩。初始化头节点自环这个动作应该封装到构造函数里让所有构造路径都统一走这一套初始化逻辑。赋值运算符重载可以用“copy and swap”惯用法这种写法简单又安全listT operator(listT lt) { swap(_head, lt._head); // 注意这里要交换头节点指针 return *this; }这里形参直接是list lt用传值的方式调用拷贝构造然后和当前对象交换头节点指针。函数出作用域时lt会析构它带着原来这个对象的旧链表一并销毁。这写法干净利落不用考虑异常安全的问题。3.9 构造与初始化头节点自环别漏了所有构造函数无论是默认构造还是迭代器区间构造都要把哨兵头节点初始化成自环状态然后再进行后续操作list() { _head new Node(); _head-_prev _head; _head-_next _head; }如果不自环空链表的end()返回的头节点它的前驱和后继都是空指针那么你一旦调用push_back代码里取tail _head-_prev就会解引用空指针程序崩得毫无征兆。链表最忌讳的就是边界调试而自环头节点从源头上规避了这一整个维度的bug。3.10 完整代码的串联演示把上面的代码按顺序拼装起来就是一个可以跑通的迷你版list。用它跑一段测试#include iostream #include string // 这里放前面写好的 ListNode, __list_iterator, list int main() { listint lt; lt.push_back(1); lt.push_back(2); lt.push_back(3); lt.push_front(0); for (auto e : lt) { std::cout e ; // 0 1 2 3 } std::cout std::endl; auto it lt.begin(); it; // 指向1 it lt.insert(it, 100); // 在1前面插入100 for (auto e : lt) { std::cout e ; // 0 100 1 2 3 } std::cout std::endl; it lt.erase(it); // 删除100 lt.pop_back(); for (auto e : lt) { std::cout e ; // 0 1 2 } std::cout std::endl; return 0; }我实际跑下来的输出和我预期完全一致看起来虽然简单但这段代码里涉及的每一个细节包括模板实例化、迭代器运算符重载、节点内部指针维护都是list这个容器最核心的底层机制。能独立写出并能调试通过说明你对链表、模板和对象生命周期的理解已经跨上了一个台阶。4. list的迭代器失效问题与避坑指南迭代器失效是个老生常谈但非常容易踩的坑初学list时大家普遍用vector的惯性思维来思考结果容易踩坑的事情不少。4.1 insert、erase之后的迭代器状态开头我用list做insert的时候强调过要使用它返回的新迭代器。因为内部结构发生了变化老迭代器所指位置虽然还存在但在erase的情况下那个位置的内存已经被释放了再使用就是悬垂指针。链表erase只影响被删节点对应的迭代器其他节点的迭代器并不会失效——这一点和vector大不相同vector erase之后后面所有迭代器全失效。实际写代码时为了让erase的使用更安全我给自己立了一个规矩凡是涉及循环中删除的代码一律使用返回值重新赋值迭代器。比如listint l {1, 2, 3, 4, 5}; for (auto it l.begin(); it ! l.end(); ) { if (*it % 2 0) { it l.erase(it); // erase返回下一个迭代器 } else { it; } }这个模式通用于所有顺序容器建议背下来。4.2 与vector的选型对比直接用一张图整理list和vector的使用场景差别维度listvector底层结构双向循环链表连续存储数组随机访问不支持O(N)支持O(1)头部插入O(1)O(N)所有元素后移中间插入O(1)找到位置后改指针O(N)元素搬移内存占用每节点额外存两个指针几乎无额外开销缓存友好性差节点分散好连续访问快迭代器类型双向迭代器随机访问迭代器扩容机制无扩容按需分配有扩容容量翻倍式增长选择原则很简单如果代码里主要是“随机访问你想读第几个元素就读第几个”选vector如果主要是“频繁在中间或头部插入删除但几乎不按下标访问”选list。还要有一个判断点对CPU缓存命中率有高要求的场景vector通常表现更优秀因为它的元素在内存中紧密排列而list的节点分散分配缓存命中率不如vector。4.3 三类常见错误与排查心得第一个常见错误是end()失效。很多人知道清空list要用clear()但实际写循环的时候会写出这样危险的代码while (it ! l.end()) { if (需要删除) { l.erase(it); // 错误没有接收返回值it已经失效了还要被用来比较 } it; }正确的做法在4.1已经给出来了。先要保留迭代器值再自增否则删除后自增就是访问悬垂指针。第二个常见错误是想当然地用下标遍历for (size_t i 0; i l.size(); i) { std::cout l[i]; // 编译错误list没有operator[] }list没有随机访问运算符是设计使然不是遗漏。非要用下标索引应该先转成vector或者用迭代器走n步。第三个常见错误是把自定义类型的指向搞混。如果list里存储的是一个动态指针例如listint*那你清空list时系统只会释放节点本身不会自动delete你那个int*指针指向的堆内存。这样容易泄漏内存空间。我建议list栈上元素用RAII管理要么用智能指针包裹要么自己明确负责释放。这块最容易在“看起来内存管理正确”的情况下悄悄泄漏。4.4 实际项目中我总结出两个细节第一list的size()在C11之前可能是O(N)的C11之后标准要求必须O(1)所以写循环判断用size()的位置不再需要担心性能退化。第二list有专门的remove接口可以直接按值删除所有匹配元素std::listint l {1, 2, 3, 2, 4, 2}; l.remove(2); // 删除所有值为2的节点 // l: 1 3 4remove的实现本质是遍历erase但它替你封装好了迭代器失效的细节初学阶段能用接口就先用接口比自己写循环稳得多。5. list的进阶接口splice、merge、unique怎么用才优雅5.1 splice零拷贝的节点搬家刚才提到splice可以把一个链表里的节点搬到另一个链表并且不涉及任何数据拷贝纯粹就是改几根指针。它的经典场景是把一个链表的一部分拆给另一个链表。比如有两个有序链表想从第二个链表里把最小的两个节点摘给第一个链表当成新头那就不需要拷贝直接splice就行std::listint src {1, 2, 3, 4}; std::listint dst {10, 20}; auto it dst.begin(); dst.splice(it, src, src.begin()); dst.splice(it, src, src.begin()); // 此时 dst: {1, 2, 10, 20}src只剩 {3, 4}注意参数顺序是你从哪里来、要谁、放到哪。splice的调用方是“目标链表”第一个参数是目标位置第二个参数是源链表第三四个参数可以指定单个节点或一段区间。5.2 merge有序list的合并神器两个分别有序的list可以通过merge合并成一个有序list。注意两个list都必须已经有序否则行为未定义std::listint a {1, 3, 5, 7}; std::listint b {2, 4, 6, 8}; a.merge(b); // a: {1, 2, 3, 4, 5, 6, 7, 8} // b: 空merge之后b会被清空。这个接口特别适合处理需要归并的场景比手动while循环给你省很多事。要注意的是merge对两个链表都要求“已经按operator排序”如果顺序不对它只会机械地把节点链在一起出来的链表整体不满足有序性后续再unique、再查找都会出问题。5.3 unique只去相邻重复unique的设计有点懒它只移除“相邻且相等”的重复项。如果重复元素不相邻例如{1, 2, 1}它不会去重。所以标准做法是先sort再unique。和合并列表一样unique之后链表顺序不变但这意味着只有排好序的list才能彻底去重std::listint l {1, 2, 2, 3, 1, 1, 4}; l.sort(); l.unique(); // 此时 l: {1, 2, 3, 4}如果不sortunique只会消掉相邻重复的部分出现漏网。这个点我见过不止一次被误解面试时也常被拿来当概念题考。6. 我实际验证过的几个list操作技巧6.1 在list中按条件删除部分元素很多场景下不是按值删除而是按条件删除比如删掉小于某个阈值的所有元素。此时有一个更好用的成员函数叫remove_ifstd::listint l {3, 1, 4, 1, 5, 9, 2, 6}; l.remove_if([](int x) { return x 3; }); // 删除所有小于3的元素 // l: {3, 4, 5, 9, 6}它内部封装了遍历erase的细节一开始写代码时用这个和别人讨论代码时也显得专业。6.2 两个list按需合并再排序去重我处理过一份业务场景这里不暴露项目细节需要用两个不同来源的数据联合处理要求不能再有重复项。我的解决思路是std::listint list_a {9, 3, 5}; std::listint list_b {5, 1, 7}; // 先把list_b并入list_a的尾部 list_a.splice(list_a.end(), list_b); // list_a: {9, 3, 5, 5, 1, 7} // 排序 list_a.sort(); // list_a: {1, 3, 5, 5, 7, 9} // 去重 list_a.unique(); // list_a: {1, 3, 5, 7, 9}这组合拳打得很舒服代码量不大逻辑还清楚。核心是“先拼、再排、再去重”这个顺序不能乱乱一个环节结果就是错的。6.3 从list中取某个迭代器指向的元素list没有operator[]但我们有std::advance和std::next可以做定步长移动std::listint l {10, 20, 30, 40, 50}; auto it l.begin(); std::advance(it, 3); std::cout *it std::endl; // 40 auto it2 std::next(l.begin(), 2); std::cout *it2 std::endl; // 30std::next不会修改原迭代器返回一个偏移后的新迭代器std::advance是直接修改传入的迭代器。两者时间复杂度都是O(N)但这么写语义清晰代码可读性比手工循环好很多。7. 写list模拟实现过程中我踩过的坑7.1 构造函数初始化顺序的坑我第一次写list的默认构造的时候构造函数体里只有一个new Node忘了让头节点自环。结果第一次调用push_back内部取得tail _head-_prev时得到的是空指针Node的prev被我初始化为nullptr随后的tail-_next new_node直接访问空指针。程序崩溃之后查了很久最后在push_back入口加输出才意识到是哨兵头没自环的锅。解决办法就是把这套初始化逻辑彻底统一分配头节点然后把它的prev和next都指向它自己。只要涉及创建list这一套动作必须pa执行。我现在写任何一个带哨兵节点的链表前脑子里第一个浮现的就是“先自环再干活”。7.2 迭代器自增的返回引用问题这是一个编译层面的坑。我给迭代器重载前置时最初写法是Self operator() { _node _node-_next; return *this; }看起来没问题但仔细想返回的是一个临时对象是值返回不是引用返回。这会导致两个问题语义上不符合前置的惯例前置应该返回自增后的对象本身。在连续自增场景如it的时候第一个修改了当前对象但返回的是副本第二次作用于副本实际原本的it只前移了一次。正确的写法是把返回类型改成Self返回*this。后置保持值返回返回自增前的副本这样才能符合大家的直觉。这个细节可以通过打印对象地址观察验证前置返回的地址和原对象一致后置返回的地址是新副本。7.3 拷贝赋值写成了浅拷贝模拟实现阶段我第一次没写拷贝构造函数和赋值运算符重载而是直接使用编译器默认版本。结果连续两个list对象相互赋值后又各自修改程序跑着跑着就无征兆地崩了。原因是默认拷贝只复制了_head指针本身两个对象共用一个哨兵头节点和同一串节点任何一方析构释放了内存另一方就变成悬垂状态。写容器类牢记三/五法则要么把拷贝构造、析构、拷贝赋值都实现要么把它们显式删除。模仿list写一个类时这三个一定得成组出现。7.4 自定义类型作为list元素的注意事项当T是自定义类时list的节点构造会用T的默认构造函数初始化_data所以你的自定义类型必须有可访问的默认构造函数。如果这个类定义了带参数的构造函数而没有默认构造编译就会在ListNode的构造函数默认参数T()处爆出一堆错误而且错误位置往往不在TreeNode而是层层展开的模板实例化信息初学者很容易看懵。解决方式是给自定义类型补一个默认构造函数或者在创建节点时显式传值。每次遇到模板展开的报错建议先看最底下的“required from here”之类的提示行那才是出错根源比看上面几百行模板线索更高效。8. 这些接口以后能做什么扩展list作为一个典型的双向循环链表容器除了教学价值之外在实际项目里也是常客。常见扩展包括LRU缓存需要用list维护访问顺序配合哈希表实现O(1)查找和更新。list的splice正好适合把某个节点搬到头部。内存池需要高效分配和释放节点时可以自定义一个内存池管理器list的节点分配就不再每次走系统new/delete。垃圾回收器某些语言的运行时管理对象列表时会频繁插入删除对象list的迭代器结构适合管理这种“强引用节点”的动态集合。双端队列模拟list天然支持两端插入删除一些简单的任务队列可以直接用它实现。如果你能自己把list的模拟实现完整写出来再回头看这些场景你就会发现它们真正需要用的其实只是头尾操作、任意位置插入删除这几个基础能力想到这一层很多设计你就看明白了。9. 最后的几点实操心得从一个“会用list”到“能写出list”中间隔的不是代码量而是对节点、指针、迭代器这三个东西的掌控感。我建议学习者分三步走第一步先把list的接口过一遍每个都写小例子跑一遍。重点看sort前后迭代器位置变化、splice之后源链表的变化、merge之后的清空效果。这一阶段不急慢就是快。第二步把模拟实现代码自己重写一遍不要看答案写不出来就回去看。这里最值得反复推敲的是insert和erase的指针顺序以及迭代器为什么需要用Ref、Ptr两个模板参数。记不住就动手动手就会被记忆。第三步给手写的list加一个自己设计的测试用例把自定义类放进去验证深拷贝、clear和析构再测试迭代器遍历。这一步能暴露很多“以为自己会了”的问题。我在实际学习和训练过程中发现最容易拉开人与人差距的不是方法技巧而是“读完就动手”的执行力。这篇文章从list用法一路讲到模拟实现代码我都贴出来了尤其模拟实现的每一段都可以直接在本地编译器里编译运行。真正值得花时间的地方就是那几段自定义模板代码。把它敲进去改掉所有坑再自己加需求比看十遍文档都有用。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号