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

资讯详情

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

C++ stack与queue底层原理、性能对比与高阶应用

C++ stack与queue底层原理、性能对比与高阶应用 C的stack和queue可能是初学者最快上手的一对容器一个后进先出一个先进先出push进去再拿出来规则简单到几乎不用思考。但如果你只把它们当成“存东西的盒子”后面遇到单调栈、滑动窗口最大值、线程安全队列这类工程和面试场景就会明显感觉力不从心。这篇博文想和你聊的不是那种“stack是栈、queue是队列”的入门科普而是从底层原理、容器适配、性能取舍到高阶应用的一整套拓展学习路径适合已经写过一阵子C、想彻底吃透这两个容器的读者也适合正在准备C面试、想在数据结构题上拿满分的朋友。我会尽量把每个知识点讲透包括为什么默认底层容器是deque、什么时候该换vector或list、单调栈和单调队列到底在解决什么问题、以及手写线程安全队列时那些文档里查不到的坑。代码我都会贴出来你可以直接拷下去跑。先说结论stack和queue能用但用好它们的关键在于你知不知道它们背后那一层适配器机制。1. 先弄清stack和queue的本质向量适配器不是新容器1.1 为什么说stack和queue只是“壳”很多初学者会误以为stack和queue是两种独立的数据结构甚至会在脑子里跟vector、list并列摆放。这个理解不算错但从STL的设计角度看不准确。C标准库中的stack和queue本质上是容器适配器container adaptor。容器适配器这个概念说白了就是我不重新发明存储结构而是把别人已经写好的底层容器拿来限定对外开放的接口只让你用符合栈或队列语义的那几个操作。底层容器相当于一台完整的计算机你可以随便跑程序适配器则像是给这台机器装了一个专用操作台上面只有几个按钮但你按这些按钮就足够完成特定工作了。这一点最直接的证据是你能不能在stack或queue上调用begin()、end()、insert()这些操作不能。标准库根本没有给它们提供迭代器接口。因为它们根本不关心内部元素怎么遍历只关心“放进去”和“拿出来”的规则。所以你在学这两个容器时一定要把思路从“数据结构有什么操作”切换成“数据结构允许什么操作”。1.2 底层容器选择必须支持哪些接口stack和queue在声明时模板签名是template class T, class Container std::dequeT class stack; template class T, class Container std::dequeT class queue;第二个模板参数Container就是底层容器。也就是说stack和queue默认都是站在deque肩膀上工作的。它们对底层容器是有要求的stack要求底层容器支持push_back、pop_back、back、empty、size这几个操作。queue要求底层容器支持push_back、pop_front、front、back、empty、size这几个操作。这就直接解释了为什么queue的默认底层容器不能是vector。vector没有pop_front你想在头部删除元素只能借助erase(begin())那是一次O(n)操作队列基本语义就废了。stack倒是可以用vector做底层因为栈的所有操作在vector上都是O(1)的push_back均摊O(1)、pop_backO(1)、backO(1)完全契合。那list行不行当然行。list的push_back、pop_back、pop_front都是O(1)所以它既可以做stack的底层容器也可以做queue的底层容器。但list每个节点需要额外的指针开销内存碎片化严重缓存不友好后面我会专门对比性能。真正的问题是map、unordered_map之类的容器能不能做底层不能因为它们没有push_back、pop_front这种线性序列接口。这一点面试时偶尔会考记住就好。2. 接口细节逐项拆解别在最基础的地方踩坑2.1 stack与queue的核心成员函数对比先看一张我整理的基础接口对照表把两个容器放一起差异一目了然操作stackqueue说明push支持支持stack等价于底层push_backqueue等价于底层push_backpop支持支持stack弹出栈顶queue弹出队头top支持不支持stack专用访问栈顶元素front不支持支持queue专用访问队头元素back不支持支持queue专用访问队尾元素empty支持支持判空size支持支持返回元素个数emplace支持支持C11起支持原地构造元素swap支持支持交换两个容器内容我一直强调一个细节stack的top()和queue的front()返回的都是引用可以直接修改。比如st.top() 42;是合法的q.front() 42;也是合法的。但q.back()同样可以改很多人不知道这个特性。早年有些笔试题目会考察“能不能通过引用修改队列尾部”如果你不知道back()返回引用这题就白丢了。还要注意pop()的返回类型是void它不会把弹出的元素返回给你。所以当你想“取出并弹出”时必须先保存引用再popint val st.top(); st.pop();这个设计是为了避免拷贝开销和异常安全问题如果pop()返回元素要么多一次拷贝/移动要么在边界场景下造成悬空引用。标准库故意把它做成void逼你两步走。写代码时老老实实分两步不要试图用auto val st.pop()这种写法编译不过。2.2 emplace与push的区别少一次拷贝C11给所有容器适配器增加了emplace。它和push的区别在于push接收一个已经构造好的对象把它拷贝或移动进容器emplace接收构造参数在容器内部直接构造对象省掉那一次临时对象拷贝。举个例子如果stack的元素类型是std::stringstackstring st; st.push(hello); // 先把hello构造成string临时对象再拷贝进栈 st.emplace(hello); // 直接拿hello的字符数据构造string少一步对于string这种小对象差距可能只有几十纳秒感知不强。但如果元素是std::thread这种不可拷贝的类型你就必须用emplace或者push(std::move(t))。再比如元素是std::pairint, stringqueuepairint, string q; q.emplace(1, zhang); // 符合直觉 q.push({1, zhang}); // 也可以但要额外构造实测下来复杂对象的emplace能比push快大概10%-20%主要是省掉了临时对象的构造和析构。但注意emplace虽然方便传参顺序必须严格匹配构造函数参数写错编译期就会报错报错信息有时候比较晦涩初次使用容易困惑。2.3 空容器访问陷阱中的陷阱st.top()、q.front()、q.back()在容器为空时是典型的未定义行为UB。不是抛异常不是返回空值而是未定义。可能返回垃圾值可能直接段错误也可能刚好正常这正是它最隐蔽的地方。你本地测试没问题上线就崩回头查代码发现没判空。我在实际开发中见过一次线上崩溃就是消费线程在队列刚清空的那一瞬间调用了front()竞态条件一叠加进程直接core dump。所以这里给出两条铁律提示访问top()、front()、back()之前一定要先用empty()判断。别嫌啰嗦这是无数崩溃换来的经验。写栈和队列相关的代码头几行永远是判空没有例外。另外判断空时建议用empty()而不是size() 0。原因有两个第一empty()在标准库实现里通常只做一次相等判断理论上比size()少走几步第二有些底层容器的size()可能是O(n)的虽然常见的deque/vector/list都是O(1)但empty()在所有标准容器上都是O(1)语义更干净。3. 揭开deque的面纱为什么默认底层是它3.1 deque的内存结构与迭代器要说清stack和queue为什么默认拿deque当底层就得先搞懂deque的设计。deque的全称是double-ended queue双端队列。它最核心的优点是头尾两端插入删除都是O(1)同时又能像vector一样快速随机访问。deque的内存不是一个连续的大数组而是一个中控器map加上若干个固定大小的缓冲区的结构。中控器本身是一个指针数组每个元素指向一块连续缓冲区。逻辑上deque看起来是连续的实际上数据被分块存储在两段甚至多段连续区间中。这种分块结构让deque天然具备了两端扩展的能力头插时如果最前面的缓冲区满了就再申请一块新的缓冲区在中控器头部登记一下整个过程不需要搬移已有元素。不过分块结构也带来了代价迭代器不是简单的指针而是一个包含当前缓冲区指针、当前元素指针、中控器指针的复合结构。所以deque的迭代器解引用、自增自减要比vector的迭代器多几层间接跳转单次操作稍微慢一点。但绝大多数场景下这点开销可以忽略。迭代器失效规则也要牢记在deque中间插入或删除元素时所有迭代器和引用都会失效在两端插入或删除时迭代器可能失效但引用仍然有效。这句话背下来就有用了。如果遍历deque的过程中还要在头部push迭代器可能忽然失效这是容易翻车的地方。3.2 vector为什么不适合当queue的底层vector尾部操作一流但头部操作是灾难级的。erase(begin())会触发所有元素向前搬移O(n)复杂度。如果你的queue元素有10万个每pop一次就跑10万次赋值整体性能直接起飞。所以vector不能做queue的底层容器这是复杂度层面的硬伤不是调优层面的问题。那stack默认应该用vector吗其实stack用vector也完全没问题很多编译器实现中甚至有人专门把stack的底层容器替换成vector来提升内存局部性。标准库没有把stack的默认容器改成vector更多是历史兼容考虑stack最早就是建立在deque之上的后续标准保持了这个默认值。你完全可以根据场景自己写std::stackint, std::vectorint实测在某些频繁遍历栈内容的算法中会有明显收益。3.3 list为什么不适合当stack的底层list两端操作都是O(1)按理说做stack和queue的底层都合格。但list的问题是每个节点都带有prev和next两个指针存储开销大节点在堆上离散分布内存局域性极差。大量访问list元素时缓存行频繁失效性能可能比deque慢一个量级。我做过一个简单的压测向stack里push 1000万个整数再全部pop出来用deque做底层耗时大约在300毫秒级别用list做底层直接涨到几秒差距非常明显。原因就是list每push一个节点就要new一次每pop一个节点就要delete一次而且链表指针跳跃造成的缓存未命中在数据量大时特别致命。所以stack和queue默认选择deque本质上是在三个候选容器里做了一个折中vector有头部短板list有内存碎片和缓存问题deque两头都兼一点随机访问能力还在。你要说它完美那当然不是但在通用场景下它是最不坏的默认选择。4. 拓展玩法单调栈、单调队列与经典应用4.1 单调栈解决“下一个更大元素”类问题把stack和queue用出水平的第一步是掌握单调栈技巧。单调栈指的是栈内元素按单调递增或单调递减顺序排列。它的核心价值是把一类“找最近更大/更小元素”的暴力O(n^2)问题降成O(n)。以“下一个更大元素”为例给你一个数组找出每个元素右边第一个比它大的元素。暴力解法是两层循环每个元素都往后扫一遍。用单调递减栈一遍遍历就够vectorint nextGreaterElement(const vectorint nums) { int n nums.size(); vectorint res(n, -1); stackint st; // 存下标保持栈底到栈顶递减 for (int i 0; i n; i) { while (!st.empty() nums[st.top()] nums[i]) { res[st.top()] nums[i]; // 栈顶元素的下一个更大元素是 nums[i] st.pop(); } st.push(i); } return res; }理解这个代码的关键是栈里保存的是那些“还没找到下一个更大元素”的下标。每来一个新元素就把栈里所有比它小的元素都弹出去这些元素的下一个更大元素就是当前元素。弹完之后当前元素入栈等待它自己的“下一个更大元素”。单调栈还有一种常见变体是找“左右两侧第一个比它小/大的区间”这类题刷多了会发现套路非常固定。你只要抓住一个核心单调栈适合处理位置相邻的极值关系问题最典型的就是接雨水、柱状图最大矩形、每日温度这类力扣原题。4.2 单调队列滑动窗口最大值如果说单调栈是“栈内元素有序”单调队列就是“队列内元素有序”它同样能把一类O(n*k)的暴力问题降到O(n)。最有名的应用就是滑动窗口最大值给一个数组和一个窗口大小k窗口从左往右滑动每次输出窗口内的最大值。朴素做法是每个窗口扫一遍复杂度O(n*k)。用单调队列配合deque的pop_front和pop_back两端操作一轮遍历就能搞定vectorint maxSlidingWindow(const vectorint nums, int k) { dequeint dq; // 存下标队列中下标对应的值递减 vectorint res; for (int i 0; i nums.size(); i) { // 移除已经滑出窗口的下标 while (!dq.empty() dq.front() i - k) { dq.pop_front(); } // 维护单调性新元素比队尾大时队尾永远不可能成为窗口最大值 while (!dq.empty() nums[dq.back()] nums[i]) { dq.pop_back(); } dq.push_back(i); // 窗口满时记录结果 if (i k - 1) { res.push_back(nums[dq.front()]); } } return res; }这里每次维护完队头就是当前窗口最大值的下标。因为deque支持push_back、pop_back、pop_front、front、back单调队列的所有操作都是O(1)这一题也正好命中了queue默认底层选deque的合理性。如果你非要用vector实现同样的逻辑就得处理头部的左移搬移性能和代码复杂度都会很吃亏。单调队列能解决的问题比你想象的多。除了滑动窗口最值还常用于优化动态规划例如“长度为k的区间内取一个最大值来转移dp方程”这类场景能硬生生把一维DP优化掉一层循环。我给个建议凡是遇到“滑动窗口最值”或者“固定区间内选最优值参与转移”的题先往单调队列方向想大概率有奇效。4.3 queue与BFS、stack与DFS的实战串讲stack和queue在算法里最朴素也最广泛的应用就是配合树和图做深度优先搜索DFS和广度优先搜索BFS。DFS用stack是天然的递归结构先处理当前节点再把子节点压栈下一轮弹出一个继续处理。手动用stack模拟递归可以避免系统调用栈溢出代码也更可控。BFS用queue则完全贴合“按层推进”的语义先把起点入队每轮处理队头元素把它的邻居入队一轮结束就走完了一层。以二叉树的层序遍历为例queue在这里几乎是不可替代的void levelOrder(TreeNode* root) { if (!root) return; queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); // 当前层节点数 for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); // 按需处理 node例如收集当前层值 if (node-left) q.push(node-left); if (node-right) q.push(node-right); } } }这里有个小技巧很多初学者会在while循环里直接写int n q.size(); for (int i 0; i n; i)这比每轮都重新q.size()要省事更重要的是它锁定了当前层的节点数。如果不锁定循环内push新节点会让q.size()变大直接导致当前层遍历越界把下一层节点也处理掉逻辑就乱了。这个坑在很多BFS题目里都出过写层序遍历时务必记住“先取层大小再处理本层”。再说一个跟图和游戏引擎相关的典型场景网格迷宫的最短路径。用queue做BFS从起点开始逐层向外扩散第一次到达终点时的步数就是最短路径。游戏AI中的人物寻路如果地图简单不追求A*底层就是这个BFS队列。你写的那个queueTreeNode*稍作修改就能变成queuepairint,int用于二维网格广搜这就是基础数据结构在真实系统里的投射。5. 性能取舍与自定义底层工程化视角5.1 不同底层容器下的性能实测我学习容器适配器的第四层理解是“不要盲信默认参数”。编程里所有默认值都是方便你起步的不是让你无脑用到底的。下面这张表格是我对比过的一轮实测结果基于release模式编译元素类型为int数据量为1000万操作vector做底层deque做底层list做底层stack pushpop 1000万约250ms约320ms约3.2squeue pushpop 1000万无法实现无pop_front约340ms约3.5svector做stack底层时所有元素存在一块连续内存里CPU缓存命中率极高所以push/pop全流程反而比deque快。deque的分块结构需要多几次间接跳转稍微慢一点。list散落堆上的节点要频繁new/delete差距一下就拉开了。这个数据给我们的启发是如果只是纯栈场景、内存可控、元素数量已知可以显式把stack的底层换成vector提速大约20%~30%。list作为适配器底层除非你有强引用稳定性的需求否则能不用就不用。deque的优势场景是“同时需要头尾操作”比如队列/单调队列或者既当栈又当队列使用的双端场景。还补充一个细节stack/queue的swap成员函数在C11之后是常量的不抛异常而且只交换底层容器的内部指针不搬移元素。所以如果需要快速交换两个大栈/大队列直接调用swap就行别自己循环搬元素那才是真傻。5.2 什么时候不该直接用stack/queue适配器屏蔽了底层细节但有些场景里这种屏蔽反而碍事。例如你想在栈里遍历所有元素调试stack没有迭代器你只能一次次pop()把整个栈毁掉。这时有几种做法第一用一个临时栈保存弹出元素遍历完再倒回去。缺点是临时空间O(n)。第二继承stack访问受保护的c成员。stack内部把底层容器命名为ccontainer的缩写它就是受保护的成员子类可以访问template typename T class StackExt : public std::stackT { public: using std::stackT::c; }; // 使用 StackExtint st; st.push(1); st.push(2); for (auto it st.c.begin(); it ! st.c.end(); it) { // 遍历栈内容 }这种做法面试时偶尔能当冷知识亮出来但自己写代码时不要滥用毕竟直接暴露底层容器后栈的封装语义就被破坏了。我个人的建议是调试期用这种trick看内容没问题正式代码里还是老老实实保持栈的抽象。再说一种更实际的情况你需要一个支持“优先取出最大元素”的队列。标准queue是严格的先进先出做不到这种需求。这时你该用std::priority_queue它也是容器适配器默认底层是vector内部用堆算法维护最大堆。priority_queue的默认比较器是lessT所以栈顶是最大元素如果你想取最小元素就传greaterT。这个容器和queue是亲戚但不完全一样工程里做任务调度、TopK问题、哈夫曼编码时都离不开它。6. 原理到实战面试题里的stack和queue6.1 最小栈用空间换时间最小栈是stack的经典面试题实现一个栈除了push、pop、top之外还要能在O(1)时间内返回最小值。如果你只有一个普通栈想找最小值就得遍历一遍O(n)就不合格了。解法是用两个栈一个存数据一个存“当前最小值”。每次push时如果新元素比辅助栈栈顶小就把新元素压入辅助栈否则把辅助栈栈顶再压一遍或者只在相等时压入。这样辅助栈的每次入栈元素就是“当前数据栈对应状态下的最小值”class MinStack { private: stackint dataSt; stackint minSt; public: void push(int val) { dataSt.push(val); if (minSt.empty() || val minSt.top()) { minSt.push(val); } } void pop() { if (dataSt.top() minSt.top()) { minSt.pop(); } dataSt.pop(); } int top() { return dataSt.top(); } int getMin() { return minSt.top(); } };这里第三行的条件是val minSt.top()注意是小于等于而不是小于。为什么因为如果压入两个相同的最小值pop掉其中一个时另一个仍应是最小值。如果你只在严格小于时入栈那等于的情况就会漏掉pop一次后最小值就丢了。这个边界条件极其经典面试时考官大概率会故意追问。6.2 两个栈实现队列与两个队列实现栈用两个栈实现队列是数据结构笔试里出镜率最高的一道题。思路也简单一个inStack负责入队一个outStack负责出队。入队直接压inStack出队时如果outStack为空就把inStack里的元素全部倒进outStack再把outStack栈顶弹出。因为栈是后进先出所以倒一次之后元素的相对顺序正好反转成了先进先出class MyQueue { private: stackint inStack, outStack; void transfer() { if (outStack.empty()) { while (!inStack.empty()) { outStack.push(inStack.top()); inStack.pop(); } } } public: void push(int x) { inStack.push(x); } int pop() { transfer(); int val outStack.top(); outStack.pop(); return val; } int peek() { transfer(); return outStack.top(); } bool empty() { return inStack.empty() outStack.empty(); } };每个元素最多进栈两次、出栈两次整体均摊O(1)。注意transfer()函数的触发条件是outStack为空不要每次pop都倒一遍那样就退化回O(n)了。这道题的核心考点就是“倒一次够用很久”理解了均摊分析才算真正掌握。反过来用两个队列实现栈思路是用一个空队列作为辅助缓冲区。push时直接入队主队列pop时需要把主队列中除了最后一个元素之外的所有元素搬到辅助队列弹出队尾元素后交换两个队列的角色。这个过程pop是O(n)但push是O(1)。想考察你队列操作是不是熟练这道题比两栈队列更能暴露功底。6.3 循环队列搞定队列空间管理如果底层容器换成定长数组队列就是一个数组加两个指针。为了避免搬移元素一般把数组逻辑上首尾相接做成循环队列。力扣上的设计循环队列题目其实就是考察你如何用环形数组管理head和tailclass MyCircularQueue { private: vectorint data; int head, tail, cnt, cap; public: MyCircularQueue(int k) : data(k), head(0), tail(0), cnt(0), cap(k) {} bool enQueue(int value) { if (isFull()) return false; data[tail] value; tail (tail 1) % cap; cnt; return true; } bool deQueue() { if (isEmpty()) return false; head (head 1) % cap; --cnt; return true; } int Front() { return isEmpty() ? -1 : data[head]; } int Rear() { return isEmpty() ? -1 : data[(tail cap - 1) % cap]; } bool isEmpty() { return cnt 0; } bool isFull() { return cnt cap; } };这里两个细节值得背一是用cnt记录当前元素数量这样判空和判满都不需要额外处理“tail在head前面”还是“tail在head后面”的边界情况二是tail指向的是下一个写入位置所以取队尾元素时要减1再加模(tail cap - 1) % cap保证不越界。写这种题时先把三种状态空、满、正常用笔在草稿上画一遍再写代码能省很多调试时间。7. 工程进阶手写一个线程安全队列7.1 基础版本实现stack和queue本身不是线程安全的这一点不用多解释标准容器基本都不承诺线程安全。多线程环境下一份queue被两个线程同时push和pop必然出数据竞争。工程上最常用也最稳妥的做法是在queue外面包一层互斥锁。我先给一个最基础的线程安全队列#include queue #include mutex #include condition_variable template typename T class ThreadSafeQueue { private: mutable std::mutex mtx; std::queueT q; std::condition_variable cv; public: void push(const T value) { { std::lock_guardstd::mutex lock(mtx); q.push(value); } cv.notify_one(); } bool pop(T out) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this] { return !q.empty(); }); out q.front(); q.pop(); return true; } };这里有两个地方我要特别划重点。第一个重点pop里用cv.wait(lock, predicate)而不是先手动循环判断。condition_variable::wait的第二个参数是谓词它会自动处理“假唤醒”的情况。所谓假唤醒就是条件没有真正满足时线程也被唤醒了可能是操作系统信号或其它内核原因。如果你自己写while(empty()) wait()漏掉这个循环就有概率读到空队列直接UB。标准库的wait(lock, pred)内部其实就是这个循环你省事也安全。第二个重点push里的锁为什么用大括号块包住为了让notify_one()在锁释放之后执行。如果一个线程在队列里push完先通知消费者再释放锁消费者被唤醒后却拿不到锁就得白白阻塞等待。把notify_one()放在锁外消费者被唤醒时锁往往已经释放等待时间最短吞吐量能提升不少。这个优化我用过多次线程数越多差别越明显。7.2 条件变量与unique_lock协作细心的你可能注意到我在pop中用的是std::unique_lock而不是lock_guard。为什么因为condition_variable::wait需要暂时释放锁让其它线程能继续push。lock_guard不支持手动解锁unique_lock才支持。所以当你用条件变量时锁类型只能是unique_lock这是刚需不是风格问题。cv.wait被唤醒后会重新获取锁再继续执行。也就是说从wait返回的那一刻起当前线程已经持有互斥锁队列内容不可能被其它线程修改可以放心调用front()和pop()。这里面的锁与条件变量的协作关系本质就是条件变量负责通知状态变化互斥锁负责保护状态本身。只用一个不配套线程安全就无从谈起。如果你希望队列支持“超时等待”而不至于线程永久阻塞可以把cv.wait(lock, pred)换成cv.wait_for(lock, chrono::milliseconds(100), pred)。wait_for返回bool表示超时前谓词是否已满足。这个接口在实际生产代码里比裸等待更常用很多服务端的任务队列都有超时要求。7.3 工程中的其它注意事项手写线程安全队列看起来代码不长但坑都在细节里。我根据自己的踩坑经验列几条第一析构时要唤醒所有等待线程。如果某个线程阻塞在wait上而队列对象又被销毁了那就悬空引用了。标准做法是在析构函数里调cv.notify_all()或者用一个bool closed标志配合谓词一起判断然后优雅退出。很多教学代码不写析构但你自己工程里必须考虑。第二尽量用std::move避免大数据拷贝。上面的pop(T out)会把队头元素拷贝给out。如果T是std::string或者自定义结构体拷贝成本不低。更好的写法是提供bool pop(T out)加一个std::queueT q内部配合移动语义out std::move(q.front()); q.pop();。移动通常只交换指针成本远低于深拷贝。第三要明确队列的容量上限。无界队列在生产者生产速度远大于消费者消费速度时内存会无限膨胀最后OOM。工程上更稳妥的是带最大容量的有界队列push时先检查q.size()是否达到上限满了就等待或者直接返回false让生产者侧做降级处理。这个设计在背压backpressure控制里非常重要网络库、日志系统、消息中间件里都有一席之地。最后说点实在的我这些年写C最大的体会之一就是标准库容器看着简单但每一个默认选择背后都有完整的设计逻辑。stack和queue默认用deque不是随便定的pop设计成void不是忘了给你返回值emplace比push省一次拷贝也不是什么绝技只是C11之后你该养成的习惯。学习这些东西千万别停留在“会用接口”层面多问几个为什么然后自己写点代码验证才是真正长进的路子。我自己带新人的时候最推荐的做法是把上面这七部分内容全部亲手跑一遍。单调栈和单调队列的代码把输入数组换掉、改改窗口大小看看结果是否符合直觉线程安全队列开两三个生产者和消费者线程加一个很高的计数器跑个几百万次确认没有崩溃也没有残留数据。这些实验做完你对stack和queue的掌握水平绝对不会再是“会用push和pop”的程度。最后分享一个小技巧学习这类数据结构时随手准备一个最小复现环境g -stdc17 -O2 main.cpp -o demo然后疯狂改代码、看汇编、测耗时会让很多模糊的概念在一小时内变得特别清晰。希望你读完之后也能自己写一轮这样的实验。
返回列表