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

资讯详情

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

C++: 顺序容器与适配器深度拆解——从内存底层到API江湖

C++: 顺序容器与适配器深度拆解——从内存底层到API江湖 在C STL的偌大江湖里容器是每个C开发者日夜相伴的兵器谱。而顺序容器便是兵器谱里最朴实也最硬核的基础兵刃——它们老老实实按线性顺序排列元素不搞排序、不玩映射只专注于存、取、增、删四大基本功。本文将从内存字节层面剖开把vector/deque/list三大顺序容器以及stack/queue/priority_queue三位适配器套壳选手挨个扒个明白。一、先搞懂派系分类什么是顺序容器什么是适配器STL容器分两大派系序列式容器Sequence Containers元素按插入顺序排列位置由插入时机决定与元素值无关。代表vector、deque、list、array、forward_list。本文重点讲前三位顶流。关联式容器Associative Containers元素按键值排序组织底层多为红黑树或哈希表。比如map、set、unordered_map等今天不聊它们。而容器适配器Container Adaptors本质是换皮大师——它们自己不实现底层存储而是包一层现成的顺序容器对外暴露一套受限的专用接口。就像给普通水杯加个吸嘴就变成了运动水杯杯子本身没变只是用法变了。stack、queue、priority_queue就是三位著名的适配器选手。二、三大顺序容器vector、deque、list内存底层剖析2.1 vector连续内存的卷王数组界的天花板如果说C有什么容器是日用而不知那vector绝对排第一。它的本质是动态数组一块连续的线性内存和C语言原生数组是亲兄弟只不过自带自动扩容Buff。底层原理三个指针撑起一片天vector的底层数据结构极简到离谱通常就三个指针// 简化版底层结构templatetypenameTclassvector{T*_start;// 数组起始位置T*_finish;// 已使用元素的末尾T*_end_of_storage;// 整块内存的末尾};size() _finish - _start已存元素个数capacity() _end_of_storage - _start总容量三者关系size() ≤ capacity()正因为内存连续vector支持随机访问——算个偏移量就能直达元素时间复杂度 O(1)快到飞起。vec[i]本质就是*(start i)和原生数组一模一样。灵魂拷问vector是怎么扩容的回答当调用push_back时若函数内部逻辑判断发现size capacity时则会触发自动扩容操作大致的步骤如下申请新内存按一定倍数开辟一块更大的连续空间GCC标准是2倍MSVC是1.5倍都是经验值搬运元素把旧内存里的元素全部拷贝/移动到新内存释放旧内存销毁旧元素回收旧空间更新三个指针指向新家新分配的内存地址划重点扩容会导致所有迭代器、指针、引用全部失效。因为老家都被拆了你还攥着旧门牌号那不就是野指针了嘛。为什么是1.5倍或2倍这是时间与空间的权衡倍数太大浪费空间太小则频繁扩容。2倍扩容的均摊时间复杂度是 O(1)——虽然某次扩容要搬O(n)个元素但平均到每个元素头上每个元素只会被搬运常数次。常用API与性能真相操作时间复杂度备注push_back均摊O(1)触发扩容时为O(n)pop_backO(1)只移动尾指针不释放内存operator[]/atO(1)随机访问at会抛越界异常insert(pos, val)O(n)插入点之后的元素全部后移erase(pos)O(n)删除点之后的元素全部前移reserve(n)O(n)手动扩容避免频繁搬家shrink_to_fitO(n)释放闲置容量瘦身操作迭代器失效重灾区vector是迭代器失效的惯犯记住两条铁律插入操作如果触发扩容全部迭代器失效没触发扩容插入点之后的迭代器失效删除操作被删元素及其之后的迭代器全部失效形象点总结vector 就像一排连在一起的工位 —— 想在中间加个人后面所有人都得挪位置人坐满了就得整层搬家唯独在末尾加人最省事。优点是找第几号人一眼就能看见缺点是中间插人能累死。优缺点与适用场景优点随机访问极快、缓存友好连续内存命中率高、尾部操作高效、内存紧凑缺点中间插入删除巨慢、扩容有性能开销、可能浪费部分容量适用场景90%的常规场景、需要随机访问、主要在尾部增删、元素数量可预估2.2 deque分段连续的两面派双端操作专家dequedouble-ended queue双端队列是个很有意思的存在它对外宣称支持随机访问背地里却不是一块连续内存它头尾插入都很快却又不像链表那样完全离散。底层原理中控器 缓冲区deque的核心设计是分段连续——由一段段大小固定的缓冲区buffer组成再用一个中控数组map注意不是STL的map记录每个缓冲区的首地址。它的迭代器是个加强版指针内部维护四个值cur当前元素指针first当前缓冲区首地址last当前缓冲区尾地址node指向中控器中对应缓冲区的指针所以deque的随机访问是这么实现的先算清楚在第几号缓冲区、偏移量是多少再跳转过去。比vector多了一步寻址随机访问是 O(1)但常数更大。头尾插入为什么快尾插当前缓冲区没满就直接放满了就新开一块缓冲区在中控器末尾加个指针头插当前缓冲区前面有空间就直接放没空间就新开一块缓冲区插到中控器开头头尾插入都不需要移动现有元素只可能需要新开缓冲区和更新中控器均摊O(1)。而且deque没有vector那种全量扩容不会出现一次性搬所有元素的情况。但如果在中间插入那可就惨了——要么往前搬要么往后搬比vector还慢。常用API与特性操作时间复杂度备注push_back/push_front均摊O(1)双端都能快速插入pop_back/pop_frontO(1)双端都能快速删除operator[]O(1)比vector慢有缓冲区跳转开销insert/erase中间位置O(n)比vector还慢涉及跨缓冲区移动size()O(1)直接返回计数形象点总结deque就像一栋多单元的住宅楼每个单元内部是连续楼层单元之间靠走廊连接。你可以从单元1的一楼和单元N的顶楼快速加房间但想在中间插一层那得把半个楼的住户都挪位置。它两头都能进能出号称双向开门的卷王。优缺点与适用场景优点头尾双端O(1)增删、支持随机访问、无全量扩容抖动缺点中间插入删除很慢、随机访问比vector慢、缓存局部性不如vector、实现复杂适用场景需要同时在头尾操作比如滑动窗口、BFS队列、元素数量巨大且怕扩容卡顿冷知识stack和queue默认底层都是deque就是看中了它头尾操作快、不用频繁大扩容的特点。2.3 list双向链表的逍遥派插删界的天花板如果说vector追求的是访问快那list追求的就是插删快。它的底层是双向循环链表每个元素都是独立的节点散落在内存的各个角落靠指针互相串联。底层原理节点 哨兵list的每个节点长这样templatetypenameTstruct__list_node{__list_node*prev;// 前驱指针__list_node*next;// 后继指针T data;// 数据};标准实现通常用一个哨兵节点sentinel node来简化边界处理——链表首尾相连哨兵节点就是那个虚拟头/尾end()迭代器就指向这个哨兵。这样空链表也有一个节点插入删除时不用特判空指针。正因为是链表任意位置插入删除只需要改前后两个指针O(1) 时间复杂度——前提是你已经拿到了那个位置的迭代器。但代价是不支持随机访问。想找第1000个元素就得从头指针开始一个一个next跳过去O(n) 复杂度。特色操作splice 链表拼接list有个独门绝技splice可以把另一个list的一段节点直接剪过来只需要改几个指针不需要拷贝元素O(1) 完成。这是vector和deque做梦都想有的能力。listinta{1,2,3};listintb{4,5,6};a.splice(a.begin(),b);// 把b整个插到a开头b变空除此之外list还自带sort、merge、reverse、unique、remove等成员函数——因为通用算法std::sort需要随机访问迭代器list用不了只好自己实现。常用API与性能操作时间复杂度备注push_back/push_frontO(1)头尾插一样快pop_back/pop_frontO(1)头尾删一样快insert(pos, val)O(1)已知迭代器位置时erase(pos)O(1)已知迭代器位置时查找第n个元素O(n)只能遍历spliceO(1) / O(k)转移节点不拷贝迭代器失效特性list在这方面堪称君子插入操作所有迭代器不受影响删除操作只有被删元素的迭代器失效其他全都好好的原因很简单每个节点都是独立的删别人不影响我家的地址。形象的总结list就像一串珍珠项链每颗珍珠都独立存在靠线连起来。想在中间加颗珍珠只需剪断线重新系上其他珍珠纹丝不动但想数第100颗珍珠你得一颗一颗数过去。内存里七零八落缓存极不友好——CPU缓存预取到的大概率是下一个节点吗根本不是所以跳节点经常缓存失效慢得离谱。优缺点与适用场景优点任意位置O(1)插删、迭代器失效极少、支持splice等链表专属操作缺点不支持随机访问、遍历极慢、缓存不友好、每个元素多两个指针的内存开销适用场景频繁在中间插入删除、元素数量多但很少遍历、需要转移节点而非拷贝2.4 三大顺序容器横向对比表特性vectordequelist底层结构连续数组分段数组中控双向链表内存连续性完全连续分段连续完全离散随机访问O(1)极快O(1)较慢不支持O(n)尾部增删均摊O(1)均摊O(1)O(1)头部增删O(n)极慢均摊O(1)O(1)中间增删O(n)O(n)更慢O(1)已知位置迭代器失效严重中等极轻微缓存友好度最好一般最差内存额外开销最小中等最大选型一句话口诀无脑先用vector两头操作上deque中间插删用list。90%的场景vector都是最优解别上来就怀疑它。三、三大容器适配器stack/queue/priority_queue换个接口就是新容器讲完了底层打工的现在来看看三位套壳的适配器。适配器模式的精髓是复用底层容器的存储能力只对外暴露特定接口限制访问方式。它们都有一个模板参数Container可以指定底层用什么容器不指定就用默认值。3.1 stack后进先出的叠盘子stack是典型的LIFOLast In First Out结构——最后放进去的最先拿出来。就像餐厅叠盘子只能从最上面拿和放。底层默认deque是的stack默认底层容器是deque不是vector。原因很简单deque头尾操作都是O(1)stack只在一端操作完全够用deque不会像vector那样突然全量扩容性能更平稳vector扩容时要全量拷贝deque只需新增缓冲区当然你也可以手动指定用vector或liststackint,vectorintstk;// 底层用vectorstackint,listintstk2;// 底层用list核心接口接口作用底层调用push(val)压栈c.push_back(val)pop()弹栈c.pop_back()top()取栈顶c.back()empty()/size()判空/大小直接转发看到没stack的所有操作全都是调用底层容器的尾部操作。它就像给deque加了个盖子把前面的接口全封死了只留屁股那一头能用。形象的总结stack是只能摸屁股的容器——前面不让碰只能从尾部塞和取。典型应用括号匹配、深度优先搜索(DFS)、函数调用栈、表达式求值。3.2 queue先进先出的排队打饭queue是FIFOFirst In First Out结构——先来的先服务。就像食堂打饭排队队尾进队头出。底层默认还是dequequeue默认底层也是deque原因和stack类似queue需要一头进一头出正好对应deque的push_back和pop_front如果用vector做底层pop_front是O(n)那队列出队就慢死了list也可以用但缓存性能不如deque核心接口接口作用底层调用push(val)入队c.push_back(val)pop()出队c.pop_front()front()队首c.front()back()队尾c.back()queue的设计更绝一头只管进一头只管出中间的元素你连看都别想看。完美符合队列的语义。诙谐版总结queue是老实排队的容器——不许插队、不许中间走、只能从尾巴进、脑袋出。典型应用广度优先搜索(BFS)、消息队列、任务调度、缓冲区。3.3 priority_queue带VIP特权的优先级队列priority_queue是三位适配器里最有技术含量的一个。它不是按插入顺序出队而是按优先级大小出队——优先级最高的先出。底层默认vector 堆算法和前两位不同priority_queue默认底层是vector然后在上面构建大顶堆max-heap。为什么不用deque因为堆算法需要频繁随机访问元素vector的随机访问比deque快得多缓存也好。堆是什么简单说就是一棵完全二叉树用数组存储满足父节点大于等于子节点大顶堆。每次插入元素会上滤每次弹出堆顶会下滤时间复杂度都是 O(log n)。在下一章节我们会重点讲解这部分的内容。核心接口接口作用时间复杂度push(val)入队调整堆O(log n)pop()弹出优先级最高的元素O(log n)top()查看堆顶元素O(1)大小顶堆与自定义比较默认是大顶堆也就是lessT比较器最大的元素在队首。想搞小顶堆就得指定greaterTpriority_queueintpq;// 默认大顶堆最大的先出priority_queueint,vectorint,greaterintmin_pq;// 小顶堆最小的先出注意比较器的模板参数顺序很容易写错第二个参数是底层容器第三个才是比较器。形象的总结priority_queue是VIP插队的队列——不管你什么时候来的级别高的就站最前面。典型应用Dijkstra最短路径、哈夫曼编码、任务优先级调度、Top K问题。四、进阶话题与避坑指南4.1 关于迭代器失效的终极总结容器插入删除vector扩容则全失效否则插入点之后失效删除点及之后失效deque头尾插入迭代器失效引用不失效中间插入全失效头尾删除仅该端迭代器失效中间删除全失效list全部不失效仅被删元素失效其中deque的迭代器失效规则最反直觉因为它的迭代器依赖缓冲区指针插入可能导致中控器扩容进而让迭代器里的node指针失效。4.2 vector的reserve和resize别搞混reserve(n)只改容量capacity不改变元素个数不构造对象纯粹预留空间resize(n)改变元素个数size多退少补多出来的会默认构造少的会销毁4.3 为什么优先用vector而不是list很多人学完数据结构觉得插删多用list但实际工程中vector往往更快。原因是现代CPU缓存极其重要vector连续内存的缓存命中率碾压list即使是中间插入只要元素不大、数量不多vector移动内存的开销可能比list遍历到插入点还小list每个节点多两个指针内存开销大还容易产生内存碎片业界共识除非你实测证明list更快否则默认用vector。4.4 适配器不是容器stack、queue、priority_queue不提供迭代器也不能遍历。因为它们的语义就是只能访问特定位置如果允许遍历就破坏了封装。想遍历那你不该用适配器直接用底层容器。五、总结STL的顺序容器和适配器看似简单实则每个设计背后都有内存布局和性能权衡的深思熟虑vector是连续内存的全能选手访问快、尾部快是日常开发的首选deque是双端操作的专家两头都快还支持随机访问常作为适配器底层list是链表的代表插删极快但访问巨慢只在特定场景发光stack/queue是简单的接口包装分别对应LIFO和FIFO语义priority_queue是堆的封装按优先级出队算法题常客理解它们的底层差异才能在合适的场景选对容器写出真正高效的C代码。毕竟真正的C高手不是API背得熟而是知道每个操作背后花了多少代价。
返回列表